资讯动态

手写神经网络调参实战:权值衰减、Dropout与优化器提升验证集准确率

发布时间:2026/10/14 8:16:23 来源:尧图企业网站定制
如果你已经照着这个系列的前面几篇把Affine层、ReLU、Softmax、交叉熵和反向传播都手写通了那么接下来大概率会进入一个很憋屈的阶段训练损失一路降到0.03以下Loss曲线漂亮得像教科书配图可一拿到验证集上准确率卡在93%附近怎么敲打都不往上走。我第一反应是加层数、换更大的隐藏层折腾了两天验证集纹丝不动训练集倒是学得更“精细”了。后来才明白问题不在网络结构而在寻找最优权重参数这件事本身——我既没做超参数设定也没用dropout和权值衰减优化器只会用最朴素的SGD。这篇是这个系列的第7篇核心就是把超参数设定、dropout、权值衰减、不同优化器这四个东西逐个手写并串进同一个训练循环里。目标很直接让验证集准确率真正动起来。1. 训练曲线不会说谎拿到网络先做诊断再动手很多人调网络一上来就调结构、换激活函数这是典型的“头痛医脚”。在动手加任何正则化或换优化器之前先要学会读训练曲线。大多数情况下曲线已经把答案写在脸上了你要做的只是别假装看不见。1.1 一个典型的“假病”现象我之前那个“训练很好、验证很差”的状态其实对应一个经典组合训练损失持续下降验证集准确率从某个epoch开始不再上升甚至缓缓往下掉。这不叫模型训练不好恰恰相反它训练得太“死”了——网络把训练样本里的噪声和细节都背下来了却学不到能泛化的规律。用大白话说最优权重参数的目标从来不是让训练损失降到0而是让验证集。或者今后遇到的新数据上预测得准。训练损失只是一个中间指标不是终点。想清楚这点诊断方向就清晰了。1.2 把三条曲线放在一起读我每次训练都会同时记录三个数训练损失、训练准确率、验证准确率。两两组合能拼出好几种结果这里列一个我自己常用的速查表曲线表现问题定性优先处理方向训练损失降验证准确率停滞差距大过拟合权值衰减、dropout、增大数据训练损失和验证准确率都差欠拟合或学习率问题调大学习率、增加容量、换优化器训练损失快速降到很低验证准确率也高正常继续训练或考虑收敛细节训练损失震荡剧烈偶尔爆到NaN学习率过高调低学习率、检查batch大小这张表帮我过滤掉了80%的无效操作。尤其是第一行我在前面几篇里反复中招。如果你现在看到的也是这个形态直接往下跳到权值衰减和dropout这两节就行。1.3 先用小数据验证网络有“记忆能力”在开始调各种参数之前还有一个五分钟能做完的关键试验从训练集里随机抽5%的数据用小网络、大学习率去训练看能不能过拟合。# 过拟合探测5%数据 较大学习率 small_train sample_from_train(ratio0.05) model SimpleNet(hidden_sizes[128, 64]) trainer.train( model, small_train, epochs30, lr0.1, weight_decay0.0, # 先不开正则 use_dropoutFalse # 先不dropout ) # 观察最后几个epoch的训练损失 print(model.history[train_loss][-5:])如果训练损失依然降不到接近0说明是反向传播写错了、学习率奇怪、或者网络结构本身有问题。这时候去调正则化是没有意义的。如果训练损失能一路压下去说明网络有足够的记忆容量剩下的事才轮到权值衰减、dropout和优化器来操心。这个“过拟合探测法”我从那以后每次搭新结构都会先跑一遍比直接看一堆指标曲线高效得多。2. 权值衰减给权重一个“收紧”的理由把网络训练比作开车如果没有任何限速和交通规则车子会越开越快、越来越飘。权值衰减weight decay就是那个限速牌它要求权重尽量小不要一个比一个极端从而让决策边界更平滑、更不容易被输入中的细微扰动带着走。2.1 数学直觉惩罚大权重换更平滑的边界权值衰减实现起来很简单在原始损失函数后面加上一项通常是所有权重矩阵的平方和再乘一个系数。L_total L_data 0.5 × λ × ||W||²这里的0.5纯粹是为了求导方便。你真正要关心的是λ也就是权值衰减系数。λ越大对权重的大小的惩罚越狠权重就越倾向于贴着0走。反向传播的时候新梯度会多出一项dL_total/dW dL_data/dW λW也就是说每次更新权重时除了原本梯度的方向还会有一个把权重往回拉的力。它的别名“权值衰减”也来源于这个更新过程。对于SGD来说W W - lr × (dW λW) (1 - lr × λ) × W - lr × dW有意思的地方在这里如果不看梯度权重自己会先乘以(1 - lr×λ)所以哪怕没有任何数据权重也在缓慢收缩这就是“衰减”二字的由来。另外一般只惩罚权重W不惩罚偏置b。偏置的维度太低对过拟合的“贡献”远不如权重强行惩罚还会限制网络的平移能力。2.2 手写实现在梯度上做手脚而不是改Loss函数我见过有人在前向计算里显式拼一个正则项到损失上然后通过对整个式子求导来实现权值衰减。逻辑上没问题但完全没有必要每一步都要多算一遍还容易把数值搞乱。更干净的做法是在拿到反向传播的梯度之后统一加上λW。def apply_weight_decay(params, grads, weight_decay): if weight_decay 0.0: return grads return [g weight_decay * p for p, g in zip(params, grads)]训练循环的用法大概是这样# 每个batch之后 model.backward(dout) # 先把数据部分的梯度算好 grads model.grads # 取出所有参数梯度 grads apply_weight_decay(model.params, grads, weight_decay0.001) for optimizer_idx in range(len(model.params)): optimizer.update( model.params[optimizer_idx], grads[optimizer_idx] )注意weight_decay0.0时直接返回原梯度不增加任何开销。这个函数要放在优化器update之前所以不管后面你用的是SGD、Momentum还是Adam权值衰减都能统一生效非常省心。2.3 实测λ怎么选以及动态观察权重分布初学的时候最迷茫的是λ到底给多少。我给你一组参考感受在一个3层全连接网络、手写数字识别任务上的表现。固定SGD学习率0.01训练50轮λ取值验证准确率权重标准差现象092.6%0.35基准1e-494.2%0.27轻微收紧泛化变好1e-395.1%0.12泛化继续提升1e-293.8%0.03权重被压得太小开始欠拟合1e-188.4%0.008模型基本失去表达力权重标准差这个指标很有用你可以定期打印出来观察。如果std从0.35掉到0.03说明你已经从“过拟合状态”走进了“欠拟合状态”中间那个临界点往往就是λ的最优区域。另外提醒一句用Adam这类自适应优化器时L2正则和权值衰减并不完全等价因为Adam会按梯度尺度去归一化λW这一项在归一化后会被吃掉一部分。这也是为什么一些现代框架直接提供“解耦权值衰减”的选项。手写代码阶段你只要记住Adam下想用权值衰减直接在梯度上加λW效果有限最好单独在参数更新时做衰减或者就老老实实把λ调小一点。3. Dropout训练时裁员测试时全员上岗权值衰减管的是权重的“数值大小”但过拟合还有一个来源是神经元的“共适应”。翻译成人话某些神经元之间形成了小团体彼此的配合很默契但换一批数据就露馅。Dropout的做法简单粗暴——训练时随机让一部分神经元“请假”逼其他神经元单独扛事这样整个网络就不会过度依赖某个固定搭配。3.1 原理破坏共适应迫使每个神经元独立想象一支团队的PPT模板是A和B两个人配合做出来的默契到了顶峰但C和D完全没参与一旦A请假整个流程就崩了。Dropout就是强制团队定期随机轮休让每个人都能独立完成任务。反映在网络上就是前向传播时以一定概率把隐藏层的输出置为0。这个“一定概率”通常记作keep_prob比如0.8表示每个神经元有80%的概率保留、20%的概率被清零。输出层的神经元不参与dropout因为最后要拿它算概率少一个类别都不行。3.2 手写实现inverted dropout的mask与缩放手写dropout代码量非常小但有两个细节必须抠明白mask怎么生成以及输出要不要缩放。class Dropout: def __init__(self, keep_prob0.8): self.keep_prob keep_prob self.mask None def forward(self, x, train_flgTrue): if train_flg: self.mask (np.random.rand(*x.shape) self.keep_prob) / self.keep_prob return x * self.mask else: return x def backward(self, dout): return dout * self.mask这里训练时每个保留下来的神经元的输出被除以keep_prob这是inverted dropout的关键。为什么因为训练时有20%的神经元被清零整体输出会被缩小到原来的80%左右。如果不做缩放测试时所有神经元都在输出就会比训练时大一个量级。做了除以keep_prob的操作后训练时输出的期望和测试时保持一致测试inference代码里就不需要额外乘任何系数。这个设计还有一个好处你把网络部署到生产环境时只做一次前向不需要管keep_prob因为测试分支直接跳过mask生成返回原样。3.3 三个容易踩的坑第一忘了传train_flgFalse。有些人把dropout写成固定生成mask结果测试阶段也在随机丢神经元验证集准确率忽高忽低。这不是玄学只是代码写错了。建议每个Layer都带一个training标识或者模型统一管理。第二dropout加错了地方。通常加在ReLU激活之后、下一层全连接之前而且对输入层很少用。我见过有人把dropout放在输入层直接把原始特征丢掉一批效果自然很差。图示如下输入 → 全连接 → ReLU → Dropout → 全连接 → ReLU → Dropout → 输出。第三keep_prob设太小。0.5在很多论文里很常见但那只代表那个网络足够大、容量足够冗余。如果你手头是一个只有100个神经元的全连接网络keep_prob0.5相当于训练时永远只有50个神经元在工作信息瓶颈严重别说泛化连训练都难收敛。我的经验是先从0.8或0.9开始不够再往下探。3.4 和权值衰减会不会冲突很多人问dropout和权值衰减能不能一起用能。它们是两个维度的约束一个限制单次前向的依赖关系一个限制权重的整体尺度。实际操作中我会先用小数据探测确定网络基础表现再同时开这两个开关看验证集是否站立。如果网络本身很小两个一起开可能双重欠拟合验证集反而更差这时就先只开一个、把另一个关了。4. 优化器选型从SGD到Adam逐个手写一遍在网络寻优这条路上优化器决定了“参数往哪个方向挪、每次挪多远”。应该说很多新手对优化器的理解是“换个名字跑得快”但手写一次之后你会发现它改变的不只是速度更是搜索轨迹的形状。4.1 SGD为什么震荡梯度方向只能代表局部朴素的SGD更新公式非常简单W W - lr × dW问题在于当前点的梯度方向只能代表这个点附近最陡的下降方向不代表长期最优方向。如果损失面是一个狭长的山谷SGD会在谷壁两侧来回横跳前进速度很慢。你可以想象一个人下坡不带拐杖每一脚都踩在最陡的地方但走两步就被路边的土坎弹回另一边。class SGD: def __init__(self, lr0.01): self.lr lr def update(self, params, grads): for p, g in zip(params, grads): p - self.lr * g4.2 Momentum给参数更新加“惯性”Momentum的思路很简单不要每次都只看当前梯度保留一个速度变量v让历史方向对当前更新保持影响。v β × v - lr × dW W W vβ一般取0.9相当于把最近十步左右的历史梯度做加权平均。这个“惯性”的物理类比最形象推一个石头下山石头越滚越快并且在遇到小坑时不容易被卡住因为速度还在能直接碾过去。class Momentum: def __init__(self, lr0.01, momentum0.9): self.lr lr self.momentum momentum self.v None def update(self, params, grads): if self.v is None: self.v [np.zeros_like(p) for p in params] for i, (p, g) in enumerate(zip(params, grads)): self.v[i] self.momentum * self.v[i] - self.lr * g p self.v[i]我在手写数字识别任务里实测SGD收敛到93%大约需要30轮Momentum能在20轮左右冲到95%以上。同样是0.01的学习率Momentum没有带来更高的上限但明显减少了震荡、加速了收敛。4.3 AdaGrad与RMSProp让每个参数拥有自己的学习率SGD和Momentum对所有参数用的是同一个学习率。但如果某些参数更新频率低、梯度特别小它们就需要更大的步子反过来梯度特别大的参数要防止步子迈过头。AdaGrad的做法是累积每个参数的历史梯度平方然后对学习率按比例缩小h g² W W - lr × g / (sqrt(h) eps)累积值h只增不减导致学习率会越来越小最后几乎停止更新。这有点像一个预算只出不进的账户花完就没钱了。RMSProp解决这个问题把累积操作换成了滑动平均h β × h (1 - β) × g² W W - lr × g / (sqrt(h) eps)β取0.9或0.99让h不会无限膨胀相当于账户每隔一段时间自动刷新预算。class RMSProp: def __init__(self, lr0.01, decay_rate0.9, eps1e-8): self.lr lr self.decay_rate decay_rate self.eps eps self.h None def update(self, params, grads): if self.h is None: self.h [np.zeros_like(p) for p in params] for i, (p, g) in enumerate(zip(params, grads)): self.h[i] self.decay_rate * self.h[i] (1 - self.decay_rate) * g * g p - self.lr * g / (np.sqrt(self.h[i]) self.eps)4.4 Adam冲量加自适应步长再补一步偏差修正把Momentum的“惯性”和RMSProp的“自适应学习率”合在一起就是Adam。它维护两个状态变量一阶动量m梯度方向的指数滑动平均和二阶动量v梯度平方的指数滑动平均。m β1 × m (1 - β1) × g v β2 × v (1 - β2) × g²但训练刚开始时m和v都被初始化为0会让早期更新被严重低估所以Adam加了偏差修正m_hat m / (1 - β1^t) v_hat v / (1 - β2^t) W W - lr × m_hat / (sqrt(v_hat) eps)t是当前训练步数。默认参数通常是β10.9、β20.999、eps1e-8。这套默认参数在大多数任务里都能直接工作这也是它成为默认优化器的原因。class Adam: def __init__(self, lr0.001, beta10.9, beta20.999, eps1e-8): self.lr lr self.beta1 beta1 self.beta2 beta2 self.eps eps self.m None self.v None self.t 0 def update(self, params, grads): if self.m is None: self.m [np.zeros_like(p) for p in params] self.v [np.zeros_like(p) for p in params] self.t 1 lr_t self.lr * np.sqrt(1 - self.beta2 ** self.t) / (1 - self.beta1 ** self.t) for i, (p, g) in enumerate(zip(params, grads)): self.m[i] self.beta1 * self.m[i] (1 - self.beta1) * g self.v[i] self.beta2 * self.v[i] (1 - self.beta2) * g * g p - lr_t * self.m[i] / (np.sqrt(self.v[i]) self.eps)注意上面这种写法把偏差修正折进了lr_t数学上等价于标准的Adam。实际调试时我不建议你手改β1、β2我这边的经验是改lr就够了其他的参数是Adam最稳定的部分。4.5 优化器对比与我的选型心得固定同一个3层全连接网络同一个手写数字识别任务不加任何正则化学习率各自取能稳定收敛的值训练50轮我得到过这样一张对比印象表优化器典型lr收敛速度最终验证准确率稳定性SGD0.01慢91%左右容易震荡Momentum0.01中93%~95%稳定不少RMSProp0.001较快94%~96%稳定Adam0.001快95%~97%非常稳定我的选型建议非常实用主义先无脑用Adam配lr0.001把网络结构、正则、数据处理的实验跑完因为它省心不需要盯太多参数。等整个流程稳定了想要冲击更高精度再换成SGDMomentum搭配学习率衰减偶尔能比Adam再高零点几个百分点。调高精度追求阶段的重点放在学习率退火和正则强度上而不是频繁换优化器。5. 超参数设定为什么学习率永远排在第一位如果说优化器决定了参数更新的轨迹那么超参数设定就是给整趟寻优旅程划路线。超参数种类很多但它们的地位完全不同。有些超参数设错了连跑都跑不起来有些设错了只是最终效果差几个百分点。5.1 按影响力给超参数排序我按自己大量实验的体感给优先级排座次优先级超参数设错后果1学习率lr不收敛、发散、收敛极慢2权值衰减系数λ过拟合或欠拟合3dropout的keep_prob泛化差距明显4隐藏层大小/层数容量不足或冗余5mini-batch大小收敛速度与稳定性变化不少新手把隐藏层大小放在第一位我反而把它排在第四。原因很简单网络结构决定上限但学习率决定你能不能达到这个上限。一个宽得不合理的网络配上错误的学习率结果可能不如一个小网络配合理的学习率。5.2 对数坐标搜索学习率学习率的搜索范围要有讲究不要线性扫描0.1、0.2、0.3而要按指数取点1e-4、1e-3、1e-2、1e-1。这样一次实验就能覆盖几个数量级先确定量级再小幅微调。我的习惯做法是拿一个小子集固定其他超参分别跑几个epoch画一条“损失 vs 步数”的曲线。看最初的几百步就能大致判断如果损失直接爆成NaN说明学习率太高如果损失降幅极小说明太低如果训练损失能在20个epoch内稳定下降那这个量级基本可用。之后再在1e-3、3e-3、1e-2这种尺度里选具体值。学习率还有一个反直觉的规律略高的学习率虽然让训练损失降得不那么完美但最终验证集不一定差因为大学习率本身带一点正则化效果相当于每一步都跳得比较大不容易钻进尖锐的局部极小点。这点我是在对比了好几个lr结果之后才意识到的。5.3 mini-batch和学习率的联动mini-batch大小直接影响梯度估计的噪声。batch越小梯度越“抖”但抖动也像一种随机扰动有时能帮助跳出局部极小batch越大梯度越平滑训练更稳但容易停留在某个平坦的坑里且每轮epoch需要的更新次数变少。这里需要记住一条经验法则如果你把batch从64翻到128学习率可以按比例放大一倍左右。这个在线性缩放规则里有严格推导实践里也会看到明显效果。用手写网络做实验的时候我会先把batch固定成64调通所有配置之后再试128或者32避免两个变量同时变出现问题都定位不到是哪个引起的。5.4 一套可以直接抄的调参顺序我自己现在固定用这套五步流程遇到新任务基本不出错过拟合探测取5%数据跑大学习率确认训练损失能压到很低。粗选学习率固定batch64、无正则用对数坐标跑几个量级找出能收敛的lr。加正则先开权值衰减看验证集提升情况再开dropoutkeep_prob从0.8起步。精调学习率在第二步的量级范围内取3-5个值配合正则全开训练到固定epoch选验证集最高的。最后动结构如果正则和lr都已经到了增益很小的地步再考虑加隐藏单元或加层。每步只改一个变量记录所有结果。这个看起来很笨的流程其实比同时调几个超参然后祈祷出奇迹靠谱得多。6. 组合拳实测把四个招式塞进同一个训练循环单看某一招都能理解但最终要看组合效果。我用一个手写数字识别任务跑了一组完整实验3层全连接网络结构是784-128-64-10ReLU激活Softmax交叉熵损失batch64。没有用任何现成深度学习框架全部是前面几篇手写的Affine层和反向传播只是把权值衰减、dropout、优化器逐一把它们挂进训练循环。6.1 一条训练循环把方案挂起来def train_one_epoch(model, optimizer, loader, weight_decay0.0, keep_prob0.8): for x_batch, y_batch in loader: # 前向 h1 relu(model.affine1.forward(x_batch)) h1 model.dropout1.forward(h1, train_flgTrue) h2 relu(model.affine2.forward(h1)) h2 model.dropout2.forward(h2, train_flgTrue) score model.affine3.forward(h2) loss softmax_cross_entropy(score, y_batch) # 反向 dout softmax_cross_entropy_backward(score, y_batch) dout model.affine3.backward(dout) dout model.dropout2.backward(dout) dout relu_backward(dout) dout model.affine2.backward(dout) dout model.dropout1.backward(dout) dout relu_backward(dout) dout model.affine1.backward(dout) # 权重衰减 grads model.grads grads apply_weight_decay(model.params, grads, weight_decay) # 优化器更新 optimizer.update(model.params, grads)需要注意的顺序是dropout的forward在激活函数之后反向同样要经过它自己的mask权值衰减作用在数据梯度全部算完之后、优化器更新之前。测试阶段的前向和训练略有不同dropout层一律传train_flgFalse权重衰减和优化器不再参与。6.2 逐项打开开关看验证准确率的爬升过程配置验证准确率SGD无正则92.6%SGD 权值衰减(λ1e-3)94.8%SGD dropout(keep_prob0.8)95.5%SGD 权值衰减 dropout96.9%Adam 权值衰减 dropout97.6%看完这组数字可以得出几个结论单一手段都有用但幅度有限权值衰减和dropout同时打开提升是叠加的不是互相替代最后把SGD换成Adam优化器带来的提升依然可观。不过我要强调这个表格不是让你背结论而是展示“寻优是一套组合动作”每一层都能把验证集往上顶一点。6.3 组合拳也有失效边界如果你在别人问“怎么让网络更准”的时候只会回答“加正则、换Adam”那迟早会翻车。因为当网络容量本身不足时正则只会雪上加霜。我测试过一个只有32个隐藏单元的小网络开权值衰减和dropout反而比什么都不开更差原因就是模型本来就没有多少余量去“分摊风险”再加约束就把仅剩的表达能力也压掉了。正确的做法是先用过拟合探测确认容量足够再上正则和优化器。如果你发现无论怎么调验证集都上不去重新检查网络结构、数据预处理、学习率量级而不是继续加大λ或者调低keep_prob。最后分享一个我现在固定用的流程这个系列写到第7篇不同方法单独看都不复杂真正难的是把它们组织进同一条训练流水线里。我现在接到任何新任务都是先跑一组“穷人版标配”Adam、lr0.001、weight_decay1e-4、dropout0.8把它当作基线基线稳定之后再去动学习率量级、考虑换SGDMomentum冲刺更高精度。每一步实验我都会把验证集准确率和关键超参记在一个表格里这个习惯帮我省掉大量重复劳动也让我回头看某个结果时能立刻明白当时改了哪一项。如果你正在手写网络的过程中卡在“训练好、验证差”这一关照着这篇把四个开关都挂上应该能感觉到明显的差别。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑