最近在折腾食物图片分类,拿 Food-11 数据集练手,从零手写了一个 CNN,又用 PyTorch 迁移学习刷了一轮,两种方案都完整跑通了。这篇东西把整个项目的思路、网络设计、训练细节、踩过的坑都整理出来,想给刚学完 PyTorch 基础、正准备做图像分类实战的人一点参考。适合两类人:一是想彻底搞懂 CNN 每层在干什么、怎么调参的初学者,二是手上数据量不大、想用迁移学习快速出效果的朋友。整个过程没有用特别复杂的结构,重点是把每一步为什么这么做讲清楚。Food-11 这个数据集很有意思,它包含 11 类常见食物图片,类别包括面包、甜点、鸡蛋、油炸食品、肉类、面条、披萨、米饭、海鲜、汤和沙拉蔬菜。和 MNIST、CIFAR 那种入门数据集相比,它的难度高不少——同类食物有煎炸、烘焙、炖煮等不同形态,不同类别之间又存在视觉相似性,比如米饭和面条、汤和炖菜,很容易混淆。这种"类内差异大、类间有重叠"的特点,恰好能检验一个模型真正的特征提取能力。数据规模大约是训练集 9866 张、验证集 3430 张、测试集 3347 张,对消费级显卡来说完全跑得动。1. 项目整体设计与思路拆解1.1 为什么选 Food-11 作为实战任务很多新手入门 CNN 都从 CIFAR-10 或者 MNIST 开始,这两个数据集确实经典,但有个问题:类别之间差异太大,背景干净、物体居中,随便搭一个简单网络就能刷到 90% 以上,很容易给人"深度学习挺简单"的错觉。一旦换到真实场景的数据,立刻被打回原形。Food-11 的好处在于它足够"真实"。图片不是规范化的商品图,而是带着不同光照、不同拍摄角度、不同摆盘方式的生活照,每张图的尺寸也不一样,有横构图有竖构图,还有各种背景干扰。你要做分类,模型必须学会忽略背景、聚焦食物主体。这正好模拟了实际项目里最常见的场景,用手机拍菜单识别菜品、用监控视频分析食堂消耗量之类的需求,本质上都是同一类问题。数据规模方面,Food-11 也处于一个微妙的位置。它比 CIFAR-10 大,但又远小于 ImageNet。这个规模决定了,从头训练一个小型 CNN 是有可能收敛到不错精度的,但又不会让你轻松到接近 90%,让你能充分体验过拟合和欠拟合之间的拉扯。与此同时,这个规模也适合对比迁移学习——你很快就能看出同样一个任务,带预训练权重和从零初始化,收敛速度差距有多大。1.2 先手写 CNN 再迁移学习的意义我见过不少人直接上来就用迁移学习,ResNet 加载预训练权重、替换全连接层、训练完事,准确率看着不错,但问到他为什么 ResNet 里的 shortcut 能解决梯度消失、为什么冻结底层比冻结高层效果好、为什么微调时学习率要调小,他就答不上来了。这不怪他,因为迁移学习把太多东西封装在看不见的地方,你不需要理解也能出结果,但出了问题就不知道怎么排查。手写 CNN 的价值恰恰在这里。当你自己定义每一个卷积层、自己计算每一层的尺寸变化、自己观察训练集和验证集准确率差距的时候,你会对"为什么卷积核要越来越大"、"为什么通道数要逐层翻倍"、"为什么全连接层这么容易过拟合"有直观感受。这些东西写 100 遍笔记不如实际跑一轮来得深刻。所以我的路线是:先花几天时间从零写一个简单的 CNN,调参、观测、记录,把这个过程做完吃透。然后在此基础上引入迁移学习,把预训练模型换上来,同样是训练、观测、记录。最后把两套方案的准确率曲线、训练时长、显存占用放在一起对比。这样做完整跑一遍,你对"网络容量"、"数据增强"、"特征迁移"这几个概念的理解会比刷课程深刻得多。1.3 方案选型与预期目标在开始之前,我给自己定了几个具体目标,方便最后做复盘的时候衡量:方案预期测试集准确率训练预算从零训练 CNN65%-75%50-70 epoch迁移学习 ResNet1885% 以上20-40 epoch实际做下来,从零训练的 CNN 测试集准确率落在 70% 附近,迁移学习的 ResNet18 微调后测试集达到 87% 左右。两套方案都算达到了预期。评估维度我选了四个:测试集准确率、训练收敛速度、显存占用、过拟合程度。准确率是最直观的,收敛速度看验证集损失下降到平稳值所需的 epoch 数,显存占用直接看训练时 GPU 占用峰值,过拟合程度则看训练集和验证集准确率之间的差距。2. 环境准备与数据管线搭建2.1 环境配置与依赖安装这次项目的环境用的是 Python 3.10 + PyTorch 2.1 + CUDA 11.8,显卡恰好是 8GB 显存。这个配置很普通,不挑硬件,大多数人的笔记本都能跑,唯一需要注意的是显存不够时要把 batch size 调小。安装依赖的时候有个常见的坑值得提醒:PyTorch 的安装要匹配 CUDA 版本。直接用pip install torch装的是 CPU 版,训练速度会慢到让人怀疑人生。正确做法是去 PyTorch 官网根据你的 CUDA 版本复制对应的安装命令。如果不想查 CUDA 版本,也可以在命令行跑nvidia-smi看右上角的 CUDA Version,那个数字决定了你该选哪一档。除了 torch 本体,还需要 torchvision——因为要用它加载数据集和预训练模型。另外装上 matplotlib 用于画训练曲线,tensorboard 用于实时监控训练过程,这个后面会细说。完整安装命令大致长这样:pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 pip install matplotlib tensorboard pandas如果机器上没有独立显卡,或者 CUDA 装不上,也不是完全不能做这个项目。把代码里的.cuda()换成.to('cpu'),batch size 调小一点,训练时间大概会多 5 到 10 倍,但代码流程是一样的,原理也能学明白。不过迁移学习部分在 CPU 上跑会非常煎熬,建议至少用云平台的 GPU 实例。2.2 Food-11 数据集下载与加载Food-11 数据集来自某高校公开的食品图像研究项目,在公开渠道可以下载到。下载后是一个压缩包,解压出来分三个文件夹,分别是 training、validation、testing,每个文件夹下面再按类别分子目录。这种目录结构正好可以用torchvision.datasets.ImageFolder直接加载,不需要手写 Dataset 类。不过要留意一个细节:Food-11 有些版本用的是类别数字编号做目录名,比如 0、1、2、3 一直到 10,有些版本则标注了类别名称。你需要先在验证集上跑一次小测试,确认目录编号和实际食物类别是否对得上,否则后面画混淆矩阵的时候会一脸懵。我自己就遇到过一次,编号 5 标的是肉类但图片全是披萨,排查了半天才发现是数据集内部分类顺序和文档不一致。加载代码很简单:from torchvision import datasets, transforms train_transform = transforms.Compose([ transforms.Resize(256), transforms.RandomResizedCrop(224), transforms.RandomHorizontalFlip(p=0.5), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) val_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_dataset = datasets.ImageFolder("food11/training", transform=train_transform) val_dataset = datasets.ImageFolder("food11/validation", transform=val_transform) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True, num_workers=4, pin_memory=True) val_loader = DataLoader(val_dataset, batch_size=64, shuffle=False, num_workers=4, pin_memory=True)这里定义了两种不同的 transform:训练集用了随机裁剪和随机翻转做数据增强,验证集只用固定尺寸的中心裁剪,不做随机增强。因为验证集的作用是评估模型真实表现,如果每一轮验证的图片都在变化,你看到的准确率波动就会很大,干扰判断。2.3 数据增强策略的设计逻辑数据增强是这个小项目里最容易被忽视却非常重要的环节。Food-11 每类大概不到 1000 张训练图,这点数据量对于从零训练的 CNN 来说是不够的。如果不用数据增强,模型很快就会把训练集背下来,然后验证集准确率卡在一个尴尬的位置。我用的训练增强组合是RandomResizedCrop(224)+RandomHorizontalFlip+ColorJitter。RandomResizedCrop会从原图随机裁剪一块比例不同的区域然后缩放到 224x224,等于同时做了尺度扰动和位置扰动,这比RandomCrop更能模拟真实场景中食物在画面中大小不一的情况。ColorJitter则是随机调整亮度、对比度和饱和度,帮助模型对光照变化不敏感。但迁移学习阶段我特意把ColorJitter的强度调低了,只保留轻微的变化。原因是预训练模型在 ImageNet 上见过的是比较"标准"的颜色分布,如果用强颜色增强把图片色调改得面目全非,预训练特征就发挥不出作用了。这种情况下你等于拿从零训练的规则去约束一个有经验的老模型,反而束缚了它。归一化参数用的是 ImageNet 的均值和标准差,即使是从零训练我也用了这套参数。严格的讲,自己动手统计 Food-11 的均值和标准差会稍微更准确,但我实测下来两者差异很小,不值得为这点提升额外增加代码复杂度。使用统一归一化参数还有个额外好处:后期迁移学习阶段直接复用同一套 transform,代码不用改。3. 从零手写 CNN 并训练到收敛3.1 网络结构设计手写 CNN 不是越复杂越好,关键是结构合理、每层的作用清清楚楚。我用的结构是三个卷积块加两个全连接层,输入 224x224x3 的彩色图片,输出 11 个类别的分数。设计原则就三条:通道数逐层翻倍、空间尺寸逐层减半、在卷积和全连接的衔接处用池化降维。import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self, num_classes=11): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 32, kernel_size=3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(64, 128, kernel_size=3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.MaxPool2d(2), ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(128 * 28 * 28, 256), nn.ReLU(inplace=True), nn.Dropout(0.5), nn.Linear(256, num_classes) ) def forward(self, x): return self.classifier(self.features(x))为什么每个卷积层都用 3x3 卷积?因为两个串联的 3x3 卷积堆叠出来的感受野相当于一个 5x5 卷积,参数更少、非线性表达更强。为什么通道数从 32 到 64 到 128 翻倍?因为随着空间尺寸不断减半,要保持特征信息量不下降,就得靠增加通道数来补偿。展平后的维度是这样算的:输入 224x224,经过三次 MaxPool2d(2) 后空间尺寸变为 224/2/2/2 = 28,所以展平后是 1282828 = 100352 维。这个全连接输入维度有点大,但实际训练时问题不大。如果不想每次手算维度,也可以在最前面加一层nn.AdaptiveAvgPool2d((7, 7)),这样不管输入尺寸怎么变都能统一输出到 7x7,代码更优雅,我后面在迁移学习中就用这种写法。还有两个容易被忽略的细节。一个是BatchNorm2d放在卷积和 ReLU 之间,先归一化再激活,这是目前实践中最稳定的排列。另一个是最后一个卷积块后面没有接全局平均池化,而是直接把特征图展平喂给全连接层。这么做不是最优的,VGG 时代的大量实验表明展平操作会导致较大参数量、更容易过拟合;换成AdaptiveAvgPool2d后参数会少很多。你可以把这个当成一个优化方向,但作为学习用的第一版网络,展平方案更直观,能让你看懂特征图是如何变成一维向量的。3.2 训练流程与损失函数设置训练一个小型 CNN 分类模型的代码框架是通用的,以后你换任何一个数据集这个骨架都能用。核心就是四个步骤:前向传播、计算损失、反向传播、更新参数。import torch import torch.nn as nn import torch.optim as optim device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = SimpleCNN(num_classes=11).to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=20, gamma=0.5) epochs = 60 for epoch in range(epochs): model.train() total_loss, correct, total = 0.0, 0, 0 for images, labels in train_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) loss = criterion(outputs, labels) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() * images.size(0) _, preds = torch.max(outputs, 1) correct += (preds == labels).sum().item() total += labels.size(0) train_acc = correct / total avg_loss = total_loss / total # 验证阶段 model.eval() val_correct, val_total = 0, 0 with torch.no_grad(): for images, labels in val_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) _, preds = torch.max(outputs, 1) val_correct += (preds == labels).sum().item() val_total += labels.size(0) val_acc = val_correct / val_total print(f"Epoch {epoch+1:03d} | Loss {avg_loss:.4f} | " f"Train Acc {train_acc:.4f} | Val Acc {val_acc:.4f}") scheduler.step()这里的几个设计点都有实际意义。损失函数用nn.CrossEntropyLoss(),这个函数内部自动帮你做了 softmax 和 log,所以不要在模型最后一层额外加 softmax,否则等于做了两次归一化,数值行为和梯度都会受影响。优化器我选了 Adam,初始学习率 1e-3,配了一个很轻的 L2 正则化(weight_decay=1e-4)。Adam 的好处是对学习率不敏感、收敛稳,对新手友好。SGD 配合动量理论上有更好的泛化性能,但需要调学习率和动量策略,花的时间更多,实战中遇到 Adam 收敛不上去再考虑换 SGD。训练过程中我用了model.train()和model.eval()来切换模式。这个切换非常关键,Dropout在训练时随机丢弃神经元,但验证时不能丢;BatchNorm在训练时用当前 batch 的统计量,验证时用训练阶段累积的滑动平均。忘了切回 eval 模式是新手最容易犯的错误之一,后果是验证准确率莫名其妙地低,而且每次跑结果不一样。3.3 训练过程监控与调参记录训练到 20 轮左右的时候,训练准确率已经到了 85% 左右,验证集 62% 左右,这时明显出现了过拟合迹象。训练集学得比验证集快很多的情况在从零训练的小网络上几乎无法避免,只能通过增强正则化和降低模型容量来缓解。我把 Dropout 从 0.3 提到了 0.5,同时把ColorJitter的增强幅度调大了一点,让模型看到的训练样本更加多样化。调整后验证集准确率往上走了一些,但最终也只是稳定在 70% 附近。从这里你能直观感受到"浅层网络的容量瓶颈"是什么意思。不是过拟合问题解决不了,而是即便解决了过拟合,这个网络本身的表达能力也就到这个程度。11 个类别里,米饭和面条、汤和炖菜这类细粒度差异,需要更深层的特征才能区分,而三层卷积只能学到纹理和颜色级别的特征,再往上就需要更多层来组合语义信息。另一个值得记录的调参经验是学习率衰减。我用StepLR(step_size=20, gamma=0.5),也就是每 20 轮把学习率减半。如果一直用恒定学习率训练,后期 loss 会在一个区间反复震荡,准确率曲线也呈锯齿状;衰减之后曲线明显平滑下来。你可以在 tensorboard 里观察 loss 曲线的尾部,如果出现高频震荡而验证集准确率不再提升,大概率就是学习率偏大的信号。最终手写 CNN 的结果是:训练 60 轮,测试集准确率 70.8%。这个成绩在没有任何预训练权重的情况下,对于 Food-11 这类真实图片数据集来说,属于一个合理但谈不上优秀的结果。它证明了这个网络架构是能学到东西的,但天花板也很明显。4. 迁移学习:从预训练权重到微调落地4.1 预训练模型选型迁移学习的核心思路是:不从一个全新的随机初始化开始,而是用一个已经在 ImageNet 上训练过的模型作为起点。ImageNet 有 1000 个类别、上百万张图片,模型在分类训练中被迫学会识别各种通用视觉模式,比如边缘、纹理、形状、物体部件。这些特征对绝大多数图像任务都有复用价值。torchvision 里提供多种预训练模型,我选了 ResNet18。选它的理由有三个:参数量约 1170 万,不算大,8GB 显存跑起来毫无压力;结构经典,残差连接解决了深层网络的梯度传播问题,这让你在微调时即使解冻更多层也不用太担心训练崩溃;在 Food-11 这种中等复杂度的任务上,ResNet18 的能力已经足够,上 ResNet50 提升有限但训练时间和显存占用都会明显增加。ResNet50 适合什么时候用?如果你手里的数据量更大、图片分辨率更高、类别更多,或者类别之间差异非常细微,那时候 ResNet50 或更大的模型才有意义。但在这个项目里,选了 ResNet50 也不会让准确率有质的飞越,反而会让训练变慢,过拟合风险也更高。4.2 模型替换与参数冻结策略加载预训练 ResNet18 之后,第一件事就是把输出层的全连接替换掉。ResNet18 原来的全连接层输出 1000 个类别分数,我们的任务是 11 类,所以需要换成一个nn.Linear(resnet.fc.in_features, 11)。这里用in_features拿原层的输入维度是标准写法,避免硬编码 512 这种数字,以后换 ResNet34、ResNet50 时代码不用改。接下来是最关键的策略决策:冻结哪些层,解冻哪些层。import torchvision.models as models model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) for param in model.parameters(): param.requires_grad_(False) for param in model.layer3.parameters(): param.requires_grad_(True) for param in model.layer4.parameters(): param.requires_grad_(True) model.fc = nn.Linear(model.fc.in_features, 11)这个代码做了三件事:先把所有参数锁定(requires_grad=False),然后解冻了 layer3 和 layer4 这两个残差块,最后替换分类头。解冻的含义是这些层的参数会在训练中被更新,没解冻的层参数保持预训练权重不变。冻结层的参数不参与训练,这两大好处:第一,大幅减少需要计算的梯度,显存占用下降;第二,避免在小数据集上破坏预训练学到的高质量通用特征。但也不宜冻太多——layer1 和 layer2 学到的是边缘、纹理这些通用的特征,layer3 和 layer4 学到的是偏语义的特征,对食物类别更关键,解冻它们让模型有空间去适应当前任务,这是精度和数据规模之间的一个平衡点。如果你想先跑一个快速的 baseline,可以把所有层都冻结,只训练新的全连接层。这样训练速度极快,十几分钟就能出一个结果,通常在 Food-11 上能到 80% 左右。解冻 layer3、layer4 后,准确率可以再提升几个点。所以一个实用的流程是:先只训分类头,确认 baseline;再逐步解冻更深层,观察准确率变化,找到收益最大且不发生过拟合的解冻深度。优化器这里需要特别处理一下,不同层要用不同的学习率。因为全连接层是随机初始化的,需要较大的学习率快速收敛;而预训练层已经有较好的初始化,只需要很小的学习率做微调,不然一步下去就把之前学好的特征给破坏了。optimizer = optim.Adam([ {'params': model.layer3.parameters(), 'lr': 1e-4}, {'params': model.layer4.parameters(), 'lr': 1e-4}, {'params': model.fc.parameters(), 'lr': 1e-3}, ], weight_decay=1e-4)权重衰减对微调阶段同样重要,它能抑制预训练层在微调过程中的参数漂移。有人担心weight_decay加在预训练层上会破坏原有权重,实测下来只要 lr 足够小,这个影响可以忽略,反而能提升最终精度。4.3 微调实验结果与对比分析迁移学习阶段我用相同的数据增强和 batch size,训练了 30 轮。前 10 轮就达到了手写 CNN 最终的水平,验证集准确率超过 70%;20 轮后验证集稳定在 87% 左右;30 轮后测试集准确率定格在 87.3%。整个过程没有遇到明显的过拟合,训练集和验证集准确率的差距大约在 3-4 个百分点,比手写 CNN 的 10 个百分点以上健康得多。这个结果说明,预训练模型在 ImageNet 上学到的通用特征,对食物图片依然高度有效。底层卷积提取的纹理、边缘、颜色斑块等低层特征本身就和食物图像没有太大领域差异,而高层的语义特征通过微调也能很快适应"食物"这个特定领域。这是迁移学习最核心的逻辑:低层特征通用,高层特征特异,微调相当于把"特异的部分"重新训练一下,而低层保持不动。对比手写 CNN,迁移学习带来的收益非常明确:准确率提升接近 17 个百分点,收敛轮数减少一半以上,过拟合风险大幅降低。唯一的代价是需要额外加载一个 44MB 左右的预训练权重文件,以及第一轮训练前的参数下载时间。5. 两套方案的对比与选型建议5.1 准确率、训练时长、鲁棒性三维对比两套方案完整跑完后,我列了一张对比表,直观反映差异。对比维度从零训练 CNNResNet18 迁移学习测试集准确率70.8%87.3%达到 70% 所需轮数约 40 轮约 8 轮显存峰值占用约 1.2GB约 1.8GB训练/验证准确率差距10%+3%-4%过拟合风险高低收敛稳定性需要调参相对稳定从零训练 CNN 最大的问题是过拟合。训练集准确率接近 96%,验证集只有 79% 左右,差距接近 17 个百分点。迁移学习就健康得多,训练集 91%,验证集 87%,差距被压缩在可接受范围内。这背后的原因不只是预训练权重,更重要的是预训练模型本身的设计,残差结构配合 batch normalization,让梯度传播更稳定,训练过程不容易被噪声样本带偏。5.2 什么场景该选哪种方案做完这个项目之后我形成了一个判断框架,以后拿到新的图像分类任务可以直接套用。当数据量每类超过 5000 张、图片内容高度标准化、且类别间差异明显时,从零训练一个浅层网络可能是一个选项,但那也仅仅是为了研究网络结构才需要做的事。工程上无论是训练效率还是最终精度,迁移学习几乎总是更好的选择。数据量中等、每类有几百张图片,而且任务和自然图像比较接近的时候,迁移学习是首选,推荐先用冻结全部层训练分类头做 baseline,再逐步解冻深层,这是最快找到最优配置的路径。数据量极少、每类只有几十张图片时,那就只训练分类头、保持所有特征层完全冻结,同时依赖更强的数据增强来撑住训练。有一种情况值得警惕:如果目标图片是医学影像、卫星遥感、工业检测这类和自然图像差异巨大的领域,预训练权重的价值会打折扣。底层卷积的纹理、边缘特征仍然有用,但高层的语义特征跟你的任务完全对不上。这时候不要盲目解冻所有层,应该从浅到深逐层解冻,每解冻一层就评估一次,一旦发现验证集准确率下降就停在那里。5.3 迁移学习工程的四个经验第一个经验是 BatchNorm 的统计量与 batch size 高度相关。微调时如果 batch size 设得太小,比如小于 16,BN 层计算出来的均值和方差波动很大,会拖累整个模型的表现。遇到这种情况可以把冻结层的 BN 也设成 eval 模式,或者干脆用一个足够大的 batch size。我建议微调过程中的 batch size 不要低于 32。第二个经验是输入分辨率尽量保持 224x224。预训练模型在 ImageNet 上训练时用的就是 224 分辨率,你如果为了省显存用 160x160,虽然 ResNet 的全连接层会因为输入变小而无法 forward,但用AdaptiveAvgPool2d的结构能适应不同分辨率,只是准确率会下降几个点。所以最稳妥的方式是 resize 到 256 再 center crop 到 224,这个方案在速度和精度之间是公认的平衡点。第三个经验是数据增强可以比手写网络用的更强,但也不能乱来。Food-11 上我稍微调大了RandomResizedCrop的裁剪范围,让模型看到更多不同大小的食物区域,效果是正向的。但ColorJitter如果设置得过强,预训练层的颜色统计会被打乱,效果反而变差。第四个经验关于推理阶段。训练结束后做测试推断时,记得也要用验证集那套 transform,不要用包括了随机翻转的训练 transform。如果测试时图像被随机翻转,你的预测结果每次都不一样,无法得到稳定的测试集准确率。6. 常见问题与排查技巧实录6.1 损失不下降或训练成 NaN训练过程中损失值不下降,或者直接变成 NaN,是新手遇到最多的问题。我自己在这上面踩过坑,整理一下排查顺序。先看学习率。如果损失长时间在一个高数值徘徊不往下走,十有八九是学习率偏大或者偏小。Adam 的默认学习率 1e-3 在这个小网络上通常合适,但如果换成更大更深的网络,这个值就偏大了,损失会在几个小 epoch 内直接炸掉。遇到 NaN 的第一反应就是把学习率除以 10 再试试。再看有没有在模型最后一层重复加 softmax。nn.CrossEntropyLoss内部的实现是log(softmax(x)),如果你额外在forward里加了一个 softmax,梯度计算路径就多了一层非线性变换,数值不稳定,损失甚至会出现负值。这个错误在代码里不好肉眼发现,最好用一个小批量数据做一轮前向推断,打印输出层数值范围,正常应该在 -5 到 5 之间。最后看数据归一化。如果忘了用Normalize直接把原始像素值(0-255)喂给网络,或者归一化的 mean/std 写错位置,输入分布的数值范围会和模型预期差异很大,导致训练很难收敛。Food-11 这种自然图像一般按 ImageNet 的统计参数归一化就好。6.2 过拟合严重时的应对次序当训练集准确率很高、验证集准确率停滞不前的时候,按照这个顺序调整:先从数据侧入手,增强RandomResizedCrop和RandomHorizontalFlip,有需要再加RandomRotation和ColorJitter;再从模型侧入手,提高 Dropout 比例、降低通道数;最后从训练策略入手,加 weight_decay、早停、降低学习率。迁移学习阶段如果出现过拟合,最快的办法是减少解冻的层数,不要一上来就解冻全部。另一个有效手段是使用更小的学习率,比如把微调学习率从 1e-4 降到 3e-5,让参数在已经很小的范围内继续调整。微调更怕"矫枉过正",宁可慢一点,也不要让预训练权重被几个 high-loss 的样本带着跑偏。6.3 加载预训练模型报 key 不匹配加载torchvision预训练模型偶尔会遇到state_dict的 key 不匹配,或者下面的报错:RuntimeError: Error(s) in loading state_dict for ResNet: Missing key(s) in state_dict: fc.weight, fc.bias. Unexpected key(s) in state_dict: module.fc.weight, module.fc.bias.这个报错通常有两种原因。第一种是你用了多 GPU 训练保存的权重,key 前面统一带着module.前缀;第二种是你已经替换了最后的全连接层,但加载的权重还是旧的 1000 类版本。解决办法是加载时用strict=False,并且手动剔除module.前缀。from collections import OrderedDict state_dict = torch.load("your_checkpoint.pth") new_state_dict = OrderedDict() for k, v in state_dict.items(): if k.startswith("module."): k = k[7:] # 去掉 module. 前缀 new_state_dict[k] = v model.load_state_dict(new_state_dict, strict=False)预处理最好是在保存 checkpoint 时就规范化 key,省得每次加载都写这段代码。如果不小心保存了带module.的权重,上面这段代码可以直接复用。6.4 显存不足(OOM)时的三板斧8GB 显存跑 ResNet18 很从容,但并不说明你可以随意浪。batch size 设到 128、图片分辨率用到 256,OOM 就会找上门。遇到显存不足,按照下面的顺序处理。第一,减小 batch size。这是最直接的办法,从 64 降到 32,显存立刻减半。代价是训练会多一些波动,可以配合适当降低学习率弥补。第二,利用冻结层的特性。如果只训练分类头,整个特征提取器不需要梯度,但实际上前向计算中的中间特征图仍然会被保留用于后续的反向传播,解决办法是把requires_grad=False的层放在torch.no_grad()上下文里计算,或者直接用autocast混合精度训练。第三,开自动混合精度。from torch.cuda.amp import autocast, GradScaler scaler = GradScaler() for images, labels in train_loader: images, labels = images.to(device), labels.to(device) with autocast(): outputs = model(images) loss = criterion(outputs, labels) optimizer.zero_grad() scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()混合精度训练把部分计算改为半精度浮点数,显存占用和计算量都能减少将近一半,而精度损失在这个任务上几乎观察不到。这是现代 PyTorch 训练里性价比最高的一个优化手段。6.5 训练速度慢排查方向如果你的训练速度异常慢,先用nvidia-smi看一下 GPU 利用率和显存占用。如果 GPU 利用率只有 20% 以下,问题多半不在模型,而在数据加载流程。PyTorch 的 DataLoader 里num_workers设置太小时,CPU 处理图像速度跟不上 GPU 的消费速度,整个训练就卡在数据读取上。num_workers设成 0 表示在主进程里加载数据,速度最慢,通常 2 到 4 就够用。如果设置到 8 以上,你就需要检查系统内存是否足够,内存交换会让训练变慢几个数量级。另外开启pin_memory=True可以加速 GPU 和设备端的数据拷贝,这个小参数很多人会忽略。如果数据加载已经很快但 GPU 利用率还是低,就检查 transform 的复杂度。RandomResizedCrop这类操作是 CPU 密集型计算,再叠加ColorJitter传统实现也比较重。有条件的可以把 transform 简化,或者用多进程预处理。在普通项目里,我通常把num_workers调到 4、pin_memory打开就能解决大部分速度问题。7. 训练过程中记录的几条心得整个项目跑完,我对图像分类实战有了更具体的认识。从零手写 CNN 的过程虽然最后准确率不高,但它教会我"网络容量限制了模型的表达能力,数据增强和正则化只是缓解症状,真正解决问题要靠结构设计"。这种领悟不是看任何教程能直接获得的,必须在调参和对比中慢慢沉淀。迁移学习让我重新理解了"预训练"这三个字的重量。同样的网络结构、同样的数据、同样的训练轮数,唯一的差别就是初始化方式不同,最终结果差了 16 个百分点。在工业界大多数场景里,数据和算力才是稀缺资源,而预训练权重提供了一条用别人已训练好的知识来弥补自己数据不足的捷径。最后一个小技巧想分享给做类似项目的朋友:从最开始就建立固定格式的训练日志,把每一轮的损失、准确率、学习率、显存占用都记录到文件里。这些看似琐碎的数据,在你需要回头对比不同方案时价值极大。我的习惯是用一张 CSV 记录关键指标,再用 Python 脚本画准确率曲线,几个方案放在同一张图上对比,谁好谁差一目了然。
最近在折腾食物图片分类,拿 Food-11 数据集练手,从零手写了一个 CNN,又用 PyTorch 迁移学习刷了一轮,两种方案都完整跑通了。这篇东西把整个项目的思路、网络设计、训练细节、踩过的坑都整理出来,想给刚学完 PyTorch 基础、正准备做图像分类实战的人一点参考。适合两类人:一是想彻底搞懂 CNN 每层在干什么、怎么调参的初学者,二是手上数据量不大、想用迁移学习快速出效果的朋友。整个过程没有用特别复杂的结构,重点是把每一步为什么这么做讲清楚。Food-11 这个数据集很有意思,它包含 11 类常见食物图片,类别包括面包、甜点、鸡蛋、油炸食品、肉类、面条、披萨、米饭、海鲜、汤和沙拉蔬菜。和 MNIST、CIFAR 那种入门数据集相比,它的难度高不少——同类食物有煎炸、烘焙、炖煮等不同形态,不同类别之间又存在视觉相似性,比如米饭和面条、汤和炖菜,很容易混淆。这种"类内差异大、类间有重叠"的特点,恰好能检验一个模型真正的特征提取能力。数据规模大约是训练集 9866 张、验证集 3430 张、测试集 3347 张,对消费级显卡来说完全跑得动。1. 项目整体设计与思路拆解1.1 为什么选 Food-11 作为实战任务很多新手入门 CNN 都从 CIFAR-10 或者 MNIST 开始,这两个数据集确实经典,但有个问题:类别之间差异太大,背景干净、物体居中,随便搭一个简单网络就能刷到 90% 以上,很容易给人"深度学习挺简单"的错觉。一旦换到真实场景的数据,立刻被打回原形。Food-11 的好处在于它足够"真实"。图片不是规范化的商品图,而是带着不同光照、不同拍摄角度、不同摆盘方式的生活照,每张图的尺寸也不一样,有横构图有竖构图,还有各种背景干扰。你要做分类,模型必须学会忽略背景、聚焦食物主体。这正好模拟了实际项目里最常见的场景,用手机拍菜单识别菜品、用监控视频分析食堂消耗量之类的需求,本质上都是同一类问题。数据规模方面,Food-11 也处于一个微妙的位置。它比 CIFAR-10 大,但又远小于 ImageNet。这个规模决定了,从头训练一个小型 CNN 是有可能收敛到不错精度的,但又不会让你轻松到接近 90%,让你能充分体验过拟合和欠拟合之间的拉扯。与此同时,这个规模也适合对比迁移学习——你很快就能看出同样一个任务,带预训练权重和从零初始化,收敛速度差距有多大。1.2 先手写 CNN 再迁移学习的意义我见过不少人直接上来就用迁移学习,ResNet 加载预训练权重、替换全连接层、训练完事,准确率看着不错,但问到他为什么 ResNet 里的 shortcut 能解决梯度消失、为什么冻结底层比冻结高层效果好、为什么微调时学习率要调小,他就答不上来了。这不怪他,因为迁移学习把太多东西封装在看不见的地方,你不需要理解也能出结果,但出了问题就不知道怎么排查。手写 CNN 的价值恰恰在这里。当你自己定义每一个卷积层、自己计算每一层的尺寸变化、自己观察训练集和验证集准确率差距的时候,你会对"为什么卷积核要越来越大"、"为什么通道数要逐层翻倍"、"为什么全连接层这么容易过拟合"有直观感受。这些东西写 100 遍笔记不如实际跑一轮来得深刻。所以我的路线是:先花几天时间从零写一个简单的 CNN,调参、观测、记录,把这个过程做完吃透。然后在此基础上引入迁移学习,把预训练模型换上来,同样是训练、观测、记录。最后把两套方案的准确率曲线、训练时长、显存占用放在一起对比。这样做完整跑一遍,你对"网络容量"、"数据增强"、"特征迁移"这几个概念的理解会比刷课程深刻得多。1.3 方案选型与预期目标在开始之前,我给自己定了几个具体目标,方便最后做复盘的时候衡量:方案预期测试集准确率训练预算从零训练 CNN65%-75%50-70 epoch迁移学习 ResNet1885% 以上20-40 epoch实际做下来,从零训练的 CNN 测试集准确率落在 70% 附近,迁移学习的 ResNet18 微调后测试集达到 87% 左右。两套方案都算达到了预期。评估维度我选了四个:测试集准确率、训练收敛速度、显存占用、过拟合程度。准确率是最直观的,收敛速度看验证集损失下降到平稳值所需的 epoch 数,显存占用直接看训练时 GPU 占用峰值,过拟合程度则看训练集和验证集准确率之间的差距。2. 环境准备与数据管线搭建2.1 环境配置与依赖安装这次项目的环境用的是 Python 3.10 + PyTorch 2.1 + CUDA 11.8,显卡恰好是 8GB 显存。这个配置很普通,不挑硬件,大多数人的笔记本都能跑,唯一需要注意的是显存不够时要把 batch size 调小。安装依赖的时候有个常见的坑值得提醒:PyTorch 的安装要匹配 CUDA 版本。直接用pip install torch装的是 CPU 版,训练速度会慢到让人怀疑人生。正确做法是去 PyTorch 官网根据你的 CUDA 版本复制对应的安装命令。如果不想查 CUDA 版本,也可以在命令行跑nvidia-smi看右上角的 CUDA Version,那个数字决定了你该选哪一档。除了 torch 本体,还需要 torchvision——因为要用它加载数据集和预训练模型。另外装上 matplotlib 用于画训练曲线,tensorboard 用于实时监控训练过程,这个后面会细说。完整安装命令大致长这样:pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 pip install matplotlib tensorboard pandas如果机器上没有独立显卡,或者 CUDA 装不上,也不是完全不能做这个项目。把代码里的.cuda()换成.to('cpu'),batch size 调小一点,训练时间大概会多 5 到 10 倍,但代码流程是一样的,原理也能学明白。不过迁移学习部分在 CPU 上跑会非常煎熬,建议至少用云平台的 GPU 实例。2.2 Food-11 数据集下载与加载Food-11 数据集来自某高校公开的食品图像研究项目,在公开渠道可以下载到。下载后是一个压缩包,解压出来分三个文件夹,分别是 training、validation、testing,每个文件夹下面再按类别分子目录。这种目录结构正好可以用torchvision.datasets.ImageFolder直接加载,不需要手写 Dataset 类。不过要留意一个细节:Food-11 有些版本用的是类别数字编号做目录名,比如 0、1、2、3 一直到 10,有些版本则标注了类别名称。你需要先在验证集上跑一次小测试,确认目录编号和实际食物类别是否对得上,否则后面画混淆矩阵的时候会一脸懵。我自己就遇到过一次,编号 5 标的是肉类但图片全是披萨,排查了半天才发现是数据集内部分类顺序和文档不一致。加载代码很简单:from torchvision import datasets, transforms train_transform = transforms.Compose([ transforms.Resize(256), transforms.RandomResizedCrop(224), transforms.RandomHorizontalFlip(p=0.5), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) val_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_dataset = datasets.ImageFolder("food11/training", transform=train_transform) val_dataset = datasets.ImageFolder("food11/validation", transform=val_transform) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True, num_workers=4, pin_memory=True) val_loader = DataLoader(val_dataset, batch_size=64, shuffle=False, num_workers=4, pin_memory=True)这里定义了两种不同的 transform:训练集用了随机裁剪和随机翻转做数据增强,验证集只用固定尺寸的中心裁剪,不做随机增强。因为验证集的作用是评估模型真实表现,如果每一轮验证的图片都在变化,你看到的准确率波动就会很大,干扰判断。2.3 数据增强策略的设计逻辑数据增强是这个小项目里最容易被忽视却非常重要的环节。Food-11 每类大概不到 1000 张训练图,这点数据量对于从零训练的 CNN 来说是不够的。如果不用数据增强,模型很快就会把训练集背下来,然后验证集准确率卡在一个尴尬的位置。我用的训练增强组合是RandomResizedCrop(224)+RandomHorizontalFlip+ColorJitter。RandomResizedCrop会从原图随机裁剪一块比例不同的区域然后缩放到 224x224,等于同时做了尺度扰动和位置扰动,这比RandomCrop更能模拟真实场景中食物在画面中大小不一的情况。ColorJitter则是随机调整亮度、对比度和饱和度,帮助模型对光照变化不敏感。但迁移学习阶段我特意把ColorJitter的强度调低了,只保留轻微的变化。原因是预训练模型在 ImageNet 上见过的是比较"标准"的颜色分布,如果用强颜色增强把图片色调改得面目全非,预训练特征就发挥不出作用了。这种情况下你等于拿从零训练的规则去约束一个有经验的老模型,反而束缚了它。归一化参数用的是 ImageNet 的均值和标准差,即使是从零训练我也用了这套参数。严格的讲,自己动手统计 Food-11 的均值和标准差会稍微更准确,但我实测下来两者差异很小,不值得为这点提升额外增加代码复杂度。使用统一归一化参数还有个额外好处:后期迁移学习阶段直接复用同一套 transform,代码不用改。3. 从零手写 CNN 并训练到收敛3.1 网络结构设计手写 CNN 不是越复杂越好,关键是结构合理、每层的作用清清楚楚。我用的结构是三个卷积块加两个全连接层,输入 224x224x3 的彩色图片,输出 11 个类别的分数。设计原则就三条:通道数逐层翻倍、空间尺寸逐层减半、在卷积和全连接的衔接处用池化降维。import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self, num_classes=11): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 32, kernel_size=3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(64, 128, kernel_size=3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.MaxPool2d(2), ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(128 * 28 * 28, 256), nn.ReLU(inplace=True), nn.Dropout(0.5), nn.Linear(256, num_classes) ) def forward(self, x): return self.classifier(self.features(x))为什么每个卷积层都用 3x3 卷积?因为两个串联的 3x3 卷积堆叠出来的感受野相当于一个 5x5 卷积,参数更少、非线性表达更强。为什么通道数从 32 到 64 到 128 翻倍?因为随着空间尺寸不断减半,要保持特征信息量不下降,就得靠增加通道数来补偿。展平后的维度是这样算的:输入 224x224,经过三次 MaxPool2d(2) 后空间尺寸变为 224/2/2/2 = 28,所以展平后是 1282828 = 100352 维。这个全连接输入维度有点大,但实际训练时问题不大。如果不想每次手算维度,也可以在最前面加一层nn.AdaptiveAvgPool2d((7, 7)),这样不管输入尺寸怎么变都能统一输出到 7x7,代码更优雅,我后面在迁移学习中就用这种写法。还有两个容易被忽略的细节。一个是BatchNorm2d放在卷积和 ReLU 之间,先归一化再激活,这是目前实践中最稳定的排列。另一个是最后一个卷积块后面没有接全局平均池化,而是直接把特征图展平喂给全连接层。这么做不是最优的,VGG 时代的大量实验表明展平操作会导致较大参数量、更容易过拟合;换成AdaptiveAvgPool2d后参数会少很多。你可以把这个当成一个优化方向,但作为学习用的第一版网络,展平方案更直观,能让你看懂特征图是如何变成一维向量的。3.2 训练流程与损失函数设置训练一个小型 CNN 分类模型的代码框架是通用的,以后你换任何一个数据集这个骨架都能用。核心就是四个步骤:前向传播、计算损失、反向传播、更新参数。import torch import torch.nn as nn import torch.optim as optim device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = SimpleCNN(num_classes=11).to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=20, gamma=0.5) epochs = 60 for epoch in range(epochs): model.train() total_loss, correct, total = 0.0, 0, 0 for images, labels in train_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) loss = criterion(outputs, labels) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() * images.size(0) _, preds = torch.max(outputs, 1) correct += (preds == labels).sum().item() total += labels.size(0) train_acc = correct / total avg_loss = total_loss / total # 验证阶段 model.eval() val_correct, val_total = 0, 0 with torch.no_grad(): for images, labels in val_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) _, preds = torch.max(outputs, 1) val_correct += (preds == labels).sum().item() val_total += labels.size(0) val_acc = val_correct / val_total print(f"Epoch {epoch+1:03d} | Loss {avg_loss:.4f} | " f"Train Acc {train_acc:.4f} | Val Acc {val_acc:.4f}") scheduler.step()这里的几个设计点都有实际意义。损失函数用nn.CrossEntropyLoss(),这个函数内部自动帮你做了 softmax 和 log,所以不要在模型最后一层额外加 softmax,否则等于做了两次归一化,数值行为和梯度都会受影响。优化器我选了 Adam,初始学习率 1e-3,配了一个很轻的 L2 正则化(weight_decay=1e-4)。Adam 的好处是对学习率不敏感、收敛稳,对新手友好。SGD 配合动量理论上有更好的泛化性能,但需要调学习率和动量策略,花的时间更多,实战中遇到 Adam 收敛不上去再考虑换 SGD。训练过程中我用了model.train()和model.eval()来切换模式。这个切换非常关键,Dropout在训练时随机丢弃神经元,但验证时不能丢;BatchNorm在训练时用当前 batch 的统计量,验证时用训练阶段累积的滑动平均。忘了切回 eval 模式是新手最容易犯的错误之一,后果是验证准确率莫名其妙地低,而且每次跑结果不一样。3.3 训练过程监控与调参记录训练到 20 轮左右的时候,训练准确率已经到了 85% 左右,验证集 62% 左右,这时明显出现了过拟合迹象。训练集学得比验证集快很多的情况在从零训练的小网络上几乎无法避免,只能通过增强正则化和降低模型容量来缓解。我把 Dropout 从 0.3 提到了 0.5,同时把ColorJitter的增强幅度调大了一点,让模型看到的训练样本更加多样化。调整后验证集准确率往上走了一些,但最终也只是稳定在 70% 附近。从这里你能直观感受到"浅层网络的容量瓶颈"是什么意思。不是过拟合问题解决不了,而是即便解决了过拟合,这个网络本身的表达能力也就到这个程度。11 个类别里,米饭和面条、汤和炖菜这类细粒度差异,需要更深层的特征才能区分,而三层卷积只能学到纹理和颜色级别的特征,再往上就需要更多层来组合语义信息。另一个值得记录的调参经验是学习率衰减。我用StepLR(step_size=20, gamma=0.5),也就是每 20 轮把学习率减半。如果一直用恒定学习率训练,后期 loss 会在一个区间反复震荡,准确率曲线也呈锯齿状;衰减之后曲线明显平滑下来。你可以在 tensorboard 里观察 loss 曲线的尾部,如果出现高频震荡而验证集准确率不再提升,大概率就是学习率偏大的信号。最终手写 CNN 的结果是:训练 60 轮,测试集准确率 70.8%。这个成绩在没有任何预训练权重的情况下,对于 Food-11 这类真实图片数据集来说,属于一个合理但谈不上优秀的结果。它证明了这个网络架构是能学到东西的,但天花板也很明显。4. 迁移学习:从预训练权重到微调落地4.1 预训练模型选型迁移学习的核心思路是:不从一个全新的随机初始化开始,而是用一个已经在 ImageNet 上训练过的模型作为起点。ImageNet 有 1000 个类别、上百万张图片,模型在分类训练中被迫学会识别各种通用视觉模式,比如边缘、纹理、形状、物体部件。这些特征对绝大多数图像任务都有复用价值。torchvision 里提供多种预训练模型,我选了 ResNet18。选它的理由有三个:参数量约 1170 万,不算大,8GB 显存跑起来毫无压力;结构经典,残差连接解决了深层网络的梯度传播问题,这让你在微调时即使解冻更多层也不用太担心训练崩溃;在 Food-11 这种中等复杂度的任务上,ResNet18 的能力已经足够,上 ResNet50 提升有限但训练时间和显存占用都会明显增加。ResNet50 适合什么时候用?如果你手里的数据量更大、图片分辨率更高、类别更多,或者类别之间差异非常细微,那时候 ResNet50 或更大的模型才有意义。但在这个项目里,选了 ResNet50 也不会让准确率有质的飞越,反而会让训练变慢,过拟合风险也更高。4.2 模型替换与参数冻结策略加载预训练 ResNet18 之后,第一件事就是把输出层的全连接替换掉。ResNet18 原来的全连接层输出 1000 个类别分数,我们的任务是 11 类,所以需要换成一个nn.Linear(resnet.fc.in_features, 11)。这里用in_features拿原层的输入维度是标准写法,避免硬编码 512 这种数字,以后换 ResNet34、ResNet50 时代码不用改。接下来是最关键的策略决策:冻结哪些层,解冻哪些层。import torchvision.models as models model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) for param in model.parameters(): param.requires_grad_(False) for param in model.layer3.parameters(): param.requires_grad_(True) for param in model.layer4.parameters(): param.requires_grad_(True) model.fc = nn.Linear(model.fc.in_features, 11)这个代码做了三件事:先把所有参数锁定(requires_grad=False),然后解冻了 layer3 和 layer4 这两个残差块,最后替换分类头。解冻的含义是这些层的参数会在训练中被更新,没解冻的层参数保持预训练权重不变。冻结层的参数不参与训练,这两大好处:第一,大幅减少需要计算的梯度,显存占用下降;第二,避免在小数据集上破坏预训练学到的高质量通用特征。但也不宜冻太多——layer1 和 layer2 学到的是边缘、纹理这些通用的特征,layer3 和 layer4 学到的是偏语义的特征,对食物类别更关键,解冻它们让模型有空间去适应当前任务,这是精度和数据规模之间的一个平衡点。如果你想先跑一个快速的 baseline,可以把所有层都冻结,只训练新的全连接层。这样训练速度极快,十几分钟就能出一个结果,通常在 Food-11 上能到 80% 左右。解冻 layer3、layer4 后,准确率可以再提升几个点。所以一个实用的流程是:先只训分类头,确认 baseline;再逐步解冻更深层,观察准确率变化,找到收益最大且不发生过拟合的解冻深度。优化器这里需要特别处理一下,不同层要用不同的学习率。因为全连接层是随机初始化的,需要较大的学习率快速收敛;而预训练层已经有较好的初始化,只需要很小的学习率做微调,不然一步下去就把之前学好的特征给破坏了。optimizer = optim.Adam([ {'params': model.layer3.parameters(), 'lr': 1e-4}, {'params': model.layer4.parameters(), 'lr': 1e-4}, {'params': model.fc.parameters(), 'lr': 1e-3}, ], weight_decay=1e-4)权重衰减对微调阶段同样重要,它能抑制预训练层在微调过程中的参数漂移。有人担心weight_decay加在预训练层上会破坏原有权重,实测下来只要 lr 足够小,这个影响可以忽略,反而能提升最终精度。4.3 微调实验结果与对比分析迁移学习阶段我用相同的数据增强和 batch size,训练了 30 轮。前 10 轮就达到了手写 CNN 最终的水平,验证集准确率超过 70%;20 轮后验证集稳定在 87% 左右;30 轮后测试集准确率定格在 87.3%。整个过程没有遇到明显的过拟合,训练集和验证集准确率的差距大约在 3-4 个百分点,比手写 CNN 的 10 个百分点以上健康得多。这个结果说明,预训练模型在 ImageNet 上学到的通用特征,对食物图片依然高度有效。底层卷积提取的纹理、边缘、颜色斑块等低层特征本身就和食物图像没有太大领域差异,而高层的语义特征通过微调也能很快适应"食物"这个特定领域。这是迁移学习最核心的逻辑:低层特征通用,高层特征特异,微调相当于把"特异的部分"重新训练一下,而低层保持不动。对比手写 CNN,迁移学习带来的收益非常明确:准确率提升接近 17 个百分点,收敛轮数减少一半以上,过拟合风险大幅降低。唯一的代价是需要额外加载一个 44MB 左右的预训练权重文件,以及第一轮训练前的参数下载时间。5. 两套方案的对比与选型建议5.1 准确率、训练时长、鲁棒性三维对比两套方案完整跑完后,我列了一张对比表,直观反映差异。对比维度从零训练 CNNResNet18 迁移学习测试集准确率70.8%87.3%达到 70% 所需轮数约 40 轮约 8 轮显存峰值占用约 1.2GB约 1.8GB训练/验证准确率差距10%+3%-4%过拟合风险高低收敛稳定性需要调参相对稳定从零训练 CNN 最大的问题是过拟合。训练集准确率接近 96%,验证集只有 79% 左右,差距接近 17 个百分点。迁移学习就健康得多,训练集 91%,验证集 87%,差距被压缩在可接受范围内。这背后的原因不只是预训练权重,更重要的是预训练模型本身的设计,残差结构配合 batch normalization,让梯度传播更稳定,训练过程不容易被噪声样本带偏。5.2 什么场景该选哪种方案做完这个项目之后我形成了一个判断框架,以后拿到新的图像分类任务可以直接套用。当数据量每类超过 5000 张、图片内容高度标准化、且类别间差异明显时,从零训练一个浅层网络可能是一个选项,但那也仅仅是为了研究网络结构才需要做的事。工程上无论是训练效率还是最终精度,迁移学习几乎总是更好的选择。数据量中等、每类有几百张图片,而且任务和自然图像比较接近的时候,迁移学习是首选,推荐先用冻结全部层训练分类头做 baseline,再逐步解冻深层,这是最快找到最优配置的路径。数据量极少、每类只有几十张图片时,那就只训练分类头、保持所有特征层完全冻结,同时依赖更强的数据增强来撑住训练。有一种情况值得警惕:如果目标图片是医学影像、卫星遥感、工业检测这类和自然图像差异巨大的领域,预训练权重的价值会打折扣。底层卷积的纹理、边缘特征仍然有用,但高层的语义特征跟你的任务完全对不上。这时候不要盲目解冻所有层,应该从浅到深逐层解冻,每解冻一层就评估一次,一旦发现验证集准确率下降就停在那里。5.3 迁移学习工程的四个经验第一个经验是 BatchNorm 的统计量与 batch size 高度相关。微调时如果 batch size 设得太小,比如小于 16,BN 层计算出来的均值和方差波动很大,会拖累整个模型的表现。遇到这种情况可以把冻结层的 BN 也设成 eval 模式,或者干脆用一个足够大的 batch size。我建议微调过程中的 batch size 不要低于 32。第二个经验是输入分辨率尽量保持 224x224。预训练模型在 ImageNet 上训练时用的就是 224 分辨率,你如果为了省显存用 160x160,虽然 ResNet 的全连接层会因为输入变小而无法 forward,但用AdaptiveAvgPool2d的结构能适应不同分辨率,只是准确率会下降几个点。所以最稳妥的方式是 resize 到 256 再 center crop 到 224,这个方案在速度和精度之间是公认的平衡点。第三个经验是数据增强可以比手写网络用的更强,但也不能乱来。Food-11 上我稍微调大了RandomResizedCrop的裁剪范围,让模型看到更多不同大小的食物区域,效果是正向的。但ColorJitter如果设置得过强,预训练层的颜色统计会被打乱,效果反而变差。第四个经验关于推理阶段。训练结束后做测试推断时,记得也要用验证集那套 transform,不要用包括了随机翻转的训练 transform。如果测试时图像被随机翻转,你的预测结果每次都不一样,无法得到稳定的测试集准确率。6. 常见问题与排查技巧实录6.1 损失不下降或训练成 NaN训练过程中损失值不下降,或者直接变成 NaN,是新手遇到最多的问题。我自己在这上面踩过坑,整理一下排查顺序。先看学习率。如果损失长时间在一个高数值徘徊不往下走,十有八九是学习率偏大或者偏小。Adam 的默认学习率 1e-3 在这个小网络上通常合适,但如果换成更大更深的网络,这个值就偏大了,损失会在几个小 epoch 内直接炸掉。遇到 NaN 的第一反应就是把学习率除以 10 再试试。再看有没有在模型最后一层重复加 softmax。nn.CrossEntropyLoss内部的实现是log(softmax(x)),如果你额外在forward里加了一个 softmax,梯度计算路径就多了一层非线性变换,数值不稳定,损失甚至会出现负值。这个错误在代码里不好肉眼发现,最好用一个小批量数据做一轮前向推断,打印输出层数值范围,正常应该在 -5 到 5 之间。最后看数据归一化。如果忘了用Normalize直接把原始像素值(0-255)喂给网络,或者归一化的 mean/std 写错位置,输入分布的数值范围会和模型预期差异很大,导致训练很难收敛。Food-11 这种自然图像一般按 ImageNet 的统计参数归一化就好。6.2 过拟合严重时的应对次序当训练集准确率很高、验证集准确率停滞不前的时候,按照这个顺序调整:先从数据侧入手,增强RandomResizedCrop和RandomHorizontalFlip,有需要再加RandomRotation和ColorJitter;再从模型侧入手,提高 Dropout 比例、降低通道数;最后从训练策略入手,加 weight_decay、早停、降低学习率。迁移学习阶段如果出现过拟合,最快的办法是减少解冻的层数,不要一上来就解冻全部。另一个有效手段是使用更小的学习率,比如把微调学习率从 1e-4 降到 3e-5,让参数在已经很小的范围内继续调整。微调更怕"矫枉过正",宁可慢一点,也不要让预训练权重被几个 high-loss 的样本带着跑偏。6.3 加载预训练模型报 key 不匹配加载torchvision预训练模型偶尔会遇到state_dict的 key 不匹配,或者下面的报错:RuntimeError: Error(s) in loading state_dict for ResNet: Missing key(s) in state_dict: fc.weight, fc.bias. Unexpected key(s) in state_dict: module.fc.weight, module.fc.bias.这个报错通常有两种原因。第一种是你用了多 GPU 训练保存的权重,key 前面统一带着module.前缀;第二种是你已经替换了最后的全连接层,但加载的权重还是旧的 1000 类版本。解决办法是加载时用strict=False,并且手动剔除module.前缀。from collections import OrderedDict state_dict = torch.load("your_checkpoint.pth") new_state_dict = OrderedDict() for k, v in state_dict.items(): if k.startswith("module."): k = k[7:] # 去掉 module. 前缀 new_state_dict[k] = v model.load_state_dict(new_state_dict, strict=False)预处理最好是在保存 checkpoint 时就规范化 key,省得每次加载都写这段代码。如果不小心保存了带module.的权重,上面这段代码可以直接复用。6.4 显存不足(OOM)时的三板斧8GB 显存跑 ResNet18 很从容,但并不说明你可以随意浪。batch size 设到 128、图片分辨率用到 256,OOM 就会找上门。遇到显存不足,按照下面的顺序处理。第一,减小 batch size。这是最直接的办法,从 64 降到 32,显存立刻减半。代价是训练会多一些波动,可以配合适当降低学习率弥补。第二,利用冻结层的特性。如果只训练分类头,整个特征提取器不需要梯度,但实际上前向计算中的中间特征图仍然会被保留用于后续的反向传播,解决办法是把requires_grad=False的层放在torch.no_grad()上下文里计算,或者直接用autocast混合精度训练。第三,开自动混合精度。from torch.cuda.amp import autocast, GradScaler scaler = GradScaler() for images, labels in train_loader: images, labels = images.to(device), labels.to(device) with autocast(): outputs = model(images) loss = criterion(outputs, labels) optimizer.zero_grad() scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()混合精度训练把部分计算改为半精度浮点数,显存占用和计算量都能减少将近一半,而精度损失在这个任务上几乎观察不到。这是现代 PyTorch 训练里性价比最高的一个优化手段。6.5 训练速度慢排查方向如果你的训练速度异常慢,先用nvidia-smi看一下 GPU 利用率和显存占用。如果 GPU 利用率只有 20% 以下,问题多半不在模型,而在数据加载流程。PyTorch 的 DataLoader 里num_workers设置太小时,CPU 处理图像速度跟不上 GPU 的消费速度,整个训练就卡在数据读取上。num_workers设成 0 表示在主进程里加载数据,速度最慢,通常 2 到 4 就够用。如果设置到 8 以上,你就需要检查系统内存是否足够,内存交换会让训练变慢几个数量级。另外开启pin_memory=True可以加速 GPU 和设备端的数据拷贝,这个小参数很多人会忽略。如果数据加载已经很快但 GPU 利用率还是低,就检查 transform 的复杂度。RandomResizedCrop这类操作是 CPU 密集型计算,再叠加ColorJitter传统实现也比较重。有条件的可以把 transform 简化,或者用多进程预处理。在普通项目里,我通常把num_workers调到 4、pin_memory打开就能解决大部分速度问题。7. 训练过程中记录的几条心得整个项目跑完,我对图像分类实战有了更具体的认识。从零手写 CNN 的过程虽然最后准确率不高,但它教会我"网络容量限制了模型的表达能力,数据增强和正则化只是缓解症状,真正解决问题要靠结构设计"。这种领悟不是看任何教程能直接获得的,必须在调参和对比中慢慢沉淀。迁移学习让我重新理解了"预训练"这三个字的重量。同样的网络结构、同样的数据、同样的训练轮数,唯一的差别就是初始化方式不同,最终结果差了 16 个百分点。在工业界大多数场景里,数据和算力才是稀缺资源,而预训练权重提供了一条用别人已训练好的知识来弥补自己数据不足的捷径。最后一个小技巧想分享给做类似项目的朋友:从最开始就建立固定格式的训练日志,把每一轮的损失、准确率、学习率、显存占用都记录到文件里。这些看似琐碎的数据,在你需要回头对比不同方案时价值极大。我的习惯是用一张 CSV 记录关键指标,再用 Python 脚本画准确率曲线,几个方案放在同一张图上对比,谁好谁差一目了然。