1. 动手之前先把整条链路的骨架搭出来
很多人在 PyTorch 上写神经网络代码,卡住的地方几乎都不在模型结构上,反而卡在训练循环里几行看起来不起眼的代码:忘了zero_grad、验证时忘了model.eval()、标签张量的 dtype 给成了 float、验证集指标比训练集高得离谱却不知道问题出在 BatchNorm。这篇就围绕 PyTorch 神经网络过程代码,把从数据管道、模型定义、训练循环、验证评估到保存导出的完整链路拆开讲,每个环节都交代清楚为什么这么写、换成别的写法会踩什么坑。内容偏向实操,默认你已经能装好环境、跑过import torch,也看得懂张量是什么;如果你还处在“知道线性层长什么样但没写过完整训练脚本”的阶段,这篇正好能填上中间那段空白。全文的代码都可以直接粘到本地脚本里跑,我尽量把每个参数取值背后的算术也写出来,方便你按自己的数据规模改。
1.1 五段式结构,为什么这样切
一个能复现、能调、能交付的训练脚本,我习惯切成五段:配置与随机种子、数据管道、模型、训练与验证循环、保存导出。切法本身没什么玄学,关键在于这五段之间的契约要清楚——数据管道只负责吐出形状和 dtype 都正确的批次,模型只吃批次产出 logits,训练循环只做前向、算损失、反向、更新,验证循环只读不写梯度。契约一旦模糊,后面调试就会变成“不知道是哪一段坏了”的盲猜。
我见过不少人把数据预处理、模型内部归一化、损失里的正则项三处混着写,最后 loss 曲线抖动得厉害,只能靠反复调学习率来压,其实根子在契约不清。切段的好处是每段都能单独验证:数据管道拿一个 batch 打印形状和取值范围,模型拿一个随机 batch 跑一次前向看输出形状和数值量级,训练循环先只跑一个 batch 看 loss 有没有下降。逐段过一遍,比一次性跑完然后盯着一条看不懂的曲线强太多。
1.2 先跑通一个“笨模型”,再谈结构优化
新项目我几乎都会先用一个两层全连接的小模型把整条链路跑通,哪怕它最终精度差得没法看。原因很实在:全连接层对输入的容忍度高,形状错了一眼就能看出来,而卷积、注意力这类结构一旦形状对不上,报错信息往往指向很深的一行。先让链路通,再把模型换掉,出问题就能立刻判断是“换模型带进来的”还是“链路本来就有的”。
这个阶段还有个动作值得养成习惯:固定随机种子。torch.manual_seed(42)、numpy.random.seed(42)、random.seed(42)三件套写进配置里,配合torch.backends.cudnn.deterministic相关开关,能让两次运行的初始权重和打乱顺序尽量一致。做消融实验时,差异来自改动的部分才有意义;种子不固定,两次结果差 0.5 个点你根本分不清是改动起效还是随机性。代价是关掉部分加速,前期完全值得。
1.3 目录与配置文件怎么放
小项目我一般就三个文件:config.py管超参、data.py管数据集和加载器、train.py管主流程。别嫌文件多,超参写进 dict 或 dataclass 之后,日志里把它原样打印一次,事后复现某个实验就不用翻聊天记录。参数命名保持前后一致,batch_size就别在另一个文件里写成bs,这种低级不一致在赶工阶段能把人逼疯。
下面这套配置结构我用了很久,够简单也够用:
from dataclasses import dataclass @dataclass class Config: seed: int = 42 epochs: int = 30 batch_size: int = 64 lr: float = 3e-4 weight_decay: float = 1e-2 num_workers: int = 4 amp: bool = True accum_steps: int = 1 ckpt_dir: str = "ckpt"参数集中管理的好处不只是好看,更在于训练循环里不要再出现魔法数字。看到if step % 100 == 0这种写法,过两周再回来就只能猜一百是什么含义。日志文件名带上关键超参,比如lr3e-4_bs64_wd1e-2,几十个实验攒下来你会感谢当时的自己。
2. 数据管道:Dataset 与 DataLoader 的那些细节
神经网络训练里真正容易出事的环节,数据管道能占一半。原因很简单:模型写错通常当场报形状错误,数据写错却会安静地跑完三十个 epoch,最后给你一个“怎么调都不涨”的结果。我自己的经验是,先把__len__、__getitem__和一个 batch 的实际内容打印清楚,再往下写任何代码。
2.1__getitem__里必须保证的三件事
写自定义 Dataset,__getitem__返回的东西要保证三件事:一是形状固定,不能 batch 之间有的 224×224 有的 256×256,否则默认的拼批函数会直接报错;二是 dtype 明确,图像转成torch.float32,分类标签写成torch.long;三是数据增强只作用于输入,别不小心把标签也变换了。
import torch from torch.utils.data import Dataset class MyDataset(Dataset): def __init__(self, samples, labels, transform=None): assert len(samples) == len(labels) self.samples = samples self.labels = labels self.transform = transform def __len__(self): return len(self.samples) def __getitem__(self, idx): x = self.samples[idx] if self.transform is not None: x = self.transform(x) y = torch.tensor(self.labels[idx], dtype=torch.long) return x, y标签 dtype 这一条踩坑的人特别多。交叉熵损失内部要求目标是一维整数张量,你给成float32会报expected scalar type Long;你给成(N, 1)的二维形状,它会静默地当成多标签处理,loss 数值看着正常但语义全错。写完先print(y.dtype, y.shape)验一眼,两秒钟的事。
注意:
__getitem__会被多个 worker 进程并发调用,里面不要写任何修改共享状态的逻辑,尤其别往实例属性里累加计数器,多进程下结果不可预期。
2.2 num_workers、pin_memory、persistent_workers 怎么配
DataLoader的参数里最值得琢磨的是num_workers。设成 0 表示在主进程里读数据,调试最省心,因为出栈的错误信息是完整的;设成大于 0 之后,worker 里抛的异常经常只剩一句含糊的提示。所以我的一般做法是:调试阶段num_workers=0,链路跑通、确认没有隐藏错误之后再调上去。
数量怎么定?经验值是 CPU 物理核心数的 60%~80%,再往上涨会被数据拷贝和进程调度开销吃掉。我见过在 8 核机器上设 32 的,训练速度反而比设 6 慢一截。另外pin_memory=True只在用显卡训练时有意义,它把批次放进锁页内存,让主机到显存的拷贝走异步通道,配合x.to(device, non_blocking=True)才有效果;如果本来就在 CPU 上训练,这个开关纯属浪费。
persistent_workers=True是在 PyTorch 较新版本里加的,作用是在 epoch 之间保留 worker 进程,避免每轮重新拉起进程的开销。数据集较小时提升明显,数据集很大、单轮时间很长时,这点开销可以忽略。
| 参数 | 建议取值 | 代价与收益 |
|---|---|---|
| num_workers | 0(调试)/ 物理核数×0.6~0.8 | 太高会争抢,太低喂不饱 GPU |
| pin_memory | 显卡训练 True,CPU 训练 False | 提升拷贝效率,占一点锁页内存 |
| persistent_workers | 小数据集 True | 省重启开销,多占进程内存 |
| drop_last | 训练集 True | 避免最后一个小批次扰动归一化统计 |
| prefetch_factor | 默认 2,IO 重可调到 4 | 提前备批,设太高占内存 |
2.3 归一化参数:别随手写 0.5
网上大量示例写Normalize(mean=[0.5], std=[0.5]),很多人直接抄过来用在自己的数据上。这组数只在输入已经在 0~1 之间且分布接近均匀时才勉强合理,换个数据集就未必。正确做法是先在训练集上算一遍均值和标准差,再写进变换里。
import torch def compute_stats(loader): n = 0 s = torch.zeros(3, dtype=torch.float64) s2 = torch.zeros(3, dtype=torch.float64) for x, _ in loader: b = x.size(0) n += b s += x.sum(dim=[0, 2, 3]).double() s2 += (x ** 2).sum(dim=[0, 2, 3]).double() mean = s / n var = s2 / n - mean ** 2 return mean, var.clamp_min(1e-8).sqrt()这段用的是按通道累计一阶矩和二阶矩的写法,一次遍历就能算完,不用担心把整个数据集读进内存。注意std夹了个极小值,防止某些通道取值恒定导致除零。算完把均值方差记在配置里,验证集和推理阶段必须复用同一组数,不能各算各的——这是评估指标不可信的一个隐蔽来源。
注意:算统计量用的增强管线要关掉随机裁剪和颜色抖动,否则算出来的均值带随机性,每次跑都不一样。
3. 模型定义:从 nn.Module 到 forward
模型这一段的错误一般都很直白,形状不匹配当场报错,反而是“能跑通但学不好”的情况更耗时间。我写模型时会强制自己回答三个问题:参数量是多少、第一层的输入维度从哪来、输出维度跟损失函数对不对得上。这三个问题答完,后面基本不会出大岔子。
3.1__init__与forward的职责边界
__init__只做两件事:登记子模块、做参数初始化。forward只做一件事:把输入张量沿着定义好的路径推一遍。把forward里塞进打印、条件分支、动态创建层这类的操作,代价是每次前向都会重跑一遍那些逻辑,torch.jit之类的手段也用不了。
import torch.nn as nn class MLP(nn.Module): def __init__(self, in_dim=784, num_classes=10): super().__init__() self.backbone = nn.Sequential( nn.Linear(in_dim, 512), nn.BatchNorm1d(512), nn.ReLU(inplace=True), nn.Dropout(0.2), nn.Linear(512, 128), nn.BatchNorm1d(128), nn.ReLU(inplace=True), nn.Linear(128, num_classes), ) self._init_weights() def _init_weights(self): for m in self.modules(): if isinstance(m, nn.Linear): nn.init.kaiming_normal_(m.weight, nonlinearity="relu") if m.bias is not None: nn.init.zeros_(m.bias) def forward(self, x): return self.backbone(x)super().__init__()那行不能省,省了会报找不到_parameters。_init_weights单独抽出来,是因为默认初始化和kaiming_normal_在深层网络上的收敛速度差距实测能有一个数量级的迭代步数,尤其配上 ReLU 时,默认的均匀初始化方差偏小,浅层梯度会衰减得很快。
3.2 激活、归一化、初始化的搭配逻辑
顺序上我一般用“线性→归一化→激活→正则”这个排布。归一化放在激活前面,是为了让进入激活的数值分布稳定;如果放在后面,被 ReLU 砍掉负半轴之后再做标准化,统计量本身就偏了。这个细节在小模型上看不出差别,在网络加深后对收敛稳定性的影响就明显了。
初始化要跟激活函数配套:ReLU 系用kaiming_normal_,tanh用xavier_normal_。原因不复杂,Kaiming 的方差推导就是按 ReLU 砍掉一半输出来算的,拿它配tanh会让前几层的输出方差偏大,训练初期 loss 容易炸。
inplace=True这个参数值得单独说一句。它省显存,但如果你在残差结构里对同一个张量做了两次原地操作,反向传播时会报“变量被原地修改”的错误。遇到这种报错,先去检查是不是inplace惹的麻烦,把其中一个改成False通常就好了。
3.3 形状推导表与参数量手算
拿上面那个 MLP、输入 784 维来说,把形状和参数逐层算一遍,心里就有底了:
| 层 | 输入形状 | 输出形状 | 参数量 |
|---|---|---|---|
| Linear(784,512) | (B,784) | (B,512) | 784×512+512 = 401,920 |
| BatchNorm1d(512) | (B,512) | (B,512) | 512×2 = 1,024 |
| Linear(512,128) | (B,512) | (B,128) | 512×128+128 = 65,664 |
| BatchNorm1d(128) | (B,128) | (B,128) | 256 |
| Linear(128,10) | (B,128) | (B,10) | 128×10+10 = 1,290 |
合计约 47 万参数,float32 下权重占 1.9MB 左右,加上优化器状态(AdamW 每参数两份动量)大概 5.7MB。这类小模型显存压力全在激活值上,权重可以忽略。
换成一个卷积网络,输入32×3×224×224,第一层Conv2d(3, 64, 7, stride=2, padding=3),输出就是32×64×112×112,即 2570 万个元素,float32 下 103MB,这一层保存下来供反向使用的激活就顶得上刚才整个 MLP 的参数量。所以在显卡上训练时,显存瓶颈几乎总是激活值而不是权重,这也是为什么梯度累积、混合精度这类手段对小模型收益有限、对大模型和 CNN 收益巨大。
估算公式可以记成:激活显存 ≈ batch × Σ(各层输出元素数) × 4 字节 × (1~2)。乘数取 1 还是 2 取决于实现里有没有额外的中间缓存,实践中按 1.5 估比较稳。按这个式子反推,24G 显存的卡跑分类任务,batch 一般能到 128 以上;跑检测分割这类多尺度输出,能到 16 就算不错。
4. 训练循环:一行一行拆开看
训练循环是整篇里最像“仪式”的部分,很多人是背下来的。背下来不算坏事,但要知道每一行的作用,否则出了问题就无从下手。
4.1 核心七步与 set_to_none 的意义
一个标准批次要走七步:搬到设备、清梯度、前向、算损失、反向、裁梯度、更新参数。代码长这样:
for epoch in range(cfg.epochs): model.train() running, seen = 0.0, 0 for x, y in train_loader: x = x.to(device, non_blocking=True) y = y.to(device, non_blocking=True) optimizer.zero_grad(set_to_none=True) logits = model(x) loss = criterion(logits, y) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() running += loss.item() * x.size(0) seen += x.size(0) print(f"epoch {epoch} loss {running / seen:.4f}")zero_grad(set_to_none=True)比默认的填零版本更省一点内存,因为它把梯度置为None而不是分配一块全零张量,反向传播时直接覆盖。大量小参数的小模型上差别不大,但这是个不难学的好习惯。
running += loss.item() * x.size(0)这一行容易写错。直接累加loss.item()再除以批次数,得到的是各批次损失的算术平均;如果最后一批样本数少,这个平均值就会偏差。按样本数加权才是真正的样本平均损失,日志看起来也更平滑。这批末尾不满的情况正是训练集该设drop_last=True的理由之一。
clip_grad_norm_本来是为 RNN 和深层网络准备的,但只要训练里出现偶发的 loss 尖峰,加上它通常能压住。max_norm=1.0是个常见起点,观察grad_norm的分布再调,绝大部分批次其实根本达不到这个阈值,它只在少数异常批次上生效。
4.2 优化器与学习率调度的选型依据
优化器上我的默认选择是 AdamW,lr=3e-4、weight_decay=1e-2起步。Adam 把权重衰减直接加进梯度里,跟自适应学习率耦合在一起;AdamW 把衰减拆成独立的解耦项,正则强度的语义更干净,这也是它在大模型上普及开的原因。任务上如果对精度特别抠,且模型不大,可以试试 SGD + momentum,收敛更慢但最终泛化有时会好一两个点,代价是调参更费劲。
学习率调度的思路很简单:前期要快,后期要稳。余弦退火几乎可以当默认值用,T_max跟总步数对齐:
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max=cfg.epochs * len(train_loader), eta_min=1e-6 )注意新版 PyTorch 里调度器的调用时机变化过,从每个 epoch 调用变成更推荐按 step 步进。我自己是统一按 step 调,配T_max等于总步数,语义最清楚。如果按 epoch 调但T_max填成步数,学习率会一直贴在最大值附近,退火等于没生效,这种情况我在别人的代码里见过不止一次。
批量变大时学习率要不要跟着放大?线性缩放规则(lr ∝ batch/256)是个有用的起点,但配套要有 warmup,否则前几百步容易发散。批量从 64 拉到 512、学习率从 3e-4 拉到 2.4e-3 但没 warmup,前几十步 loss 直接冲到 nan 的情形非常常见。
4.3 梯度累积与混合精度:显存不够时的两条路
显存不够,第一反应不应该是砍模型,而是先看能不能在训练策略上腾空间。梯度累积是最简单的一条:把一个大批次拆成若干小批次,梯度攒够再更新一次。
accum = cfg.accum_steps optimizer.zero_grad(set_to_none=True) for i, (x, y) in enumerate(train_loader): logits = model(x.to(device)) loss = criterion(logits, y.to(device)) / accum loss.backward() if (i + 1) % accum == 0: optimizer.step() optimizer.zero_grad(set_to_none=True)损失除accum是关键,不除的话梯度会被放大accum倍,等于把学习率乘上去了。另外注意 BatchNorm 的统计量是按实际批大小算的,梯度累积改变不了这一点,批太小导致统计不稳时,只能换 GroupNorm 或 LayerNorm,光靠累积没用。
混合精度在支持张量核心的显卡上通常能省近一半显存、提速三到五成,写法如下:
scaler = torch.cuda.amp.GradScaler(enabled=cfg.amp) for x, y in train_loader: optimizer.zero_grad(set_to_none=True) with torch.cuda.amp.autocast(enabled=cfg.amp): logits = model(x.to(device)) loss = criterion(logits, y.to(device)) scaler.scale(loss).backward() scaler.unscale_(optimizer) # 裁梯度前必须先反缩放 torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) scaler.step(optimizer) scaler.update()unscale_那一行漏掉的后果是裁剪作用在放大过的梯度上,阈值形同虚设。另外混合精度下有些操作(比如大数求和、部分损失函数)对数值精度敏感,遇到 loss 变 nan 先把 autocast 关掉验证一次,能快速定位是不是精度问题。
5. 验证与指标:让数字可信
训练集 loss 降下去不代表模型有用,验证这一环的意义就是把“记住训练数据”和“学到规律”区分开。这一段代码只有几十行,但写错的概率不比训练循环低。
5.1eval()与no_grad()到底改了什么
model.eval()改的是模块的行为开关,最典型的是 Dropout 停止随机置零、BatchNorm 改用运行统计量而不是当前批次统计量。torch.no_grad()关的是自动求导的图记录,省显存也提速。两个都得有,缺一个的后果不一样:只写no_grad不写eval,验证结果会带随机性,同一份权重跑两次指标能差几个点;只写eval不写no_grad,显存占用跟训练时差不多,大模型上容易直接爆。
@torch.no_grad() def evaluate(model, loader): model.eval() loss_sum, correct, total = 0.0, 0, 0 for x, y in loader: x, y = x.to(device, non_blocking=True), y.to(device, non_blocking=True) logits = model(x) loss_sum += criterion(logits, y).item() * y.numel() correct += (logits.argmax(dim=1) == y).sum().item() total += y.numel() return loss_sum / total, correct / total这个装饰器写法比手动with torch.no_grad()包一层更不容易漏。另外验证结束后记得切回model.train(),如果写在函数外部容易忘,我一般干脆把切换放在训练循环开头,每轮无条件model.train(),不给遗漏留机会。
5.2 训练曲线怎么读
把训练损失、验证损失、验证准确率画在同一张图上的习惯,能省掉很多无谓的调参。四种典型形态值得记住:
训练损失降、验证损失也降,说明还在欠拟合区间,继续训或者加容量都有收益。训练损失降、验证损失先降后升,是过拟合的经典形状,拐点之后就是在记数据了。两条曲线一起平在较高位置,通常是学习率太大或者数据本身有问题,先降学习率再看。验证损失从第一个 epoch 就明显高于训练损失且差距固定,八成是训练和验证的预处理不一致,回去检查归一化参数和增强管线。
我自己还习惯额外记录学习率的实际值和梯度范数。学习率调度写错时曲线一眼就能看出来(比如横线一条);梯度范数突然飙升往往对应某个脏数据批次,定位到那批样本看一眼,经常能发现标注或读取的问题。
5.3 过拟合与欠拟合的处置清单
按性价比排序,我的处置顺序是这样的:先看数据量和标注质量,再看正则,最后才动模型容量。
欠拟合时依次尝试:加大模型宽度或深度、提高学习率、延长训练轮数、去掉过强的正则、检查标签是否有噪声。过拟合时依次尝试:增加数据或做更强的增强、提高权重衰减、加 Dropout、早停、最后才考虑减容量。这个顺序的道理在于,模型容量是最后一道防线,先砍容量往往是把问题从过拟合换成了欠拟合。
注意:早停要基于验证指标判断,并且保留“最优轮次”的权重副本。只记录最优指标数值而不保存权重,等于白做。
6. 训练中的高频报错与排查实录
这一段是我这些年攒下来的速查表,按报错类型分了组。第一次遇到时按表操作能快速定位,反复遇到时你就会记住它们的共同根因。
6.1 设备与形状类错误
| 报错信息关键词 | 根本原因 | 处理方式 |
|---|---|---|
| Expected all tensors to be on the same device | 模型在显卡,数据在 CPU,或反之 | 统一.to(device),注意标签也要搬 |
| size mismatch for ...weight | 保存的权重和当前模型结构不一致 | 打印两边的 state_dict 键名对比 |
| mat1 and mat2 shapes cannot be multiplied | 前一层输出维度和后一层输入维度没对齐 | 用随机张量跑一次前向,看形状在哪层断掉 |
| Target x is out of bounds | 标签编号超出了分类数 | 检查类别映射,0 基还是 1 基 |
| expected scalar type Long but found Float | 分类标签 dtype 不对 | y.long()或构造时指定 dtype |
第一类错误里有个隐蔽变种:模型和数据都在显卡上,但损失函数内部的某些张量在 CPU 上,比如手工构造的权重矩阵。这种情况报错位置在损失函数里,容易让人以为是模型问题。排查方法是把next(model.parameters()).device和x.device、y.device都打印出来,三个不一致就一眼看到了。
6.2 损失异常与梯度异常
loss 变 nan 是新手最慌的情况,排查顺序我固定为四步:先把学习率除以 10 试一次,八成的 nan 出在这一步;再检查输入里有没有 inf 或异常大的值,torch.isnan(x).any()和x.abs().max()两行就够;第三步看损失函数输入有没有出现 log(0) 这类运算,交叉熵内部做了数值稳定处理,但自己手写的损失不一定;最后才怀疑梯度爆炸,看clip_grad_norm_的返回值。
反向传播时报“变量被原地修改”,几乎都是inplace=True加上共享张量导致的。处理方式是定位报错那一行,把相关的inplace关掉。这个错误在残差连接、多次复用同一个中间结果的结构里最常见。
至于“element 0 of tensors does not require grad”,说明计算图断了。要么是输入张量被detach()过,要么是用了torch.no_grad()包住了本该求导的部分,还有一种情况是把model.parameters()里的张量重新赋值过,导致优化器持有的是旧引用。最后这种情况很隐蔽,改完之后参数确实在变,但优化器更新的是已经不参与前向的那份副本。
6.3 数据加载与显存类故障
num_workers大于 0 时报出一些莫名其妙的错误,比如进程被信号杀掉、Bus error、卡在第一个 batch 不动,先做两件事:把num_workers调回 0 看错误是否复现,把数据集里涉及文件句柄、数据库连接的部分改成在__getitem__里临时打开而不是在__init__里常驻。多进程 fork 之后,父进程持有的连接对象在新进程里往往不可用,这是最典型的坑。
显存不足的报错一般很直白,但“差一点点”的情况会表现为跑到中途才炸。这时候先看是不是验证阶段没加no_grad,这是最常见的隐形占用;再看drop_last有没有开,最后一个不满批次偶尔会因为某些算子对批大小敏感而多分配缓存。真正的显存优化手段按收益排序:混合精度、梯度检查点、减小批大小、换更省内存的优化器实现。
注意:
torch.cuda.empty_cache()只是把缓存还给驱动,不会解决泄漏;频繁调用反而降低性能。它是排障工具,不是优化手段。
7. 保存、导出与上线推理
训练完的模型如果保存方式不对,换台机器加载就会出问题。这一段虽短,但交付前必须过一遍。
7.1 state_dict 保存的正确姿势
保存整个模型对象(torch.save(model, path))看起来省事,实际很脆:它把类的路径也一起序列化了,换目录、改类名、升级版本都可能加载失败。标准做法是只存参数字典:
torch.save({ "model": model.state_dict(), "optimizer": optimizer.state_dict(), "scheduler": scheduler.state_dict(), "epoch": epoch, "config": cfg.__dict__, }, f"{cfg.ckpt_dir}/last.pt")把配置一起存进去是血泪教训,隔一个月回来想复现,光看文件名根本记不住当时用的是哪个归一化参数。加载时先构造同结构模型再load_state_dict,键名对不上会明确报缺哪些键、多哪些键,比整个对象加载失败后无从下手强得多。
断点续训还要注意优化器状态。AdamW 每个参数存了两份动量,只恢复模型不恢复优化器,重启的头几十步会有明显的损失抖动,损失曲线会出现一个小台阶。严格复现的话,数据加载器和随机数状态也得存,长训练任务值得加上。
7.2 导出与推理一致性校验
推理阶段第一件要确认的事是预处理完全一致。训练时用了归一化,推理时忘了,模型输出会离谱到让人怀疑权重坏了。我的习惯是写一个preprocess函数给训练和推理共用,从源头上消灭这个分歧。
导出用 TorchScript 或 ONNX 都行,关键是导出后做一次数值对齐:同一批输入,对比原模型和导出模型的输出,最大绝对误差在 1e-4 量级以内算正常。
model.eval() dummy = torch.randn(1, 784, device=device) traced = torch.jit.trace(model, dummy) with torch.no_grad(): a = model(dummy) b = traced(dummy) print((a - b).abs().max().item())大于这个量级就要查:导出时是不是还在训练模式,网络里有没有含随机性的操作(Dropout 在eval下是恒等映射,但如果自定义了有状态的模块就要单独处理),有没有用到了导出不支持的算子。这个小校验我每次都做,省过好几次线上事故。
另外推理时的批大小和显存预分配也是交付细节。一个常见现象是离线测试全部通过,上线后发现第一个请求特别慢,原因通常是第一次前向要建缓存、分配显存。上线前先跑一次热身推理,把日志里那行“首次耗时 3 秒”消掉。
小技巧分享一个:用一份固定的验证集输入在训练全程做快照比对。每存一次权重,就用这份固定输入跑一次,把输出哈希存下来。模型更新逻辑出问题(比如误把随机种子写进前向)的时候,两次快照输出完全一致,这个信号非常明确,比看指标快得多。
我个人在长期维护里的体会是,PyTorch 这套写法真正难的从来不是记住那几行 API,而是把“每段代码的输入输出契约”这件事刻进习惯。数据管道保证形状、dtype、取值范围三稳定,模型保证输入输出维度对齐,训练循环保证梯度三步走全,验证保证模式切换干净,导出保证数值一致。这五条守住了,模型结构怎么换、任务怎么变,链路都不会塌。