如果你已经照着这个系列的前面几篇,把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(ratio=0.05) model = SimpleNet(hidden_sizes=[128, 64]) trainer.train( model, small_train, epochs=30, lr=0.1, weight_decay=0.0, # 先不开正则 use_dropout=False # 先不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_decay=0.001) for optimizer_idx in range(len(model.params)): optimizer.update( model.params[optimizer_idx], grads[optimizer_idx] )注意,weight_decay=0.0时直接返回原梯度,不增加任何开销。这个函数要放在优化器update之前,所以不管后面你用的是SGD、Momentum还是Adam,权值衰减都能统一生效,非常省心。
2.3 实测:λ怎么选,以及动态观察权重分布
初学的时候最迷茫的是λ到底给多少。我给你一组参考感受,在一个3层全连接网络、手写数字识别任务上的表现。固定SGD,学习率0.01,训练50轮:
| λ取值 | 验证准确率 | 权重标准差 | 现象 |
|---|---|---|---|
| 0 | 92.6% | 0.35 | 基准 |
| 1e-4 | 94.2% | 0.27 | 轻微收紧,泛化变好 |
| 1e-3 | 95.1% | 0.12 | 泛化继续提升 |
| 1e-2 | 93.8% | 0.03 | 权重被压得太小,开始欠拟合 |
| 1e-1 | 88.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_prob=0.8): self.keep_prob = keep_prob self.mask = None def forward(self, x, train_flg=True): 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_flg=False。有些人把dropout写成固定生成mask,结果测试阶段也在随机丢神经元,验证集准确率忽高忽低。这不是玄学,只是代码写错了。建议每个Layer都带一个training标识,或者模型统一管理。
第二,dropout加错了地方。通常加在ReLU激活之后、下一层全连接之前,而且对输入层很少用。我见过有人把dropout放在输入层,直接把原始特征丢掉一批,效果自然很差。图示如下:输入 → 全连接 → ReLU → Dropout → 全连接 → ReLU → Dropout → 输出。
第三,keep_prob设太小。0.5在很多论文里很常见,但那只代表那个网络足够大、容量足够冗余。如果你手头是一个只有100个神经元的全连接网络,keep_prob=0.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, lr=0.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, lr=0.01, momentum=0.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, lr=0.01, decay_rate=0.9, eps=1e-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是当前训练步数。默认参数通常是β1=0.9、β2=0.999、eps=1e-8。这套默认参数在大多数任务里都能直接工作,这也是它成为默认优化器的原因。
class Adam: def __init__(self, lr=0.001, beta1=0.9, beta2=0.999, eps=1e-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 | 收敛速度 | 最终验证准确率 | 稳定性 |
|---|---|---|---|---|
| SGD | 0.01 | 慢 | 91%左右 | 容易震荡 |
| Momentum | 0.01 | 中 | 93%~95% | 稳定不少 |
| RMSProp | 0.001 | 较快 | 94%~96% | 稳定 |
| Adam | 0.001 | 快 | 95%~97% | 非常稳定 |
我的选型建议非常实用主义:先无脑用Adam配lr=0.001,把网络结构、正则、数据处理的实验跑完,因为它省心,不需要盯太多参数。等整个流程稳定了,想要冲击更高精度,再换成SGD+Momentum,搭配学习率衰减,偶尔能比Adam再高零点几个百分点。调高精度追求阶段的重点放在学习率退火和正则强度上,而不是频繁换优化器。
5. 超参数设定:为什么学习率永远排在第一位
如果说优化器决定了参数更新的轨迹,那么超参数设定就是给整趟寻优旅程划路线。超参数种类很多,但它们的地位完全不同。有些超参数设错了,连跑都跑不起来;有些设错了,只是最终效果差几个百分点。
5.1 按影响力给超参数排序
我按自己大量实验的体感给优先级排座次:
| 优先级 | 超参数 | 设错后果 |
|---|---|---|
| 1 | 学习率lr | 不收敛、发散、收敛极慢 |
| 2 | 权值衰减系数λ | 过拟合或欠拟合 |
| 3 | dropout的keep_prob | 泛化差距明显 |
| 4 | 隐藏层大小/层数 | 容量不足或冗余 |
| 5 | mini-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%数据,跑大学习率,确认训练损失能压到很低。
- 粗选学习率:固定batch=64、无正则,用对数坐标跑几个量级,找出能收敛的lr。
- 加正则:先开权值衰减,看验证集提升情况;再开dropout,keep_prob从0.8起步。
- 精调学习率:在第二步的量级范围内取3-5个值,配合正则全开,训练到固定epoch,选验证集最高的。
- 最后动结构:如果正则和lr都已经到了增益很小的地步,再考虑加隐藏单元或加层。
每步只改一个变量,记录所有结果。这个看起来很笨的流程,其实比同时调几个超参然后祈祷出奇迹靠谱得多。
6. 组合拳实测:把四个招式塞进同一个训练循环
单看某一招都能理解,但最终要看组合效果。我用一个手写数字识别任务跑了一组完整实验:3层全连接网络,结构是784-128-64-10,ReLU激活,Softmax交叉熵损失,batch=64。没有用任何现成深度学习框架,全部是前面几篇手写的Affine层和反向传播,只是把权值衰减、dropout、优化器逐一把它们挂进训练循环。
6.1 一条训练循环把方案挂起来
def train_one_epoch(model, optimizer, loader, weight_decay=0.0, keep_prob=0.8): for x_batch, y_batch in loader: # 前向 h1 = relu(model.affine1.forward(x_batch)) h1 = model.dropout1.forward(h1, train_flg=True) h2 = relu(model.affine2.forward(h1)) h2 = model.dropout2.forward(h2, train_flg=True) 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_flg=False,权重衰减和优化器不再参与。
6.2 逐项打开开关,看验证准确率的爬升过程
| 配置 | 验证准确率 |
|---|---|
| SGD,无正则 | 92.6% |
| SGD + 权值衰减(λ=1e-3) | 94.8% |
| SGD + dropout(keep_prob=0.8) | 95.5% |
| SGD + 权值衰减 + dropout | 96.9% |
| Adam + 权值衰减 + dropout | 97.6% |
看完这组数字可以得出几个结论:单一手段都有用,但幅度有限;权值衰减和dropout同时打开,提升是叠加的,不是互相替代;最后把SGD换成Adam,优化器带来的提升依然可观。不过我要强调,这个表格不是让你背结论,而是展示“寻优是一套组合动作”,每一层都能把验证集往上顶一点。
6.3 组合拳也有失效边界
如果你在别人问“怎么让网络更准”的时候,只会回答“加正则、换Adam”,那迟早会翻车。因为当网络容量本身不足时,正则只会雪上加霜。我测试过一个只有32个隐藏单元的小网络,开权值衰减和dropout反而比什么都不开更差,原因就是模型本来就没有多少余量去“分摊风险”,再加约束就把仅剩的表达能力也压掉了。
正确的做法是:先用过拟合探测确认容量足够,再上正则和优化器。如果你发现无论怎么调,验证集都上不去,重新检查网络结构、数据预处理、学习率量级,而不是继续加大λ或者调低keep_prob。
最后分享一个我现在固定用的流程
这个系列写到第7篇,不同方法单独看都不复杂,真正难的是把它们组织进同一条训练流水线里。我现在接到任何新任务,都是先跑一组“穷人版标配”:Adam、lr=0.001、weight_decay=1e-4、dropout=0.8,把它当作基线;基线稳定之后再去动学习率量级、考虑换SGD+Momentum冲刺更高精度。每一步实验我都会把验证集准确率和关键超参记在一个表格里,这个习惯帮我省掉大量重复劳动,也让我回头看某个结果时能立刻明白当时改了哪一项。如果你正在手写网络的过程中卡在“训练好、验证差”这一关,照着这篇把四个开关都挂上,应该能感觉到明显的差别。