资讯详情

资讯详情

随机梯度下降SGD原理详解:从全量梯度下降到深度学习训练核心算法

从第一篇学习笔记到现在我一直在啃神经网络最基础的原理前面的内容记录了反向传播、损失函数、激活函数这些概念。今天这篇笔记要解决一个更加实际的问题当网络层数变深、训练样本变多之后梯度下降到底怎么算才能跑得动。答案是随机梯度下降算法SGD——这是深度学习训练中最核心的算法没有之一。这篇笔记会从全量梯度下降的痛点讲起把SGD的原理拆开揉碎顺带把离线学习和在线学习这对概念理清楚最后附上我自己画的理解图思路和一段最小可运行的演示代码。如果你正在学神经网络原理看到优化器那一栏写着SGD却不太清楚它和普通梯度下降有什么区别或者搞不懂“一个样本更新一次参数”到底靠不靠谱那这篇笔记就是给你准备的。1. 为什么全量梯度下降会被SGD替代1.1 全量梯度下降的计算瓶颈在讲SGD之前得先搞清楚我们到底为什么嫌弃普通的梯度下降。教科书上对梯度下降的描述通常很优雅算出损失函数对所有参数的偏导沿着梯度的反方向更新参数反复迭代直到收敛。公式长这样[ \theta \theta - \eta \nabla_\theta L(\theta) ]看起来没什么问题但这里有一个隐含的前提——这个梯度是在“所有训练样本”上计算的。也就是说每次更新参数之前你必须把整个数据集完整地过一遍网络把所有样本的损失加起来求平均才能得到这一步真正的梯度。问题就在这里。我帮某实验室跑过一个图像分类的实验当时用的数据集有一百多万张图片。如果按全量梯度下降来算每更新一次参数就要把一百多万张图全部前向传播、反向传播一遍。单次迭代跑几分钟是常态而训练完整模型需要上万次迭代。我算过一笔账按这个速度训练一轮要跑将近一个月这还只是单机的情况。除了时间内存也是一个坎。全量梯度下降需要同时保存所有样本的中间计算结果显存稍微小一点直接就爆了。即便能硬塞进去绝大多数计算也都是浪费的因为相邻样本之间的梯度方向高度相似把所有样本的梯度全部算完得到的信息增量并没有想象中那么大。1.2 随机抽样的统计学依据那换个思路行不行既然全部样本的梯度太难算那我每次随机抽一个样本用这一个样本来近似整体梯度这样计算量不就瞬间降下来了直觉上会觉得这不靠谱一个样本能代表整体吗但统计学告诉我们一个很重要的性质无偏估计。如果我们从训练集里均匀随机地抽出一个样本用它算出来的梯度期望值恰好等于全量梯度。用大白话解释就是单次估计可能会有偏差有时偏高有时偏低但平均下来这些偏差会互相抵消从长期看不会朝某个方向跑偏。我用一个生活里的例子类比过这件事。想评估一个大锅菜的口味不需要把每一锅所有菜都尝一遍哪怕只品尝几十口只要随机取样足够分散得到的结果和全部吃完再下结论基本一致。SGD就是在“尝几口”和“下结论”之间做了一个大胆的取舍——每一口可能会有误差但只要一直尝下去整体方向不会错。这份“统计学底气”是整个SGD算法的基石。理解了这一点后面看SGD那种来回震荡却不跑偏的轨迹就不会觉得奇怪了。2. 随机梯度下降算法SGD的核心原理2.1 SGD算法流程拆解SGD的完整流程我按自己最习惯的方式整理成了六步把训练数据随机打乱确保同一顺序的数据不会反复出现。从数据集里随机取一个样本或一小批样本。用当前参数对这个样本做前向传播算出预测值和损失。通过反向传播求出损失对每个参数的梯度。用当前参数减去学习率乘以梯度更新参数。回到第2步继续抽下一个样本直到满足停止条件。这里最关键的更新公式只有一个[ \theta \theta - \eta \nabla_\theta L(\theta; x_i, y_i) ]注意和全量梯度下降的区别式子里的损失L只依赖第i个样本(x_i, y_i)而不是整个数据集。每次更新只用到一条数据计算量降到原来的几百万分之一。参数更新频率也从“每看完一遍数据集更新一次”变成“每看一个样本更新一次”也就是从“跑一步算一步”变成“边跑边算”。我自己画了一个流程的可视化草图这里虽然没法直接贴图但把画图思路分享出来你照着画一遍对理解有奇效先画一个横向的长方形代表数据集在里面标出若干个密集的小点然后画一个箭头指向其中一个点旁边标注“随机抽取”再从这个小点引出两条线一条叫前向传播一条叫反向传播最终汇聚到“参数更新”方框更新完再画一个箭头指回数据集表示继续抽取下一个样本。把四个模块——随机抽取、前向传播、反向传播、参数更新——围成一个环形就能看出SGD是边抽边学、反复循环的过程。2.2 手绘图怎么画出SGD和全量梯度下降的区别标题里写了附手绘图我画的时候选了一个特别直观的角度来表现SGD的本质——把两种算法在损失等高线图上的轨迹画在一起对比会非常强烈。画图步骤分四步。第一步画一个二维坐标系横轴标w1纵轴标w2在图上画出三层椭圆形的等高线中心最低点标注为“最优解”。第二步用一条平滑的深色曲线从等高线外围逐步延伸到中心这条线上每一段都垂直于等高线旁标注“全量梯度下降轨迹”代表它每一步都严格朝最陡方向走。第三步用另一条浅色的折线从相近的起点出发轨迹明显粗糙得多出现小锯齿状的来回震荡每一步都不完全垂直于等高线但总体方向仍然是朝中心逼近旁标注“SGD轨迹”。第四步在空白处画两个小箭头对比更新量一个长箭头标注“全量梯度方向准”一个带锯齿的短箭头标注“单个样本梯度方向略偏但计算快”。这张图画完你会形成一个非常重要的直觉全量梯度下降的路线是平滑笔直的SGD的路线是杂乱但有趋势的。杂乱是因为单样本梯度带噪声趋势是因为噪声的期望为零。这张图最好用纸笔亲手画一遍画的过程中你会自然理解为什么SGD不需要“每一步都走对”。2.3 学习率这个超参数为什么最敏感SGD里最让人头疼也最影响成败的超参数就是学习率η。学习率决定了每次参数更新“迈多大步子”。我踩过最深的坑就是用一个过大的学习率跑训练损失曲线不仅不下降反而一路上扬最后直接变成NaN。当时我还以为是模型结构写错了排查了半天才发现只是学习率设成了0.5。学习率过大时参数更新的步子太大从损失函数的一侧直接跳到另一侧而且越跳越高形成发散。学习率过小时参数每次只挪动一小步训练几百轮下来损失下降幅度很小看起来就像没在训练。我的经验值是这样线性模型和简单网络可以从0.01起步深层网络通常需要更小0.001甚至0.0001如果用了带自适应学习率的优化器变种初始值可以稍微放大一些。判断学习率是否合适我习惯看前几十次更新的损失曲线。如果损失在平稳下降说明学习率适中如果损失忽高忽低震荡明显说明偏大如果损失缓慢蠕动则说明偏小。还有一个从别人那里学来的技巧把学习率设成一个非常大的值跑几个epoch观察损失变化范围再利用这个范围手动估算一个合适的初始值比自己盲猜高效得多。2.4 SGD的噪声为什么是好事单样本带来的梯度噪声看似是个缺点实际上却是SGD的一大优点。深度学习要优化的损失函数通常非常复杂里面布满了局部极小值和鞍点。全量梯度下降严格沿着负梯度方向走遇到鞍点时梯度可能接近于零模型就卡在原地一步也动不了。SGD不一样它每一步都带随机噪声即便当前位置梯度很小噪声的扰动也会把参数“推出”平坦区域。我用一个下山类比记过这件事闭着眼睛下山比睁着眼睛盯着脚下反而更不容易停在半山腰。因为闭眼时步伐会左右摇晃这些摇晃让你不容易落在同一个坑里站住不动。而全量梯度下降就像一个过于谨慎的人每走一步都要精确测量坡度结果在平坦的鞍点处彻底迷失。不过噪声也有个代价模型在收敛阶段会在最优值附近持续震荡无法精确停在最低点。这也是为什么实际训练中往往会在后期调低学习率让噪声幅度逐步减小帮助模型稳稳落在底部。理解了噪声的“两面性”才算真正掌握了SGD的脾气。3. 离线学习与在线学习3.1 离线学习批量学习离线学习在机器学习里还有一个更常见的叫法——批量学习。它的核心特征是模型在训练阶段拿到的是提前收集好的、固定不变的数据集训练流程是把所有数据反复喂给模型直到满足某个收敛条件然后冻结参数进入只做预测的部署阶段。离线学习最大的好处是稳定。训练集是固定的验证集和测试集也可以划分得清清楚楚模型的性能评估结果可靠而且是可复现的。同一个数据集、同样的超参数训练出的模型结果基本一致这对实验对比和问题排查非常友好。工程上大多数深度模型走的都是离线学习流程先收集数据、清洗标注、离线训练、评估上线、定时重训。缺点是它应对变化的成本高。如果业务上线后数据的分布慢慢变了离线训练的模型会越用越不准而修正的办法只有重新收集一批新数据、重新离线训练、再重新发布模型整个周期少则几天多则几周。我在一个推荐场景的项目里就遇到过这种情况产品策略调整导致用户行为分布变化旧模型上线一个月后指标明显下滑当时只能紧急回炉重新离线训练整个团队的交付节奏被拖得很被动。3.2 在线学习增量学习在线学习和离线学习正好相反。它的核心特征是数据流式到达模型每收到一个或一小批新样本就立即更新一次参数训练和预测彼此交叠、没有明确边界。在线学习也被称为增量学习因为它不需要保留全部历史数据只需要用当前到访的样本对现有模型做一次增量式的调整。在线学习最大的优势是及时性强。模型能够跟随最新数据的变化实时调整自己非常适合数据分布频繁变动的场景比如新闻资讯推荐、搜索引擎点击反馈、在线广告竞价这类流量大且偏好时刻演变的环境。此外在线学习不需要一次性准备完整训练集也不要求把所有历史样本都存下来对存储和训练服务器的压力都小得多。但在线学习也有一个固有问题需要处理灾难性遗忘。模型在不断用新样本更新时旧样本的信息会逐步被覆盖导致模型对历史样本的表现越变越差。这就像你只管听今天的话不记得昨天的承诺。缓解手段包括重放部分旧样本、降低新样本的学习率权重、或者用双模型结构来稳定长期记忆这些在工程上都有对应的打法和框架。3.3 两者的对比与应用场景为了把离线学习和在线学习的区别理清楚我列了一张对比表现在几乎所有笔记里都会用到这张表对比维度离线学习在线学习数据使用方式使用完整的固定数据集数据流式到达单样本或小批量即时使用训练与预测关系先训练后预测阶段分离边预测边更新阶段交叠模型更新频率定期批量重训周期较长每个样本或每批数据到达即可更新对数据分布变化的响应滞后需要等待重新训练实时能快速跟随变化评估方式离线划分测试集结果稳定可复现在线指标监控评估更困难典型应用图像分类、语音识别、离线数据分析推荐系统、广告投放、实时风控、舆情监控SGD恰好是天然的在线学习算法因为它每次只需要一个样本就可以完成参数更新无需等待整个数据集准备好。这也是为什么很多流式处理框架里内置的机器学习模型都采用SGD或小批量SGD来实时更新模型参数。实际动手选型时我一般会先回答三个问题数据分布是否稳定、业务对时效性的要求有多高、是否具备定期重新训练的工程条件。分布稳定且重训代价低选离线分布动态变化且响应速度决定业务效果选在线或离线和在线结合的方案。很多大公司实际落地的是两者混合架构先用离线数据训练一个基座模型再通过在线学习对实时反馈做增量微调兼顾稳定和时效。4. SGD的优化变种与工程实践4.1 Mini-batch梯度下降纯SGD每次只用单样本更新参数代价是梯度噪声大、迭代方向抖动严重。全量梯度下降方向准但算不动。那有没有中间的路线有——Mini-batch梯度下降每次随机从数据集中抽出一小批样本比如32个或64个对这一小批样本的梯度取平均后再更新参数。我实际上工程中使用最多的就是Mini-batch形式很多深度学习框架里名为SGD的优化器实际底层算法就是Mini-batch梯度下降。选择batch size的时候过小会导致梯度不稳定训练曲线剧烈抖动过大会让单次迭代变慢而且更接近全量梯度下降泛化能力反而可能下降。我自己的经验值是视觉任务常用64或128小数据集可以用16或32显存允许的情况下从64开始试基本不会出大问题。三类算法的对比也可以做成一张表方便直接对照算法每次使用的数据量更新频率梯度准确性训练稳定性收敛速度全量梯度下降全部样本每个epoch一次精确高慢随机梯度下降SGD单个样本每个样本一次有噪声低快但震荡Mini-batch梯度下降一小批样本每批一次折中较高快且相对平稳Mini-batch能兼顾效率与稳定性的原因很容易解释方差与样本数量成反比。用一小批样本求平均噪声彼此抵消了一部分梯度方向比单样本更可信而计算量又比全量小得多。工程上建议优先用Mini-batch纯SGD更多用于理论教学演示。4.2 动量法与学习率衰减SGD自身有两个比较明显的短板一是梯度噪声大收敛不够平滑二是在峡谷形的损失面上容易来回震荡前进缓慢。一个经典的改进思路是在SGD基础上引入动量Momentum。动量的思想很朴素——记录历史梯度的加权平均用这个累积量替代当前梯度来做更新[ v_t \gamma v_{t-1} \eta \nabla L ] [ \theta \theta - v_t ]动量项γ通常取0.9或0.99。它的物理含义类似于给参数加了一个惯性如果历史梯度的方向一直朝某个方向说明这个方向靠谱更新就快如果当前梯度方向和历史方向相反惯性会削弱这次更新的幅度从而抑制震荡。我体验过最直观的例子是在某个训练任务里纯SGD跑120个epoch才能收敛加上动量后70个epoch就稳定收敛而且损失曲线明显平滑。另一个在工程里几乎必用的技巧是学习率衰减。训练初期SGD需要较大的学习率快速接近最优点附近但越到后期参数越接近最优解固定的大学习率会导致在底部来回震荡无法精细收敛。常见做法是每隔若干个epoch把学习率乘以一个衰减系数比如每次乘以0.1或者采用余弦退火这类平滑衰减的策略。代码里我习惯写一个简单的学习率调度器每个epoch结束后自动调整学习率这个习惯强烈推荐——不衰减学习率的SGD基本只能在玩具模型上跑通。4.3 一个最小可运行的SGD训练代码为了验证上面讲的理论我写过一个十几行的最小SGD训练代码用线性回归来做演示。虽然现在实际项目里基本不会手写这种底层逻辑但把最原始的SGD更新步实现一遍对理解框架文档很有帮助。import numpy as np # 构造线性数据: y 2x 1 x_data np.random.randn(1000) y_data 2 * x_data 1 # 初始化参数 w 0.0 b 0.0 lr 0.05 # 学习率 def sgd_step(w, b, x_i, y_i): # 前向: 计算预测值和损失 y_pred w * x_i b loss (y_pred - y_i) ** 2 # 反向: 推导出的梯度 grad_w 2 * (y_pred - y_i) * x_i grad_b 2 * (y_pred - y_i) # 参数更新 w - lr * grad_w b - lr * grad_b return w, b, loss epochs 30 for epoch in range(epochs): for i in range(len(x_data)): w, b, loss sgd_step(w, b, x_data[i], y_data[i]) print(fepoch {epoch1}: loss {loss:.6f}, w {w:.4f}, b {b:.4f})这段代码演示了SGD最核心的四件事单样本前向传播、算损失、算梯度、更新参数。如果把这个简单流程跑一遍你会看到w从0开始逐步逼近2b从0开始逐步逼近1损失逐步下降。这里有个容易被忽略的细节我在外层循环里又套了一层遍历所有样本的循环这相当于完整地过了30个epoch读者可以把内层改成随机抽取下标来模拟真正的随机采样效果会更贴近SGD的名义定义。5. 常见问题与调试实录5.1 损失函数震荡的排查训练过程中损失曲线上下剧烈跳动是最常见的SGD相关现象。我第一次跑SGD时看到那个锯齿状曲线还以为是代码写错了后来才搞清楚原因。损失震荡一般源于三类问题学习率太大参数更新的步子迈得过大在损失函数两侧来回弹跳。batch size太小梯度噪声幅度过大均值的方差无法有效压低。数据没有打乱连续送入的一批样本存在相关性导致梯度方向出现周期性偏差。排查顺序按性价比来先把学习率降到当前值的三分之一或十分之一观察是否缓解如果不缓解再把batch size翻倍最后检查数据加载部分是否做了shuffle。如果三种手段都无效就要检查损失计算本身以及标签是否对齐这类问题会表现为损失值虽有波动却完全不收敛。5.2 学习率选择的实用方法学习率参数在SGD里的重要性占比很高把经验值背下来远不如掌握一套可操作的选择方法。我目前用得最顺手的流程是这样的先设一个较大的初始值比如0.1跑少量迭代观察损失变化。如果损失快速发散说明太大逐级缩小十倍如果损失下降极其缓慢说明太小逐级放大十倍。找到损失能够明显下降的量级后再在这个量级附近做微调。另一种系统化的方法是学习率范围测试。选取一个从极小到极大的学习率序列在每个学习率下各跑一小段步数把损失随学习率的变化画出来通常会出现一个先升后降再暴增的区间损失下降最陡峭的位置对应着一个较为合适的学习率。这个方法我推荐过好几次确实能省掉不少盲目试参的时间。5.3 数据顺序对训练的影响数据顺序对SGD的影响容易被新手忽视。如果不打乱数据而是按照某种固定的类别顺序排列那么模型参数更新就会产生周期性的偏向。举一个具体场景如果数据集前50%都是类别A后50%都是类别B那么前半个epoch里模型看到的梯度几乎全部朝向让类别A更准的方向后半个epoch又整体偏向了类别B模型权重就会在两类之间反复摇摆训练曲线呈现明显的锯齿。解决方式只有一条每个epoch前对数据集做一次随机洗牌。深度学习框架通常默认开启shuffle选项但自定义数据加载时容易漏掉。还有一个我踩过的细节在分布式多卡训练里如果每张卡的随机种子相同即使打乱了数据各个卡之间仍然会得到相同顺序的样本导致梯度几乎一致这种“假随机”会影响多样性和训练效果。正确做法是让每张卡使用不同的随机种子。我自己学完这一篇最深的体会是SGD的“不精确”听起来像个缺点却是深度学习能够在海量数据上高效训练的关键所在。很多刚接触的人总会纠结每一步梯度算得准不准恨不得每一步都精确再精确但实际训练追求的根本不是单步正确而是整体收敛的趋势。把这一步想通SGD的很多看似不合理的设计就都顺理成章了。最后再分享一个小技巧学这类算法时别只看推导自己动手把轨迹图画一遍再把上面的演示代码跑一遍并改一改学习率感受会比任何长篇解释都来得深刻。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →