最近在整理之前做过的某个图像分类项目时,发现一个挺有意思的现象:同样一批代码、同样的数据增强策略、同样的优化器配置,只是把训练主循环里的几个细节调整了一下,最终模型的收敛速度和泛化性能差别竟然能到三到五个点。这也是我决定把《PyTorch进阶训练技巧》这个系列第四篇专门留给迭代训练的原因——前几篇聊过优化器、学习率、数据预处理,但真正把这些组件串起来的是那个看起来最不起眼的训练循环。模型是否收敛、显存是否吃紧、断点能不能恢复、多卡训练是否同步,所有问题的答案都藏在这个循环里。这一篇我把迭代训练相关的实操经验和踩坑记录整理一下,适合已经能跑通基础训练脚本、想进一步稳定训练过程并提升迭代效率的人参考。
1. 把训练循环拆开看:一个epoch里每一步都在做什么
1.1 训练模式与验证模式的切换不只是开关
很多初学者会忽略model.train()和model.eval()这两个调用的意义,总觉得它俩只是告诉PyTorch“现在该训练了”或者“现在别更新了”。实际上这行代码直接影响BatchNorm和Dropout的行为:在train模式下,BatchNorm会使用当前batch的统计量来归一化,并且不断更新running_mean和running_var;Dropout会按设定概率随机丢弃神经元。而在eval模式下,BatchNorm要使用训练阶段累积的running_mean和running_var,Dropout直接不生效。
我见过一个非常典型的失误:某次做迁移学习时,某个同事把所有BN层设置成requires_grad=False,但在验证阶段没有调用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_none=True) x, y = batch pred = model(x) loss = criterion(pred, y) loss.backward() optimizer.step()set_to_none=True这个参数值得展开说一下。默认情况下zero_grad会把梯度置为零张量,而set_to_none=True是把梯度置为None。前者在内存中保留梯度缓冲,后者会释放掉这部分内存,下次backward时再重新分配。整体来说设置set_to_none=True能省一些显存,而且因为置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 clip:torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm)。设置多大合适呢,比较稳妥的是先跑几十个iteration收集梯度范数分布,然后取一个明显高于中位数但又不至于截断太多正常梯度的值。不要照抄别人的max_norm=1.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_none=True) 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这些事情的背后,都藏着对训练本质的理解。希望这篇总结能让你在下一轮跑训练时少一些无头苍蝇式的调参,多一些对系统整体状态的把握。