梯度下降这节是整个《机器学习》系列里我反复回看最多的一节。和前面几篇讲回归、讲误差来源不同这一篇把视角拉到训练的最底层——模型到底是怎么一步步逼近最优解的。李宏毅老师的原课里有一句容易被忽略但分量很重的话深度学习整个训练过程本质上就是在找一个能让损失函数最小的参数组合。飞桨特训营第三篇对应这段内容时把重点落在两个地方一是把梯度下降的数学直觉讲透二是真的打开代码跑起来亲眼看着 loss 一圈一圈往下降。这篇文章就按我自己过这节课的顺序把这个过程完整拆开讲一遍。这篇东西适合两类人一类是刚接触深度学习、对梯度下降只有下山这个概念、想弄清楚参数更新每一步是怎么回事的新手另一类是已经会调库训练模型、但遇到 loss 不降或收敛不稳时不知道怎么定位问题的人。我会把课程里涉及的核心公式、优化器演进逻辑、以及用飞桨手写梯度下降的完整代码都放进来尽量做到看完就能在自己的环境里复现。1. 梯度下降到底在做一件什么事1.1 从损失函数到参数更新先回到底层逻辑。模型训练的所有痛苦都来自一个核心矛盾参数空间太大不可能暴力搜索。一个稍微像样点的神经网络参数量动不动就是几十万上百万靠遍历去找最优参数是天方夜谭。所以祖师爷们才想出了沿着坡度往下滚的思路——既然没法一下子找到最低点那就每一步都往下降最快的方向走总能接近一个不错的落点。这里的坡度就是梯度。数学定义上损失函数 L(θ) 对参数 θ 的梯度是个向量指向的是 L 增大最快的方向。我们要最小化损失就得反着来让参数朝负梯度方向挪动。于是有了最核心的更新公式θ ← θ - η · ∇L(θ)其中 η 是学习率learning rate它决定每一步迈多大。∇L(θ) 就是损失对参数的梯度在反向传播里通过链式法则一层层算出来。这个公式看起来简单到不值一提但整套深度学习的训练技巧几乎都围绕它展开。我在实际跑实验时的感受是如果你只记住一个东西就记这个更新公式如果你只能调一个超参就调学习率。绝大多数训练失败根源都在学习率和梯度之间的关系没处理好。1.2 下山的故事为什么偏要往反方向走李宏毅老师在课里用过一个很直观的比喻一个人站在山腰蒙着眼要往谷底走靠的就是脚底感受哪边更陡然后朝陡的方向迈一步。梯度就是那个陡的方向但梯度方向是上坡方向所以要取负号。这个比喻很形象但它有个容易误导人的地方山是二维地形而参数空间往往是几千维的。在几百维空间里坡度不再是某个单一方向的感觉而是一个维度上各自都有分量。梯度下降做的事情是同时计算每个参数方向上的偏导数然后对每个参数分别更新。课程里特意强调了一点更新参数时梯度本身也是一个向量它的方向和大小会随着位置变化。最开始你离最低点远梯度大步子自然迈得大靠近最低点时梯度变小步子就变小。这个特性天然让梯度下降有了点自适应的味道。但问题在于步子大小还乘以一个固定的学习率 η如果 η 设得不对要么永远走不到底要么一步跨过谷底在两边反复横跳。2. 学习率最容易被忽略的生死线2.1 学习率太小和太大的后果学到这里就绕不开学习率了。课程里用同一个损失函数分别设了三种学习率画出来的 loss 曲线差异极大。我第一次看那个对比图印象很深学习率设 0.01 的时候loss 一路平稳下降最后收敛到很小的值设 0.001 时下降变得很缓慢训练了很久也没到最优位置设 0.1 时 loss 曲线像心电图一样剧烈震荡甚至直接发散成 NaN。现在的实际项目中学习率设太大会怎样不只是 loss 震荡这么简单。在深层网络里学习率太大可能让梯度更新超出正常范围引发的连锁反应包括中间层的激活值饱和、梯度消失或爆炸、某些权重变成 NaN。我踩过最痛的坑是在一个图像分类模型里learning rate 设成 0.1 跑了几百个 steploss 变成 NaN 还不自知白等了半小时。后来才明白batch size 和数据分布都会影响合适的学习率范围最好的做法是先用小学习率比如 0.001跑通再逐步调大。学习率表现排查建议过小收敛缓慢loss 下降像蜗牛看曲线斜率斜率平缓时可尝试增大 10 倍合适loss 平滑下降并稳定收敛保持或用余弦退火逐步降低过大loss 震荡、发散、出现 NaN减小 10 倍或 100 倍重新观察2.2 课程里那组经典对比实验我记得课程里的做法是挑了一个比较简单的损失函数人工设几个不同的学习率然后把训练过程和收敛点画在同一张图上。几种学习率跑出来的路径完全是天壤之别有的平滑抵达最低点有的走之字形绕来绕去有的干脆冲出了图的范围。这种亲眼看到的冲击力比干讲公式大得多。我在自己复现时也照做了一次发现两个细节是课程里不太会专门提的第一同一学习率在不同初始化下效果差别很大和随机种子强相关第二走之字形的那条路径其实是在某些特征方向上更新过度另一些方向上更新不足。后者正是后续自适应学习率算法要解决的核心问题。所以看完这个实验不要只是感慨学习率好重要更重要的是理解一个全局固定的学习率很难适配所有参数的更新需求。特征尺度的差异、梯度量级的差异都要求学习率能随参数自适应变化。2.3 为什么AdaGrad能缓解学习率难题AdaGrad 的思路很朴素每个参数拥有自己的学习率而这个学习率会除以该参数历史梯度的均方根。也就是说梯度大的参数历史累加值大学习率自动变小梯度小的参数学习率相对更大。它实现了陡峭的地方小步走平缓的地方大步走的效果。课程里还给了一个生动例子将参数更新看作一个人在峡谷中行走跨一个方向很陡另一个方向很缓固定学习率会导致人反复撞墙。AdaGrad 相当于给那个陡峭方向穿上模具限制它乱冲效果立竿见影。但 AdaGrad 有个后续问题梯度平方累加后学习率单调递减训练后期步长变得极小几乎动不了。所以后来才有 RMSProp 加入指数衰减平均取代了这种无脑累加。不过你只要记住这一整个系列的自适应方法出发点都是不同参数该有不同的步长。3. 从SGD到Momentum再到Adam3.1 小批量随机梯度下降的代价原始梯度下降每次更新都要在全部样本上计算梯度数据集万级还好百万级就根本算不动。于是工程上普遍使用小批量随机梯度下降Mini-batch SGD每次随机抽一小批样本算梯度近似看作全局梯度。这种方法让每次更新的计算量大幅下降还带了一些随机性。但随机性有好也有坏。课程里专门讲过小批量估算的梯度会有噪声导致更新方向不精准loss 曲线在下降过程中会出现抖动。尤其在局部极小值附近噪声可能让参数眼前看似在下坡但长远看是被困住了。另外一个实际问题是SGD 更新路径对学习率非常敏感在病态曲率比如峡谷地形下收敛极慢。我跑训练的时候经常遇到一种情况loss 从 2.3 降到 1.5 很顺利再往下就像进入粘滞区怎么都不动了。后来一查就是SGD在平坦区域梯度接近零走不动。3.2 动量如何走出局部极小动量Momentum是一个值得反复琢磨的改动。它的更新不再只看当前梯度而是维护一个速度项v ← β·v η·∇L(θ) θ ← θ - v这里 β 通常取 0.9含义是保留 90% 的历史速度只并入 10% 的新梯度信息。我理解它就像滚雪球物体有惯性即使当前梯度为零它也会因为之前的动量继续前冲不会当场停下。这种惯性让它能穿过某些局部极小的浅坑比普通 SGD 有更好的逃逸能力。实际用下来动量还有一个容易被忽视的好处它有效平滑了随机梯度的方向噪声收敛路径稳定不少。早期我在训练小模型时用普通 SGD 的 loss 曲线高低起伏像心电图换成带动量的 SGD 后整个曲线立刻平滑了一个量级。那时候我才真正意识到优化器带来的改进不是玄学而是数学结构上的必然。3.3 Adam为什么成了现在的默认选择Adam 全称 Adaptive Moment Estimation核心是同时维护一阶动量类似 Momentum 的速度和二阶动量类似 RMSProp 的梯度平方平均。前者管方向惯性后者管每维步长放缩。两者结合后Adam 对学习率和初始化不敏感适合在大多数场景下开箱即用。李宏毅老师的课里没有把 Adam 当作重点大讲特讲但飞桨特训营在实操演示里是用 Adam 当默认优化器用的。我的个人使用体验是Adam 确实省心尤其在复现论文或者快速验证想法时先用 Adam 把模型跑通再换成 SGD 调优是很多团队惯用的套路。注意有个细节Adam 对学习率的推荐值通常是 0.001但如果你同时在用 warmup学习率可以从 0 逐渐涨到 0.001训练会更稳。另外如果要追求极限精度一些实证研究反而认为SGD with Momentum 在后期比 Adam 容易泛化得更好这点在课程里虽然没展开但是值得留意。4. 用飞桨手写梯度下降与优化器对比4.1 准备数据和损失函数光讲原理不写代码总觉得隔了一层。飞桨PaddlePaddle的好处是既有封装好的优化器也不阻止你手写梯度计算过程非常适合用来验证刚刚讲的那些思想。我做了一个最小的实验用一条带噪声的直线数据 y 2x 1 noise训练一个单层线性模型去拟合它。首先定义数据和模型import paddle import paddle.nn as nn # 构造数据100 个点符合 y 2x 1 并加一点噪声 x paddle.linspace(-1., 1., 100).reshape([-1, 1]) true_w paddle.to_tensor([[2.0]]) true_b paddle.to_tensor([[1.0]]) noise paddle.normal(0, 0.1, shape[100, 1]) y x true_w true_b noise # 定义单层线性模型等价于 y w*x b model nn.Linear(1, 1)损失函数选择均方误差MSE。它的表达式是 1/n Σ (y_pred - y_true)²对参数的梯度推导很干净适合拿来做手工梯度验证。4.2 手写梯度更新循环下一步就不用飞桨的优化器而是自己写梯度下降循环完整走一遍前面公式里的流程# 手写梯度下降 lr 0.01 epochs 200 # 取出模型参数并创建 requires_gradTrue 的副本实际情况直接使用模型参数 for epoch in range(epochs): # 前向计算 y_pred model(x) loss nn.functional.mse_loss(y_pred, y) # 反向传播梯度会被写入每个参数的 .grad 中 loss.backward() # 手动更新参数 with paddle.no_grad(): for param in model.parameters(): param - lr * param.grad param.clear_grad() if epoch % 20 0: print(fepoch {epoch}, loss: {loss.item():.6f})这段代码最值得看的是loss.backward()后面的内容。飞桨会把每个参数的梯度存到param.grad里你手动做param - lr * param.grad就是教科书里那个更新公式。跑完后把 200 个 epoch 的 loss 打出来曲线会一路向下最后稳定在接近噪声方差的水平。我尝试把 lr 改成 0.002loss 下降明显变慢改成 0.05loss 开始震荡——亲手验证之后才算真正吃了一颗定心丸。注意手动更新时要加paddle.no_grad()否则参数更新操作也会被记录进计算图导致梯度污染和显存泄露。这是手写训练循环时最容易踩的坑。4.3 改用飞桨优化器对比效果飞桨已经内置了常用优化器下面是同一模型在不同优化器下的表现对比import paddle def train_with_optimizer(opt_name, lr0.01, epochs200): model nn.Linear(1, 1) if opt_name sgd: optimizer paddle.optimizer.SGD(learning_ratelr, parametersmodel.parameters()) elif opt_name momentum: optimizer paddle.optimizer.Momentum(learning_ratelr, momentum0.9, parametersmodel.parameters()) elif opt_name adam: optimizer paddle.optimizer.Adam(learning_ratelr, parametersmodel.parameters()) for epoch in range(epochs): y_pred model(x) loss nn.functional.mse_loss(y_pred, y) loss.backward() optimizer.step() optimizer.clear_grad() return loss.item() for name in [sgd, momentum, adam]: final_loss train_with_optimizer(name) print(f{name} final loss: {final_loss:.6f})实测下来学习率设为 0.01 时三种优化器都能收敛把学习率调到 0.1SGD 开始不稳Momentum 勉强可以Adam 仍然稳定。如果把学习率调到 0.01 但把数据改成更崎岖的误差面SGD 和 Momentum 的差距会更明显。这就是课程里那些公式的直观验证。更值得做的一个实验是记录每个 epoch 的 loss 并画在同一张图上观察差异。我的经验是SGD 的曲线像一条颤抖的线滑向低处Momentum 是平滑许多的滑降Adam 则是快速落到底后基本不再有大动作。这种视觉差异比任何文字描述都更有说服力。5. 训练中常见的收敛问题与排查思路5.1 损失不降怎么办真到了实际项目里最让人头大的是模型训练了很多个 epochloss 就是停在原地不动。按我排查的经验应该按顺序检查四件事学习率是否太小先试着增大 10 倍数据是否归一化特征尺度差异过大会让梯度失衡梯度是否消失了打印每个参数的 grad norm模型是否太浅或太小容量不足时 loss 会降到一定值后无法继续下探。课程里专门有个片段提到loss 不下降不一定是优化器的问题也可能是模型一开始就没把特征表达出来梯度反传回去已经衰减到感觉不到这就是梯度消失。排查最简单的方式是在训练循环里加一行打印grad_norm sum([p.grad.norm().item() ** 2 for p in model.parameters()]) ** 0.5 if epoch % 20 0: print(fgrad norm: {grad_norm:.6f})如果 grad norm 已经小到 1e-8 级别可以考虑换初始化方式、加深网络但加残差连接或改用 Adam 这种能为小梯度放大步长的算法。5.2 梯度消失和梯度爆炸的预警梯度爆炸在大模型里更常见尤其在序列模型或者深层网络中。它的表现是 loss 突然变成 NaN或者参数更新出现离谱的跳变。针对这个问题有两个实用技巧相当推荐梯度裁剪gradient clipping和更温和的初始化。飞桨里梯度裁剪可以这样写clip paddle.nn.ClipGradByNorm(clip_norm1.0) optimizer paddle.optimizer.Adam( learning_rate0.001, parametersmodel.parameters(), grad_clipclip, )clip_norm 该怎么定我的一般做法是先跑 100 个 step打印未经裁剪的 grad norm 分布取最大值的 0.1 倍到 1 倍之间作为裁剪阈值。如果裁剪阈值设太大就起不到保护作用设太小则会把有效梯度也给削掉。5.3 局部最小值与鞍点的辨认课程里还重点讲过一个概念在高维空间中找到真正的局部极小值其实比想象中难大多数情况下你遇到的不是低谷而是鞍点。鞍点在某些方向上梯度为零但不上升在另一些方向上还可能下降所以参数会停留在那里很久表现为 loss 长时间卡住不动。怎么判断是不是卡在鞍点一个简单办法是看当前梯度范数是不是接近 0但 Hessian 对角元有正有负。手算 Hessian 不现实实践中我更喜欢换优化器跑一跑。比如用 Adam 时如果还卡住就切到带动量的 SGD或者反过来通常能打破僵局。另一种暴力但有效的方式把学习率先临时调大跑几十个 step再调回原值这相当于用助推跳出鞍点和浅谷。这些经验课程里不一定每条都写明但它们都是基于这个梯度下降原理延伸出来的通用手段。理解了优化过程本身你就有了诊断训练问题的底气。最后再分享一个我自己的习惯。每开一个新模型实验我第一件事不是去调什么结构、加什么正则而是先打一个小模型配上学习率 0.001 和 Adam跑几十个 step检查 loss 能不能往下降、grad norm 是不是在合理范围通常 1e-6 到 1 之间。这个冒烟测试成本极低却能筛掉一大批初始化、数据预处理和网络实现上的低级错误。等这套机制确认没问题了再回头去研究要不要换优化器、要不要调学习率策略。用飞桨做这些实验有个额外好处它的动态图机制和 Python 原生语法很贴近打印梯度、观察中间张量都很顺手非常适合把课程里的原理逐行验证一遍。如果你也在学这一节的梯度下降建议别只看视频打开 Jupyter 亲手跑一遍那种哦原来如此的感觉是看多少遍讲义都换不来的。