📗 第6章 提高泛化能力的方法
📄 一句话概括

训练集上分数高不算本事,新数据上站得住才算。 这一章五种手段都在做同一件事:把训练与测试之间那道沟压小。 第一条路是加样本。同一个网络,训练集从 20 张加到 320 张,测试准确率从 0.3387 爬到 0.8900, 而训练准确率一直顶在 1.0000。 第二条路是造样本:40 张标签平移放大到 320 张,测试从 0.4863 跳到 0.7913。 第三条路是清样本:标签错四成时,测试从 0.9250 掉到 0.6787。

📝 本节和前面几节的关系

前五章都在讲「网络怎么搭」:卷积、池化、RBM、自编码器。 这一章换个方向,讲「搭好了怎么让它在新数据上管用」。 这一章的五种手段有一个共同点:都不改网络的核心结构。 改的是喂进去的数据、算子的形状、以及训练时的随机性。 这也意味着它们能叠在任何一个模型上,包括下一章要讲的现成框架。

一、先看那道沟

先说清「泛化」这个词。 模型在训练集上答对,只能说明它记住了这些题;在新数据上答对,才说明它学会了规律。 记住和学会,是两件事。 这一节的网络是 64 到 32 到 16 到 10,一共 2704 个参数。 训练集给 400 张图,平均每个参数摊到不到 0.15 张。 参数比样本多这么多,网络完全有能力「背下」训练集——它也确实背下来了。 衡量这件事的指标叫「沟」:训练准确率减去测试准确率。 沟小,说明两边表现一致;沟大,说明模型在训练集上表现好只是因为见过。 这一章所有的手段,目标都是把这个沟压小。

二、第一条路:加样本

最直接的办法就是多给数据。 固定网络结构和训练步数,只改训练集的张数:

训练张数 训练准确率 测试准确率
20 1.0000 0.3387
40 1.0000 0.4688
80 1.0000 0.6562
160 1.0000 0.8700
320 1.0000 0.8900
20 张时训练准确率就到 1.0000,测试只有三成三——这是过拟合最典型的样子。
张数一路加上去,训练准确率一格没动,测试准确率从三成三爬到八成九。
注意最后两行:160 张 0.8700,320 张 0.8900,只差两个点。
光靠加数据也到了天花板。
剩下的差距要靠别的手段补,这就是本章后面四节要讲的东西。

三、第二条路:造样本

标注是有成本的:一张图可能要人看几秒到几分钟。 但如果你已经知道「某种变化不该改变答案」,就可以自己造样本。 数字挪一格还是同一个数字,这个规律不用问别人。 手里只有 40 张带标签的图,靠平移和加噪点变出多个版本:

做法 等效样本数 测试准确率
只训 40 张,不增强 40 0.4863
平移加减 1 像素,放大 4 倍 160 0.6637
平移加减 1 像素,放大 8 倍 320 0.7913
平移加减 2 像素加 2 个噪点,放大 8 倍 320 0.5000
同样的 40 张标签,只做平移就让测试从 0.4863 跳到 0.7913。
这是整章性价比最高的一步:不需要新数据,只需要你懂任务。
但最后一行往下掉了将近三成。
多加噪点并没有帮上忙,因为噪点不属于「已知不该改答案的变化」——它只是把图弄脏,
等于教模型认脏图。
增强的前提是「你确定这个变换保标签」,不确定就别加。

四、第三条路:清样本

现实中标签也会错:人工标注会看走眼,众包会有人乱点。 下面故意把一部分标签随机改成别的数字,看模型被带偏多少:

标签错的比例 训练准确率 测试准确率
0.00 1.0000 0.9250
0.05 0.9575 0.8850
0.10 0.9125 0.8538
0.20 0.8075 0.7800
0.40 0.6600 0.6787
训练准确率这一列是拿「被打乱的标签」算的,所以它自己先塌。
这不是模型变差了,是我们在用错答案给自己打分。
测试准确率才是真账:标签错四成时,从 0.9250 掉到 0.6787,掉了二十多个百分点。
而且这个损失是加数据补不回来的——错标签越多,你需要的新数据越多。
所以清洗标签的回报很高,尤其是小数据集。
它也是最容易被忽略的一条:很多人宁愿去调网络结构,也不愿意回头看一眼标注。

五、动手 Demo:三条路各跑一遍:加样本、造样本、清样本

数据用七段数码管的仿制图:8 乘 8 像素,10 个数字。 训练集 400 张(每个数字 40 张,只做平移),测试集另做 800 张(多敲一个噪点)。 代码里的 fit 和 acc 就是第 2 章那两个函数,这里不再重复贴。

def make_data(n=40, jitter=1, noise=0, seed=0):
    u"""每个数字 n 张;随机平移 jitter 格,再随机敲掉 noise 个像素。"""
    rg = np.random.default_rng(seed)
    X, y = [], []
    for k in range(10):
        base = one_digit(k)
        for _ in range(n):
            img = base.copy()
            if jitter:
                dr, dc = rg.integers(-jitter, jitter + 1, 2)
                img = np.roll(np.roll(img, dr, 0), dc, 1)
            x = img.ravel().copy()
            if noise:
                idx = rg.choice(64, noise, replace=False)
                x[idx] = 1.0 - x[idx]
            X.append(x)
            y.append(k)
    return np.array(X), np.array(y, dtype=int)


Xtr, ytr = make_data(40, jitter=1, noise=0, seed=0)    # 400 张训练
Xte, yte = make_data(80, jitter=1, noise=1, seed=99)   # 800 张测试,另做一批

# 第一条路:只改训练张数,结构和步数都不动
for N in (20, 40, 80, 160, 320):
    pick = np.random.default_rng(31).choice(400, N, replace=False)
    m = fit(Xtr[pick], ytr[pick], steps=1500, seed=1)
    print(N, acc(Xtr[pick], ytr[pick], m), acc(Xte, yte, m))

# 第二条路:拿 40 张,靠平移和噪点变出 320 张
pick = np.random.default_rng(31).choice(400, 40, replace=False)
base_X, base_y = Xtr[pick], ytr[pick]
for mult, jit, noi in ((4, 1, 0), (8, 1, 0), (8, 2, 2)):
    XX, yy = [], []
    rg = np.random.default_rng(77)
    for _ in range(mult):
        for xa, ya in zip(base_X, base_y):
            img = xa.reshape(8, 8).copy()
            dr, dc = rg.integers(-jit, jit + 1, 2)
            img = np.roll(np.roll(img, dr, 0), dc, 1)
            v = img.ravel().copy()
            if noi:
                ii = rg.choice(64, noi, replace=False)
                v[ii] = 1.0 - v[ii]
            XX.append(v)
            yy.append(ya)
    m = fit(np.array(XX), np.array(yy), steps=1500, seed=2)
    print(len(XX), acc(Xte, yte, m))

# 第三条路:故意把一部分标签改错
for rate in (0.0, 0.05, 0.10, 0.20, 0.40):
    rg = np.random.default_rng(41)
    yb = ytr.copy()
    nbad = int(rate * len(yb))
    bad = rg.choice(len(yb), nbad, replace=False)
    yb[bad] = rg.integers(0, 10, nbad)
    m = fit(Xtr, yb, steps=1500, seed=3)
    print(rate, acc(Xtr, yb, m), acc(Xte, yte, m))

实跑输出:

--------------------------------------------------------------------
训练样本:加样本、造样本、清样本
--------------------------------------------------------------------
[1] 泛化问题:训练集上分数高,不等于在新数据上好用
  网络有 2704 个参数,训练集给 400 张图
  参数一多,它就有能力「背下」训练集而不学规律
  测试集另做 800 张,同样平移一格再敲一个噪点,比训练集略难
  这一节看三个办法:加样本、造样本(数据增强)、清样本(去标签噪声)

[2] 第一条路:加样本
  固定网络结构和训练步数,只改训练集的张数:
  训练张数    训练准确率    测试准确率
  20          1.0000        0.3387
  40          1.0000        0.4688
  80          1.0000        0.6562
  160         1.0000        0.8700
  320         1.0000        0.8900

  20 张时训练准确率就到 1.0000,测试只有三成三——这就是过拟合的样子
  张数上去之后,训练准确率一直顶在 1.0000,测试准确率却从三成三爬到八成九
  最后两行几乎一样,说明光靠加数据也到了天花板,剩下的要靠别的手段
  所以「加数据」是提高泛化最有效的手段,没有之一

[3] 第二条路:造样本(数据增强)
  手里只有 40 张带标签的图,但可以拿每张图变换出多个版本:
  平移一两个像素、随机翻转几个像素,标签保持不变
  下面对比四种量级的增强:
  做法                                  等效样本数    测试准确率
  只训 40 张,不增强                    40            0.4863
  平移加减 1 像素,噪点 0 个,放大 4 倍 160           0.6637
  平移加减 1 像素,噪点 0 个,放大 8 倍 320           0.7913
  平移加减 2 像素,噪点 2 个,放大 8 倍 320           0.5000

  同样的 40 张标签,只平移就够用了:测试准确率从四成八跳到七成九
  增强的本质是把「你已知的规律」写成数据:数字挪一格还是同一个数字
  但最后一行比上一行掉了将近三成——多加噪点并没有帮上忙
  噪点不是「已知不该改答案的变化」,它只是把图弄脏,等于教模型认脏图

[4] 第三条路:清样本(标签噪声)
  现实中标签也会错。下面故意把一部分标签随机改掉,看模型被带偏多少:
  标签错的比例      训练准确率    测试准确率
  0.00              1.0000        0.9250
  0.05              0.9575        0.8850
  0.10              0.9125        0.8538
  0.20              0.8075        0.7800
  0.40              0.6600        0.6787

  训练准确率已经按「被打乱的标签」算,所以它自己先塌下去
  测试准确率才是真账,它掉得慢一些,但一路往下
  错四成标签时训练准确率只剩六成六,测试从九成二掉到六成八
  结论很朴素:清洗标签比换模型更划算,尤其是小数据集

[5] 三条路的账
  加样本:最有效,但最贵——一张图可能要好几分钟标注
  造样本:最便宜,但只在你「已经知道什么变化不该改变答案」时才成立
  清样本:最容易被忽略,回报却很高,尤其标签是人手工打的时候
  下一节讲预处理:不改数据量,只改数据的「摆放方式」,也能明显改善训练

第一段说明两件事。 一是 make_data 的 jitter 和 noise 两个参数能独立控制「平移」和「敲点」; 二是测试集用另一个种子(99)另做一批,而不是从训练集里切——这样测试集才算真正没见过。 第二段那张表最值得盯的是训练准确率那一列:五行全是 1.0000。 这说明「加样本」并没有让模型在训练集上变差,它只是让模型没机会死记。 换个说法:数据一多,模型想背也背不完。 第三段最后一行掉到 0.5000,这是个反例。 平移 2 格加 2 个噪点,看起来「增强得更狠」,实际上是让标签和图像对不上了: 数字 1 平移两格可能撞上数字 7 的形状,噪点多了也可能把 3 变成 8。 增强的强度不是越大越好,得贴着真实变化来。

30 秒实验

  1. 把测试集的 noise 从 1 改成 0 再跑一遍。 所有准确率都会涨,而且「加样本」那条曲线会更早饱和——因为测试集变简单了,天花板变低了。
  2. 把训练集的 jitter 从 1 改成 2,再看纯平移的增强还有没有用。 会有用,但幅度变小:模型见过的位移范围已经够宽了。
  3. 把标签噪声那一组的种子从 41 换成别的。 曲线的形状会变,但「错得越多掉得越多」这个趋势不会变。

常见误区

  1. 「训练准确率 100% 说明模型很好。」 恰恰相反,在小数据集上这是个危险信号。 这一节的 20 张那组训练准确率就是 1.0000,测试只有 0.3387。 要看的是测试准确率,以及训练减测试那道沟。
  2. 「数据增强越猛越好,能变出十倍样本。」 实测把平移加到 2 格、再加 2 个噪点,测试从 0.7913 掉到 0.5000。 增强的每一种变换都得满足「标签不变」,否则你是在往训练集里灌错标签。
  3. 「标签错一点没关系,模型能自己纠回来。」 错 20% 时测试已经掉到 0.7800,错 40% 掉到 0.6787。 模型不会纠错,它只会老老实实拟合你给的答案。
自测三问(点开对答案) 1. **为什么「加样本」比「调结构」更有效?** —— **因为过拟合的根源是「参数能表达的东西比数据能约束的多」。** **调结构是在减少参数能表达的东西,加样本是在增加约束。** **后者不损失容量,所以上限不会掉,这才是它更有效的原因。** 2. **训练准确率用「被打乱的标签」算,为什么会塌?** —— **因为答案本身就是矛盾的:同一张图,这次标 3,下次标 7。** **模型再强也学不会这种矛盾,只能学到一个折中的输出,于是对任何一份标签都只有部分正确。** 3. **数据增强的「标签不变」这个前提,怎么判断?** —— **问一个具体问题:这个变换做完,人还能一眼认出是同一个东西吗?** **数字左右翻转就不行(6 会变 9),数字平移一格就行。** **靠常识判断,不要靠「多增强总没坏处」的直觉。**

三条路都在改「喂什么」。 下一节改另一件事:数据的「摆放方式」——同样的像素,减个均值、除个标准差、白化一下, 训练难度会明显不同。

相关笔记