训练跑通只是第一步真正检验模型价值的是它在没见过的图片上能不能给出正确预测。这篇接着前面的猫狗大战项目把“预测模型结果”这一步完整拆开来讲从测试集DataLoader怎么写到加载保存好的CNN权重再到批量输出预测标签、生成提交CSV最后用验证集反推模型的真实水平。这个环节看着简单——模型早就训练好了只要加载权重forward一下就行——但实际操作里踩坑的点非常多设备不匹配、归一化不一致、类别索引对调任何一个出问题都会让整个预测结果报废而且报错信息往往不会直接告诉你错在哪。这一篇把过去踩过的坑和验证过的流程整理出来给正在做PyTorch图像分类、尤其是要交“模型预测结果”的同学做一个可以直接照抄的模板。1. 预测前的准备测试集DataLoader与训练时有哪些不同1.1 测试数据变换为什么不能照搬训练集训练阶段做数据增强的目的是让模型见过更多“变形”后的样本从而提升泛化能力——随机裁剪、随机翻转、旋转、颜色抖动这些操作本质上是在告诉模型“同一个物体换个方向、换个角度依然是同一个物体”。但测试和预测阶段完全不是这个逻辑。预测时你希望模型看到的是最接近样本真实分布的、稳定可复现的输入而不是随机的、每次跑结果都可能变的输入。所以测试集的transform通常长这样test_transform transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ])对比一下训练集的典型写法train_transform transforms.Compose([ transforms.RandomResizedCrop(224), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness0.2, contrast0.2), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ])两种写法的差异不是随意的而是由训练和预测的本质区别决定的。训练时需要多样性来防止过拟合预测时需要确定性和一致性来做公平比较。我遇到过有人直接把训练transform套在测试集上结果每次跑预测同一张图的预测结果都不太一样——这就是RandomResizedCrop和RandomHorizontalFlip在作怪。凡是名字带Random的变换预测阶段一律不用。CenterCrop和不带随机性的Resize是可以保留的。1.2 顺序与乱序shuffleFalse不是小事训练时DataLoader设置shuffleTrue是常识因为打乱顺序可以避免模型学到批次之间的顺序依赖也能让梯度下降更稳定。但测试集预测时shuffle这个参数常常被忽略一不留神就照抄了训练时的写法。这里要非常明确预测阶段shuffle必须为False。原因很实际——最终生成CSV提交文件时你需要知道每一行预测结果对应哪一张图片。虽然可以通过路径来重新对齐但多一步对齐就多一个出错的环节。尤其是当图片ID和预测结果是分开保存的时候一旦DataLoader内部顺序错乱最终提交文件就全盘不对了。一个稳妥的测试集DataLoader写法如下test_dataset DogCatDataset( data_dirdata/test, transformtest_transform, is_trainFalse ) test_loader DataLoader( test_dataset, batch_size64, shuffleFalse, num_workers4, pin_memoryTrue )另外测试阶段遍历完一遍之后按顺序把预测结果append到列表里这个列表的索引顺序就和test_dataset本身一致。如果测试集图片ID不是从0开始的那就在构造dataset时把图片文件名单独存一份后面生成CSV的时候直接用这份文件名列表来匹配预测结果而不是靠索引。提示文件名的排序也需要固定。比如测试集图片名是001.jpg、002.jpg……如果文件名长度不齐1.jpg和10.jpg普通的字符串排序会出问题。统一用zfill或sorted(..., keylambda x: int(x.split(.)[0]))来处理。1.3 要不要做TTA翻倍的时间换0.5%的提升TTATest Time Augmentation是一种成熟的预测技巧思路很简单预测时不只看原图还看看水平翻转、略微缩放后的版本最后把多个预测概率取平均作为最终结果。在我做过的几个图像分类项目里TTA通常能带来0.3%-1%的准确率提升代价是推理时间按倍数增加。def predict_with_tta(model, image_tensor, n_flip2): model.eval() preds [] with torch.no_grad(): for flip in range(n_flip): if flip 1: image_tensor torch.flip(image_tensor, dims[3]) pred torch.softmax(model(image_tensor.unsqueeze(0)), dim1) preds.append(pred) final_pred torch.mean(torch.cat(preds, dim0), dim0) return final_predflip就是水平翻转对应训练时RandomHorizontalFlip这个增强所以翻转后的样本分布仍然在模型可接受的范围内。要不要上TTA取决于你对精度和速度的权衡。如果是几千张测试图、批推理、GPU算力充足那TTA完全可以用如果是单张图片实时推理、对延迟敏感那就别用直接单次前向即可。2. 模型加载与单张图片预测把权重真正用起来2.1 保存的是state_dict不是“模型”训练结束后大部分人会用torch.save(model.state_dict(), model.pth)这种方式保存权重。对应的加载的时候必须先实例化出和你训练时一模一样的网络结构再调用load_state_dict把权重放进去。这里有个新手很容易踩的坑网上很多教程教torch.save(model, model.pth)直接保存整个模型对象加载的时候torch.load就能直接用。但这种写法在换环境、换Python版本、网络结构代码改动之后会报出各种诡异的反序列化错误。torch.load本质上是pickle机制一旦类定义的位置变了它就找不到对应的类了。所以我在预测脚本里永远只用state_dict方案import torch from models.cnn_model import DogCatCNN model DogCatCNN(num_classes2) # 关键加载的是state_dict不是整个模型 state_dict torch.load(checkpoints/best_model.pth, map_locationcpu) # 严格模式会检查key是否完全匹配key不匹配时会报错 model.load_state_dict(state_dict, strictTrue) model.to(device) model.eval()strictTrue默认开启这其实是个好机制。如果训练时的网络结构和现在实例化的网络在层名上对不上哪怕只是某个卷积层名字多了个1它会明确报出missing keys和unexpected keys帮你尽早发现结构不一致的问题。不要为了省事把它关掉。2.2 单图预测函数一个输入tensor的完整生命周期单张图片预测是整个流程的最小单元把它写对跑通批量预测就有信心了。下面这个函数是我已经跑过多轮预测、没什么毛病的版本from PIL import Image import torchvision.transforms as transforms def predict_one_image(model, image_path, device, class_names[cat, dog]): model.eval() image Image.open(image_path).convert(RGB) # 这里必须和测试集transform保持一致不能有随机增强 transform transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) input_tensor transform(image) # shape: [3, 224, 224] input_batch input_tensor.unsqueeze(0) # shape: [1, 3, 224, 224] with torch.no_grad(): output model(input_batch.to(device)) # shape: [1, 2] probabilities torch.softmax(output, dim1) # 转成概率每行和为1 class_idx torch.argmax(probabilities, dim1).item() confidence probabilities[0][class_idx].item() return class_names[class_idx], confidence有几个基础知识点这里着重解释一下。PIL读出来的图片是HWC格式通道在最后一维而模型输入要求CHW格式也就是通道在第一维ToTensor()会帮你自动把HWC转成CHW。unsqueeze(0)是在第0维增加一个batch维度因为模型处理的是批量数据即使只有一张图也要告诉它“batch size为1”。no_grad()是省内存和加速的开关预测阶段不需要反向传播不需要计算梯度关掉之后内存占用会低很多如果模型大、测试图多这个操作省出来的内存非常可观。2.3 关键动作eval()、no_grad、argmax的前后顺序这三个动作是预测流程的标配但很多人直接用却说不清为什么。第一model.eval()。这个操作会切换BatchNorm和Dropout这两类模块的行为。训练时BatchNorm用当前batch的均值和方差做归一化Dropout随机丢掉一部分神经元预测时如果不切到eval模式BatchNorm还是用训练时的数据预测得到的结果会根据batch内的统计量浮动Dropout还会随机丢掉信息导致同一张图两次预测结果不同。所以eval()必须放在预测之前。第二with torch.no_grad()。它影响的是整个自动求导引擎。训练时每个前向过程会构建计算图保存中间张量因为后面backward要使用这些信息预测时不需要反向传播计算图可以不要这样能显著降低显存和内存消耗并提升速度。第三torch.argmax和torch.softmax的理解。softmax把logits转成和为1的概率分布比如输出[2.5, -1.2]经过softmax变成[0.976, 0.024]含义是猫的概率97.6%、狗的概率2.4%。argmax则是直接取出概率最大的那个索引。两者通常配合使用——argmax决定了预测类别softmax给了置信度。调试的时候只看argmax不知道模型判断得多坚决而置信度又是后续分析的重要依据。3. 全量测试集批量预测与提交文件生成3.1 批量预测的主循环与结果记录单张图片预测跑通之后批量预测就是一个循环的事情。但循环里有个容易忽视的细节一次循环的批次不能积累梯度缓存也得处理好tensor从GPU迁回CPU的时机。这里放一个可以直接用的批量预测代码def predict_all(model, test_loader, device): model.eval() all_probabilities [] with torch.no_grad(): for batch_idx, (images, _) in enumerate(test_loader): images images.to(device) outputs model(images) # 把概率张量转成numpy或保存在CPU probabilities torch.softmax(outputs, dim1) all_probabilities.append(probabilities.cpu().numpy()) if batch_idx % 50 0: print(fPredicted batch {batch_idx}/{len(test_loader)}) all_probabilities np.concatenate(all_probabilities, axis0) return all_probabilities有人会问为什么测试集DataLoader明明有标签这边用_把标签扔了因为很多数据集在划分的时候测试集是不给标签的预测任务就是要在没有标签的情况下给模型判分。PyTorch的Dataset即使读了标签在预测阶段你也不应该依赖它更不应该把它混进预测结果里。所以这里用占位符_明确表示“不关心标签”。3.2 生成CSV文件ID与标签的对齐方式批量预测完之后最关键的收尾动作就是生成提交CSV。这个文件格式非常讲究第一行通常是表头比如filename,label后续每行第一列是测试图片的文件名或ID第二列是预测的类别不是类别索引。大部分竞赛评分都是以这个文件作为最终依据文件名和标签对不上哪怕模型准确率是100%都得零分。对齐的玩法是这样预测之前从test_dataset里拿一份带顺序的文件名列表。预测之后按相同顺序把概率结果和文件名列表zip到一起然后写CSV。import pandas as pd # file_names在构造test_dataset时收集好的和test_loader顺序一一对应 # label: 1dog, 0cat pred_labels np.argmax(all_probabilities, axis1) submission pd.DataFrame({ filename: file_names, label: pred_labels }) submission.to_csv(submission.csv, indexFalse)这里有个细节值得注意索引1代表什么类别完全取决于你训练时Dataset里类别到索引的映射。如果训练时用的是{cat: 0, dog: 1}那argmax回来索引1就是狗。如果用的是{dog: 0, cat: 1}那同理索引0才是狗。不要凭感觉假设。我见过有人因为类别映射表写反整个提交文件猫狗互换模型本身没问题纯粹是最后一步索引对应错了。提示写CSV之前建议花10秒打印前20行肉眼检查一下filename列是否连续递增、label列是否0和1都有分布。这一步能拦住一大半低级错误。3.3 预测结果的可视化回放别只交作业不检查生成CSV之后直接交上去等于蒙着眼睛交卷。正确做法是把预测结果可视化挑一批图出来看模型到底判成了什么。这样你心里有底也知道模型在什么样的图上容易犯错误。可视化代码可以用matplotlib直接画网格import matplotlib.pyplot as plt def visualize_predictions(image_paths, true_labels, pred_labels, confidences, num_samples16): fig, axes plt.subplots(4, 4, figsize(12, 12)) axes axes.flatten() for i in range(num_samples): img Image.open(image_paths[i]) axes[i].imshow(img) axes[i].axis(off) truth dog if true_labels[i] 1 else cat pred dog if pred_labels[i] 1 else cat conf confidences[i] color green if truth pred else red axes[i].set_title(fPred: {pred} ({conf:.2f})\nTrue: {truth}, colorcolor) plt.tight_layout() plt.savefig(prediction_review.png, dpi100)绿色是预测对的红色是预测错的置信度放在标题里。这一张图胜过看一堆日志输出——你会立刻看出模型是不是把白色背景的狗全判成猫了、是不是对模糊小图完全失去判断力。这种直观感受对后续调优方向非常重要。4. 预测质量自查用验证集反推模型的真实水平4.1 混淆矩阵与指标解读预测完测试集你其实不知道模型真实表现如何因为测试集没有标签。但如果你在训练时留了验证集可以用同样的预测流程过一遍验证集自己看看模型到底什么水平。这是“预测模型结果”这步最有价值的检查环节。混淆矩阵是必看的它比单纯准确率信息量大得多from sklearn.metrics import confusion_matrix, classification_report true_labels ... # 验证集真实标签 pred_labels ... # 用同样流程预测出来的标签 cm confusion_matrix(true_labels, pred_labels) print(cm) print(classification_report(true_labels, pred_labels, target_names[cat, dog]))打个比方来解释混淆矩阵它有四个格子左上角是“本来就是猫模型也说猫”右下角是“本来就是狗模型也说狗”左下角是“本来是猫却判成狗”右上角是“本来是狗却判成猫”。如果模型两边都判断得不准那大概率是学习能力的问题或训练不足如果只有一边总是搞混那往往是特征差异不明显的问题比如白猫和白狗在特定背景下区分度很低。4.2 错误样本复盘模型的“盲区”到底在哪只看混淆矩阵还不够要把具体错在哪张图上翻出来看。做法和前面的可视化差不多但只挑预测错误的样本集中展示。从我自己的观察来看猫狗分类模型常见的错误样本集中在这么几类小型、分辨率低的图片模型看不清纹理细节狗的毛、猫的耳朵这些关键特征全糊掉了。遮挡和侧面角度猫只要看不到正脸特征靠身体轮廓去判断很容易和长毛狗混淆。颜色、背景干扰强的图片一只白狗趴在白墙上或者一只黑猫在暗光环境里如果模型学到的是颜色统计信息而非形状特征就会分错。非常规姿态比如狗蜷成一团睡觉、猫坐着但尾巴露出来模型容易抓错特征重点。这种复盘的价值是帮你理解模型“学到了什么”。如果你的模型在这种经典赛题上仍然大量误判短毛猫和长毛狗那大概率是网络容量不够或者训练轮次不足如果错误集中在模糊图片、低清图上那反而说明模型本身是健康的因为这是一个信息量不足导致的必然错误。4.3 预测置信度与阈值调整的思路默认的预测策略是取softmax概率最大的类别也就是固定阈值0.5。但实际业务场景中你可以根据“错误带来的代价”来调整阈值。比如误把猫判成狗和误把狗判成猫代价未必相同——在宠物相关应用里把狗识别成猫可能会让用户网线对面那个毛孩子被喂成猫粮代价明显不同。这时候就不是简单取argmax了而是设置一个自定义阈值只有当狗的概率超过0.7时才算狗否则算猫。prob_dog all_probabilities[:, 1] # 取“狗”那个类别的概率 custom_labels (prob_dog 0.7).astype(int)这个思路在预测阶段依然适用而且能直观改变最终的提交结果分布。用验证集先算一遍不同阈值下的准确率找出最优阈值再应用到真正未知的测试集上去。这种基于验证集做决策的思路比拍脑袋定一个0.5要专业得多。5. 预测环节最常见的5个坑与排查手册5.1 GPU模型加载到CPU时的地图定位问题模型是在GPU上训练的checkpoint里的tensor也都存在cuda显存里。如果你换了一台没有GPU的机器做预测或者本地环境压根没有GPU直接torch.load(best_model.pth)会抛出一个和CUDA设备相关的反序列化错误。解决方法是加载时加map_location参数state_dict torch.load(best_model.pth, map_locationcpu)这个参数的含义是“把原本存在cuda上的张量映射到CPU上”等效于告诉torch加载时做一次显式迁移。反过来说如果你在GPU机器上加载CPU保存的模型也可以map_locationcuda:0。写法很简单但很多人遇到报错才想起这个参数。还有一种情况值得警惕模型本身.to(device)之前要确保所有输入tensor也在同一个device上。8月份我在一台只有CPU的机器上跑预测忘了写map_location报错点指的却是model.to(device)那一行排查了好一会儿才意识到是加载checkpoint时就埋了雷。5.2 归一化参数不一致导致预测值漂移这是所有坑里最隐蔽的一个。训练时用的Normalize均值和标准差是ImageNet的[0.485, 0.456, 0.406]和[0.229, 0.224, 0.225]预测时如果忘了归一化或者写错参数模型接收到的输入分布和训练时完全不同——训练时的输入像素值是被约束在近似[-1, 1]区间的不归一化则像素在[0, 255]区间模型很可能会输出几乎不变的错误结果比如全部预测成某一类。排查思路很简单预测之前随机挑一张图分别对“有归一化”和“没有归一化”两种输入做前向打印概率分布差异。如果差异巨大检查transform里是不是漏了Normalize。我没有见过哪个现成的预训练模型能接受未归一化的输入还能表现正常的所以这一条几乎可以当成硬性要求。5.3 忘记eval()模式训练/预测行为错乱如果模型包含BatchNorm或Dropout层那么它在training模式下的行为和eval模式下完全不一样。BatchNorm在训练时会实时统计当前batch的均值和方差在预测时会使用累计统计数据如果预测时忘了model.eval()模型可能沿用训练时的统计结果批次和批次之间产生不一致Dropout还会随机置零结果就是同一张图预测两次结果不同。这种错误debug起来特别烦因为不是“完全错”而是“偶尔错”有时还时好时坏。写代码时把model.eval()放在显眼的位置或者在predict_one_image函数开头强制调用一次从行为上杜绝这个坑。5.4 类别索引和标签对调的惨案训练时如果Dataset里定义的类别映射是{cat: 0, dog: 1}那么测试集最终输出的argmax索引也是0或1对应的类别必须和这个映射一致。可惜这是个非常容易出错的环节——中途改过一次类别顺序、或者迁移学习时改过标签编码都会让最终提交文件猫狗互换。我自己的验证方法是预测完验证集之后单独挑出几张我确定是狗、确定是猫的图片手动检查预测输出。通过少数人工样本的复核可以快速发现类别映射是否对调。5.5 多线程随机数抖动与可复现性DataLoader的num_workers开启后会用子进程或线程加载数据。虽然图片内容不会变但如果你在transform里不小心用了随机操作测试集却忘了去掉不同线程的随机数种子不同预测结果就会抖动。一旦发现“每次跑出来的CSV不太一样”第一反应就是检查transform里有没有Random开头的东西第二检查有没有设置全局随机种子。虽然测试阶段设种子不解决根本问题但至少能帮你稳定复现某个结果来做对比import random import numpy as np import torch random.seed(42) np.random.seed(42) torch.manual_seed(42) if torch.cuda.is_available(): torch.cuda.manual_seed_all(42)这几个坑看着零散但其实有一条共同的根因预测和训练不是一回事不能用训练时的习惯直接套。训练时需要随机性来增加泛化预测时需要确定性来保证每张图的结果可靠。你在这个阶段花十分钟检查transform、device、类别映射能省下提交后才发现问题的整段返工时间。我自己的习惯是每次做预测前先跑一遍小批量的“冒烟测试”——随意拿16张测试图走一遍完整预测流程并打印概率分布确认一切正常后再放开全量预测。这个习惯救过我很多次比任何经验总结都管用。希望这篇围绕猫狗大战第四步“预测模型结果”的复盘能让你少走一段弯路。