同一个网络、同一个初始化,只改学习率:从 0.001 扫到 5.0。 0.2 到 1.0 之间都能到 1.0000,0.001 跑 300 轮只有 0.6400。 2.0 开始崩:最终损失 1.5907、准确率 0.3333,等于三类瞎猜。 5.0 更直接——第一轮损失就从 1.10 跳到 17.2544。 这不是过拟合,是发散:一步跨得太远,落到了曲面上更差的地方。
上一节已经露出苗头:批量变小、学习率不变,纯随机就学不动了。 这一节专门把学习率这一个旋钮扫一遍。 学习率是深度学习里最常被调、也最容易调错的超参数。 它只做一件事:决定沿着负梯度走多远。 但就是这一件事,能让同一个模型从 1.0000 掉到瞎猜。
一、它只干一件事
更新式是 w 等于 w 减去 lr 乘 梯度。 梯度给方向,学习率给步长。 方向对了,步长不对,照样到不了。 三种典型症状:
| 学习率 | 症状 | 损失曲线的样子 | |---|---|---|---| | 太小 | 方向对但走得太慢 | 平滑下降,300 轮还没到底 | | 合适 | 稳定收敛 | 快速下降然后贴着低处走 | | 太大 | 在谷底附近横跳 | 下降一点又弹回去,来回震荡 | | 太大太大 | 发散 | 一步跳到很大的值,之后回不来 |
二、扫一遍看全貌
三条螺旋 450 个点,两层网络,小批量 32,300 轮:
| 学习率 | 第 1 轮 | 第 10 轮 | 第 100 轮 | 最终损失 | 准确率 |
|---|---|---|---|---|---|
| 0.001 | 1.7251 | 1.4451 | 0.8170 | 0.6499 | 0.6400 |
| 0.010 | 1.4190 | 0.8132 | 0.4729 | 0.2085 | 0.9711 |
| 0.050 | 1.0037 | 0.5887 | 0.1074 | 0.0278 | 0.9978 |
| 0.200 | 1.0796 | 0.3838 | 0.0203 | 0.0065 | 1.0000 |
| 0.500 | 0.9749 | 0.1997 | 0.0115 | 0.0026 | 1.0000 |
| 1.000 | 0.7005 | 0.2543 | 0.0153 | 0.0041 | 1.0000 |
| 2.000 | 0.6165 | 0.7626 | 1.0467 | 1.5907 | 0.3333 |
| 5.000 | 17.2544 | 1.5163 | 1.5293 | 3.4519 | 0.3333 |
| 这张表有四段。 | |||||
| 0.001 到 0.010:明显在学,但 300 轮不够。 | |||||
| 0.05 到 1.00:都能到 1.0000,其中 0.5 的最终损失最低(0.0026)。 | |||||
| 2.00:彻底崩掉,最终 1.5907,比「三类平均输出」的 1.0986 还差。 | |||||
| 5.00:第一轮就是 17.2544,一步跳飞。 |
三、学习率太小,是慢不是错
把 0.001 那一行多给 10 倍轮数: 跑 3000 轮之后,损失 0.207297、准确率 0.9711。 比 0.2 跑 300 轮的 0.0065 还差一截,但方向是对的,继续跑会到。 这个实验的意义在于区分「慢」和「错」。 损失在平滑下降、准确率在涨,说明梯度方向没错,只是步长小。 这种情况加轮数、加学习率都能解决。 但如果损失曲线一直是平的,那不是慢,是根本学不动—— 可能是梯度消失、可能是反向传播写错了、可能是数据没归一化。
四、学习率太大,是错不是慢
5.0 那一行:第一轮损失 17.2544,之后在 1.5 到 6.5 之间乱撞,最终停在 3.4519。 准确率 0.3333,正好是三类瞎猜。 为什么会停在瞎猜? 步长太大,参数被推到很远的地方,那里所有输出的差距都被压平了,softmax 之后接近均匀分布。 均匀分布的损失正好是 ln(3) 等于 1.0986——而它比这个还差,说明输出不仅均匀, 还有点偏向错误的类别。 这时候加轮数没用。 参数在那个位置附近震荡,梯度方向一直在变,永远走不到低处。 唯一的办法是减小学习率,或者加学习率衰减。
五、怎么选
第一步:从 0.1 开始试。 这是大多数网络能接受的量级,跑几十轮看损失有没有明显下降。 第二步:按 10 倍往下和往上各试一档。 0.01 太慢、1.0 还能跑,说明最优在 0.1 附近;如果 1.0 已经崩了,就往下试 0.03、0.05。 第三步:看损失曲线而不只看最终值。 平滑下降但没到底,是学习率偏小;上下震荡,是偏大; 一直平,是模型或数据的问题,不是学习率的问题。 第四步:考虑「先大后小」。 前期用大步长快速接近,后期用小步长精调。 这是今天所有学习率调度策略(阶梯衰减、余弦退火)的共同思路。
六、动手 Demo:从 0.001 扫到 5.0,看它怎么炸
这段代码在同一份数据、同一个初始化下,把学习率从 0.001 扫到 5.0,记录每一轮之后的损失; 再把 0.001 多跑 10 倍轮数,最后打印 5.0 那组的前五轮。 重点看三处。第二段八行的最终损失和准确率;第三段 0.001 跑 3000 轮之后的 0.207297; 第四段 5.0 的第一轮 17.2544。
# -*- coding: utf-8 -*-
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 run(lr, epochs=300):
p = init([2, 8, 3], seed=1)
rg = np.random.default_rng(0)
hist = [loss(p, X, T)]
for _ in range(epochs):
idx = rg.permutation(len(X))
for s in range(0, len(X), 32):
j = idx[s:s + 32]
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]
v = loss(p, X, T)
hist.append(v if np.isfinite(v) else float('nan'))
if not np.isfinite(v):
break
A, _ = fwd(p, X)
a = float(np.mean(np.argmax(sm(A[-1]), axis=1) == y))
return hist, a
print('[1] 学习率只干一件事:决定沿着负梯度走多远')
print(' w <- w - lr * dE/dw')
print(' 太小:方向对但走得慢,300 轮跑不到底')
print(' 太大:一步跨过谷底,损失开始反弹,再大就飞出去变成 nan')
print('')
print('[2] 同一个网络、同一个初始化,只改学习率')
print(' 学习率 第 1 轮 第 10 轮 第 100 轮 最终损失 准确率')
for lr in (0.001, 0.01, 0.05, 0.2, 0.5, 1.0, 2.0, 5.0):
hist, a = run(lr)
def get(i):
return hist[i] if i < len(hist) else float('nan')
print(' ' + pad('%.3f' % lr, 10) + pad('%.4f' % get(1), 12)
+ pad('%.4f' % get(10), 12) + pad('%.4f' % get(100), 12)
+ pad('%.4f' % hist[-1], 12) + '%.4f' % a)
print('')
print('[3] 把 0.001 那一行多给点轮数,看它是不是「慢」而不是「不行」')
h2, a2 = run(0.001, epochs=3000)
print(' 学习率 0.001,跑 3000 轮:损失 %.6f,准确率 %.4f' % (h2[-1], a2))
print(' 比 0.2 跑 300 轮的 0.0065 还差一截,但方向是对的——继续跑会到')
print(' 小学习率的问题是「要多少轮」,不是「能不能」')
print('')
print('[4] 学习率太大时损失怎么炸的')
h3, a3 = run(5.0)
head = ' '.join('%.3f' % v for v in h3[1:6])
print(' 学习率 5.0,前 5 轮的损失:%s' % head)
print(' 第一轮直接从 1.10 跳到 17.25,之后在几个数之间乱撞,最终停在 %.4f' % h3[-1])
print(' 准确率 %.4f,等于三类瞎猜——网络被推到了所有输出都差不多的位置' % a3)
print(' 这不是过拟合,是发散:一步跨得太远,落到了曲面上更差的地方')
print('')
print('[5] 三条实践结论')
print(' 学习率和「损失曲面的陡峭程度」要匹配,所以换网络结构就要重调')
print(' 常用的起步值是 0.1 到 0.001,然后按 10 倍或 3 倍往下试')
print(' 今天更常见的做法是「先大后小」:前期大步快走,后期小步收敛')
实跑输出:
--------------------------------------------------------------------
学习率:从 0.001 扫到 5.0,看它怎么炸
--------------------------------------------------------------------
[1] 学习率只干一件事:决定沿着负梯度走多远
w <- w - lr * dE/dw
太小:方向对但走得慢,300 轮跑不到底
太大:一步跨过谷底,损失开始反弹,再大就飞出去变成 nan
[2] 同一个网络、同一个初始化,只改学习率
学习率 第 1 轮 第 10 轮 第 100 轮 最终损失 准确率
0.001 1.7251 1.4451 0.8170 0.6499 0.6400
0.010 1.4190 0.8132 0.4729 0.2085 0.9711
0.050 1.0037 0.5887 0.1074 0.0278 0.9978
0.200 1.0796 0.3838 0.0203 0.0065 1.0000
0.500 0.9749 0.1997 0.0115 0.0026 1.0000
1.000 0.7005 0.2543 0.0153 0.0041 1.0000
2.000 0.6165 0.7626 1.0467 1.5907 0.3333
5.000 17.2544 1.5163 1.5293 3.4519 0.3333
[3] 把 0.001 那一行多给点轮数,看它是不是「慢」而不是「不行」
学习率 0.001,跑 3000 轮:损失 0.207297,准确率 0.9711
比 0.2 跑 300 轮的 0.0065 还差一截,但方向是对的——继续跑会到
小学习率的问题是「要多少轮」,不是「能不能」
[4] 学习率太大时损失怎么炸的
学习率 5.0,前 5 轮的损失:17.254 6.481 1.468 3.278 1.530
第一轮直接从 1.10 跳到 17.25,之后在几个数之间乱撞,最终停在 3.4519
准确率 0.3333,等于三类瞎猜——网络被推到了所有输出都差不多的位置
这不是过拟合,是发散:一步跨得太远,落到了曲面上更差的地方
[5] 三条实践结论
学习率和「损失曲面的陡峭程度」要匹配,所以换网络结构就要重调
常用的起步值是 0.1 到 0.001,然后按 10 倍或 3 倍往下试
今天更常见的做法是「先大后小」:前期大步快走,后期小步收敛
这张表最该记住的不是「0.2 最好」,而是「0.05 到 1.0 一整段都能用」。
学习率不是精确值,是一个区间。
在这段区间里结果差别不大,说明这个任务对这个旋钮不敏感——换个更深的网络就没这么宽容了。
2.0 那一行的最终损失 1.5907 比 ln(3) 还大,这个细节值得停一下。
不是「学得不好」,是「学坏了」——网络的输出不仅不平均,还有系统性偏差。
准确率 0.3333 是瞎猜,但它不是均匀地瞎猜。
第三段那个实验是关键的一步:它把「慢」和「错」分开了。
0.001 跑 300 轮损失 0.6499,看上去像是「学不动」;
给 10 倍轮数之后 0.207297,证明它只是在走路,没走错路。
第四段第一轮 17.2544 这个数比后面几轮都大,说明它先跳出去再被拉回来一点。
一个正常的训练里,第一轮的损失应该在 1 到 2 之间(随机初始化时的 ln(类别数))。
17 说明那一步直接把参数推到了极端位置。
代码里 run 在每一步都记录损失,而且遇到非有限值就停下来。
这个 np.isfinite 检查在扫学习率时必须有——再大一点的学习率会产生 nan,
循环会一直跑下去但结果是垃圾。
30 秒实验
- 把学习率的候选改成 0.02、0.03、0.07、0.15,重跑。 会看到 0.05 到 0.2 之间的差别很小,说明这段就是「安全区」。
- 把
epochs从 300 改成 30,重跑。 最优学习率会往大移——轮数少了,需要更大的步长才能在限定步数内到位。 这说明「学习率最优值」和「训练多久」是绑定在一起的。 - 把学习率设成 0.5,但每 100 轮乘 0.3,重跑。最终损失会比固定 0.5 更低。 这就是最朴素的「学习率衰减」,也是所有调度策略的原型。
常见误区
- 「学习率是最重要的超参数」。 它很重要,但排在前面的还有网络结构、数据质量、损失函数。 这一节也看到:0.05 到 1.0 一整段都能到 1.0000,说明它没那么脆弱。
- 「损失不降就调学习率」。 先看曲线:平滑但不降,可能是梯度消失或代码写错;上下震荡,才是学习率大。 乱调学习率会掩盖真正的 bug。
- 「学习率越小越稳」。 0.001 跑 300 轮只有 0.6400。 小学习率不是稳,是慢——在限定轮数里根本到不了。
- 「学习率太大就是发散,会变成 nan」。 不一定。2.0 和 5.0 都没有变成 nan,它们停在了一个很差的解上。 这种「不 nan 但很烂」的情况反而更难发现。
自测三问(点开对答案)
1. 学习率太小和太大,损失曲线的形状有什么不同? —— 太小时平滑单调下降,但下降很慢,限定轮数内到不了底。 太大时下降一点就弹回去,来回震荡,甚至一步跳到很大的值。 一个是「慢」,一个是「错」,处理方式完全不同。 2. 怎么判断「学不动」是学习率的问题还是别的问题? —— 给 10 倍轮数再跑一次。如果损失继续明显下降、准确率继续涨,就是学习率偏小; 如果曲线一直平,那就不是学习率,可能是梯度消失、反向传播写错、或数据没归一化。 实测 0.001 跑 3000 轮损失从 0.6499 降到 0.207297。 3. 为什么说学习率最优值和训练轮数有关? —— 因为总步数是「轮数乘每轮步数」,步长和步数要匹配。 轮数少的时候需要更大的步长才能在限定步数内走到低处,轮数多就可以用小步长精调。 所以调学习率时要固定训练预算。第 2 章的零件到这里就齐了:神经元、感知器、多层、反向传播、损失、似然、随机梯度、学习率。 最后一节把它们装进同一个网络,做一张八组配置的对照表,看谁的贡献最大。
- ☐ (本节完,下一节 2.10 小结)