资讯详情

PyTorch迭代训练进阶:从训练循环到稳定收敛的完整指南

📅 2026/10/18 11:18:07 | 华诺云谱 👁 阅读
PyTorch迭代训练进阶:从训练循环到稳定收敛的完整指南
最近在整理之前做过的某个图像分类项目时发现一个挺有意思的现象同样一批代码、同样的数据增强策略、同样的优化器配置只是把训练主循环里的几个细节调整了一下最终模型的收敛速度和泛化性能差别竟然能到三到五个点。这也是我决定把《PyTorch进阶训练技巧》这个系列第四篇专门留给迭代训练的原因——前几篇聊过优化器、学习率、数据预处理但真正把这些组件串起来的是那个看起来最不起眼的训练循环。模型是否收敛、显存是否吃紧、断点能不能恢复、多卡训练是否同步所有问题的答案都藏在这个循环里。这一篇我把迭代训练相关的实操经验和踩坑记录整理一下适合已经能跑通基础训练脚本、想进一步稳定训练过程并提升迭代效率的人参考。1. 把训练循环拆开看一个epoch里每一步都在做什么1.1 训练模式与验证模式的切换不只是开关很多初学者会忽略model.train()和model.eval()这两个调用的意义总觉得它俩只是告诉PyTorch“现在该训练了”或者“现在别更新了”。实际上这行代码直接影响BatchNorm和Dropout的行为在train模式下BatchNorm会使用当前batch的统计量来归一化并且不断更新running_mean和running_varDropout会按设定概率随机丢弃神经元。而在eval模式下BatchNorm要使用训练阶段累积的running_mean和running_varDropout直接不生效。我见过一个非常典型的失误某次做迁移学习时某个同事把所有BN层设置成requires_gradFalse但在验证阶段没有调用model.eval()结果验证集的准确率一直在0.8上下浮动看起来像是数据集有问题排查了半天才发现是BN在验证时仍然按当前batch统计量做了归一化。小batch的验证集统计噪声本来就大这个波动会被直接放大。所以我的习惯是把train/eval切换写成一个独立的工具函数内部负责zero_grad、反传、参数更新之外还顺带把损失累积和数据加载器的行为一并处理避免在主循环里漏掉。验证阶段还要额外考虑一件事torch.no_grad()。有些人在eval循环里忘记使用它虽然eval模式下no_grad不是自动生效的梯度图还是会照常构建显存一点点被撑满速度也慢得离谱。另一个相关经验是如果数据集的验证集很小其实不需要每个epoch都跑完整验证可以在固定iteration间隔做一次轻量验证既省时间又能及时发现发散。1.2 zero_grad的位置为什么总有人写错训练循环里最常见的一个坑就是optimizer.zero_grad()的位置。它应该在forward之前、每个batch的开头调用而不是在loss.backward()之后。原因是PyTorch的梯度是累积accumulate的如果不在每个batch开始时清零上一个batch的梯度会叠加到当前batch的梯度上等效于把多个batch的梯度合并成了一次更新batch size瞬间变大学习率却没有跟着调整模型大概率在训练早期就出现loss震荡甚至直接发散。位置写错的另一个表现是把zero_grad放在了optimizer.step()后面。在单卡单batch的场景下这种写法不算致命但一旦切到梯度累积或多卡训练梯度归属就会混乱。举个例子某次我需要在4卡环境下复现一个单卡batch size 256的实验因为每张卡只能塞下64所以准备用梯度累积4步来等价一个大batch。排查了很久最后发现是把zero_grad放在step()之后导致优化器更新的时候用的是上一个batch的梯度整个累积逻辑全乱套了。我自己的规范写法是这样的for batch in train_loader: optimizer.zero_grad(set_to_noneTrue) x, y batch pred model(x) loss criterion(pred, y) loss.backward() optimizer.step()set_to_noneTrue这个参数值得展开说一下。默认情况下zero_grad会把梯度置为零张量而set_to_noneTrue是把梯度置为None。前者在内存中保留梯度缓冲后者会释放掉这部分内存下次backward时再重新分配。整体来说设置set_to_noneTrue能省一些显存而且因为置None后第一次backward一定不会出现“旧值残留”的问题不容易出隐藏bug。1.3 损失值的记录方式决定你能否判断收敛很多人记录loss是直接print(loss.item())打印出来的其实只是当前一个batch的瞬时损失。这个值噪声很大特别是batch size比较小或者数据分布波动的时候只看瞬时值很容易误判训练状态。我习惯用一个累积窗口来平滑loss比如维护一个running_loss每N个iteration输出一次平均损失。但这里有一个常见的错误有的人会把各个batch的loss直接累加然后除以总batch数注意loss.item()已经是mean reduction之后的标量直接平均问题不大但如果你改了loss函数的reduction参数比如设成sum那累加出来的东西就不具备可比性了不同batch大小之间、不同reduction策略之间数值含义完全不同。我建议在实验中统一使用mean reduction并把loss按iteration记录到一个CSV或TensorBoard曲线里。判断收敛时更重要的是看验证集上的评估指标而不只是train loss。因为在迭代训练的中后期train loss持续下降但验证指标不再变化甚至回落的情况太常见了这通常是过拟合的早期信号。我通常会把“验证指标连续多少个epoch不改善”作为早停条件而不是死等到设定的epoch数跑完。2. 迭代规模怎么定epoch数、数据增强和训练量之间的匹配2.1 用epoch规划还是用iteration规划大多数人的训练脚本写成for epoch in range(num_epochs)这没什么问题。但真正要思考的是num_epochs这个数字是靠什么定出来的。有些人简单拍脑袋定个100有些人照搬论文里的小规模数据集设置这两种做法在真实项目里都容易出问题。一个更工程化的思路是先估算总迭代次数total iterations。理论上模型对某个数据集需要看的有效样本量是一定的同样的数据集你用强数据增强需要的epoch数可能就要偏多你用弱增强则训练太快到达过拟合点。而iteration与epoch的关系是total_iterations epochs × len(train_loader)。如果你调整了数据加载方式、batch size变了同样epoch数对应的真实迭代量就已经不一样了。我用batch size举例原来batch size是32跑100个epoch现在显存够大batch size改成128。很多人直接保留100个epoch认为“迭代次数差不多够用”但实际每个epoch内step数量变成了原来的四分之一总参数更新次数直接缩水四倍整个训练过程就不完整了。这种情况下合理的做法是保持总iteration数量基本不变也就是把epoch数乘回来比如100 × 32 ÷ 128 25么不一定。还需要考虑batch size变大后梯度估计更平滑可能确实能少更新一些。但绝不能完全不做换算直接沿用epoch数。2.2 数据增强强度与总迭代数的协调数据增强本质上是在扩大有效训练集的规模让模型每次看到的样本都略有不同变相降低过拟合风险。但增强的强度不是越大越好过强的增强会让模型连原始样本的基本特征都学不牢固尤其在训练前期可能出现loss长时间不降的现象。在设置迭代规模时一定要把数据增强的强度考虑进去。一个经验参考是如果你的增强策略里包含了大量随机裁剪、随机颜色扰动这类强变换那么每个epoch的有效信息量会下降训练总epoch数应该相应增加比如从100调到150甚至200反过来如果你只是做了简单的归一化和随机水平翻转那么总epoch数按常规设置就行。之前在某次图像分类任务中A同学结合了Cutout和Mixup两类增强训练集本身只有两万张图他按常规跑了80个epoch模型一直处于欠拟合状态藏层特征的可视化结果很混乱。后来我把epoch调整到200并给学习率scheduler加了warmup模型才开始正常收敛。原因并不神秘强增强相当于给模型“制造了更多没见过的情况”模型自然需要更多时间消化。2.3 什么时候该靠验证集叫停理想情况下训练应该在验证指标达到峰值附近时停而不是学到最后再把所有checkpoint都拿出来看。早停的实现很简单但难的是定义“什么时候算不改善了”。我的做法是设置一个patience参数比如连续15个epoch验证F1或准确率没有超过历史最优值就触发早停。不要用loss做早停依据因为验证loss在小数据集上噪声大而且它对过拟合的敏感度通常低于分类指标。更稳妥的方式是同时盯住两个信息验证集目标指标和训练集与验证集指标之间的gap。如果gap持续扩大且验证指标进入平台期那基本可以确定过拟合开始了。还有一点早停之后你手上可能已经存了历史最优模型不一定需要用最后一步的权重。我在工程脚本里会把“验证指标最佳时的状态”单独存一份加载时用这份而不是最新epoch的结果。这个细节看起来简单但实际很多人复现实验时直接load了最后一个checkpoint白白损失几个点。3. 迭代过程中最容易出现的三处隐性损耗3.1 学习率scheduler的步进位置Scheduler.step()放在哪里是我在代码审查时最常指出的一个问题。PyTorch里不同的scheduler对step()的语义不一样比如StepLR是按epoch回调而OneCycleLR和CosineAnnealingWarmRestarts这类scheduler则可能同时支持按step或按epoch调用。把step()放进iteration循环同时又按epoch调用学习率曲线会被切得很碎模型的收敛行为会变得难以解释。为了避免这个问题我现在的统一约定是如果scheduler按epoch调整就在每一个epoch结束后调用如果按iteration调整比如OneCycleLR就只在optimizer.step()之后调用。有一回排查某次训练为什么后半程学习率降得特别快、模型一直没有收敛最后发现就是因为CosineAnnealingWarmRestarts被错误地放到epoch循环里结果LLR按iteration被缩小了一整个数量级。这种问题最难查因为代码能跑、loss在降、日志也正常只是最终效果不对。如果使用ReduceLROnPlateau这类基于验证指标的scheduler必须在验证完一轮后、拿到该轮的指标再调用step(val_loss)不能放在训练循环的前面否则它会收到上一个epoch的信息甚至初始值。3.2 验证集评估频率与显存释放验证集评估的频率直接影响训练墙钟时间。如果epoch很大、训练集有十万个batch每跑完一个epoch才验证一次可能间隔太久模型中间发散了也发现不了。我的习惯是对训练集的规模做一个预估如果单个epoch时间超过10分钟我会把验证放到固定的iteration间隔执行比如每1000个iteration验证一次而不是等完整epoch。验证集评估时还有一个显存释放的问题。在torch.no_grad()下forward过程不构建计算图但这不代表显存一定会被及时释放。如果验证循环里把整个验证集的所有预测结果都collect到CPU甚至直接保存到列表里内存和显存都可能持续攀升。我建议验证时只统计指标所需的聚合值不要保存每个样本的logits。如果需要保存特征做后续分析可以分batch计算按需落盘。另外还有一个小坑多卡验证。如果用了DistributedDataParallel验证循环里必须注意每个进程都会独立跑一遍验证集最后如果不做gather操作你打印出来的“验证指标”其实是每张卡各自的结果数字会明显偏低因为它只看了验证集的一个分片。我遇到过不少人在单卡转多卡之后发现验证精度掉了好几个点最后发现是验证集被每张卡切分导致。3.3 EMA影子权重与优化器更新的时序EMA指数移动平均是很多进阶实验里提升模型鲁棒性的常用手段它维护一份参数影子通过影子权重做评估或推理。但EMA更新必须在optimizer.step()之后进行顺序反了就会让EMA混入更新前的权重导致影子权重失效。简单的EMA实现是每次迭代后执行 shadow decay × shadow (1 - decay) × model_param。decay一般取0.99或0.999。要注意的是这里decay越大影子权重跟踪越慢但最终效果更平滑decay太小则几乎不提供额外提升。在小数据集上EMA的收益往往比加大正则化更明显因为影子权重等价于对参数空间做了平滑降低了训练后期的抖动。我常用的一个写法是把EMA封装成一个类它维护state_dict在每次step之后按参数名更新。保存模型时要保存的是EMA的影子权重而不是模型当前的参数。这里还要注意如果你先load了EMA权重再做模型的fine-tune等于又覆盖回去了需要来回切换时务必确认当前load的是哪一份状态。4. 给训练循环装上仪表盘记录指标与自检机制4.1 每条日志应该包含哪些关键字段训练走上正轨后最怕的就是模型中途发散但没人知道是从哪一步开始的。我现在的日志系统会在每一次eval之后记录一组固定字段当前epoch、当前iteration、train loss平滑后、验证集主指标、学习率、平均梯度范数、显存占用。这些信息汇总到一起才能真正定位一个训练过程中的异常。为什么梯度范数值得记录因为loss和梯度范数结合能判断出很多问题。比如loss缓慢下降但梯度范数突然冲到很高的数值大概率是某个batch的数据噪声过大或特征尺度异常导致梯度爆炸如果梯度范数长期接近零而loss还特别高那可能是某些层饱和了比如深层ReLU输出几乎全零。此外log里我习惯用json格式或者CSV追加方便后续分析。不要只在终端print因为print输出容易丢失且不利于事后分析。建议logging到文件的同时打印到stdout这样训练结束后还能把整条训练曲线draw出来复盘。4.2 梯度范数与layer输出波动检查梯度范数不只是用来观察还可以直接做保护。一个常见做法是grad cliptorch.nn.utils.clip_grad_norm_(model.parameters(), max_norm)。设置多大合适呢比较稳妥的是先跑几十个iteration收集梯度范数分布然后取一个明显高于中位数但又不至于截断太多正常梯度的值。不要照抄别人的max_norm1.0因为不同任务、不同模型、不同loss尺度下梯度范数可能是数量级的差别。如果你遇到loss突然变成nan要学会用pdb定位而不是一遍遍重新跑流程。一个比较快的方法是单独构造一个小数据集只跑单batch把梯度回传之后的参数梯度打出来观察是不是某一层产生了inf、nan。如果要彻底查可以检查forward输出的每一层张量是否包含异常值。我发现65%以上的nan问题都出现在数值溢出即之前某个loss计算没错但反向传播中指数函数、log函数等操作产生了inf。碰到这种情况优先考虑把计算改成数值稳定形式。4.3 损失突降或突升的快速归因训练过程中loss突然下降一大截有时不是模型“开窍了”而是有个batch的标签分布异常。例如在分类任务里某个batch绝大多数都是同一类模型快速压低loss看起来像性能提升实际上是一次过拟合信号的脉冲。这种信号如果只看平滑曲线是容易忽略的我建议把每个batch的原始loss保留一份通过绘制散点图来快速发现异常batch。loss突然升高则可以从几个方向同时排查学习率是否因为scheduler进入了一个陡峭区间、数据增强是否某次产生了一个极端样本、模型某些层是否发生了梯度累积后的大更新、BatchNorm的running mean是否因为验证阶段被错误更新。我的做法是给每个batch的loss记录一个batch id如果某个batch的loss明显偏离同周期其他batch就直接把这个batch的数据样本打印出来看看通常能很快找到问题。5. 断点续训checkpoint不只是保存模型5.1 到底要保存哪些东西断点续训在长迭代任务里几乎是刚需但很多人保存checkpoint时只保存了model.state_dict()结果恢复训练时只能从该点的模型参数继续而学习率scheduler、优化器动量、EMA影子、数据加载器进度全部丢失。这对于短任务无所谓对于以周为单位的训练来说几乎不可接受。一个完善的checkpoint应该包含以下部分checkpoint { model: model.state_dict(), optimizer: optimizer.state_dict(), scheduler: scheduler.state_dict(), ema: ema.state_dict() if ema else None, epoch: current_epoch, iteration: current_iteration, best_metric: best_metric, rng_state: torch.get_rng_state(), cuda_rng_state: torch.cuda.get_rng_state() if torch.cuda.is_available() else None, }看起来多但每一样都有用。optimizer.state_dict()里保存了Adam的两阶动量估计不保存这个恢复训练后前几步的更新方向都是歪的scheduler.state_dict()保证恢复之后学习率曲线不跳变rng_state保证断点续训之后的数据增强顺序和打乱顺序能接上否则同一个epoch内的样本排列完全不同一定程度影响复现性。保存epoch和iteration的目的是日志系统和学习率计算能对齐。如果不保存恢复之后从epoch 0开始scheduler已经步进到很靠后的位置前面的训练记录就会被覆盖只能靠手写补偿太容易出错了。5.2 恢复训练时的隐藏状态AMP缩放器与种子如果你用了AMP自动混合精度还有一个很重要的状态GradScaler的state_dict。AMP训练时GradScaler会动态维护一个scale因子如果断点续训时不恢复它第一次forward的scale会被重新初始化为默认值可能导致后续步长偏大或偏小早期阶段出现不稳定的现象。同样的道理适用于torch.backends.cudnn的相关设置。如果训练中用了cudnn.benchmark恢复训练时最好不更改算法选择逻辑。虽然这个影响比较微妙但在追求完全复现的实验中还是要尽量保持一致。恢复训练还有一种常见操作是“部分恢复”比如我只想加载模型的backbone权重不加载分类头这种情况下不能直接load_state_dict需要先读取原state_dict后过滤掉不匹配的key再手动覆盖。如果不做处理PyTorch会直接报错很多人第一次遇到时以为是代码问题其实是因为分类头的shape不一致这是完全正常的情况。5.3 最佳权重与最近权重分开管理在长训练任务里历史最优模型和最新训练状态是两个不同维度的资产。我习惯把它们放到不同文件里best_model.pt只存模型参数用于推理和评估checkpoint_latest.pt存完整状态用于断点续训。绝不能把两者的作用混在一起否则当你为了恢复训练加载了checkpoint后用来做推理时发现模型精度只有历史最好状态的70%就很难定位是哪一步出了问题。磁盘空间的清理策略也很重要。每次保存完整checkpoint大概几百MB到几GB跑几百个epoch下来磁盘很容易爆掉。我的策略是只保留最近三个checkpoint以及始终保留历史最优其余自动清理。这一步可以写在训练循环的save逻辑里不要手动定期清理否则一旦忘了清理磁盘写满导致训练中断是特别尴尬的事情。除开保存策略还有一个我认为同样重要的事训练前规划checkpoint路径时把实验名称、模型结构、训练日期写进目录名。做对比实验时一条清晰的checkpoint目录树能帮你快速定位模型质量不然过一个月再回来看到一堆名叫model_100.pth的文件你会完全分不清它们之间对应的数据增强策略或损失函数版本。6. 把迭代效率提上去AMP、梯度累积与多卡同步6.1 AMP自动混合精度的接入点AMP是提升GPT类大模型和小batch训练速度最直接的手段之一它通过自动混合精度让部分计算使用FP16减少显存占用同时保持关键参数使用FP32防止精度损失。在PyTorch中AMP的标准接入点是torch.autocast和GradScaler基本改法如下scaler torch.amp.GradScaler(cuda) for batch in loader: optimizer.zero_grad(set_to_noneTrue) with torch.amp.autocast(cuda): pred model(batch) loss criterion(pred, target) scaler.scale(loss).backward() scaler.unscale_(optimizer) # 选择是否手动uncmap torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm) scaler.step(optimizer) scaler.update()关键在于forward和loss计算在autocast上下文里做backward通过scaler.scale(loss)完成。这里的逻辑是FP16梯度可能因为数值过小而underflow所以先把loss放大再反传得到FP16梯度后再unscale回真实值最后用真实梯度更新参数。如果忘记调用scaler.unscale_就直接做clip你clip的是缩放后的梯度这会让clip阈值与真实阈值相差一个scale因子整个训练都会受影响。AMP训练后验证阶段同样需要使用autocast否则推理时的数值精度和训练时不一致可能在验证指标上看到明显波动。另外有一些自定义的loss函数内部有大量累积操作对FP16不够稳定前面提到过的数值稳定性问题在AMP场景下会更明显建议在实现时就用更高精度的累加器。6.2 梯度累积的迭代节奏设计梯度累积是显存不够时模拟大batch的常用手段它的核心逻辑是连续积累多个batch的梯度积累到预定的accumulation_step后再做一次optimizer.step()。但是这里有个容易忽略的点不同batch的梯度累积时loss scale也要保持一致的精度否则累积后数值会产生偏差。我建议accumulation_step不要大于8除非你有明确的实验依据。累积步数过大会让参数更新频率变得极低训练时间拉长而且优化器的动量信息都是基于累积梯度计算出来的batch size的增大在Adam里会改变一阶动量估计的分布这未必是期望的正向效果。另外在梯度累积模式下日志记录和验证循环的iteration计数要重新对一下。比如你每4个batch才更新一次参数那么“真实迭代次数”应该是optimizer.step()的次数而不是batch的个数。很多人在报告训练进度时把累积前的batch数当迭代数导致learning rate sceduler计算出来的坐标和训练曲线的横轴对不上。6.3 多卡同步时BN统计与梯度归一的问题多卡训练时每个进程处理自己的数据分片梯度需要跨卡同步。使用PyTorch自带的DistributedDataParallel时backward会自动做梯度all-reduce但BatchNorm层的统计量不是自动同步的。如果模型中有BN层而每个进程的batch size又比较小比如只有8那么每张卡算出来的batch统计量噪声会很大整体效果反而不如单卡大batch稳定。解决方法包括使用SyncBatchNorm把BN统计量在卡之间做同步代价是一些通信开销或者干脆把目标任务设计成大batch单卡训练。在多卡场景下还有一个梯度尺度的问题。DDP默认对梯度做均值归并即把各卡的梯度相加后除以world_size这等价于把全局batch size当作所有卡的总和学习率不需要额外调整。但如果你手动做了梯度累积同时又开DDP累积步数和卡数AND在一起产生的有效batch size很容易被算错。我建议在做多卡梯度累积的复杂实验时先把有效batch size写成一个显式变量在scheduler和日志里都引用它避免写死数字。关于学习率的scale规则有一个简单参考线性缩放原则即batch size放大k倍初始学习率可以按sqrt(k)或k倍做调整具体是哪种取决于模型结构、优化器和任务规模。业界实践中视觉任务用sqrt更稳语言模型场景则更倾向于直接用线性缩放的变体。但需要注意关键是在调大batch后同步增大warmup的步数否则模型在训练的早期会被过大的lr冲击。7. 一些在长迭代任务中代替拍脑袋的实用建议训练迭代这件事很多人觉得只要模型结构对、学习率设个熟知的默认值就可以直接跑但真正动手起来你会发现影响最终结果的变量比想象中多得多。我这里分享几个自己经过多轮实验后沉淀下来的习惯也许能帮你少踩一些坑。第一重视初始随机种子和DataLoader的worker数量设置。PyTorch的DataLoader多进程加载时如果你的增强逻辑里用了随机数生成器那么不同worker的seed必须固定否则每个epoch的数据增强序列都不一样训练结果难以复现。我常见有人为了求快把num_workers调到32甚至更高但worker过多会增加通信开销在小数据集上反而拖慢速度。一个粗略的参考是worker数不要超过CPU逻辑核心数的75%并且要在固定seed的基础上保证每个worker拿到不同的初始种子。第二不要在一开始就使用太激进的余弦退火或OneCycle策略。这类scheduler在训练后半段会把学习率压得非常低如果前期的超参设定不理想后期的低学习率基本无法修正已走偏的参数空间。我自己更倾向于先用StepLR或带warmup的多步衰减验证一轮基础性能后再切换到更激进的退火方案。这不是说后者不好而是说在实验早期曲线的可解释性比极限性能更重要。第三把“最大训练量”和“理想训练量”做区分。在启动一个长迭代任务之前我先预估这个任务在合理时间内可以跑多少epoch然后把这个epoch上限写到配置文件里。但真正的早停逻辑按照验证指标来做这样既保证了训练过程不会无限拖下去也不会因为提前设定了一个过小的epoch数而当模型还没收敛时就被强行打断。最后我想特别提一下日志管理。很多人的训练脚本里只打印一行“epoch: 10, loss: 0.43”这对调试来说信息严重不足。我强烈建议把每次验证后的指标、每个epoch的平均loss、梯度范数、学习率、时间戳等字段统一落盘。说句实在话项目越多越久你会越发觉得这些日志才是训练过程中最重要的产出之一模型权重可以被替代但这些记录不会说谎。绝大多数“当时看起来没问题后来怎么也想不通”的训练实验最后都是靠日志里的细节找回原因的。整个迭代训练说复杂也复杂说简单也简单把每个细节都当成有意义的决策而不是机械地照抄。我在做这些实验的过程中最大的体会是不要轻视那些看似基础的环节zero_grad、train/eval、scheduler.step、checkpoint这些事情的背后都藏着对训练本质的理解。希望这篇总结能让你在下一轮跑训练时少一些无头苍蝇式的调参多一些对系统整体状态的把握。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑