第 5 章 · 学习是怎么发生的
怎么变“好”:梯度下降
上一章我们把“错”变成了一个数字——损失。现在的问题非常具体: 该把每个参数往哪个方向、调多大,才能让这个数字变小? 答案是深度学习的发动机:梯度下降(gradient descent)。
读完这一章,你会明白
- “梯度”到底是什么,为什么它指向“上升最快的方向”、更新时又要朝它的反方向走;
- 为什么梯度其实是一个向量——几百万个参数怎么“同时”决定往哪走;
- 参数更新那条核心公式 w ← w − η·梯度;
- 学习率 η 是什么、太大太小分别会怎样;
- 什么是 mini-batch SGD,以及 batch / epoch / step 三个词到底谁管谁;
- batch size 该取多大——“稳”和“快”之间的权衡,并逐行读懂参数更新的真实代码;
- 训练更容易卡在哪里:比起“局部谷底”,高维空间里其实鞍点和大片平原更常见。
1. 把训练想成“蒙着眼下山”
想象损失是一片山地的海拔:你站的位置由所有参数决定,海拔就是当前的损失。 我们想走到谷底(损失最小)。但你蒙着眼,看不到全局地形,只能感受 脚下哪个方向最陡,然后朝下坡迈一步;到了新位置再感受、再迈一步…… 反复下去,通常就能走到一个谷底。
每一步都朝“最陡下坡”方向走一小步,一步步逼近损失的谷底。
2. “梯度”就是最陡的方向
“脚下最陡的方向”,在数学里有个名字叫梯度(gradient)。 它其实就是损失对每个参数的导数:导数告诉你“这个参数动一点点,损失会朝哪边变、变多快”。
- 导数为正:增大这个参数,损失会上升 → 所以我们要减小它;
- 导数为负:增大这个参数,损失会下降 → 所以我们要增大它。
两种情况合起来,规律惊人地一致:朝“梯度的反方向”走,损失就会下降。 这就是“梯度下降”名字的由来。写成公式就是那条你以后会见无数次的更新规则:
梯度是一个“向量”:几百万个旋钮一起转
上面为了好懂,只画了一个参数的山谷。但真实网络有成千上万、乃至上亿个参数, 它们一起决定了你此刻站在哪、脚下海拔(损失)多高。这时“最陡的方向”就不再是一根数轴上的 左还是右,而是高维空间里的一个方向——朝这个方向,损失上升得最快。
怎么找到它?做法其实没变:对每一个参数,都单独算一个“它动一点点、损失会变多少”的 导数(叫偏导数,因为只看这一个、把别的暂时按住不动); 再把这些偏导数按参数顺序排成一串,就得到一个和参数一样长的向量——这才是 梯度(gradient)的全貌。它的每个分量各管一个参数“该往哪调”,合在一起,恰好指向让损失 上升最快的那个方向(这正是第 1 章“梯度和参数同形状”那句话的意思)。
俯视两个参数张成的平面:一圈“等高线”就是损失相同的一条线,越往里损失越低。两个偏导 ∂L/∂w₁、∂L/∂w₂ 是梯度的两个分量,合成的粉色向量 g 指向上坡;朝它反方向迈 η 那么大一步(绿色),就下坡了。参数一多,这张图只是从 2 维变成上百万维,道理一模一样。
所以那条更新公式 w ← w − η·梯度,其实是对整串参数同时做的: 每个参数各自朝自己那个分量的反方向挪一点点,合起来,就是整个网络朝“损失下降最快”的方向迈了一步。 小结里那句“对每一个参数都做一遍”,做的就是这件事。
3. 学习率 η:每一步迈多大
公式里的 η(读作 eta)就是学习率(learning rate), 它控制每一步的步子大小。这是整个训练里最关键、也最需要调的一个旋钮:
- 太小:每步挪一点点,要走非常久才到谷底,训练慢得让人崩溃;
- 太大:一步迈过头,直接越过谷底冲到对面更高的地方,损失反而来回震荡甚至发散;
- 刚好:稳步下降,又快又稳。
下一章的“前向 / 反向传播动画”里有一个 Learning Rate 滑块, 你可以拖动它,观察同样的梯度下,参数每一步被改动的幅度如何随学习率变化。 到第 9 章我们还会让学习率在训练过程中动态变化(学习率调度)。
4. 不必每步都看完所有数据:mini-batch SGD
严格说,要算“真正的梯度”,得把所有训练样本都过一遍。但数据量动辄百万, 每走一步都全看一遍太慢了。实践中我们用一个聪明的折中:每次只随机抓一小批(mini-batch) 样本,用这一小批估算梯度,就更新一次。这叫小批量随机梯度下降(mini-batch SGD)。
第 4 章讲的是:一条样本有多个输出维度时,loss 怎么压成一个标量。 这里讲的是:一次参数更新要抽多少条样本来估计梯度。前者是损失的形状,后者是训练的取样策略。
看看训练主循环是怎么做的(精简自 Train):
// 每轮开始前打乱样本顺序
std::shuffle(index_pos.begin(), index_pos.end(), shuffle_gen); 1
// 取出一小批样本, 走一遍 "前向 → 反向 → 更新"
ForwardPropagationBatch(batch_data); 2
ResetGradients();
BackPropagationBatch(batch_target); 3
ApplyGradient(B); 4
一批里每个样本都会各自贡献一份梯度,代码把它们累加后再除以 B(见后面 ApplyGradient 里的 1/B)。
为什么要除?因为这样一来,梯度的大小就和“这批抓了多少个样本”无关了——batch 取 32 还是 256,得到的都是“平均每个样本希望参数往哪挪”,
步子大小只由学习率 η 说了算。要是只求和不平均,batch 越大梯度越大,等于偷偷放大了步长。
(正因如此,后面第 6 节才敢说“batch 翻倍、学习率大致也翻倍”。)
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 等技巧,能帮我们更稳地走下去。
“局部谷底”指的是:站在它附近往左往右都更高,所以梯度会接近 0、训练容易停住;但从全局看,旁边可能还有更深的谷。随机 mini-batch 的噪声、Momentum/Adam 等优化器技巧,有时能帮模型越过较差的局部谷底。
动手玩:拖动学习率 η,点“走一步 / 自动跑”,看小球怎么滑向谷底。把 η 调到很大(如 > 10),它会反复横跳直到发散——这就是“学习率太大”的样子。
8. 比“局部谷底”更常拦路的:鞍点和大片平原
第 2、3 节那张图是一个参数的直觉,很容易让人以为“训练卡住 = 掉进了某个局部谷底”。但在几百万维的真实空间里, 研究发现真正常见的“梯度接近 0、走不动”的地方,往往不是局部谷底,而是另外两种地形:
- 鞍点(saddle point):同一个点,有的方向是下坡、有的方向是上坡——就像一副马鞍(你坐上去:左右两侧往下垂,前后两头往上翘)。这里梯度也几乎为 0,但它根本不是谷底,只要挑对方向仍能继续往下走。维度越高,“所有方向都朝上”(真局部谷底)越罕见,“有上有下”(鞍点)越普遍。
- 平坦区 / 高原(plateau):大片近乎水平的区域,坡度极小,梯度小得可怜。参数不是不能动,而是每步都挪得微乎其微,训练像陷进泥潭、半天不见损失下降。
鞍点为什么“骗人”?关键在于:同一个点,从不同方向看,坡完全不一样。下面把马鞍切两刀看剖面—— 一刀沿“上坡方向”,一刀沿“下坡方向”(都是指从鞍点出发往外走会升还是会降),你就明白为什么它梯度为 0 却不是谷底:
同一个黄点(鞍点),换个方向切开,面貌完全相反:左图那个方向上它是谷底(往两边走都升,所以叫“上坡方向”);右图那个方向上它却是山脊顶(往两边走都降,所以叫“下坡方向”)。正因为“有的方向还能往下走”,它虽然梯度≈0,却不是真正的谷底——只要沿右图那种方向迈出去,损失还能继续降。
好在这些“卡住”都有办法:mini-batch 带来的随机噪声能推着参数从鞍点上“滑”下去; 第 8 章的动量(Momentum)给下山加惯性,能冲过平坦区和鞍点; Adam 还会给每个参数配自适应步长,在坡度很小的方向也敢多挪一点。所以“卡住”并不可怕,它正是第 8 章那些优化器要解决的问题。
小结
- 训练就像蒙眼下山:每步朝“最陡下坡”走一小步,逼近损失谷底。
- 梯度 = 损失对参数的导数,指向“上升最快”的方向;朝它的反方向走,损失下降。
- 核心更新规则:w ← w − η·梯度,对每个参数都做一遍。
- 学习率 η 是步长:太小慢、太大震荡发散、刚好又快又稳。
- 梯度其实是一个向量:把损失对每个参数的偏导排成一串,合起来指向“上升最快”的方向;更新就是让每个参数各自朝自己那个分量的反方向挪一点。
- 实践用 mini-batch SGD:每次随机抓一小批样本估算梯度并更新一次;梯度要除以 batch 大小取平均,步长才只由学习率决定。
- batch 是一口吃多少、step 是更新几次、epoch 是全套数据刷几遍;三者用“样本数 ÷ batch”串起来。
- batch size 是“稳(大)vs 快而抖(小)”的权衡,常取 32/64/128;它一变,学习率通常要跟着线性缩放。
- 训练卡住多半不是掉进“局部谷底”,而是遇到鞍点或大片平原;mini-batch 噪声和第 8 章的动量 / Adam 能帮着走出来。
动手与思考
问题 1:某个参数的梯度是 +3,学习率 0.1,这个参数应该怎么变?
按 w ← w − η·梯度 = w − 0.1×3 = w − 0.3,也就是减小 0.3。因为梯度为正说明增大它会让损失上升,所以要往反方向减。
问题 2:训练时损失剧烈上下震荡、甚至变成 NaN,最可能是哪个旋钮出了问题?
学习率太大。步子迈得太猛,一次次越过谷底冲到更高处,导致损失不降反增甚至发散。先把学习率调小试试。
问题 3:为什么用“一小批”样本而不是每次都用全部数据?
全量数据每走一步都要算很久,太慢。小批量用少量样本就能得到对梯度的合理估计,更新更频繁、训练更快,还能借助随机性帮助跳出一些差的局部谷底。
问题 4:一个有一百万个参数的网络,它某一步的“梯度”是一个数,还是一串数?为什么?
是一串数(一个向量),而且正好有一百万个分量。因为梯度要告诉每一个参数“该往哪个方向、挪多少”,所以它对每个参数各存一个偏导数,长度和参数个数相同。这一整串合起来,才指向让损失上升最快的方向;更新时对每个分量取反方向走一小步。
我们一直说“反向传播会算出梯度”,但它到底怎么算?下一章就来揭开这个让无数初学者头疼、 其实只是“链式法则 + 一点耐心”的过程。