首页 / 资讯中心 / 文章详情

深度学习参数搜索:超参数、正则化与优化器实战指南

深度学习参数搜索:超参数、正则化与优化器实战指南 ★ FEATURED ARTICLE
1. 训练的本质在参数空间里做搜索一直写到系列第七篇前面我们把前向传播讲透了误差反向传播也手推了两遍甚至从头实现了一个 mini 框架的核心机制。但很多朋友反馈说网路搭好了梯度也算出来了训练就是死活不收敛或者收敛到某个莫名其妙的地方再或者明明训练集准确率很高一上验证集就掉链子。其实这个阶段你已经不是在写“网络结构”了而是在做“权重搜索”。深度学习的训练过程本质上就是在参数空间里用梯度信息作为向导寻找一组让损失函数尽量小的权重参数。而决定这个搜索能不能又快又稳地抵达目标靠的并不是网络结构本身而是一套训练配套方案——超参数设定、正则化手段和优化器选择。这篇内容我聚焦在四件最核心的事情上超参数怎么定、权值衰减怎么加、Dropout怎么用、优化器怎么选。全部基于手写 Python 实现不依赖任何现成框架你跟着推一遍就知道底层逻辑了。2. 超参数设定影响收敛命运的隐藏旋钮2.1 学习率牵一发动全身的家伙先说学习率这是所有超参数里最敏感、最影响成败的一个。学习率决定了每一步权重更新跨多大步子。步太大了损失函数会在山谷两边来回震荡长期卡在一个不上不下的位置步太小了收敛慢得像蜗牛爬训练几个 epoch 后损失还纹丝不动。手写网络时我见过太多人掉进学习率的坑。最典型的场景用固定学习率 0.1 训练一个三层网络梯度下降初始阶段损失下降挺正常跑到第 200 个 epoch 后损失开始剧烈震荡。原因就是初始阶段梯度大0.1 还算能驾驭但随着参数接近最优区梯度绝对值变小相对而言 0.1 这个步长就太猛了直接跨过了最优点。我个人实践中最常用的学习率搜索方式是按数量级去试0.1、0.01、0.001、0.0001。先跑 100 个 epoch 看损失曲线形态。如果损失曲线一开始就飞出天际或者变成 NaN那就是学习率太大如果 100 个 epoch 后损失几乎没动那就是太小了。这两者之间往下调半级到一级通常就是靠谱的起点了。# 一个简单的学习率对比结构手写网络里很容易嵌入 learning_rates [0.1, 0.01, 0.001, 0.0001] for lr in learning_rates: model ThreeLayerNet(input_dim784, hidden_dim64, output_dim10) history train(model, x_train, t_train, lrlr, epochs100) # 对比损失曲线是否平滑下降2.2 batch size 与 epochs效率与稳定的拉锯batch size 决定每次参数更新用多少条样本的梯度。手写实现里很多人图省事直接用全量梯度下降也就是一次性把所有样本的梯度加起来再做参数更新。这在数据集只有几百条的时候没问题但数据量一上来全量更新既慢又容易陷入局部最优点附近反复徘徊。Mini-batch 的核心思想是用一批样本的平均梯度作为真值梯度的无偏估计。虽然每次估计都带噪声但噪声恰恰起到了某种逃离局部极小的作用。我实验下来64 和 128 是两个最稳的 batch size既不会让梯度噪声大到彻底干扰收敛又比全量更新快得多。epochs 则不需要多解释完整遍历一次训练集就是一个 epoch。关键点在于epochs 不能单看数值它必须与学习率衰减配合。你不衰减学习率epochs 再大也只是在最优区域附近无意义地转圈白白消耗算力。2.3 学习率衰减把步长从大步走改成小步磨学习率衰减的必要性用一个生活中的场景来类比你开车接近目的地时总不可能一直保持 80 码冲过去吧必然是先快速接近再减速精确停靠。训练前期需要大步快速翻山越岭到了后期则需要小步精确打磨。手写代码里最常用的衰减策略是“阶梯衰减”每训练若干 epochs 就把学习率乘以一个衰减因子。另一个是“指数衰减”。两者实现都很简单# 阶梯衰减每 50 个 epoch学习率减半 if epoch % 50 0: lr * 0.5 # 指数衰减每个 epoch 都衰减 lr initial_lr * np.exp(-decay_rate * epoch)我实测下来阶梯衰减的效果比指数衰减更容易控制因为它的行为是离散的容易判断当前处于哪个阶段。指数衰减则可能出现训练后期学习率太小、参数几乎停止更新的情况。建议新手从阶梯衰减入手衰减因子设 0.5 或 0.1衰减周期设总 epoch 数的五分之一到三分之一之间。超参数这块我还有一个心得一次只改一个变量。不要同时调学习率又调 batch size 又换网络宽度否则你根本没法定位是哪个改动带来了效果提升。这个习惯能帮你省下大量排查时间。3. 权值衰减最朴素的过拟合刹车3.1 为什么权重太大会让模型变差权值衰减也叫 L2 正则化英文常见叫法是 weight decay。它的原理非常直接在原有损失函数基础上加上一个对所有权重平方和进行惩罚的项。理解这个手段可以从两个角度切入。从数学角度看加入惩罚项后梯度中会多出一部分与权重成正比的修正量迫使权重不断向零的方向收缩。一个大权重的网络对输入的变化极其敏感输入稍微偏移一点输出就可能剧烈改变。这在训练集上表现为所谓的“做作”为了拟合某些极端样本点网络被迫把决策边界扭曲得非常夸张。惩罚大权重后网络被迫选择更平滑、更简洁的边界泛化能力反而更好。另一个角度是贝叶斯视角权值衰减相当于给权重设了一个先验认为权重应该服从一个均值为零的正态分布。你不需要完全理解贝叶斯公式只需要明白它本质上是表达了一种“简单优于复杂”的偏好。3.2 手写实现加一项梯度就行在梯度下降实现里加权值衰减极其简单。损失函数变成loss cross_entropy_loss(y, t) 0.5 * reg_lambda * np.sum(W**2)反向传播时普通参数的梯度要额外加上grad_W reg_lambda * W这里的 0.5 是数学上的方便因为对 0.5 * lambda * w^2 求导后刚好得到 lambda * w不会多出系数。reg_lambda 就是正则化强度超参数。我见过有人直接把权值衰减实现为在每次更新后这样做“W * (1 - learning_rate * reg_lambda)”。这是另一种等价写法叫 L2 收缩也就是先按比例压缩权重再沿梯度方向更新。两种写法最终结果一致但如果你后续要换优化器建议使用梯度中加入 reg_lambda * W 的方式因为 Adam、RMSProp 这类自适应优化器对原始梯度的处理逻辑更复杂直接加在反向传播的梯度里才能正确适配它们的二阶矩估计逻辑。权值衰减的强度选择上我实验中的常用起点是 1e-4 到 1e-2 之间。太小的 lambda 几乎没有作用太大的 lambda 会把网络压成一条直线什么特征也学不出来。一个直观的判断方法是如果训练集和验证集的准确率差距超过 5%就加大一点衰减强度如果两者同时都低那问题不在正则化大概率是学习率或网络容量的问题。4. Dropout让网络学会走多条路4.1 过拟合的另一面神经元之间的过度依赖权值衰减是从“权重大小”的角度压制复杂度Dropout 则是从“网络结构”的角度做随机化。它的核心思想特别朴素每次训练时随机让一部分神经元不参与计算输出直接变为零。相当于每次训练你都在训练一个不同的子网络。最后预测时使用完整网络。为什么这能起到正则化作用因为它破坏了神经元之间的“协同依赖”。没有 Dropout 时某些神经元可能发展出互相打配合、共同弥补对方错误的行为这种配合在训练集上有效但换到新样本后一旦其中某个神经元给出异常激励协同链条就断裂了。Dropout 让每个神经元必须学会独立做出有用的贡献不能过度依赖别人。它的精神跟随机森林有异曲同工之妙单个模型容易过拟合但把多个有差异的模型结果集成起来方差会明显降低。Dropout 每次训练的子网络不同可以近似理解为在一个大网络里做集成学习。4.2 训练和推理阶段行为不同手写 Dropout 时最容易出问题的点在于训练阶段我们对神经元输出做了随机置零那么推理阶段该怎么办一个常见的错误是推理时不做任何处理。这会导致推理时神经元的输出量级远大于训练时的有效输出量级因为训练时每条神经元的输出平均只有原来的 keep_prob 比例在起效。结果就是训练好好的网络一到推理就表现异常。正确的做法有两种。第一种称为 Inverted Dropout训练阶段置零后输出的值除以 keep_prob保持整体输出期望不变推理阶段则原样使用完整网络。第二种是直接在推理阶段把所有输出乘以 keep_prob。两者等价但在实际工程中 Inverted Dropout 更常用因为它不需要在推理路径上做额外处理部署时逻辑更干净。class Dropout: def __init__(self, drop_prob0.5): self.drop_prob drop_prob self.keep_prob 1.0 - drop_prob self.mask None def forward_train(self, x): # 每个神经元以 keep_prob 概率保留 self.mask (np.random.rand(*x.shape) self.keep_prob) / self.keep_prob return x * self.mask def forward_test(self, x): # 推理阶段不做任何修改 return x def backward(self, dout): # 梯度也通过相同的 mask 传递 return dout * self.mask这里有一点要注意mask 必须在 forward 时生成并缓存backward 时复用同一个 mask。如果你在反向传播里重新生成 mask梯度就会被应用到一组不同的保留神经元上整个训练就乱套了。Dropout 概率的选择上对于隐藏层0.5 是个经典起点对输入层drop 概率建议小一些比如 0.2 左右因为输入特征被丢弃太多会导致有效信息量不足。如果你发现使用 Dropout 后训练集准确率下降明显不要慌这可能是正则化的正常效果——关键是看验证集准确率是否跟着上升。5. 优化器从盲目下山到自适应步长5.1 SGD 的困境方向对但步子笨SGD随机梯度下降是最基础的优化器。权重更新公式W - lr * grad它的逻辑非常朴实沿着梯度反方向走一步。问题在于损失函数在不同方向上的曲率往往差异巨大。某些方向梯度陡峭另一些方向梯度平缓。SGD 对所有方向一视同仁用同一个学习率导致在陡峭方向上震荡、在平缓方向上爬行。试想你在一个长长的峡谷里下山谷底方向坡度很缓但两侧山壁非常陡。SGD 的更新方式会让你在两侧之间来回弹跳而沿谷底方向前进得很慢。这就是 SGD 在病态曲率问题上表现差的直观解释。5.2 Momentum给小球加惯性动量Momentum的思路是模拟物理世界里小球的运动。它不是每一步都直接跟当前梯度走而是维护一个“速度”变量让历史梯度方向上的运动惯性被保留。速度更新公式v momentum * v - lr * grad W vmomentum 通常取 0.9。它与 SGD 的关键区别在于当当前梯度方向与历史方向一致时速度会叠加运动加速当方向相反时速度会被部分抵消震荡被抑制。这就像真实的小球下山——它不会因为局部斜率突变而立刻转向而是带着惯性冲过去因而能更快穿过平坦区域和局部极小区域。手写实现时必须注意v 初始化为与 W 同形状的零矩阵并且在每个 epoch 里持续复用不能每次迭代都重新置零。5.3 AdaGrad 与 RMSProp给每个参数定制步长AdaGrad 的出发点很聪明对每个参数都用它历史上累计的梯度平方来缩放学习率。梯度大的方向累计平方大步长被压缩梯度小的方向步长被扩大。累计变量 h 的更新h grad * grad W - lr * grad / (np.sqrt(h) 1e-7)它的问题也很明显h 只增不减随着训练推进h 越来越大学习率越缩越小最后几乎停止更新。所以 AdaGrad 在实际深网络中很少直接使用但它为 RMSProp 铺了路。RMSProp 改进了 AdaGrad 的缺陷——不再无限累计所有历史梯度平方而是用指数衰减移动平均来跟踪最近一段时间的梯度大小。相当于它只关注“近期的地形坡度”还保留了对新地形变化的响应能力。h decay_rate * h (1 - decay_rate) * grad * grad W - lr * grad / (np.sqrt(h) 1e-7)decay_rate 通常设为 0.9 或 0.99控制对近期梯度的关注程度。这个“近期平均”的思路在实践中的确非常有效Adam 的成功也是建立在这个基础之上的。5.4 Adam动量与自适应步长的结合体Adam 把 Momentum 的一阶矩估计和 RMSProp 的二阶矩估计结合起来。一阶矩 m 负责累积动量方向二阶矩 v 负责感知每个方向的梯度量级。对初学者来说最需要理解的是它的偏差校正机制因为 m 和 v 初始都是零早期会被低估所以需要通过除以 1 - beta^t 来修正偏差。完整的 Adam 更新流程m beta1 * m (1 - beta1) * grad v beta2 * v (1 - beta2) * grad * grad m_hat m / (1 - beta1 ** t) v_hat v / (1 - beta2 ** t) W - lr * m_hat / (np.sqrt(v_hat) 1e-8)典型参数是 beta1 0.9beta2 0.999epsilon 1e-8。这套设置几乎可以适配大多数问题。我在实际对比实验里发现Adam 的收敛速度通常优于 SGD 和 Momentum尤其是在参数初始化不太理想、数据分布不均衡的场景下。但有一个坑Adam 的泛化性能在某些任务上略逊于调好的带 Momentum 的 SGD。通俗说就是 Adam 学得快但可能到不了 SGD 那么好的最终效果。一个常见的补救策略是先用 Adam 快速热启动训练到中后期切换成 SGD 继续精调。5.5 手写实现对比我建议把以上优化器统一封装成梯度更新函数接口保持一致这样切换起来非常方便def sgd_update(W, grad, lr): return W - lr * grad def momentum_update(W, grad, lr, vel, momentum0.9): vel[:] momentum * vel - lr * grad return W vel def adagrad_update(W, grad, lr, h): h grad * grad return W - lr * grad / (np.sqrt(h) 1e-7) def rmsprop_update(W, grad, lr, h, decay_rate0.9): h[:] decay_rate * h (1 - decay_rate) * grad * grad return W - lr * grad / (np.sqrt(h) 1e-7) def adam_update(W, grad, lr, m, v, t, beta10.9, beta20.999): m[:] beta1 * m (1 - beta1) * grad v[:] beta2 * v (1 - beta2) * (grad * grad) m_hat m / (1 - beta1 ** t) v_hat v / (1 - beta2 ** t) return W - lr * m_hat / (np.sqrt(v_hat) 1e-8)从代码角度建议在类初始化时就把每个参数的 m、v、h 状态都创建好放到一个字典里。不要用全局变量记状态否则多组参数之间会互相干扰。优化器选型方面我的个人建议是快速验证效果用 Adam追求极致泛化、且你有时间调参用 Momentum SGD如果数据量小、问题简单SGD 其实也够用。没有绝对最优的优化器只有最匹配问题的组合。6. 组合拳一套完整的实战配置流程理论讲完了我把上述所有技术整合进一个可运行的训练流程里。假设我们要训练一个三层网络处理一个 784 维输入、10 类输出的分类问题。第一步初始化网络参数使用 He 初始化——对 ReLU 系列激活函数权重标准差设为 sqrt(2 / fan_in)。这一点容易被忽略但初始化对优化器行为影响非常大初始化不合理Adam 也救不回来。第二步设置超参数初始学习率 0.05batch size 128epochs 100权值衰减强度 1e-4Dropout 概率 0.5优化器选 Adam。第三步是学习率调度。我这里用阶梯衰减每 30 个 epoch 学习率减为原来的 0.3 倍。这样前期快速逼近后期精确收敛。外层训练循环的结构如下for epoch in range(epochs): if epoch in [30, 60]: lr * 0.3 idx np.random.permutation(m) for i in range(0, m, batch_size): batch idx[i:ibatch_size] x_batch x_train[batch] t_batch t_train[batch] # 前向传播训练模式含 Dropout z1 relu(x_batch.dot(W1) b1) z1 dropout.forward_train(z1) z2 relu(z1.dot(W2) b2) z2 dropout.forward_train(z2) y softmax(z2.dot(W3) b3) # 反向传播 grad_y (y - t_batch) / batch_size grad_W3 z2.T.dot(grad_y) grad_b3 np.sum(grad_y, axis0) grad_z2 grad_y.dot(W3.T) * relu_grad(z2) grad_z2 dropout.backward(grad_z2) grad_W2 z1.T.dot(grad_z2) grad_b2 np.sum(grad_z2, axis0) grad_z1 grad_z2.dot(W2.T) * relu_grad(z1) grad_z1 dropout.backward(grad_z1) grad_W1 x_batch.T.dot(grad_z1) grad_b1 np.sum(grad_z1, axis0) # 加权值衰减梯度 grad_W1 reg_lambda * W1 grad_W2 reg_lambda * W2 grad_W3 reg_lambda * W3 # Adam 更新 W1, m1, v1 adam_update_all(W1, grad_W1, lr, m1, v1, epoch1) # 其余参数同理这里有一个容易忽略的细节softmax 层的反向传播可以直接用y - t推导过程我在前面几篇博文里已经详细写过本质上是对交叉熵损失与 softmax 联合求导的结果。这意味着前向计算时你不需要将 z2 的值传进损失函数再求导直接一步到位。Dropout 的位置也值得说。我在隐藏层之间插入 Dropout输出层前通常不放因为输出层直接对应分类决策随机丢弃会干扰目标映射。输入层也建议弱化 Dropout或者干脆不加保留原始特征信息。训练完后评估验证集时需要把网络切换到推理模式——Dropout 直接跳过不做任何缩放。这是很多手写实现里最容易漏的问题。我习惯在网络类里维护一个 is_training 标志前向传播时根据它决定走哪条分支避免手里拿着两种状态的权重去评估。7. 常见问题与排查技巧实录这块内容是我从手写各类网络的过程中积累出来的经验。特别多的人跑完上述配置后仍然会遇到各种现象诡异的 bug这里统一整理成速查表。现象可能原因排查思路损失变成 NaN学习率过大、梯度爆炸、数据未归一化先调小学习率一个数量级检查输入数据是否都在合理范围内损失下降后震荡剧烈学习率偏大观察震荡幅度若振幅不随时间减小必须调小学习率或加快衰减训练准确率 100%验证集差过拟合加大权值衰减 lambda提高 Dropout 概率或减少网络层数/宽度训练和验证集都很差学习率太小或网络容量不足先确认损失是否在缓慢下降若完全不动检查权重是否有实质性更新梯度是否消失了用 Adam 前期正常后期震荡Adam 二阶矩估计接近 1学习率被放大适当调低初始学习率或者在中后期切换到带动量的 SGD加了 Dropout 后训练集变差正则化抑制过拟合的正常现象关键是观察验证集若验证集也变差才是异常验证集表现不稳定batch size 太小导致梯度噪声大增大 batch size或者固定随机种子做对比实验排查这类问题有一个基本原则先排除实现 bug再讨论超参数。怎么排除实现 bug用一个小数据集做“过拟合测试”。把训练集缩小到几十条样本关闭 Dropout、权值衰减设为零训练几十个 epoch模型应该能够轻松做到训练集 100% 准确率。如果这种极端条件下都学不出来那基本可以断定代码逻辑有误而不是超参数的问题。还有一个张量形状排查技巧。手写网络最容易犯的错是形状不匹配被 numpy 隐式广播掩盖。建议在每次前向传播时检查中间变量形状打印出来与预期对比。损失值能算出来不代表形状一定对广播机制会悄悄替你“修正”错误但计算结果完全是错的。这一点上花十分钟做验证能省下一整天调试。关于 Dropout 与权值衰减的搭配我个人的经验是它们可以同时使用且效果通常好于单独使用。两者的作用面不同Dropout 打散神经元之间的协同依赖权值衰减压制单个维度的极端敏感性组合起来在面对高维稀疏数据时特别有效。唯一要注意的是不要同时把两种正则化强度都拉到最大否则模型会被压制到欠拟合。从低强度开始逐渐上调观察验证集曲线变化是最稳妥的操作方式。最后再分享一个所有优化器通用的记忆点超参数是成套工作的。学习率、衰减策略、batch size 这三者强相关。你调大 batch size梯度噪声减小可以适当增大学习率你调小学习率训练变慢就要用更长训练时长。建议把超参数组合记录成实验日志每次跑完一组配置都记录下损失曲线尾值和验证集准确率长期积累下来你会形成对这个数据集特有规律的直觉判断。这套手写系列走到这一步你已经拥有了一套完整的训练工具箱。结构、反向传播、优化策略、正则化手段全部打通后后面再去理解学习率调度器、批归一化、更复杂的网络架构都会顺畅很多。参数空间的搜索从来不是一锤子买卖它是一个反复实验、观察、修正的过程。希望你也能在这个过程中建立属于自己的训练直觉。
阅读完成 · 觉得有帮助?
咨询建站