第 5 章 · 学习是怎么发生的
怎么变“好”:梯度下降
上一章我们把“错”变成了一个数字——损失。现在的问题非常具体: 该把每个参数往哪个方向、调多大,才能让这个数字变小? 答案是深度学习的发动机:梯度下降(gradient descent)。
读完这一章,你会明白
- “梯度”到底是什么,为什么它指向“下降最快的方向”;
- 参数更新那条核心公式 w ← w − η·梯度;
- 学习率 η 是什么、太大太小分别会怎样;
- 什么是 mini-batch SGD,以及 batch / epoch / step 三个词到底谁管谁;
- batch size 该取多大——“稳”和“快”之间的权衡,并逐行读懂参数更新的真实代码。
1. 把训练想成“蒙着眼下山”
想象损失是一片山地的海拔:你站的位置由所有参数决定,海拔就是当前的损失。 我们想走到谷底(损失最小)。但你蒙着眼,看不到全局地形,只能感受 脚下哪个方向最陡,然后朝下坡迈一步;到了新位置再感受、再迈一步…… 反复下去,通常就能走到一个谷底。
每一步都朝“最陡下坡”方向走一小步,一步步逼近损失的谷底。
2. “梯度”就是最陡的方向
“脚下最陡的方向”,在数学里有个名字叫梯度(gradient)。 它其实就是损失对每个参数的导数:导数告诉你“这个参数动一点点,损失会朝哪边变、变多快”。
- 导数为正:增大这个参数,损失会上升 → 所以我们要减小它;
- 导数为负:增大这个参数,损失会下降 → 所以我们要增大它。
两种情况合起来,规律惊人地一致:朝“梯度的反方向”走,损失就会下降。 这就是“梯度下降”名字的由来。写成公式就是那条你以后会见无数次的更新规则:
3. 学习率 η:每一步迈多大
公式里的 η(读作 eta)就是学习率(learning rate), 它控制每一步的步子大小。这是整个训练里最关键、也最需要调的一个旋钮:
- 太小:每步挪一点点,要走非常久才到谷底,训练慢得让人崩溃;
- 太大:一步迈过头,直接越过谷底冲到对面更高的地方,损失反而来回震荡甚至发散;
- 刚好:稳步下降,又快又稳。
下一章的“前向 / 反向传播动画”里有一个 Learning Rate 滑块, 你可以拖动它,观察同样的梯度下,参数每一步被改动的幅度如何随学习率变化。 到第 9 章我们还会让学习率在训练过程中动态变化(学习率调度)。
4. 不必每次都看完所有数据:mini-batch SGD
严格说,要算“真正的梯度”,得把所有训练样本都过一遍。但数据量动辄百万, 每走一步都全看一遍太慢了。实践中我们用一个聪明的折中:每次只随机抓一小批(mini-batch) 样本,用这一小批估算梯度,就更新一次。这叫小批量随机梯度下降(mini-batch SGD)。
看看训练主循环是怎么做的(精简自 Train):
// 每轮开始前打乱样本顺序
std::shuffle(index_pos.begin(), index_pos.end(), shuffle_gen); 1
// 取出一小批样本, 走一遍 "前向 → 反向 → 更新"
ForwardPropagationBatch(batch_data); 2
ResetGradients();
BackPropagationBatch(batch_target); 3
ApplyGradient(B); 4
5. batch、epoch、step:三个最容易搞混的词
一上手训练,你就会被这三个词轰炸,它们其实各管一件事,分清楚就不慌了:
| 词 | 是什么 | 一句话记住 |
|---|---|---|
| batch size | 一次拿多少个样本来估梯度、更新一次参数 | “一口吃多少” |
| step / iteration | 参数被更新了一次,就是一步 | “更新了几次” |
| epoch | 把整个训练集完整过一遍 | “全套题刷了几遍” |
三者的关系可以用一个除法串起来。假设有 10000 个训练样本、batch size 取 100:
所以“训练 5 个 epoch”和“训练 500 步”在这个设定下是同一件事,只是一个按“刷了几遍数据”数、一个按“更新了几次”数。 学习率调度通常就是按 step 来推进的。
6. batch size 该取多大:一个现实的权衡
既然一次可以吃 1 个,也可以吃几千个,那到底取多大?这又是一个没有免费午餐的权衡:
- 太小(如 1,纯 SGD):每步只看一个样本,梯度估计噪声很大,更新方向抖来抖去;但更新非常频繁,而且这种噪声有时反而有益——能帮忙跳出差的局部谷底。
- 太大(如几千):梯度估得很准、很稳,还能喂饱 GPU 的并行算力、跑得快;但每步都要算很多样本,更新次数变少,而且太稳有时反而更容易卡在平坦的次优区,泛化还可能变差。
- 常用区间:实践中常取 32 / 64 / 128 / 256 这类 2 的幂(对硬件友好),在“稳”和“快”之间找平衡。本仓库的 MNIST 例子默认 batch=64。
batch 越大,梯度越稳,就扛得住更大的学习率。一条常见的经验法则是“线性缩放”: batch 翻倍,学习率也大致翻倍。所以调参时别把这两个旋钮完全分开看——它们是一对的。
7. 逐行读懂“参数更新”
真正执行 w ← w − η·梯度 的,是优化器。最朴素的 SGD 优化器只有几行:
double g = delta; 1
if (weight_pos != -1 && weight_decay_ != 0.0)
g += weight_decay_ * param_value; 2
return learning_rate * g; 3
delta就是这个参数的梯度(由反向传播算出)。- (可选)weight decay / L2 正则:把权重稍微往 0 拉一点,抑制过拟合;注意只对权重做,偏置不动(
weight_pos != -1才进来)。第 10 章细讲。 - 返回“这一步要改变的量” = 学习率 × 梯度。这正是更新公式里的 η·梯度。
而把这个改变量真正从参数里减掉的,是 ApplyGradient:
optimizer_function_->BeforeStep(); 1
double avg_dw = g_row[i] * inv_bs; 2
double w_change = optimizer_function_->CalcChangeValue(
avg_dw, learning_rate_, {l, o}, i, w_row[i]);
w_row[i] -= w_change; 3
- 先通知优化器“新的一步开始了”。对 SGD 这是空操作;但对 Adam 这类优化器,它会在这里推进内部的计步器(第 8 章)。
- 把累加的梯度乘以
1/B,得到这一批的平均梯度。 - 算出改变量,再从参数里减掉它——这一行,就是 w ← w − η·梯度 的代码版本。
真实的损失地形坑坑洼洼,梯度下降可能停在某个“局部谷底”而不是最深的那个。 但好消息是:在深度学习里,绝大多数局部谷底的效果都已经足够好, 而且后面第 8 章的动量、Adam 等技巧,能帮我们更稳地走下去。
动手玩:拖动学习率 η,点“走一步 / 自动跑”,看小球怎么滑向谷底。把 η 调到很大(如 > 10),它会反复横跳直到发散——这就是“学习率太大”的样子。
小结
- 训练就像蒙眼下山:每步朝“最陡下坡”走一小步,逼近损失谷底。
- 梯度 = 损失对参数的导数,指向“上升最快”的方向;朝它的反方向走,损失下降。
- 核心更新规则:w ← w − η·梯度,对每个参数都做一遍。
- 学习率 η 是步长:太小慢、太大震荡发散、刚好又快又稳。
- 实践用 mini-batch SGD:每次随机抓一小批样本估算梯度并更新一次。
- batch 是一口吃多少、step 是更新几次、epoch 是全套数据刷几遍;三者用“样本数 ÷ batch”串起来。
- batch size 是“稳(大)vs 快而抖(小)”的权衡,常取 32/64/128;它一变,学习率通常要跟着线性缩放。
动手与思考
问题 1:某个参数的梯度是 +3,学习率 0.1,这个参数应该怎么变?
按 w ← w − η·梯度 = w − 0.1×3 = w − 0.3,也就是减小 0.3。因为梯度为正说明增大它会让损失上升,所以要往反方向减。
问题 2:训练时损失剧烈上下震荡、甚至变成 NaN,最可能是哪个旋钮出了问题?
学习率太大。步子迈得太猛,一次次越过谷底冲到更高处,导致损失不降反增甚至发散。先把学习率调小试试。
问题 3:为什么用“一小批”样本而不是每次都用全部数据?
全量数据每走一步都要算很久,太慢。小批量用少量样本就能得到对梯度的合理估计,更新更频繁、训练更快,还能借助随机性帮助跳出一些差的局部谷底。
我们一直说“反向传播会算出梯度”,但它到底怎么算?下一章就来揭开这个让无数初学者头疼、 其实只是“链式法则 + 一点耐心”的过程。