训了整整一晚的CNN卷积神经网络早上爬起来看训练日志loss曲线一路向下漂亮得像教科书里的插图。可把训练完的模型丢到测试集上一跑准确率比验证集上看到的低了将近5个点。我相信训过卷积神经网络的人多多少少都撞过这种场景。问题往往不在网络结构也不在学习率而是很多人从来没认真对待过一个朴素的问题训练过程中那个最优模型到底被存在哪儿了存下来的东西又该怎么原封不动地拿回来用。我带的深度学习系列课程前五课基本都在教怎么把网络搭起来、把训练流程跑通。到了第六课几乎所有学员都会在这里卡住模型学会了但保存这件事居然有这么多讲究——我应该存哪个epoch的模型拿什么判断它是最优为什么load回来之后预测数字对不上如果你正准备系统性搞懂CNN模型的持久化或者在跑实验时总是稀里糊涂不知道该保留哪个文件这篇文章就是为你写的。我会把最优模型的判定标准、保存模型的三种层级、边训练边跟踪最优状态的写法以及加载模型时的高频坑一次性捋清楚。全程用PyTorch的代码说话不像教科书那样只讲概念。1. 先厘清最优模型到底由谁说了算验证指标而不是训练loss1.1 训练损失下降说明不了任何最优这是我在没少踩过的坑。初学CNN那会儿我盯着训练集loss从2.3一路降到0.2心里就觉得稳了直接拿最后一轮的参数当最优模型去交差。结果测试集上一验证准确率跟预期差一大截。原因现在回头看非常清晰训练loss下降只能说明模型在当前训练数据上拟合得越来越好它完全不衡量模型的泛化能力。过拟合的模型可以把训练集刷到接近满分验证集照样一塌糊涂。经典的过拟合曲线是训练loss一路向下验证loss先降后升两条曲线的gap越来越大。更阴险的是验证指标最好的那个点往往不在最后一个epoch而是在训练中段某个不起眼的位置。我见过不止一个任务验证准确率在epoch 12就到了天花板之后20个epoch全在无效更新甚至把已经学好的参数慢慢带偏。如果你把最后一个epoch的权重当作最优模型保存等于默认训练越久越好这个假设在绝大多数深度任务里都不成立。1.2 验证集指标才是模型选择的裁判模型参数是靠训练集上的梯度下降学出来的但学得好不好这个判断必须由站在局外的验证集来做。训练集是考生刷题用的题库验证集是模拟考测试集才是真正的高考。保存模型本质上是一次择优录取录取依据只能是验证集不能是训练集。那评价指标怎么选这直接影响你存下来的是不是真的最优图像分类这类类别均衡的任务直接用top-1准确率就够直观。类别严重不均衡时准确率会骗人比如99%都是负样本模型全预测负样本也能有99%准确率这时候得用macro F1或AUC。回归任务看MAE或RMSE检测任务看mAP分割任务看mIoU。如果你的业务更关心查得准而不是查得全可能要优先看precision反之看recall。一定要在一开始就把评价指标和模型选择标准定死不要中途换。我见过有人在epoch 10用准确率选最优跑崩了之后又用F1重新回去选这样选出来的最优带强烈的滞后性整个实验的可复现性也没了。1.3 多任务多loss场景下别让总loss替你做决定如果你在做多任务学习比如同时预测分类和回归总loss通常是多个任务loss的加权和。这时候最容易犯的错是盯着总loss选最优模型。问题是总loss低只说明各任务折中得不错很可能每一项都不是最优或者你真正关心的主任务反而退步了。我的处理办法是主任务指标优先次任务指标设下限。举个例子一个人脸多任务模型主任务是属性分类辅助任务是关键点定位。保存模型时我只看主任务在验证集上的准确率是不是历史最高同时检查辅助任务的loss有没有比上次保存时恶化太多如果辅助loss炸了这次即便主任务涨了也不急着覆盖。至于多个loss的权重怎么调那是另一个大话题但落到保存最优模型这个动作上记住一点评价标准必须和业务目标对齐不要被总loss绑架。否则你保存的只能算是折中最优而不是业务最优。2. 别再混淆三种保存方式权重、checkpoint、整模型2.1 只存参数权重最轻量但信息很有限最基础的保存方式是用torch.save把模型的state_dict存下来。state_dict本质上是一个字典里面每个参数名对应一个张量比如卷积层的conv1.weight和conv1.bias。# 只保存模型参数 torch.save(model.state_dict(), best_model.pth)这种方式的优点是文件小、加载快、结构清晰。但代价是它只保存了参数没保存模型结构。加载的时候你必须先在自己的代码里重新定义一份和训练时一模一样的网络结构再把参数灌进去。如果你改了网络的层数、卷积核数量或者换了激活函数这个文件基本就废了。它最适合的场景是模型结构已经固化代码里随时可以重建你只需要一个参数快照比如实验收尾时把最终结果归档。2.2 保存完整checkpoint续训和复现实验的命根子实战里我几乎不单独存权重我存的是完整checkpoint。所谓完整是除了模型权重之外把训练现场的所有关键状态全部打包checkpoint { epoch: epoch, model_state_dict: model.state_dict(), optimizer_state_dict: optimizer.state_dict(), best_metric: best_metric, scheduler_state_dict: scheduler.state_dict() if scheduler else None, # 如果用AMP混合精度训练最好也把scaler状态存下来 scaler_state_dict: scaler.state_dict() if scaler else None, # 顺手记录超参和模型配置复盘时救命 config: {lr: 1e-3, batch_size: 32, model_name: SimpleCNN}, } torch.save(checkpoint, checkpoint_epoch15.pth)有人会觉得存太多没必要但只要你经历过一次训练到第30个epoch机器崩溃只能从零开始的事故你就会无条件接受这种冗余。checkpoint的本质是保存恢复现场所需的一切信息不是为了省硬盘。2.3 torch.save整模型和导出格式部署利器调试噩梦还有一种方式是torch.save(model, model.pth)直接把整个模型对象存下来。这种方式确实省事加载时不需要重新定义网络结构。但它有两个隐患一是跨版本兼容性差pickle序列化的对象在PyTorch版本升级后很容易炸二是代码可读性差拿到文件的人看不到模型结构排查问题非常痛苦。真正要上线部署时大家更常用ONNX或TorchScript导出。这一步确实是工程化的必经之路但它已经不属于训练过程中保存模型的范畴了。我的建议是保存方式内容文件大小适用场景注意点仅state_dict模型参数最小推理、归档加载时必须重建相同网络结构完整checkpoint权重优化器epoch配置较大断点续训、实验复现需要额外管理字段整模型/导出格式结构权重不定部署、快速演示版本兼容性差调试不便3. 边训练边跟踪最优一套可复制的PyTorch代码骨架3.1 训练循环里增加验证后择优的环节下面这套代码骨架是我自己在项目里一直用的结构简单但可靠。核心思想是每个epoch结束后在验证集上算一次指标如果比历史最优好就覆盖保存最优模型同时无论好不好都保存一份最新checkpoint防止进程崩溃。import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import DataLoader class SimpleCNN(nn.Module): def __init__(self): super().__init__() self.features nn.Sequential( nn.Conv2d(3, 16, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(16, 32, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), ) self.classifier nn.Linear(32 * 8 * 8, 10) def forward(self, x): x self.features(x) x x.view(x.size(0), -1) return self.classifier(x) def evaluate(model, val_loader, criterion, device): model.eval() total_loss, correct, total 0.0, 0, 0 with torch.no_grad(): for images, labels in val_loader: images, labels images.to(device), labels.to(device) outputs model(images) loss criterion(outputs, labels) total_loss loss.item() * images.size(0) preds outputs.argmax(dim1) correct (preds labels).sum().item() total labels.size(0) avg_loss total_loss / total acc correct / total return avg_loss, acc def train_one_epoch(model, train_loader, optimizer, criterion, device): model.train() for images, labels in train_loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() model SimpleCNN() optimizer optim.Adam(model.parameters(), lr1e-3) criterion nn.CrossEntropyLoss() device torch.device(cuda if torch.cuda.is_available() else cpu) model.to(device) best_acc 0.0 start_epoch 0 num_epochs 30 for epoch in range(start_epoch, num_epochs): train_one_epoch(model, train_loader, optimizer, criterion, device) val_loss, val_acc evaluate(model, val_loader, criterion, device) print(fEpoch {epoch1:02d} | val_loss {val_loss:.4f} | val_acc {val_acc:.4f}) # 验证准确率刷新历史最高就保存一份最优模型 if val_acc best_acc: best_acc val_acc best_ckpt { epoch: epoch 1, model_state_dict: model.state_dict(), optimizer_state_dict: optimizer.state_dict(), best_acc: best_acc, val_loss: val_loss, } torch.save(best_ckpt, best_model.pth) print(f - 保存新的最优模型acc{val_acc:.4f}) # 无论好坏都保存一份最新checkpoint用于崩溃恢复 torch.save({ epoch: epoch 1, model_state_dict: model.state_dict(), optimizer_state_dict: optimizer.state_dict(), best_acc: best_acc, }, last_checkpoint.pth)这套逻辑有几个细节我特意处理过新手很容易忽略第一评价之前必须切到model.eval()模式。evaluate函数里第一行就是model.eval()这一步不只是形式它会改变BatchNorm和Dropout的行为。训练模式下BatchNorm用的是当前batch的均值和方差Dropout会随机丢弃神经元到了验证阶段BatchNorm必须用累积的全局统计量Dropout必须关闭。如果你忘了切验证指标会被训练态污染选出来的所谓最优模型就是错的。第二验证过程必须包在torch.no_grad()里。验证阶段我们不计算梯度不反向传播所以没必要给中间变量保存计算图。不写这一行显存会直接涨上去尤其是验证集快的时候会更明显。第三保存最优模型时直接保存完整checkpoint而不是只存state_dict。因为最优模型经常被拿来做微调或继续训练单独存权重的话优化器状态丢了后续继续训练时学习率、动量这些都要重新热身体验很差。3.2 为了稳妥保存时把参数搬回CPU每次epoch结束都在GPU上直接调用model.state_dict()得到的张量仍在显存里。如果直接在GPU状态下torch.save以后加载到CPU机器时会因为设备不一致而报错或需要额外处理。我的习惯是保存前手动把张量搬回CPU# 在保存前先把state_dict深拷贝一份并转到CPU再把引用保存在checkpoint里 cpu_state_dict {k: v.cpu() for k, v in model.state_dict().items()}这样best_model.pth里的所有权重都是CPU张量之后想加载到GPU或者CPU机器都可以灵活度最高。这个习惯在多个机器上跑实验时非常省心。3.3 验证集小、指标波动大时怎么处理在数据集比较小的任务里单次验证准确率往往噪声很大。比如第9轮acc是87.3%第10轮是86.1%第11轮又到了87.4%这很难说谁真的更优。我自己的做法是引入窗口平均或者直接看验证loss的移动趋势简单方案每隔k个epoch做一次验证比如3个epoch验证一次减少单轮噪声。更稳的方案维护最近3-5轮的验证指标列表取平均值用滑动平均来判定最优是否被刷新。或者干脆选择验证loss最低的模型因为loss对略微过拟合更敏感能比acc更早反映泛化能力下滑。不过记住这些方案都会多消耗一些训练时间得在项目效率和结果的稳定性之间做取舍。4. 加载模型的正确姿势推理、微调、续训三条路不一样4.1 推理场景加载权重千万别忘了eval()如果只是拿训练好的CNN做预测代码如下def load_model_for_inference(model, ckpt_path, devicecpu): checkpoint torch.load(ckpt_path, map_locationdevice) model.load_state_dict(checkpoint[model_state_dict]) model.to(device) model.eval() return model model SimpleCNN() model load_model_for_inference(model, best_model.pth, devicecuda)这里面的map_locationdevice很关键。如果训练时用的GPUcheckpoint里的权重保存在CUDA内存中拿到一台没有GPU的机器上直接加载PyTorch会报CUDA相关的错误。加了map_locationcpu之后加载过程会先把张量映射到CPU再手动to(device)迁移到目标设备这样跨机器、跨设备都不怕。然后就是那个几乎每个人都会踩的坑推理前要加model.eval()。如果你把这个调用漏了模型里的Dropout层还在随机丢神经元BatchNorm还在按训练模式使用batch统计量你每次预测同一张图的结果都可能不同。4.2 继续训练/微调场景恢复完整现场想从上次中断的地方接着训练或者在最优模型基础上继续微调加载逻辑要更完整def restore_checkpoint(model, optimizer, scheduler, ckpt_path, device): checkpoint torch.load(ckpt_path, map_locationdevice) model.load_state_dict(checkpoint[model_state_dict]) optimizer.load_state_dict(checkpoint[optimizer_state_dict]) if scheduler is not None and scheduler_state_dict in checkpoint: scheduler.load_state_dict(checkpoint[scheduler_state_dict]) start_epoch checkpoint[epoch] best_metric checkpoint[best_acc] return start_epoch, best_metric model SimpleCNN() optimizer optim.Adam(model.parameters(), lr1e-3) scheduler optim.lr_scheduler.StepLR(optimizer, step_size10, gamma0.1) start_epoch, best_metric restore_checkpoint( model, optimizer, scheduler, best_model.pth, device ) for epoch in range(start_epoch, num_epochs): ...这段代码里有个容易翻车的细节optimizer.load_state_dict()要求优化器实例和保存时用的是同一个框架而且参数组的结构得一致。如果你的优化器用了不同的参数分组方式或者模型结构变了这一步会直接报错。所以在恢复之前最好先确保模型结构已经改写完成再加载优化器状态。另外恢复续训的常见认知偏差是恢复之后应该直接从best_acc继续判断是否保存。但实际上best_acc只是历史最优的参考续训时应该让best_acc保持历史最高值只有当新模型的验证指标真正超过它时才覆盖保存。否则训练初期模型的acc低于历史best就会频繁不满足保存条件导致最优模型被“冻”在续训前。5. 保存模型时我踩过的高频坑device、键名、eval三连5.1 设备映射GPU模型只能在CPU上加载怎么办前面提到过map_location这里再展开讲一个典型报错。假设你在GPU服务器上训练把best_model.pth带回家里的CPU笔记本上推理checkpoint torch.load(best_model.pth) # 直接加载如果checkpoint在CUDA上报错信息大致是RuntimeError: Attempting to deserialize object on a CUDA device...。原因就是checkpoint内部张量存储时带着devicecuda:0的信息而当前环境没有CUDA。解法就是加map_locationcuda或map_locationcpu。我习惯统一加map_locationtorch.device(cpu)再手动搬目标设备逻辑最稳。5.2 DataParallel训练留下的module.前缀如果你用nn.DataParallel或者nn.DistributedDataParallel包装过模型那么model.state_dict()里的键名会多出module.前缀。比如原来是features.0.weight保存后会变成module.features.0.weight。等你在单卡环境恢复时load_state_dict因为键名对不上直接报错。解决办法是加载前手动去掉前缀或者保存时直接保存原始模型的state_dict# 多卡训练保存时保留未包装模型的state_dict raw_state_dict model.module.state_dict() torch.save({model_state_dict: raw_state_dict}, best_model.pth) # 或者加载时剥离module前缀 checkpoint torch.load(best_model.pth) state_dict checkpoint[model_state_dict] from collections import OrderedDict new_state_dict OrderedDict( (k.replace(module., ), v) for k, v in state_dict.items() ) model.load_state_dict(new_state_dict)这个问题在我第一次用多卡训练时让我折腾了一晚上希望你能绕过去。5.3 键名不匹配改了结构还想加载旧权重还有一种很常见的报错Error(s) in loading state_dict for SimpleCNN: Missing key(s) in state_dict: classifier.2.weight...。这通常是因为你改了网络结构但还想着加载旧权重。PyTorch已经把不匹配的问题说得很明确了缺了哪些键、多了哪些键。如果你只是想用之前训练的backbone做迁移学习就不要用load_state_dict直接加载全量权重而是过滤出可复用的层pretrained_dict torch.load(best_model.pth, map_locationcpu)[model_state_dict] model_dict model.state_dict() # 只挑出形状一致的层跳过分类头 pretrained_dict { k: v for k, v in pretrained_dict.items() if k in model_dict and model_dict[k].shape v.shape } model_dict.update(pretrained_dict) model.load_state_dict(model_dict)做迁移学习时这就够用了。5.4 保存路径和命名中文路径、相对路径的坑有一阵子我的实验脚本在Windows上跑保存模型的路径里带了中文结果训练完发现文件写到了奇怪的地方加载时也经常报找不到文件。后来我把所有保存路径统一改成纯英文绝对路径并把路径管理提到配置模块里统一维护。更推荐的做法是用Path对象来操作路径避免字符串拼接出现/和\混用的问题。另外给模型文件起文件名时尽量把关键信息编码进去比如model_epoch12_valacc0873.pth这样即使不打开文件看checkpoint也知道里面是什么。6. 把最优模型保存升级成小型工程规范6.1 我现在的ModelCheckpoint管理方式踩过这么多坑之后我给自己定了一个保存模型的管理规范现在每个项目基本都直接套用训练过程中始终维护两份文件best_model.pth当前历史最优和last_checkpoint.pth最新训练现场。前者用于最终交付和推理后者用于崩溃恢复。checkpooint里统一包含epoch、model_state_dict、optimizer_state_dict、best_metric、config等字段哪怕某些字段暂时没用到也保留避免以后想用还得重新训练。不直接覆盖最优文件采用先写临时文件再原子替换的方式。因为训练到一半如果机器断电直接写best_model.pth可能损坏一个本可用的文件。我会写到一个tmp_best.pth确认写完再os.replace过去。定期清理中间文件。如果每轮都存全量checkpoint跑一百个epoch就是一百个文件几十GB很快就没了。我一般只保留最好的、最新的以及每间隔10个epoch一份存档。6.2 把评价逻辑也存进checkpoint还有一点是前面提过、但我特别想强调的checkpoint里一定要保存当时用的评价指标和阈值。比如best_acc到底是多少、用什么指标算的、在哪个验证集上算的。这些信息如果不在checkpoint里三个月后你重新翻出模型文件手里的脚本可能已经换过评价指标了你会完全想不起来这个best是怎么选出来的。把这种元信息塞进config字段是花一分钟能省一星期的事。6.3 多份实验对比时的统一命名习惯当同一批实验跑了很多组我习惯用一个短小但完整的命名模式{model_name}_{dataset}_{metric_name}{metric_value}_epoch{epoch}.pth例如simplecnn_cifar10_acc08730_epoch12.pth。这样在硬盘里找哪份模型的验证准确率最高只需要按文件名排序即可不用一个个load进来重新跑验证。这个方法朴素但在管理大量实验时异常好用。最后再分享一个我自己的体会。模型保存这个动作看上去只是两行torch.save和torch.load实际上它锚定了整个训练实验的证据链。如果你连哪一轮是最优的、为什么把它评为最优都说不清楚那训练过程再花哨对最终结果也没有任何保障。把保存逻辑想清楚、写规范花的时间不超过一个小时但它会在后续每一次模型复现、部署和迭代里持续给你回报。如果你正在自己动手实现CNN的训练脚本我建议你从这一课开始就把边训练边盯验证集、择优保存checkpoint当成默认习惯而不是事后补救。