做深度学习训练绕不开“迭代”这两个字。模型不是一次性学会的而是被一批一批的数据喂着、一轮一轮地更新参数——这个“一批一批”“一轮一轮”的过程就是 PyTorch 里说的迭代训练。很多人觉得迭代就是最外层套个 for epoch内层套个 for batch能跑通就行但真到了调模型、复现论文、压训练成本的时候对迭代粒度的理解深浅直接决定你踩坑的数量。这篇文章主要写给已经有训练经验、但对训练循环细节还停留在“能跑就行”阶段的同学。看完以后你应该能搞清楚三件事迭代训练里的关键参数到底怎么算出来的梯度累积、按迭代调学习率这类进阶技巧为什么有效、怎么落地训练出问题时应该按什么顺序排查。文章以 PyTorch 为主所有代码片段我都放在真实项目里验证过不是教科书式的伪代码。我最早写训练脚本也是照着示例代码抄epoch 套 batchbatch 里三行核心代码跑通就算完事。后来负责一个图像检测任务同样的模型、同样的数据别人收敛又快又稳我的 loss 反复震荡。对比下来发现差别不在模型结构而在训练循环的控制粒度——别人把学习率、验证频率、模型保存全部细化到了迭代级别而我只认 epoch。从那以后我对“迭代训练”的理解彻底变了epoch 是日历iteration 才是心跳。1. 迭代训练到底在“迭代”什么先把三笔账算清楚1.1 一个 epoch 里到底有多少个 iteration先明确基础概念。PyTorch 的标准训练循环是两层循环内层每执行一次就叫一个 iteration也叫一个 stepfor epoch in range(num_epochs): for batch_idx, (inputs, targets) in enumerate(train_loader): # 前向、反向、更新 ...一次 iteration 处理一个 batch 的数据完成一次参数更新。外层每完整跑完一遍训练集就是一个 epoch。总迭代次数的公式非常直接total_iterations len(train_loader) * num_epochs其中 len(train_loader) 就是数据集的 batch 数。举个例子训练集 10000 张图batch_size 128那么一个 epoch 就是 ceil(10000 / 128) 79 个 iteration。你训练 100 个 epoch总共就是 7900 次迭代。这个数为什么重要因为它是后续所有“按迭代”策略的基准。学习率调度要算总步数、warmup 要算步数、OneCycle 要算步数、日志打点要定步长、模型保存要定步长全部以这个数为锚。我见过不少人把学习率调度器的 total_steps 凭感觉填一个数结果余弦衰减根本没走完训练效果大打折扣。所以第一件事就是把你项目的这个数准确算出来写到配置里注释上来源。1.2 为什么迭代粒度比 epoch 粒度更精细直接说结论迭代粒度让你对训练过程的控制精确到“每一次参数更新”epoch 粒度做不到这种精细度。拿验证来说。如果按 epoch 验证100 个 epoch 最多验证 100 次如果按迭代验证每 500 次迭代验证一次一个 epoch 内部可能就验证了好几次。对大模型、大数据集来说一个 epoch 可能要跑几个小时等按 epoch 验证完再发现 loss 已经发散半天时间就没了。按迭代验证能更早发现异常这个时间差在大规模训练里是致命的。学习率调度同理。余弦退火本质上是关于“总步数”的函数不是关于“总 epoch 数”的函数。按 epoch 调学习率只是把“每跑完一轮数据”当成一个时间刻度但真正决定每次参数更新幅度的是 iteration。同一个 epoch 内不同 batch 带来的梯度差异可能很大按迭代调学习率可以平滑地控制每一步的更新力度。我现在的习惯是所有跟“时间顺序”相关的控制项一律按 global_step全局迭代计数来处理不按 epoch。代码里维护一个全局计数器global_step 0 for epoch in range(num_epochs): for batch_idx, (inputs, targets) in enumerate(train_loader): # 训练... global_step 1后续所有日志、验证、保存、学习率调度都以 global_step 为准。这一个习惯能让训练过程的可复现性提高一大截——换数据、换 batch_size、换 epoch 数不需要改代码逻辑只调整几个步数参数就行。1.3 迭代训练与“收敛”的关系不是跑完就完事迭代次数决定了模型参数被更新的次数但“更新次数够不够”跟“模型收敛了没有”是两回事。理论上只要优化器还在更新loss 就可能继续下降但存在收益递减的问题。实践中我通常会设定一个“最大迭代上限”同时配合早停来判断是否提前终止。这个上限不能拍脑袋定一般参考两个来源一是同规模模型在类似数据上的训练曲线通常从 loss 曲线平缓的位置往后推 20%~30%二是资源约束比如显存、训练时长、电费预算。把上限写在配置里也是为了后续对比实验时公平——每个实验跑相同的迭代次数比较结果才有意义。2. 迭代训练的核心组件拆解DataLoader 与三件套2.1 DataLoader 是迭代的燃料源细节决定下限迭代训练的心脏是 DataLoader因为每一次 iteration 拿到的数据质量直接决定梯度质量。这里有几个参数要特别留意。shuffleTrue 必须开。不开 shuffle 的话模型在每个 epoch 内看到的样本顺序永远一样容易学到数据顺序上的伪规律收敛会变差。开了 shuffle 以后每个 epoch 的 batch 组成都不一样梯度估计更接近独立同分布假设。drop_lastTrue 建议默认开。最后一个 batch 如果凑不满样本数比其他 batch 少很多导致这个 iteration 的梯度统计量偏大如果模型里有 BatchNorm还可能让 BN 的统计量抖动。drop_last 会把这个不完整的 batch 丢掉损失一点数据但换来每个 iteration 的稳定性。num_workers 和 pin_memory 影响的是“燃料供应速度”。GPU 算得快CPU 喂数据喂不过来就会造成 GPU 空转训练总时长被数据加载拖慢。一般 num_workers 设为 CPU 核心数的 1/2 到 2/3 起步再实测调整。pin_memoryTrue 可以把 CPU 侧的页锁定内存直接传给 GPU减少拷贝开销只要内存够就开着。还有一个很多人忽略的点设置好 DataLoader 的随机种子。为了复现实验我通常在构造 DataLoader 前调用torch.manual_seed(seed)并且给 DataLoader 传入generatortorch.Generator().manual_seed(seed)。否则每次重启训练数据打乱顺序不同实验结果对不上排查问题时非常痛苦。2.2 前向、反向、更新三件套的时序细节每个 iteration 内部的标准三件套是optimizer.zero_grad() outputs model(inputs) loss criterion(outputs, targets) loss.backward() optimizer.step()时序上有几个细节值得较真。zero_grad() 必须在 backward() 之前调用否则梯度会跨 iteration 累积。PyTorch 的梯度是累加机制不清零的话上一个 batch 的梯度会叠加到当前 batch 上参数更新方向完全错乱。有人习惯把 zero_grad() 放在 step() 之后效果上通常等价但放前面逻辑上更安全——万一这次迭代因为异常跳过 step下次也不会带着残留梯度。forward 和 backward 之间的梯度传播链要理清楚。如果你在训练代码里写了loss.item()或者outputs.detach().cpu()这些操作不会破坏计算图但要小心不要在不该 detach 的地方 detach否则梯度传不过去loss 下降了参数却纹丝不动——这种“假训练”问题很难排查。还有一个常见的坑把.item()用在非标量张量上。loss.item()只能在标量上调用如果 loss 是向量比如某些多任务损失是各分量拼的要先loss.mean()再.item()否则直接报错。2.3 梯度累积用迭代模拟大 Batch 的正确姿势梯度累积是迭代训练里最实用的技巧之一。核心动机是显存不够但想要更大的 batch size。一次迭代只放得下 batch_size32 的样本但模型设计或论文要求等效 batch_size128那就攒 4 次迭代的梯度再更新一次参数。直接上代码accum_steps 4 optimizer.zero_grad() for batch_idx, (inputs, targets) in enumerate(train_loader): outputs model(inputs) loss criterion(outputs, targets) / accum_steps loss.backward() if (batch_idx 1) % accum_steps 0: optimizer.step() optimizer.zero_grad()注意这里有个关键细节loss criterion(outputs, targets) / accum_steps。为什么要除因为 PyTorch 的 loss 通常是 batch 内损失的均值梯度是 loss 对参数的偏导。如果累积 4 个小 batch 直接相加等于把梯度放大了 4 倍学习率必须相应缩小否则参数更新步子过大。除以 accum_steps 之后4 次累积的梯度相当于一次大 batch 的平均梯度更新幅度与直接使用大 batch 训练基本一致。梯度累积有几个伴随着的坑我踩过之后才明白BatchNorm 的处理。BatchNorm 是按当前 batch 统计均值方差的累积 4 个 batch 并不会自动变成大 batch 的 BN 统计等效大 batch 的效果只体现在梯度上BN 仍然是按 32 的粒度统计。所以如果你的网络重度依赖 BatchNorm梯度累积的收益会打折扣这种情况要么用 SyncBatchNorm要么直接调大单次 batch。与混合精度配合时需要把 loss 缩放器GradScaler的 step 放在累积完成之后后面第 5 节单独讲。累积期间如果某个 batch 的 loss 是 NaN整段累积的梯度都会被污染建议在 backward 之后顺手检查 loss 是否有效异常就跳过本次累积。3. 基于迭代的训练流程实操从框架到策略3.1 一套可复用的迭代训练框架代码下面这套框架是我在多个视觉项目里一直沿用的骨架核心思路就是所有控制项都绑定 global_step。代码里加了注释可以直接抄走改成自己的网络和数据。import torch import torch.nn as nn from torch.utils.data import DataLoader from tqdm import tqdm import math device torch.device(cuda if torch.cuda.is_available() else cpu) model YourModel().to(device) train_loader DataLoader(dataset, batch_size64, shuffleTrue, drop_lastTrue, num_workers8, pin_memoryTrue) optimizer torch.optim.AdamW(model.parameters(), lr1e-3, weight_decay5e-2) criterion nn.CrossEntropyLoss() total_epochs 100 total_iters len(train_loader) * total_epochs warmup_iters int(0.05 * total_iters) # 前 5% 步数用于热身 log_interval 50 # 每 50 步打印一次日志 eval_interval 500 # 每 500 步验证一次 ckpt_interval 2000 # 每 2000 步存一次断点 global_step 0 best_metric 0.0 for epoch in range(total_epochs): model.train() progress tqdm(train_loader, descfEpoch {epoch1}/{total_epochs}) for batch_idx, (inputs, targets) in enumerate(progress): inputs, targets inputs.to(device), targets.to(device) # 按迭代调整学习率 lr lr_schedule(global_step, warmup_iters, total_iters) for g in optimizer.param_groups: g[lr] lr optimizer.zero_grad() outputs model(inputs) loss criterion(outputs, targets) loss.backward() optimizer.step() global_step 1 if global_step % log_interval 0: progress.set_postfix(lossloss.item(), lrlr, stepglobal_step) if global_step % eval_interval 0: metric evaluate(model, val_loader, device) if metric best_metric: best_metric metric torch.save(model.state_dict(), best_model.pt) if global_step % ckpt_interval 0: torch.save({ model: model.state_dict(), optimizer: optimizer.state_dict(), step: global_step, best_metric: best_metric, }, fcheckpoint_step{global_step}.pt)这段代码看起来普通但把迭代训练的核心思想都装进去了学习率按 global_step 算、日志按 global_step 打点、验证按 global_step 触发、断点保存也按 global_step 命名。你不需要关心现在是第几个 epoch只需要看 global_step整条训练链路的状态一目了然。3.2 按迭代调整学习率Warmup Cosine 的完整实现学习率调度是迭代训练里收益最明显的部分没有之一。我推荐的基础配置是“线性 Warmup 余弦退火”也就是前几千步把学习率从 0 逐渐升到峰值然后按余弦曲线衰减到接近 0。这样做的好处是训练初期参数离最优解远梯度方向噪声大先用小学习率“稳住方向”后期学习率降低帮助模型在最优解附近精调。自定义调度函数实现如下def lr_schedule(step, warmup_steps, total_steps, peak_lr1e-3): if step warmup_steps: return peak_lr * step / warmup_steps progress (step - warmup_steps) / (total_steps - warmup_steps) return 0.5 * peak_lr * (1 math.cos(math.pi * progress))这个函数直接给出当前步的学习率训练循环里逐迭代设置到 optimizer 的 param_groups 即可。它的好处是调度与实际训练步数严格同步不像 PyTorch 自带的部分 scheduler 依赖 epoch 调用时机epoch 中途想改验证频率都不会错乱。如果不想手写PyTorch 自带的torch.optim.lr_scheduler.OneCycleLR也能实现类似效果它本身就是按迭代设计的需要传入 total_stepsfrom torch.optim.lr_scheduler import OneCycleLR scheduler OneCycleLR( optimizer, max_lr1e-3, total_stepstotal_iters, pct_start0.05, anneal_strategycos, )使用 OneCycleLR 时要注意每个 iteration 结束都要调用scheduler.step()而且不要在循环里再手动设置 param_groups 的 lr否则会被覆盖。我遇到过有人同时用手动调度和 scheduler结果学习率忽大忽小训练曲线像锯齿一样。3.3 迭代级日志、验证与断点续训的落地细节日志打点的间隔要跟单次迭代耗时匹配。如果你的单次迭代只有 0.2 秒每 10 步打一次日志屏幕会刷爆如果单次迭代要 10 秒每 1000 步打一次可能半天看不到反馈。一般原则是让日志间隔控制在半分钟到两分钟出现一次既能及时发现问题又不至于干扰训练。我在项目里常设 log_interval 50 或 100配合 tqdm 的 set_postfixloss 和 lr 实时显示非常直观。验证频率也要跟迭代耗时联动。验证太频繁GPU 大部分时间在跑验证集而不训练总体效率下降验证太少过拟合信号发现太晚。建议验证耗时控制在训练耗时的 5%~10% 以内也就是每 500~2000 次迭代验证一次具体按数据集大小调。断点续训必须保存的关键信息不是只有模型权重还要有优化器状态、global_step、best_metric。只保存 model.state_dict() 的坑在于重启训练后优化器的动量状态丢失学习率位置也不对前几千步的收敛速度会明显变差。保存完整状态后续训时恢复如下ckpt torch.load(checkpoint_step2000.pt) model.load_state_dict(ckpt[model]) optimizer.load_state_dict(ckpt[optimizer]) global_step ckpt[step] best_metric ckpt[best_metric]注意优化器状态恢复后要继续使用同样的优化器超参数否则状态可能不匹配。有一点要提醒PyTorch 版本升级后旧的优化器状态字典可能加载失败报 key 不匹配的错如果遇到优先用同一版本环境续训或者用 load_state_dict 时忽略不匹配的 key。4. 高频踩坑与排错实录迭代训练常见问题速查4.1 Loss 不降、震荡、NaN 的排查顺序这是迭代训练里最让人头疼的一类问题我根据自己的排错经验整理了一个固定的排查顺序按这个顺序来不容易乱。第一步确认梯度真的有在更新。很多人调了半天结果梯度根本没回传。检查方式很简单在 step 之前打印模型某层权重的梯度范数看是否为 0。如果梯度一直为 0检查是不是忘了 backward或者模型参数被 detach 截断了。第二步确认学习率没有过大或过小。lr 过大loss 会震荡甚至变成 NaNlr 过小loss 下降极其缓慢像没在训练。先把学习率调回经验值Adam 系 1e-3 到 3e-4 区间验证基线能学会再逐步调高。第三步检查数据。标签有没有从 0 开始类别数对不对归一化是否合理我就遇到过 label 从 1 开始导致 CrossEntropyLoss 直接报错的情况。还可以用一个小数据子集跑一个 batch过拟合到单个 batch 上看 loss 能不能降到很低——如果连一个 batch 都拟合不了问题大概率在模型或数据预处理不在训练策略。第四步排查数值稳定性。输入含 NaN 或 inf、没有归一化导致特征值巨大、loss 本身包含对数运算都是常见诱因。可以给模型加输入 clamp或者减小初始学习率或者换更稳定的 loss 实现。4.2 梯度累积与 BN、混合精度的兼容问题梯度累积看起来简单但跟其他机制搭配时容易出问题。BN 前面已经说了累积不会改变 BN 的统计粒度所以网络里有 BN 时梯度累积的收益要打折扣。混合精度配合梯度累积时正确写法是scaler torch.cuda.amp.GradScaler() for batch_idx, (inputs, targets) in enumerate(train_loader): inputs, targets inputs.to(device), targets.to(device) with torch.cuda.amp.autocast(): outputs model(inputs) loss criterion(outputs, targets) / accum_steps scaler.scale(loss).backward() if (batch_idx 1) % accum_steps 0: scaler.step(optimizer) scaler.update() optimizer.zero_grad()关键点scaler.step() 只累积满之后调用一次update() 也只调用一次如果每个 batch 都调用 scaler.step()那么累积就失去了意义。我最早写错过一次结果等效 batch size 根本没变大只是把梯度累加了又清零白白浪费显存优化的机会。4.3 显存溢出与数据加载瓶颈显存溢出OOM是迭代训练里最常遇到的硬错误。常规处理手段是减小 batch_size、减小模型输入分辨率、使用混合精度。但如果这些都做了还不够可以考虑梯度累积配合小 batch_size——单次迭代显存占用小但等效 batch 不小这是兼顾显存与效果的常用解法。还有两个容易被忽视的点。一是验证阶段也可能 OOM验证时不要一次性把整个验证集塞进模型用 DataLoader 分批验证并且验证阶段用torch.no_grad()包裹。二是 CPU 内存溢出num_workers 开太大、DataLoader 的 prefetch 机制会占用大量 CPU 内存如果系统经常卡死把 num_workers 调小或者设置prefetch_factor2限制预取数量。数据加载瓶颈的表现是 GPU 利用率忽高忽低。排查方法训练时用nvidia-smi观察 GPU 利用率如果经常在 30% 以下跳动多半是数据加载跟不上了。除了调大 num_workers还可以把数据提前预处理成内存格式比如把图像先转成 tensor 缓存起来或者用更高效的解码库。这个优化在迭代速度上提升非常明显有时候能把训练时间缩短一半。4.4 常见问题速查表现象可能原因验证方法解决方案Loss 为 NaN学习率过大、数据含 NaN、loss 数值不稳定打印输入是否含 NaN调低 lr 复跑降低 lr / 加 clamp / 检查数据预处理Loss 几乎不降lr 过小或梯度未回传打印梯度范数调大 lr / 修复 backward 链路GPU 利用率低DataLoader 喂数据慢nvidia-smi 观察利用率增大 num_workers / 预缓存数据显存 OOM单次迭代占用过大观察报错位置减小 batch / 梯度累积 / 混合精度重启训练效果变差断点未保存优化器状态检查保存的 ckpt 键保存 optimizer 与 global_step验证结果突然暴跌模型保存时机不对查看保存时的 global_step按 best metric 保存而非最后一步5. 迭代训练的三个进阶习惯混合精度、EMA 与早停5.1 混合精度训练每个迭代省显存提速度混合精度AMP已经是迭代训练的事实标配。原理一句话前向和反向用 FP16 计算梯度用 FP32 保存通过损失缩放防止 FP16 下梯度下溢。配合支持 Tensor Core 的 GPU每个迭代的计算速度明显提升。PyTorch 的落地写法前面已经出现过核心四行scaler torch.cuda.amp.GradScaler() with torch.cuda.amp.autocast(): outputs model(inputs) loss criterion(outputs, targets) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()注意事项BatchNorm 在 AMP 下仍以 FP32 运行这是正常的Loss 中存在自定义的 exp/log 操作时要注意 FP16 精度下可能溢出必要时把那部分计算切到 FP32梯度累积时前文已经提过scaler.step() 只在累积满后调用。如果训练过程中出现 loss 持续抖动但没 NaN可以检查是不是 scaler 的 loss_scale 在频繁降——这通常意味着某个中间结果在 FP16 下溢出了。5.2 EMA让每个迭代的结果“平滑”起来指数移动平均Exponential Moving Average是迭代训练里性价比极高的稳定性技巧。思路是维护一份模型参数的影子副本每个迭代把当前模型参数按一定比例“融入”影子副本ema_decay 0.999 ema_model copy.deepcopy(model) ema_model.eval() # 每个迭代训练完之后更新 with torch.no_grad(): for ema_p, p in zip(ema_model.parameters(), model.parameters()): ema_p.data.mul_(ema_decay).add_(p.data, alpha1 - ema_decay)预测时用 ema_model 而不是原模型结果通常更稳尤其适合分割、检测这类对预测平滑性敏感的任务。这个技巧不增加任何训练开销只多一份模型参数的内存。需要注意EMA 一般只对模型参数做不包含 BatchNorm 的 running_mean 和 running_varBN 统计量还是用原模型在训练集上跑的。5.3 基于迭代的早停把“跑完”改成“验证通过”很多训练任务其实不需要跑满全部迭代。基于迭代的早停逻辑很简单记录验证集上的最佳指标如果连续 N 次验证都没有刷新最佳值就提前终止训练。patience 5 # 连续 5 次验证没有提升就停 bad_count 0 best_metric 0.0 # 每次验证后 if metric best_metric: best_metric metric bad_count 0 else: bad_count 1 if bad_count patience: print(fEarly stop at step {global_step}) break早停的 patience 要结合验证频率设置。如果每 500 步验证一次patience5 意味着允许模型 2500 步没有变好。这个值太小可能误停太大又浪费计算资源一般先跑一轮完整的看曲线再根据实际波动幅度定 patience。我通常把早停的 best_metric 变化阈值放宽一点避免 loss 在小范围内抖动时频繁触发早停——比如只有指标提升超过 0.001 才算“提升”。我个人的体会是迭代训练的核心不是记住这些代码而是建立一种“以参数更新为最小时间单位”的思维方式。当你把学习率、验证、保存、早停全部切换到 global_step 坐标系之后训练脚本会变得异常清爽换任何数据集和模型都能快速迁移。最后再分享一个小技巧每次启动大型训练前先跑 200 步“烟雾测试”确认 loss 在下降、梯度范数合理、验证能跑通再放量到完整训练。这 10 分钟的前置检查省下的是你可能浪费的几十个小时。