深度学习课程系列写到这里,已经是第8篇。如果你是从第一篇一路跟过来的,应该已经熟悉了神经网络的前向传播、反向传播、激活函数、深层网络这些基础模块。这一篇我想专门聊聊让模型真正“好用”的两个关键环节:正则化和优化算法。这两个话题在课程里是放在“改善深度神经网络”这个章节讲的,但说实话,它们的重要性远不止一个章节——这是决定你的模型是“过拟合的玩具”还是“能上线的工具”的分水岭。
我把这一篇定位成:在已有神经网络基本功的前提下,把“训练过程”本身吃透。也就是说,前面我们关心的是网络结构怎么搭,这一篇关心的是怎么把网络训练得又快又稳又不“死记硬背”。适合的读者是那种已经能跑通一个简单网络,但是发现验证集准确率上不去、损失曲线抖得像心电图、或者模型在训练集上表现完美换一批数据就崩的人。
1. 系列第8篇的定位与整体思路
吴恩达这门课的内容安排其实很有讲究。前面几篇都在解决“怎么把网络搭起来”——从单个神经元到深层网络,从激活函数到初始化方法。但到了“改善深度神经网络”这一部分,课程的核心问题变了:既然网络能跑起来了,怎么让它跑得更好?
这里说的“更好”不是简单的准确率更高,而是两个更具体的目标:
第一,解决过拟合。用他自己的话说就是“high variance”,模型在训练集上记得太牢,碰到没见过的数据就抓瞎。这是深度学习最容易踩的坑,因为深度网络参数量巨大,天生就有强大的“记忆”能力,你不主动约束它,它就真的把训练样本背下来了。
第二,让训练过程加速且稳定。梯度下降表面上看起来很简单——算梯度,沿反方向更新参数——但实际操作中你会遇到一堆问题:Loss震荡不收敛、收敛太慢、卡在局部最优附近、或者收敛到一个看起来不错但泛化很差的点。这些问题的根源往往不在网络结构,而在优化策略。
理解这一篇的核心思路,可以类比成学开车。前面几篇教你的是“如何把车发动、挂挡、踩油门”,相当于搞懂了神经网络的前向传播和反向传播。这一篇教你的则是“什么时候该踩刹车、什么时候该换挡、过弯怎么控制速度”——正则化是刹车系统,优化算法是换挡和导航策略。
课程里吴恩达反复强调的一点是:深度学习与其说是一门科学,不如说是一门经验科学。同一个网络结构,在不同的数据分布、不同的任务上,需要不同的正则化强度和优化策略。理论给你的是理解问题的框架,真正让你模型变好的,是你怎么根据验证集的表现去调整这些“超参数”。
所以这一篇的写作思路,我不打算把课程内容简单罗列一遍,而是把正则化和优化算法这两个模块拆开,每个部分都讲清楚“它解决什么问题、它的直观原理是什么、实际使用中怎么选参数、有什么坑”。最后再用一个完整的实操案例,把从头到尾的调优过程串一遍。
2. 正则化:让模型不再死记硬背
2.1 为什么深度网络容易过拟合,以及L2正则化背后的逻辑
过拟合的本质是模型容量超过了数据信息量。深度网络参数量动辄几十万上百万,而在很多实际任务里训练样本可能就几千张图、几千条记录。用一百万个参数去拟合几千个样本,就像用一支千人规模的装修队去装修一套小两居——不是装不好,是大家没事干就会开始“自由发挥”,把地砖拼出自定义花样来。
L2正则化的做法非常朴素:在损失函数后面加上一个权重的平方和惩罚项。
完整的目标函数变成了:
J(w, b) = 原始损失 + (λ / 2m) * Σ||w||²
这里的λ叫正则化系数,m是样本数量。为什么要除以2m?一方面是为了和梯度计算时的1/m对齐,另一方面是为了求导时系数整洁——这个细节不需要死记,但理解它可以帮助你推导反向传播时不被常数项搞晕。
从直觉上来理解它的作用机制:加了惩罚项之后,权重的绝对值越大,惩罚就越重。模型在梯度下降的时候,不仅要最小化原始损失,还要尽量让权重变小。而权重变小意味着什么?某些特征对输出的影响被削弱了,模型就不得不“抓重点”,而不是依赖某些个别特征的极端取值来硬拟合样本。
这里有一个重要的推导关系值得一提:加入L2正则化之后,梯度下降的更新规则会发生一个微妙的变化——权重每次更新的时候会先乘以一个略小于1的因子(1 - α*λ/m),所以这个操作也被叫做“权重衰减”(weight decay)。
这个物理图像很有意思:每次更新都在给权重“降温”,温度降下来了,模型就不容易“兴奋过度”去记住噪声。
实际使用中λ的选择经验是:先从λ=0.01开始试,如果欠拟合就减小,如果过拟合就增大。我个人的习惯是把它和训练曲线一起看——如果训练损失下不去而验证损失在涨,那就是λ太大,模型被“压”得太死。
注意:L2正则化对偏置b不要做正则化。原因很实际——偏置本身只是平移,不参与特征缩放,正则化它除了增加不必要的约束外没有收益,而且会导致模型表达能力下降,特别是最后一层偏置影响很大的分类任务。
2.2 Dropout的随机失活机制与Inverted Dropout
如果说L2正则化是“拧紧螺丝”,Dropout就是“随机拆掉一部分零件再让你工作”。它的操作极其简单:每次前向传播时,以某个概率p随机关闭一部分神经元,让这些神经元本次不参与计算。
为什么这样能防止过拟合?有两个层面的解释:
第一个层面是“集成学习”视角。每次随机dropout不同的神经元,相当于训练了一个不同结构的子网络。一个训练周期里你会“隐式”地训练了无数个共享参数的子网络,最后预测时用全部神经元——这等价于对这些子网络的输出做了一个平均。平均化天然能降低方差。
第二个层面是“去协同”视角。如果某些神经元经常“勾结”在一起——一个依赖另一个的输出——Dropout会切断这种依赖,迫使每个神经元学会独立提取有用特征。用吴恩达课程里的比喻,这就像训练一群士兵时,随机让一部分人休假,剩下的士兵必须学会独立作战。
实现上要注意一个关键细节:Inverted Dropout(反向Dropout)。这是课程里明确强调的细节,也是工程实现中和早期论文不同的地方。
普通的Dropout实现是:训练时把神经元以概率p置零,同时为了保持输出尺度,预测时要把权重乘以(1-p)。但这样会给预测阶段的代码引入额外的缩放逻辑,也容易出错。
Inverted Dropout的做法更优雅:在训练阶段,把保留的神经元输出除以(1-p),也就是放大保留部分的信号。这样做的效果是,无论dropout的比例是多少,每一层的输出期望值大致不变。预测阶段则完全不需要任何额外处理,直接前向传播即可。
用代码来表示就是:
# 训练阶段:保留概率为 keep_prob dropout_mask = np.random.rand(n_layer) < keep_prob # 当前层输出 a 乘以 mask,同时除以 keep_prob 保持尺度 a_after_dropout = (a * dropout_mask) / keep_prob这个“除以keep_prob”的操作,就是在做Inverted Dropout的尺度补偿。很多初学者会漏掉这一步,结果发现训练和预测时表现差距巨大——大概率就是这里出了问题。
选择keep_prob的经验值:对于全连接层,通常取0.5;对于卷积层,因为本身参数量少且空间结构重要,通常取0.7~0.8;对于输入层,一般不建议dropout,或者取0.9以上。课程里特别提醒的一个坑是:Dropout只在训练时激活,测试时一定要关掉——听起来是废话,但框架用多了之后你会发现,很多人把Dropout层留在测试代码里忘了切换模式,损失函数还正常,但指标就是上不去。
Dropout的另一个隐性好处是它相当于一种“数据增强的近似”。每看到一个样本,模型实际上用的是不同的子网络视角,样本的有效多样性增加了。这就是为什么在数据量不足的时候,Dropout的效果往往比L2正则化更“猛”。
2.3 早停与数据增强:两个容易被忽略的正则化手段
课程里吴恩达特别强调:正则化不止是L2和Dropout,还包括那些“看起来不像正则化”的操作。我实际用下来,最常用也最“无脑有效”的是早停(Early Stopping)和数据增强。
早停的原理特别好理解。训练过程中,训练损失通常是单调下降的,但验证损失往往是先下降后上升——那是一条U形曲线。最低点对应的就是“模型刚好学到了数据中的一般规律、还没开始背噪声”的时刻。早停就是在验证损失开始连续上升时停止训练,把参数回滚到最低点。
这个做法的优缺点都很明显。优点是省事——你不用去调正则化系数,不用反复试验λ的大小,只要盯着验证曲线就行。缺点是它把“模型容量控制”和“训练过程优化”这两个问题耦合在了一起——你可能因为早停而无法充分训练一个表达能力强的网络。课程里的建议是:如果训练资源充足,优先用独立的正则化手段;如果你只想快速得到“还不错”的结果,早停是性价比最高的选择。
数据增强则是另一种思路:不约束模型,而是增加数据多样性。对于图像任务,随机裁剪、水平翻转、色彩抖动、旋转都是经典操作;对于文本任务,同义词替换、随机mask也有效果。它的本质是在制造“虽然一样,但看起来不完全一样”的样本,让模型学到不变性。
我自己在图像分类任务中实测过:在不改变网络结构、不调L2和Dropout的情况下,仅仅加上随机的水平翻转和裁剪,验证集准确率能提升2~3个百分点。这个提升幅度在深度学习里已经算非常可观了。
有一点要提醒:数据增强的强度要温和。有人为了“增强效果”把裁剪比例拉得过狠,结果样本已经被扭曲到看不出原始类别了,反而拉低了训练效率。一个可参考的经验是:增强后的样本要“人眼依然能轻松识别原始类别”为标准。
3. 优化算法:从“笨重下山”到“智能导航”
3.1 Mini-batch梯度下降:为什么批量大小这么敏感
在系列前面的文章中,我们用的是“批量梯度下降”(Batch Gradient Descent)——在全部训练样本上计算损失和梯度,然后更新一次参数。这在数据量小时没问题,但数据量一上来,每更新一次就要遍历全量数据,速度慢得不可接受。
与之相对的是“随机梯度下降”(Stochastic Gradient Descent)——每次只用一个样本算梯度并更新。这样参数更新频率极高,但单个样本的梯度噪声很大,Loss曲线会剧烈震荡,而且很难充分利用向量化计算的优势。
Mini-batch梯度下降是两者之间的折中:每次用一个中等大小的批量(比如64、128、256个样本)来计算梯度并更新。它有两个好处:一是计算效率高,可以矩阵化批量运算;二是梯度估计的方差比SGD小,训练更稳定。
批量大小怎么选?我的经验是——这个参数在深度学习里像一个“玄学旋钮”,但它其实有规律可循。较大的batchsize会让梯度估计更准确,训练更稳定,但会占用更多内存;较小的batchsize会让更新更频繁,有时反而更容易逃出局部最优,但也更震荡。
一个常见的误区是“batchsize越大越好”。实际上,过大的batchsize会导致收敛到尖锐极小值(sharp minimum),泛化能力反而变差。Bengio等人的研究和大量工程经验都指向这一点。吴恩达课程里给的建议是:不要用太大的批量,尤其是在数据集有限时,64、128、256通常是好选择。如果内存允许,可以先从128开始试,对比一下验证集表现再调。
3.2 动量与RMSProp:两个方向上的改进
基础的梯度下降有一个先天缺陷:它在所有方向上使用相同的学习率。这在损失函数的“等高线”呈狭长形状时很致命——某个方向梯度大但不需要走那么远,另一个方向梯度小但需要多走几步,结果就是来回震荡,收敛极慢。
**动量法(Momentum)**的思路是:引入一个“速度”变量,让参数的更新方向不仅取决于当前梯度,还受之前更新方向的影响。想象一个球从山坡上滚下来,它不仅有当前的重力加速度,还带着之前的惯性——如果一直沿某个方向滚,它会越来越快,遇到小坑时也能凭借惯性冲过去。
更新规则:
v = βv + (1 - β) * dw
w = w - α * v
β的典型值是0.9。v初始为0。这个(1-β)系数是为了让v的尺度与梯度大致匹配,避免了初始化阶段v被β累积偏置。
动量法最大的实际收益是:大幅减少了在梯度方向变化剧烈的维度上的震荡,同时加速了稳定方向的移动。我在训练深层CNN时明显感觉到,加了动量之后Loss曲线的“锯齿感”消失了,收敛速度几乎是普通SGD的两倍。
RMSProp则是另一条路:它不累积速度,而是累积梯度的平方的滑动平均,然后用这个值来“归一化”每个参数的更新步长。直观理解:如果某个维度的梯度一直很大(陡峭方向),它的累积平方值大,更新步长就被压缩;如果某个维度梯度很小(平缓方向),累积平方值小,更新步长就被放大。
更新规则:
s = βs + (1 - β) * dw²
w = w - α * dw / (√s + ε)
这里的ε是一个极小的常数(比如1e-8),防止除以零。β通常取0.999。
RMSProp的价值在于:它对学习率不那么敏感。你用普通SGD时,学习率大了发散、小了龟速,让人抓狂;换成RMSProp之后,每个参数自动获得了“自适应”的学习率,调参的负担显著减小。
3.3 Adam:动量与RMSProp的合体
如果说动量法是“惯性导航”,RMSProp是“地形自适应”,那Adam就是把这两者结合了起来。它的名字本身就是Adaptive Moment Estimation的缩写,同时估计梯度的一阶矩(均值)和二阶矩(未中心化的方差)。
Adam的更新规则稍微复杂一点,有初始化偏置校正:
# 初始化 v = 0 # 一阶矩估计 s = 0 # 二阶矩估计 t = 0 # 时间步 # 每个迭代 t += 1 v = β1 * v + (1 - β1) * dw s = β2 * s + (1 - β2) * dw² # 偏置校正(因为v和s初始为0,早期会偏小) v_corrected = v / (1 - β1**t) s_corrected = s / (1 - β2**t) # 更新参数 w = w - α * v_corrected / (√s_corrected + ε)默认参数是β1=0.9,β2=0.999,ε=1e-8。这套参数在绝大多数任务上“开箱即用”,这也是Adam成为深度学习默认优化器的原因。
但Adam也不是万能的。我在实际使用中发现它的两个问题:一是它可能导致模型收敛到比SGD+Momentum稍差的极小值,尤其是在一些需要精细调优的任务上;二是它对学习率的“遗忘”比较慢——当训练后期你想把学习率降下来精细打磨时,Adam的响应不像SGD那么直接。
因此经验是:先用Adam快速找到一个好的解,然后用SGD+Momentum在这个解附近继续微调。这不是我一个人的经验,很多工程师的调优流程都是这个套路。
3.4 学习率衰减:不是锦上添花,而是刚需
很多人训练深度学习模型时只关注初始学习率,却忽略了训练过程中“逐步降低学习率”的重要性。
为什么需要学习率衰减?想象你正在用一个放大镜寻找地面上的一枚硬币,恐龙骨架天线杆杆。开始时你不知道硬币在哪,所以大步流星地走,这是大学习率阶段;等你能隐约看到硬币了,如果你还跨一大步,很容易踩过头——这时就需要放慢脚步,小心地靠近。
在深度学习训练中,训练前期损失高,梯度大,模型需要大步探索参数空间;训练后期模型已经接近一个较优的区域,如果学习率还很大,参数会在最优解附近来回震荡,无法精确收敛。这就是为什么训练曲线的末端经常是“波浪状”而不是“平滑趋稳”——大概率就是学习率没有衰减。
常见的学习率衰减策略有:
- 步长衰减:每训练N个epoch,学习率乘以一个因子(比如每5个epoch乘0.5)
- 指数衰减:α = α0 * e^(-kt),连续平滑地下降
- 余弦退火:按余弦曲线从初始学习率降到接近0,近年在视觉任务中很流行
- ReduceLROnPlateau:当验证损失连续几个epoch不下降时,自动减小学习率(Keras和PyTorch都内置了这个回调)
我的个人习惯是:优先使用ReduceLROnPlateau或者简单的按epoch数衰减,并用验证集表现来触发。触发条件可以设为“连续3个epoch验证损失没有改善”,学习率降为原来的0.1。这个方案几乎不需要人工干预,而且效果稳定。
学习率衰减和优化器的配合也有讲究。如果你用Adam,因为Adam本身就带有自适应学习率,“是否需要额外的衰减”存在一定争议。我的经验是:即便用Adam,小幅度衰减(比如每20个epoch乘0.9)依然有正面效果——尤其是在训练后期,Adam的自适应机制不会自动把步长压到足够小。
4. 一次完整的实操:用正则化加Adam打赢一场实战
空谈原理没意思,这一节我用一个模拟场景来演示整套流程怎么配合。假设你要训练一个图像分类模型,区分10类物体,训练数据有5000张图,验证集1000张。网络结构是个5层CNN加2层全连接,参数量大概在120万左右。这个配置在深度学习里算是“小模型”,但5000张图对这个模型来说明显不够——这就是一个天然的过拟合场景。
4.1 设定基线:先跑一个“裸奔”版本
我先把网络搭好,不加载任何正则化手段,优化器就用最基础的SGD,学习率固定为0.01,batchsize设为128,训练50个epoch。
结果符合预期:训练损失一路狂降到接近0,准确率逼近100%;但验证损失从第15个epoch开始反弹,验证准确率卡在82%左右再也没上去。典型的过拟合——模型把5000张训练图背得滚瓜烂熟,但那些“变了个姿势就认不出来”的情况让它露馅了。
这个基线的价值在于:它告诉我们当前模型的“容量上限”和问题瓶颈在哪。如果连训练损失都降不下去,那是网络太浅或学习率不对的问题,跟正则化没关系,先别急着调正则化。
4.2 分步加入正则化与优化改进
第一步,我加一个Dropout层,放在最后一个全连接层之前,keep_prob设为0.5。其他不变。结果:验证准确率从82%升到86%,验证损失虽然还在上升,但上升速度明显减缓。训练损失不再降到0,而是在1.0左右徘徊——看起来“有点欠拟合”,但这是健康的现象,说明模型不再死记硬背。
第二步,把SGD换成Adam,学习率改成0.001(Adam通常需要更小的学习率),加上ReduceLROnPlateau回调,监控验证损失,连续3个epoch不降就减半学习率。结果:前10个epoch收敛速度显著加快,验证准确率在第20个epoch就摸到88%,最终稳定在89%。训练曲线也不再大幅震荡。
第三步,加入数据增强:随机水平翻转、随机裁剪加填充、轻微亮度抖动。batchsize不变,各参数不变。结果:验证准确率从89%升到91.5%,同时验证损失基本不再上涨——数据增强补上了“数据多样性”这个短板,模型终于从“防过拟合”转向“学得更好”。
整趟调优下来,准确率从82%到91.5%。这不是某一个单独手段的功劳,而是正则化、优化器、学习率策略、数据增强四者协同的结果。
4.3 参数复盘:哪些超参值得再调
到了这一步,我会按“收益/成本比”来评估下一步调优方向:
优先调Dropout的概率——从0.5改成0.4或0.6,各跑一版对比验证准确率;
其次调数据增强强度——把随机裁剪的比例从0.8放宽到0.7,亮度抖动的范围从0.1扩到0.2;
然后是学习率初始值——Adam试试0.0005和0.002,通常差别在1个点以内;
最后才是网络结构改动——比如卷积核数量增减,这一步成本最高,留给真正值得投入的场景。
我在实际项目中有一个重要的原则:一次只改一个变量。很多人会同时调Dropout、学习率、batchsize,结果模型变好了也不知道是谁的功劳,变差了你更无从排查。调参不是开盲盒,是控制变量的科学实验。
5. 常见问题与排查技巧实录
我把自己在训练过程中踩过的坑和排查思路整理成一张表,这些场景在课程里未必提到,但实际工作中几乎人人都会碰到:
| 现象 | 主要原因 | 排查和解决办法 |
|---|---|---|
| 训练损失不下降 | 学习率太小、网络初始化不当 | 先用0.01的学习率试跑几十步,看损失有没有下降趋势;检查初始化的方差设置;确认数据归一化是否做好 |
| 损失变成NaN | 数值不稳定,梯度爆炸 | 减小学习率一个数量级;检查是否有log(0)或除零;加上梯度裁剪(clip by norm,阈值一般5.0) |
| 训练损失下降但验证损失涨 | 过拟合 | 优先加Dropout和L2;考虑数据增强;早停;减少网络层数或每层神经元数 |
| 损失曲线剧烈震荡 | 学习率太大,或batchsize太小 | 降低学习率或增大batchsize;尝试Momentum或Adam平滑曲线 |
| 训练结束验证准确率有个“刺眼”的突降 | 测试阶段忘了关Dropout | 检查模型是否调用了eval模式;确认BatchNorm的running mean/var是否更新正确 |
| 训练速度慢得离谱 | 数据加载成了瓶颈 | 检查数据加载线程数和预取队列;确认GPU利用率是否接近0;优化预处理流水线 |
| 加了L2正则化后训练损失反而更低 | 大概率是代码bug | 检查权重衰减是否只加到了权重上而不是所有参数;确认惩罚项没有把偏置也算进去 |
除了这张表,我再分享一个很多人不知道的小技巧:在训练的早期阶段(前几十个迭代),不要把模型调得太好。具体做法是,先用一个较大的学习率快速热身(warmup),让模型逃离初始状态的不稳定区域,再切换到正常的学习率策略。特别是用Adam的时候,前期梯度估计的方差很大,直接用小学习率容易浪费大量时间。
另外,关于损失落地的判断,一个实用的参照是:二分类任务的随机猜测对应的loss约为0.693(即-ln0.5),多分类任务则是-ln(1/类别数)。如果你的模型卡在这个值附近纹丝不动,问题多半不在训练过程,而在数据本身或模型结构——比如标签是不是没对齐、特征是不是完全没区分度。
写在最后的实操体会
课程系列写到这一篇,我想分享一个自己的真实感受:吴恩达这门课教的理论框架,和实际工程之间的“最后一公里”往往要靠自己补。课程告诉你Dropout的原理和公式,但不会告诉你Dropout和BatchNorm同时使用的顺序问题——实战经验告诉我:Dropout加在BatchNorm之后效果更好,因为BatchNorm已经做了数据分布的归一化,Dropout再在后面随机关一些信号,不会破坏BN的口径。
还有一件事值得重复强调:验证集是你最诚实的裁判。训练集上的指标再好都是虚的,一切以验证集为准。我在项目里养成了一个习惯——每次改动超参之前先记下当前验证集的三个指标,跑完回来对比,变化在1个点以内就视为无效改动。这听起来很机械,但它的作用远超你的想象,能防止自己“自我感觉良好”地瞎调。
希望这篇内容能帮你把训练过程中的关键环节理顺。下一篇我会继续沿着课程路线往前走,聊聊网络结构的经典案例和它们的设计思路。如果你在实际操作中遇到了什么野生的坑,欢迎带着问题来交流,大概率我也踩过。