实验3的名字叫"网络优化",很多同学一看实验指导书就以为是"调参",把学习率从0.1改到0.01,跑一遍,精度涨了两个点,交报告。我在带本科深度学习实验课的时候发现,同样的硬件、同样的数据、同一份网络结构,两组同学交上来的结果能差出十几个点,而差的那些组往往不是不会写代码,而是不知道自己在改什么。这篇就按我带实验的顺序,把网络优化这一节从目标拆解、技术选型、参数计算到踩坑排查完整讲一遍,适合正在做这个实验的本科生,也适合刚接手训练调优工作、想把"玄学调参"变成可复现流程的同行参考。核心关键词是网络优化、收敛、学习率调度、批量归一化、正则化和优化器选型,这些内容我会拆到每个环节的实际操作里说清楚。
1. 实验目标拆解:网络优化到底在优化什么
1.1 从"能跑通"到"跑得好"的分水岭
前两个实验的重点是"网络能不能跑起来":张量维度对得上、前向能算出 logits、反向能算出梯度、loss 能往下走。实验3的定位完全不同,它要求你在一个已经能跑通的网络上,把泛化精度、收敛速度、训练稳定性三个指标同时往好的方向推。这三件事经常互相打架:想让训练 loss 掉得更快,学习率调大,结果验证集精度抖得厉害;想压住过拟合,权重衰减加狠了,欠拟合又来了。所以这个实验真正考的不是"你会不会用某个 API",而是你能不能建立一套可归因的调优逻辑——每改一个变量,你能预判它影响哪条曲线,跑完能解释为什么结果符合或不符合预期。
我给自己的实验定的验收口径是三条,缺一不可。第一,测试集精度相对基线要有明确提升,而且提升要在多个随机种子下稳定复现,不能是某次运气好。第二,训练曲线要"干净",也就是 loss 单调下降、没有明显尖峰、梯度范数不发散也不塌到零。第三,报告里每一个超参数的取值都要给出理由,哪怕这个理由是"我试了 3 个值,另外两个的效果记录在附录表里"。第三条最容易被忽略,但它恰恰是这门课和"跑个开源脚本"的区别所在。
这里补一句我的看法:网络优化这个词在工业界和课堂上含义不完全一样。课堂上它偏向"训练技巧的组合",工业界它更多是"在固定算力预算下把模型推到可上线状态"。两者共通的部分是——都要有基线、都要有对照、都要有日志。所以这篇的内容你放在实验报告里能用,放在实际项目里也不会过时。
1.2 实验骨架与评测口径的确定
动手之前先把"评测口径"钉死,这是整个实验里性价比最高的一步。我见过太多同学跑了一晚上,回头发现两组实验用的验证集划分不一样,或者一组开了数据增强另一组忘了开,结果全部作废。我的做法是:数据划分只做一次,把索引存成文件,后续所有实验读同一份索引;随机种子固定成一个列表(比如三个种子),最后报均值±标准差;评估指标固定成 top-1 精度加验证集 loss,两个都记录,因为精度只反映"排序对不对",loss 还能反映"置信度校准得怎么样"。
网络结构方面,实验3一般不需要你重新设计网络,给定一个中等规模的卷积网络或者两层隐藏层的全连接网络就够。我倾向于用卷积网络,因为它更容易暴露批量归一化、数据增强这些优化手段的差异;全连接网络在小数据集上往往几个 epoch 就过拟合,曲线形状不明显,调优的因果链不好观察。
训练预算也要提前约定。本科实验的算力通常有限,我一般把单次训练控制在 20~40 分钟,epoch 数取 30 左右,配合早停。这样做的好处是允许你在一次实验里跑 6~8 组对照,而不是只跑一组然后靠猜。对照组数量直接决定了这份报告的说服力,我个人认为 6 组是一个及格线:基线 1 组、初始化 1 组、归一化 1 组、优化器 1 组、学习率调度 1 组、正则化组合 1 组。
2. 影响收敛的四个杠杆:初始化、激活、归一化、优化器
2.1 权重初始化:为什么不能全零,Xavier 与 He 的推导思路
先说结论:全零初始化会让同一层里所有神经元在梯度上完全等价,前向输出一模一样,反向梯度也一模一样,更新之后还是对称的,等于你花了几百万参数只训练了一个神经元。这个现象叫对称性破坏失败,是实验报告里经常被问到的一个点。随机初始化(比如标准正态乘 0.01)能打破对称性,但会带来另一个问题:方差在层间传播时逐级衰减或者逐级爆炸,十层之后激活值不是接近零就是溢出。
Xavier 初始化想解决的就是这件事。它的思路是让每一层输出的方差等于输入的方差,前向和反向都成立,推导出来权重方差要取 2/(fan_in + fan_out),fan_in 是输入维度,fan_out 是输出维度。He 初始化针对 ReLU 做了修正,因为 ReLU 会把一半的负值砍掉,实际有效方差只有原来的一半,所以权重要乘 2,即方差取 2/fan_in。这两条公式建议你写进报告,并且在代码里显式指定,别依赖框架默认值——大多数框架对卷积层的默认初始化已经比较合理,但对自定义层或者线性层,默认值未必匹配你用的激活函数。
我在实验里设置的对照是:一组用 0.01 倍标准正态,一组用 He 正态。预期是浅层网络下两者差异不大,网络加深到 8 层以上、且激活函数是 ReLU 时,He 初始化的收敛速度明显更快,前期 loss 下降更陡。实测结果基本符合预期,8 层卷积网络上,He 初始化在第 3 个 epoch 就把验证 loss 压到了固定初始化在第 8 个 epoch 才达到的水平。
注意:如果你在第一个 epoch 就看到 loss 卡在某个值附近几乎不动,而且梯度范数极小(比如 1e-7 量级),优先怀疑初始化方差太小导致激活值塌缩,别急着调学习率。反过来,如果 loss 一开始就是 nan 或者几千,先查初始化方差是不是太大,再查输入有没有做归一化。
2.2 激活函数选择与梯度消失的现场诊断
激活函数这块,本科实验常见的选择是 ReLU、LeakyReLU、GELU 三选一。Sigmoid 和 Tanh 建议只在做对照实验时出现,因为它们饱和区的导数趋近于零,深层网络里梯度连乘几下就没了,你会看到前面的层几乎学不动。这不是理论上的担心,是可以直接观测的:把每一层梯度的 L2 范数打出来,Sigmoid 网络里第一层的梯度范数往往比最后一层小两三个数量级。
ReLU 的问题是"神经元死亡"。学习率设得偏大时,某次更新把某个神经元的 bias 推得很负,之后所有输入过来输出都是零,梯度也是零,这个神经元就永久失活了。诊断方法很直接:训练若干 epoch 后统计每层激活值为零的比例,如果某一层超过 50% 的通道恒为零,基本可以判定死亡。处置方式有几种,我一般先用 LeakyReLU(负半轴给一个 0.01 的斜率)或者把学习率降下来,两者能解决大部分情况。
GELU 相比 ReLU 更平滑,在中等规模网络上往往有零点几个点的精度收益,代价是计算稍慢。我通常把它作为"精细调优阶段"的选项,而不是一开始就用,因为实验初期你需要的是清晰的因果信号,不是最后那一点点收益。
还有一个容易被忽略的点:激活函数和初始化是绑定的。你把 ReLU 换成 Tanh,初始化就应该从 He 换成 Xavier,否则方差假设不成立,前面推导的东西全废。我见过报告里写"换用 Tanh 后效果变差",一看代码初始化还是 He,这个结论就是不成立的。
2.3 批量归一化的位置、动量与训练推理差异
批量归一化(BN)是实验3里最容易出效果、也最容易用错的一层。它的作用是在每个 mini-batch 内把激活值标准化到零均值单位方差,再用两个可学习参数 gamma 和 beta 做仿射变换,把表达能力还回去。效果上,它允许你用更大的学习率、对初始化不那么敏感、还能起到一点正则化作用。
位置方面,标准做法是放在卷积(或线性)之后、激活函数之前。这个顺序不是随便定的:如果放在激活之后,归一化会破坏激活函数引入的非线性分布特性,实测精度会掉。至于 BN 之后的卷积层需不需要 bias,严格来说不需要,因为减均值那一步会把常数项吸收掉,但留着也不影响结果,我一般为了代码整齐就都保留。
动量参数 momentum 控制的是运行时统计量的滑动平均速度,框架默认一般是 0.1,含义是 running_mean = 0.9 * running_mean + 0.1 * batch_mean。这个值偏小意味着统计量更新慢、更平滑,训练早期会有一点滞后;如果 batch size 很小,滞后更明显。我在实验里会把 batch size 和 momentum 放在一起看,batch size 小于 16 的时候会把 momentum 调小一点,让统计量跟得上。
训练和推理的差异是必须讲清楚的一点。训练时用当前 batch 的统计量做归一化,同时更新 running 统计量;推理时用 running 统计量做归一化,不再更新。所以你要是在验证阶段忘了调model.eval(),验证精度会莫名其妙地波动,而且 batch 越小波动越大。这个坑我每年都能在学生的代码里抓到。
注意:BN 在小 batch 下表现会明显退化,因为 batch 统计量本身的噪声太大。如果你的显存只够 batch size 8,与其硬上 BN,不如考虑 GroupNorm 或者干脆不用归一化改用更小的学习率。这是我在实际项目里踩过的坑,实验报告里写一句能加分。
2.4 优化器对比:SGD 加动量与 Adam 的取舍
优化器的选择是实验3的核心对照之一。SGD 加上动量(momentum 0.9)的做法是沿历史梯度方向累积速度,能穿过狭窄的峡谷、抑制震荡;Adam 则是对每个参数自适应调整步长,用一阶矩和二阶矩估计做归一化,前期收敛非常快,对学习率不那么敏感。
我整理了一张对照表,这是我带实验时直接发给学生的版本:
| 维度 | SGD + Momentum | Adam / AdamW | 我的实践建议 |
|---|---|---|---|
| 前期收敛速度 | 慢,需要 warmup 和较长训练 | 快,几个 epoch 就有明显下降 | 实验课时间紧,基线优先用 Adam |
| 最终泛化精度 | 通常略好,尤其卷积网络分类任务 | 略低,但差距随训练轮数缩小 | 追求极限精度时用 SGD 精调 |
| 超参敏感度 | 对学习率非常敏感,差 3 倍就崩 | 相对鲁棒,默认 1e-3 常有不错效果 | 新手先用 Adam 建立信心 |
| 权重衰减耦合 | 解耦,直接作用在参数上 | 原版 Adam 与梯度平方耦合,建议用 AdamW | 用 Adam 时优先选 AdamW |
| 显存开销 | 小,只存动量 | 大,存一阶和二阶矩 | 显存吃紧时 SGD 更友好 |
有一个细节值得单独说:原版 Adam 的 L2 正则和自适应学习率是耦合的,导致大梯度方向的权重衰减被削弱,实际正则效果不如预期。AdamW 把权重衰减解耦出来,直接作用在参数更新上,这也是为什么现在大家说 Adam 的时候基本都在用 AdamW。你在报告里如果能指出这一点,说明你不是照着教程抄的。
我给的实验路径是:先用 AdamW、学习率 1e-3 跑出基线,确认流程没问题;然后把优化器换成 SGD + Momentum 0.9,学习率从 1e-2 开始试,配合余弦退火,跑到同样的 epoch 数,对比最终精度和曲线平滑度。多数情况下你会看到 AdamW 前 5 个 epoch 领先,SGD 在 20 个 epoch 之后反超,两条曲线交叉的位置大概在训练中段。这个交叉现象本身就是很好的报告素材。
3. 超参数与训练策略的实操配置
3.1 学习率与调度:warmup 加余弦退火的具体参数计算
学习率是单个影响最大的超参数,没有之一。我的经验是先用一个比较大的值跑几十步,看 loss 有没有爆炸,逐步缩小到一个"能降但不抖"的区间,然后再上调度策略。
warmup 加余弦退火是我目前最常用的一套。warmup 的作用是训练初期参数离最优点很远,梯度方向不稳定,如果直接用大学习率容易一步跨过头,所以前若干个 step 把学习率从接近零线性涨到目标值。余弦退火则是让学习率从目标值按余弦曲线平滑降到一个小值,后期小学习率有助于收敛到更平坦的极小值。
具体怎么算,我拿一个典型配置走一遍。假设训练集有 50000 个样本,batch size 取 128,那么每个 epoch 的 step 数是 ceil(50000/128) = 391。训练 30 个 epoch,总 step 数就是 391 × 30 = 11730。warmup 比例取 5%,warmup_steps = 586。峰值学习率取 1e-3(AdamW),最小学习率取峰值的 1%,也就是 1e-5。这样第 587 步到第 11730 步之间,学习率按余弦从 1e-3 降到 1e-5。
公式写出来是:lr(t) = lr_min + 0.5 × (lr_max - lr_min) × (1 + cos(π × (t - warmup) / (total - warmup)))。这个式子你完全可以在代码里用框架的 LambdaLR 实现,不必依赖内置的 CosineAnnealingLR,因为后者不含 warmup 段,需要自己拼接。
注意:如果你改了 batch size,学习率要跟着改,否则对照实验的结论会被污染。经验法则是线性缩放:batch size 翻倍,学习率翻倍。但这个法则对 SGD 成立得比较好,对 Adam 系列不完全适用,Adam 在大 batch 下更建议只做温和的放大(比如开方缩放),或者延长 warmup。这一点我在实验里专门做了一组验证,batch 从 64 到 256,学习率按线性放大后,AdamW 前期出现了明显的 loss 尖峰,把 warmup 比例从 5% 提到 10% 之后才稳住。
3.2 正则化组合:权重衰减、Dropout、标签平滑与早停
正则化是"抑过拟合"这一侧的主力。这几种手段的机制不同,叠加使用时要清楚各自解决什么问题,否则容易出现"加了半天没效果"的情况。
权重衰减(L2 正则)作用在参数上,抑制参数幅度变大,等价于给损失加了一个参数范数的惩罚项。典型取值在 1e-4 到 1e-3 之间,我用 5e-4 比较多。关键细节是不要给 BN 的 gamma/beta 和所有 bias 加权重衰减,因为这些参数的理想值本来就未必接近零,对它们做收缩会损害表达能力。实现上就是用参数分组,把二维以上的权重张量放一组,一维的放另一组,后一组的 weight_decay 设为 0。
Dropout 是在训练时以概率 p 随机把神经元输出置零,推理时关闭。它相当于每次迭代训练一个不同的子网络,起到模型平均的效果。全连接层常用 0.5,卷积层因为参数共享,p 一般取 0.1 到 0.3。这里有个新手常犯的错:把 dropout 加在输出层前面,p 又设成 0.5,结果模型收敛极慢,因为每次有一半的类别证据被丢掉。输出层之前不加 dropout,这是底线。
标签平滑把硬标签从 1 和 0 改成 1-ε 和 ε/K,ε 常取 0.1,K 是类别数。它的作用是防止模型对错误样本过度自信,能改善校准性,对精度也有小幅提升。这个技巧成本极低,我一般默认开着。
早停是按验证集指标挑最优 checkpoint,patience 设 5 到 10 个 epoch。它既是正则化也是省算力的手段。要注意早停的判据要和最终报告指标一致:如果你报的是测试集精度,早停就该看验证集精度而不是验证 loss,否则可能选到一个 loss 低但精度也低的点。
我把这些手段的作用位置整理成一张表,方便你按症状下药:
| 手段 | 主要作用 | 典型取值 | 代价 |
|---|---|---|---|
| 权重衰减 | 抑制参数幅度,缓解过拟合 | 5e-4(仅权重) | 过大会欠拟合 |
| Dropout | 集成式正则,打破特征共适应 | 全连接 0.5,卷积 0.1~0.3 | 收敛变慢 |
| 标签平滑 | 改善置信度校准,轻微涨点 | 0.1 | 几乎无 |
| 早停 | 选最优轮次,避免过训练 | patience 5~10 | 需要验证集 |
| 数据增强 | 扩充数据分布 | 随机裁剪、水平翻转 | 训练时间增加 |
3.3 数据增强与批大小、学习率的联动
数据增强严格来说不属于"网络优化",但它对训练曲线的影响比很多优化技巧都大,所以实验里绕不开。随机裁剪加水平翻转是图像分类的标准配置,成本低、效果好。需要注意的是增强的强度要和训练轮数匹配:增强越强,模型需要越多的 epoch 才能收敛,如果你只跑 15 个 epoch,强增强反而会让结果变差,因为模型还没学完就被打断了。
批大小的影响则体现在梯度的噪声水平上。batch 小,梯度噪声大,有一定的正则化效果,但训练不稳定;batch 大,梯度估计准,可以用更大的学习率,但泛化精度有时会略微下降。我一般把 batch size 定在显存允许的 60%~70%,留出余量应对不同模型结构。
还有一个实操细节:数据加载的 worker 数量和随机种子。如果你开了多进程加载并且用了随机增强,一定要给每个 worker 设置不同的种子,否则可能出现多个 worker 产生相同随机序列的情况,相当于增强的多样性打了折扣。框架一般会帮你处理,但如果你自己写了 Dataset,记得在 worker_init_fn 里设种子。
3.4 一套可以直接抄的配置
下面这套代码是我实验课上发的模板,删掉了数据集相关的部分,保留了优化相关的骨架。参数分组、warmup 余弦调度、梯度裁剪都在里面。
import math import torch from torch import nn model = build_model() # 你自己的网络 EPOCHS = 30 BATCH_SIZE = 128 STEPS_PER_EPOCH = math.ceil(50000 / BATCH_SIZE) # 391 TOTAL_STEPS = STEPS_PER_EPOCH * EPOCHS # 11730 WARMUP_STEPS = int(0.05 * TOTAL_STEPS) # 586 LR_MAX, LR_MIN = 1e-3, 1e-5 # 1. 参数分组:权重做衰减,bias 和 BN 参数不做 decay, no_decay = [], [] for name, p in model.named_parameters(): if not p.requires_grad: continue if p.ndim <= 1: # bias / gamma / beta no_decay.append(p) else: decay.append(p) optimizer = torch.optim.AdamW( [{"params": decay, "weight_decay": 5e-4}, {"params": no_decay, "weight_decay": 0.0}], lr=LR_MAX, betas=(0.9, 0.999), eps=1e-8) # 2. warmup + 余弦退火 def lr_lambda(step): if step < WARMUP_STEPS: return (step + 1) / WARMUP_STEPS progress = (step - WARMUP_STEPS) / max(1, TOTAL_STEPS - WARMUP_STEPS) cosine = 0.5 * (1.0 + math.cos(math.pi * progress)) return (LR_MIN + (LR_MAX - LR_MIN) * cosine) / LR_MAX scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda) # 3. 训练循环里的关键三行 for epoch in range(EPOCHS): model.train() for x, y in train_loader: optimizer.zero_grad(set_to_none=True) loss = criterion(model(x), y) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() scheduler.step()梯度裁剪这一行很多人会漏。它的作用是在梯度范数超过阈值时按比例缩放,防止个别异常 batch 把参数冲飞。max_norm 取 1.0 到 5.0 之间都常见,我习惯用 5.0,因为它在正常训练时几乎不触发,只在出问题时兜底。你可以在训练日志里记录裁剪前的梯度范数,如果发现它长期贴着阈值,说明学习率偏大或者数据里有异常样本。
4. 实验流程落地:从基线到调优的对照实验设计
4.1 基线跑通与日志记录规范
基线的作用是给后面所有改动提供一个参照点,所以它必须是"最朴素但正确"的配置:默认初始化、ReLU、不加 BN、AdamW 默认学习率、不做调度、不做增强、固定种子。这个基线大概率精度一般,但它干净,任何后续改动带来的差异都能归因到那一个改动上。
日志规范我要重点强调。我要求学生每次实验必须记录:完整配置(用一份 yaml 或者 dict 存下来,和结果一起归档)、每个 epoch 的训练 loss、验证 loss、验证精度、当前学习率、梯度范数均值。这五个量足够你事后复盘。日志按实验编号命名,比如 exp03_baseline_seed42,不要用 final、new、test2 这种命名,一周之后你自己都分不清哪个是哪个。
我还会让学生画一张总览图:横轴 epoch,纵轴三个子图分别是 loss、精度、学习率。这张图在报告里比任何文字描述都直观。图上基线和改进版的曲线放在一起,交叉点、收敛速度差异一目了然。
4.2 单变量对照实验的记录表和执行顺序
对照实验的原则是一次只改一个变量。我给的执行顺序是这样的,从影响最大的往下排:
| 编号 | 改动内容 | 预期观察 | 需要记录的结论 |
|---|---|---|---|
| E0 | 基线:默认配置 | 收敛但精度一般 | 作为参照 |
| E1 | 初始化换成 He 正态 | 前期 loss 下降更陡 | 收敛速度提升幅度 |
| E2 | 加入 BN(卷积后激活前) | 允许更大学习率,曲线更平滑 | 精度与稳定性变化 |
| E3 | 优化器换 SGD+Momentum | 前期慢、后期可能反超 | 交叉点位置 |
| E4 | 加 warmup 余弦调度 | 前期更稳,末期 loss 更低 | 最终精度差异 |
| E5 | 加正则组合(衰减+dropout+标签平滑) | 验证 loss 与训练 loss 差距缩小 | 泛化 gap 变化 |
| E6 | 全部合并 | 综合最优 | 相对基线的总提升 |
这个顺序背后的逻辑是:初始化和归一化影响的是"能不能稳定训练",属于地基;优化器和调度影响"训得多快多好",属于上层;正则化影响"泛化",属于收尾。地基没打好就调上层,你会得到一堆互相矛盾的结论。我见过有同学先加了 dropout,发现精度掉了,又去调学习率,最后绕了一大圈。正确的做法是先确认训练能稳定收敛,再谈正则。
每组实验建议跑三个种子,报均值±标准差。标准差大于 1 个点的时候,两个配置之间的差异就要谨慎解读了,很可能只是噪声。这一点在报告里写清楚,比硬说"提升了 2 个点"要专业得多。
4.3 训练曲线怎么读:四条线各自的含义
看曲线是网络优化里最核心的技能,我一般让学生盯四条线。
第一条是训练 loss。它应该整体下降,允许有小幅震荡。如果它不降反升,先查学习率、查标签有没有错位、查损失函数和输出维度是否匹配。如果它降得极慢但确实在降,可能是学习率太小或者初始化太保守。
第二条是验证 loss。理想情况是先降后升,最低点就是早停的位置。如果验证 loss 从第一个 epoch 就开始上升,说明模型容量相对任务太大或者正则不够;如果它一直跟着训练 loss 降但精度不涨,说明模型在拟合噪声,也需要加正则。
第三条是学习率。这条线主要是验证你的调度有没有按预期执行,warmup 段是不是真的在涨,余弦段是不是真的在降。很多"调度没效果"的问题,其实是 scheduler.step() 的位置放错了,比如放在 epoch 级别而不是 step 级别,导致整条曲线变成了阶梯状。
第四条是梯度范数。它应该是相对平稳的,前期略大后期略小。如果它突然出现尖峰,说明遇到了异常 batch;如果它单调衰减到接近零,说明模型进入饱和区,学习率该降了或者该换激活函数了。
把这四条线放在一张图上对照着看,很多问题不需要跑新实验就能定位。我个人的习惯是每次实验结束后先看图再看数字,图能告诉我"哪里出了问题",数字只能告诉我"差了多少"。
5. 常见问题与排查实录
5.1 症状、原因与处置速查表
下面这张表是我这些年攒下来的,覆盖了实验里 90% 的异常情况。用法是从左往右对:先看症状,再看可能原因,再按处置顺序试。
| 症状 | 可能原因 | 处置顺序 |
|---|---|---|
| loss 变 nan | 学习率过大、梯度爆炸、输入未归一化 | 降学习率 → 加梯度裁剪 → 检查输入范围 |
| loss 长时间不降 | 学习率过小、初始化方差过小、标签错误 | 提学习率 10 倍试一次 → 换 He 初始化 → 抽查标签 |
| 训练精度高验证精度低 | 过拟合 | 加权重衰减 → 加增强 → 加 dropout → 早停 |
| 训练验证都低 | 欠拟合或优化失败 | 加大模型 → 降正则强度 → 检查学习率调度 |
| 验证指标剧烈抖动 | 忘了 model.eval()、batch 太小、BN 统计量不稳 | 切 eval 模式 → 增大 batch → 降 BN momentum |
| 前期 loss 尖峰后恢复 | warmup 不足、学习率偏大 | 提高 warmup 比例 → 降峰值学习率 |
| 梯度范数长期贴阈值 | 学习率偏大、存在异常样本 | 降学习率 → 检查数据清洗 |
| 训练速度异常慢 | 数据加载瓶颈、未开混合精度 | 增加 worker → 开 amp → 检查是否每次都在同步 |
这张表我建议你打印出来贴在显示器边上,出问题时按顺序试,不要东一榔头西一棒子。排查的核心原则是二分:先用一次极端实验确认问题出在哪个环节,比如把学习率降到 1e-5 看 loss 还爆不爆,爆就说明不是学习率的问题,往下游查。
5.2 几个我自己踩过的坑
第一个坑是权重衰减加到了 BN 参数上。当时的现象是模型怎么调都欠拟合,训练 loss 一直下不去,我换了优化器、加了容量、延长了训练都没用。后来把参数分组打出来一看,gamma 被衰减得快接近零了,等于把 BN 的表达能力废掉了。从那以后我养成了习惯:任何新项目第一步先把参数分组打印出来,确认哪些参数在衰减、哪些不在。
第二个坑是 dropout 和 BN 的顺序。早期我把 dropout 放在 BN 之前,训练很不稳定,因为 dropout 随机置零会改变 BN 输入分布的方差,导致统计量估计偏差。正确顺序是卷积 → BN → 激活 → dropout,让 BN 处理的是完整的、未被置零的激活。
第三个坑是多进程加载数据时种子重复。表面上开了 4 个 worker,实际上四个 worker 给出的增强结果高度相关,等于没增强。现象是加了增强但验证精度没有提升,甚至略降。解决办法是在 worker_init_fn 里用 worker_id 加上基础种子重新设随机状态。
第四个坑是 scheduler 和 optimizer 的调用顺序。有些调度器依赖 optimizer 的当前状态,如果先 scheduler.step() 再 optimizer.step(),某些实现会给出警告甚至算错。我现在的写法固定为:zero_grad → forward → backward → clip → optimizer.step → scheduler.step,按 step 级别更新。
第五个坑是恢复训练时忘了保存和加载 scheduler 与 optimizer 的状态。只存模型权重,中断之后学习率从峰值重新开始,曲线就断成两截,前面白训了。标准做法是 checkpoint 里存 model、optimizer、scheduler、epoch、最佳指标五个东西。
提示:如果你发现实验结果"不可复现",先别怀疑硬件。按这个顺序查:随机种子是否固定、是否开了确定性模式、数据划分是否一致、数据加载顺序是否受多进程影响、混合精度是否引入了非确定性算子。多数不可复现都能在这五项里找到答案。
还有一个心得,关于实验报告怎么写。我不看重你最后精度有多高,我看重的是你能不能解释清楚"为什么是这个结果"。有一次一个同学的最终精度只比基线高 1.2 个点,但他的报告里做了完整的单变量对照,把每一项的贡献拆得很清楚,还分析了两个技巧叠加时的相互抵消现象。这份报告我给了最高分。反过来,有的同学堆了一堆技巧把精度刷得很高,但问他哪个技巧起了主要作用,答不上来,那这次实验的意义就丢了大半。
最后再分享一个我在实际操作中的体会。网络优化这件事,最难的从来不是记住了多少个技巧,而是建立"改动—观察—归因"的闭环。我现在的习惯是每次动手之前先在纸上写一句假设,比如"我认为验证 loss 抬头是因为过拟合,加权重衰减到 1e-3 后训练验证 gap 应该缩小",跑完再写一句结论,符合或者不符合都写。这个习惯坚持下来,你对超参数的直觉会以肉眼可见的速度变准,比盲目跑一百组实验有用得多。