📗 第2章 神经网络
📄 一句话概括

同样的 300 轮、同一个初始化:全批量 300 步、0.107 秒、最终损失 0.1431; 小批量 32 走 4200 步、0.373 秒、0.0065;纯随机 135000 步、7.8 秒。 纯随机用同一个学习率会抖到学不好(0.5884、准确率 0.6267),换成 0.02 才收敛到 0.0018。 步数差 450 倍,时间只差 75 倍——向量化摊平了一部分,但没摊平全部。

📝 本节和前面几节的关系

前几节一直在说「小批量 32」,但没说为什么。 这一节把三种做法摆在一起量。 它们算的都是同一个梯度,区别只在于「用多少样本估这个梯度」。 样本越多估得越准,但每一步越贵;样本越少每步越便宜,但方向越抖。 这是一个纯粹的权衡,没有免费的选项。

一、三种做法

全批量:把所有样本一起算,得到「真实梯度」。 一轮只能更新一次,所以 300 轮就是 300 步。 随机(SGD):一次只用一个样本,梯度噪声极大。 一轮能更新 450 次,300 轮就是 135000 步。 小批量:折中,一次 32 个。 一轮 14 步,300 轮 4200 步。 这也是今天所有框架的默认做法。

二、实测三种做法的代价与效果

做法 学习率 300 轮总步数 耗时 最终损失 准确率
全批量 0.200 300 0.107 秒 0.1431 0.9778
小批量 32 0.200 4200 0.373 秒 0.0065 1.0000
随机 0.200 135000 7.814 秒 0.5884 0.6267
随机 0.020 135000 7.725 秒 0.0018 1.0000
这张表里有三个反直觉的地方。
第一,全批量的损失降得最慢。
它的梯度最准,但 300 轮只走 300 步——步数太少,再准也没用。
第二,纯随机在同学习率下反而最差。
每步只用一个样本,方向噪声很大,学习率 0.2 会让它一直在谷底附近乱撞。
换成 0.02 之后它才学得动,最终损失 0.0018,是四个里最好的。
第三,耗时和步数不成正比。
135000 步对 300 步是 450 倍,但时间是 7.8 秒对 0.107 秒,只有 73 倍。
因为全批量那一步是 450 乘 2 的矩阵乘法,单次开销大得多。

三、损失曲线长什么样

每 10 轮取一个点:

轮次 全批量 小批量 32 随机 0.2 随机 0.02
1 1.3307 1.0796 0.7214 0.5607
5 0.8881 0.5438 2.3601 0.2502
10 0.7644 0.3838 2.3379 0.0991
20 0.6652 0.1527 0.3877 0.0381
100 0.4145 0.0203 0.6188 0.0069
300 0.1431 0.0065 0.5884 0.0018
全批量那一列是平滑单调下降的——每次用的都是同一批样本,梯度方向稳定。
小批量下降得最快,因为每轮走了 14 步。
随机 0.2 那一列在第一轮之后反而涨到 2.36,然后一直在 0.39 到 0.62 之间撞。
这就是「学习率没跟着批量调小」的典型症状。

四、为什么小批量成了默认

三个原因,按重要性排。 第一,它同时拿到了两个好处:梯度足够准(32 个样本的均值,噪声比单个小 5.7 倍), 更新又足够频繁(一轮 14 次)。 第二,它刚好喂满矩阵乘法和 GPU。 单样本时矩阵退化成向量,并行度为零;批量越大,单位时间内算的乘法越多。 第三,它的噪声其实是有用的。 梯度里的随机性让优化不容易卡在尖锐的局部极小里,这一点在深层网络里很重要。 纯全批量反而更容易掉进坑里出不来。

五、两个实践细节

批量大小和学习率要一起调。 批量放大 k 倍,梯度噪声降到 1 除以根号 k,学习率通常也可以放大一些。 表里前两行批量从 450 降到 32,学习率没变还能跑得动,是因为这个任务本身不敏感; 换成更难的网络就不行了。 打乱顺序是必须的。 如果数据按类别排好序,每个小批量里就只有一类,梯度会一直在同一个方向,相当于白走。 代码里每轮都做一次 permutation,就是为了打散这件事。

六、动手 Demo:全批量、小批量、纯随机,三条曲线

这段代码在同一份数据、同一个初始化下跑四种配置,记录每轮的损失、总耗时和最终准确率。 随机那两行用的是同一个批量(1),只有学习率不同。 重点看三处。第二段的步数 300、4200、135000、135000; 第三段随机 0.2 那一列在第 5 轮涨到 2.3601;以及耗时的对比。

# -*- coding: utf-8 -*-
import time
import unicodedata

import numpy as np


def wd(s):
    return sum(2 if unicodedata.east_asian_width(c) in ('W', 'F') else 1 for c in s)


def pad(s, n):
    return s + ' ' * max(0, n - wd(s))


def spiral(n=150, C=3, seed=1, noise=0.12):
    rg = np.random.default_rng(seed)
    X, Y = [], []
    for c in range(C):
        t = np.linspace(0.4, 4.2, n)
        ang = t + c * 2.0 * np.pi / C
        X.append(np.column_stack([t * np.cos(ang), t * np.sin(ang)])
                 + rg.normal(0.0, noise, (n, 2)))
        Y.append(np.full(n, c))
    return np.vstack(X), np.concatenate(Y)


X, y = spiral(150, 3, seed=1)
T = np.zeros((len(y), 3))
T[np.arange(len(y)), y] = 1.0


def init(sizes, seed=1):
    r = np.random.default_rng(seed)
    W, b = [], []
    for i in range(len(sizes) - 1):
        W.append(r.normal(0.0, np.sqrt(2.0 / sizes[i]), (sizes[i], sizes[i + 1])))
        b.append(np.zeros(sizes[i + 1]))
    return {'W': W, 'b': b}


def fwd(p, X):
    Z, A = [], [X]
    h = X
    for i in range(len(p['W'])):
        z = h @ p['W'][i] + p['b'][i]
        Z.append(z)
        h = z if i == len(p['W']) - 1 else np.maximum(z, 0.0)
        A.append(h)
    return A, Z


def sm(S):
    S = S - S.max(axis=1, keepdims=True)
    E = np.exp(S)
    return E / E.sum(axis=1, keepdims=True)


def back(p, X, T):
    A, Z = fwd(p, X)
    L = len(p['W'])
    d = (sm(A[-1]) - T) / len(X)
    gW = [None] * L
    gb = [None] * L
    for i in range(L - 1, -1, -1):
        gW[i] = A[i].T @ d
        gb[i] = d.sum(axis=0)
        if i > 0:
            d = (d @ p['W'][i].T) * (Z[i - 1] > 0.0)
    return {'W': gW, 'b': gb}


def loss(p, X, T):
    A, _ = fwd(p, X)
    P = sm(A[-1])
    return float(-np.mean(np.log(np.maximum((P * T).sum(axis=1), 1e-12))))


def acc(p, X, y):
    A, _ = fwd(p, X)
    return float(np.mean(np.argmax(sm(A[-1]), axis=1) == y))


def run(batch, lr=0.2, epochs=300, seed=0):
    """batch=None 表示全批量;否则是小批量大小。返回 (损失曲线, 用时, 准确率)。"""
    p = init([2, 8, 3], seed=1)
    rg = np.random.default_rng(seed)
    n = len(X)
    step = n if batch is None else batch
    hist = []
    t0 = time.perf_counter()
    for _ in range(epochs):
        idx = rg.permutation(n)
        for s in range(0, n, step):
            j = idx[s:s + step]
            g = back(p, X[j], T[j])
            for i in range(len(p['W'])):
                p['W'][i] -= lr * g['W'][i]
                p['b'][i] -= lr * g['b'][i]
        hist.append(loss(p, X, T))
    dt = time.perf_counter() - t0
    return hist, dt, acc(p, X, y)


print('[1] 三种梯度下降:每步用多少样本算梯度')
print('  全批量:450 个样本一起算,梯度最准,但一轮只能走一步')
print('  随机:一次只用一个样本,梯度最糙,但一轮能走 450 步')
print('  小批量:折中,一次 32 个——这就是今天所有框架的默认做法')
print('')

print('[2] 同一份数据、同一个初始化、同样的 300 轮')
print('  做法               学习率   300 轮总步数   耗时       最终损失   准确率')
res = {}
rows = ((None, '全批量', 0.2), (32, '小批量 32', 0.2),
        (1, '随机 SGD', 0.2), (1, '随机 SGD', 0.02))
for batch, tag, lr in rows:
    hist, dt, a = run(batch, lr=lr)
    key = tag if lr == 0.2 else tag + '(小学习率)'
    res[key] = hist
    steps = (1 if batch is None else len(X) // batch) * 300
    print('  ' + pad(key, 19) + pad('%.3f' % lr, 9) + pad('%d' % steps, 15)
          + pad('%.3f 秒' % dt, 11) + pad('%.4f' % hist[-1], 11) + '%.4f' % a)
print('')

print('[3] 损失下降的形状不一样')
print('  轮次     全批量      小批量 32    随机 lr=0.2   随机 lr=0.02')
for e in (0, 4, 9, 19, 49, 99, 199, 299):
    print('  ' + pad('%d' % (e + 1), 9)
          + pad('%.4f' % res['全批量'][e], 13)
          + pad('%.4f' % res['小批量 32'][e], 14)
          + pad('%.4f' % res['随机 SGD'][e], 15)
          + '%.4f' % res['随机 SGD(小学习率)'][e])
print('')

print('[4] 四件事要分开看')
print('  全批量一轮只更新一次,300 轮才走 300 步,所以「轮」这个单位对它太粗')
print('  小批量一轮走 14 步,总共 4200 步,损失降得最干净:0.0065')
print('  随机 SGD 用同一个学习率会抖到学不好——单样本梯度噪声太大,步长必须调小')
print('  换成 0.02 之后它能学,但一轮 450 步的总开销,比小批量贵了二十倍')
print('')
print('  耗时这一列值得盯一下:全批量 300 步 0.1 秒,随机 SGD 135000 步 8 秒')
print('  步数差 450 倍,时间只差 75 倍——向量化确实摊平了一部分,但没摊平全部')
print('  所以真正的做法是「小批量」,不是「纯随机」')

实跑输出:

--------------------------------------------------------------------
随机梯度下降:全批量、小批量、纯随机,三条曲线
--------------------------------------------------------------------
[1] 三种梯度下降:每步用多少样本算梯度
  全批量:450 个样本一起算,梯度最准,但一轮只能走一步
  随机:一次只用一个样本,梯度最糙,但一轮能走 450 步
  小批量:折中,一次 32 个——这就是今天所有框架的默认做法

[2] 同一份数据、同一个初始化、同样的 300 轮
  做法               学习率   300 轮总步数   耗时       最终损失   准确率
  全批量             0.200    300            0.107 秒   0.1431     0.9778
  小批量 32          0.200    4200           0.381 秒   0.0065     1.0000
  随机 SGD           0.200    135000         7.814 秒   0.5884     0.6267
  随机 SGD(小学习率)0.020    135000         7.711 秒   0.0018     1.0000

[3] 损失下降的形状不一样
  轮次     全批量      小批量 32    随机 lr=0.2   随机 lr=0.02
  1        1.3307       1.0796        0.7214         0.5607
  5        0.8881       0.5438        2.3601         0.2502
  10       0.7644       0.3838        2.3379         0.0991
  20       0.6652       0.1527        0.3877         0.0381
  50       0.5376       0.0448        0.5862         0.0133
  100      0.4145       0.0203        0.6188         0.0069
  200      0.2353       0.0098        0.6226         0.0027
  300      0.1431       0.0065        0.5884         0.0018

[4] 四件事要分开看
  全批量一轮只更新一次,300 轮才走 300 步,所以「轮」这个单位对它太粗
  小批量一轮走 14 步,总共 4200 步,损失降得最干净:0.0065
  随机 SGD 用同一个学习率会抖到学不好——单样本梯度噪声太大,步长必须调小
  换成 0.02 之后它能学,但一轮 450 步的总开销,比小批量贵了二十倍

  耗时这一列值得盯一下:全批量 300 步 0.1 秒,随机 SGD 135000 步 8 秒
  步数差 450 倍,时间只差 75 倍——向量化确实摊平了一部分,但没摊平全部
  所以真正的做法是「小批量」,不是「纯随机」

「轮」这个单位对不同批量含义完全不同,这是最容易搞混的地方。 全批量的一轮是一步,纯随机的一轮是 450 步。 所以「跑了 300 轮」这句话本身不含信息,要说清批量。 第二段随机 0.2 那一行的准确率 0.6267 不是「随机 SGD 不行」,是「学习率太大」。 同一个批量换成 0.02 就到 1.0000。 这两个行放一起看,才说明「批量变小,学习率要跟着变小」。 第三段的曲线里,随机 0.2 在第 5 轮涨到 2.3601 是最有信息量的一个数。 噪声大的梯度配上大步长,会让参数跳到一个更差的位置,然后在那里反复横跳。 这不是发散(没到 nan),是不收敛到一个好点。 耗时那两列要一起读:135000 步花了 7.8 秒,平均每步 0.06 毫秒; 300 步花了 0.107 秒,平均每步 0.36 毫秒。 单步开销差 6 倍——这就是批量的价值:把矩阵喂饱。 代码里 step = n if batch is None else batch 这一行决定了每轮走几步。 注意 range(0, n, step) 会丢掉最后不足一批的样本, 这在 n 不能整除时会让每轮漏掉几个点——真实框架里也有这个问题。

30 秒实验

  1. 把 epochs 从 300 改成 30,重跑。全批量会掉到 0.5 以上,小批量还能到 0.9 附近。 轮数少的时候,步数多的做法优势明显。
  2. 把批量的候选从 32 改成 (8, 64, 128),重跑。 会看到一条 U 形曲线:批量太小抖,批量太大一轮步数太少,中间最划算。
  3. 把 train 里每轮的 permutation 去掉,改成按原顺序取批量,重跑。 数据是按类别排好的,每个批量里只有一类,损失会卡在某个值下不去。 这就是「为什么必须打乱」的直接演示。

常见误区

  1. 「随机梯度下降是「随机」的,所以结果不可复现」。 随机性来自批量划分,只要种子固定就完全可复现。 代码里每一步都用同一个 rg,重跑一次结果一个数都不差。
  2. 「全批量梯度最准,所以效果最好」。 实测 300 轮后全批量 0.1431,小批量 0.0065。 梯度准不等于学得好:步数不够,准也没用。
  3. 「批量越小越省时间」。 135000 步花了 7.8 秒,300 步只花 0.107 秒。 单步开销随批量下降,但步数上涨得更快,总时间反而增加。
  4. 「批量大小只是个工程参数,不影响结果」。 它影响梯度的噪声水平,进而影响能用的学习率、收敛的稳定性、最终解的性质。 同一个模型换批量,常常要重新调学习率。
自测三问(点开对答案) 1. 全批量、小批量、纯随机,本质区别是什么? —— 区别只在「用多少样本估这一步的梯度」。全批量用全部,梯度最准但一轮只走一步; 纯随机用一个,每步最便宜但方向最抖;小批量折中。三者算的是同一个梯度的不同估计。 2. 为什么纯随机用同一个学习率反而最差? —— 因为单样本梯度的噪声很大,大步长会让参数在谷底附近反复横跳,甚至跳到更差的位置。 实测学习率 0.2 时第 5 轮损失涨到 2.3601,最终 0.5884。降到 0.02 之后就到了 0.0018。 3. 为什么小批量成了今天的默认做法? —— 它同时拿到「梯度够准」和「更新够频」两个好处,还能喂满矩阵运算和 GPU。 另外梯度的随机性有助于跳出尖锐的局部极小。代价是要多调一个超参数——批量大小。

说到学习率,它已经被提了好几次:太大发散、太小太慢。 到底多大算大、多小算小?有没有办法一次看全? 下一节把学习率从 0.001 扫到 5.0,把每条曲线都打出来。

相关笔记