资讯动态

PyTorch图像分类实战:手写CNN到ResNet迁移学习全流程解析

发布时间:2026/10/20 2:47:28 来源:尧图企业网站定制
1. Food-11 分类任务的前世今生去年年底我接了个活儿要做一个食物图片识别的小系统数据就是那套公开的 Food-11 数据集。说它小众吧这几年做 CV 的人多多少少都碰过说它热门吧跟 ImageNet 那种百万级数据集比起来又实在不算什么。但恰恰是这种体量适中的数据集特别适合用来完整走一遍“手写模型 - 训练调优 - 迁移学习”的标准流程。Food-11 数据集本身并不复杂11 个食物类别包含面包、甜品、水果、蔬菜、肉类等日常食物训练集一万多张图片验证集和测试集各一千多张。图片是真实拍摄的日常食物照片不是那种规规整整的商品图所以光照、角度、遮挡、摆盘各种情况都有噪声和干扰非常多。别看类别不多真要把它做到高精度远没有想象中那么容易。我最初的想法很简单——先用 PyTorch 从零搭一个 CNN实打实跑一遍完整的训练流程看看纯手写模型的天花板在哪然后在这个基础上引入迁移学习用预训练的 ResNet 做对比实验看看“大力出奇迹”和“站在巨人肩膀上”之间的差距到底有多大。整个过程走下来我踩了不少坑也验证了不少想法今天这篇就把完整路径和细节都摊开来讲。如果你是刚接触 PyTorch 不久、想系统走一遍图像分类完整流程的开发者或者你已经有了一些基础但一直没搞明白为什么迁移学习总是比手写模型强那这篇应该对你有用。我会从数据加载讲到模型实现从训练调参讲到迁移学习对比带你完整过一遍这条路。2. 动手前的三件套环境准备、数据剖析、评估策略2.1 软硬件环境与依赖安装工欲善其事必先利其器。我的开发环境是 Ubuntu 系统配一张 RTX 级别的显卡显存 8GB 以上足够CUDA 版本 11.8PyTorch 用 2.x 系列。如果你手头没有 GPU用 CPU 也能跑但训练时间会拉长很多建议还是想办法搞一块卡哪怕是云服务按小时租也行。# 创建虚拟环境并安装依赖 conda create -n food11 python3.9 conda activate food11 pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118 pip install numpy pandas matplotlib tqdm tensorboard版本这块我多说一句。PyTorch 的 API 在 1.x 到 2.x 之间有一些细微变化比如torchvision中某些预训练模型的加载方式。我不建议用太老的教程里的代码直接跑新版环境容易在weights参数上踩坑。后面讲迁移学习时会专门提这个。2.2 Food-11 数据集结构剖析Food-11 数据集的目录组织很直观food-11/ ├── training/ │ ├── 0_bread/ │ ├── 1_dessert/ │ ├── 2_fruit/ │ ├── 3_vegetable/ │ ├── ...共11个类别文件夹 ├── validation/ └── testing/每个类别文件夹以数字前缀命名对应一个食品类别。数据总量虽然不大但有一个非常关键的特点——训练集和验证集的分布有明显差异这也是很多人在这个数据集上踩坑的根源。我在开始训练之前做了两件事一是统计每个类别的图片数量确认没有严重的类别不均衡二是随机挑了几张图出来看直观感受一下数据的真实面貌。这里我把统计代码的核心逻辑放在下面建议你也在自己的项目里复制一份用import os from collections import Counter def count_images_per_class(data_dir): class_counts {} for class_name in os.listdir(data_dir): class_path os.path.join(data_dir, class_name) if os.path.isdir(class_path): count len([f for f in os.listdir(class_path) if f.lower().endswith((.jpg, .jpeg, .png))]) class_counts[class_name] count return class_counts train_dir ./food-11/training counts count_images_per_class(train_dir) for cls, cnt in sorted(counts.items()): print(f{cls}: {cnt} 张图片)从结果看Food-11 各类别之间数量基本均衡没有特别极端的分布问题这让我们可以把主要精力放在模型和训练策略上不用先被类别不均衡折磨。2.3 评估指标怎么选才合理图像分类任务最常用的指标是准确率Accuracy在 Food-11 这种类别相对均衡的任务上准确率确实是最直观的评估维度。但只看准确率远远不够我习惯同时记录每个 epoch 的训练损失和验证损失观察两者的走势来判断过拟合程度。另外我会额外关注每个类别的召回率。食物分类有一个很现实的问题——某些类别之间长得太像了。比如甜品和面包视觉边界非常模糊再比如水果沙拉和蔬菜沙拉区别可能只是有没有加沙拉酱。如果全局准确率看起来不错但某个具体类别召回率极低说明模型在这个易混淆类别上学歪了。这种信息藏在大类别的准确率之下不细看根本发现不了。基于这些考虑我的评估策略分三层第一层全局验证准确率作为模型好坏的快速判断标准第二层训练损失 vs 验证损失的曲线走势判断是否过拟合第三层每个类别的召回情况分析具体错误类型3. 从零手写 CNN网络设计思路与逐层拆解3.1 网络结构设计的核心理念手写 CNN 这部分是整个项目的基石。很多人直接拿 LeNet-5 或者 AlexNet 的架构抄一遍跑个结果就完事了。但那样做意义不大——你并没有真正理解每一层在干什么。我设计网络的时候遵循了几个基本原则第一渐进式通道扩展。浅层用少量卷积核提取基础特征越往深层通道数成倍增加用来提取更抽象的特征。从 32 到 64 再到 128这个比例是工程实践中验证过性价比最高的组合。第二空间尺寸逐步压缩。每次卷积后接池化让特征图从 224x224 一路降到 7x7 左右最后用全局平均池化或展平方式接入全连接层。第三在合适的位置加 Dropout。全连接层参数量大最容易过拟合所以在全连接前面加 Dropout 是性价比极高的防过拟合手段。基于这些原则我为 Food-11 设计的网络结构如下import torch import torch.nn as nn class FoodCNN(nn.Module): def __init__(self, num_classes11): super(FoodCNN, self).__init__() self.features nn.Sequential( # Block 1: 224x224x3 - 112x112x32 nn.Conv2d(3, 32, kernel_size3, padding1), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.Conv2d(32, 32, kernel_size3, padding1), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), # Block 2: 112x112x32 - 56x56x64 nn.Conv2d(32, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.Conv2d(64, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), # Block 3: 56x56x64 - 28x28x128 nn.Conv2d(64, 128, kernel_size3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.Conv2d(128, 128, kernel_size3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), # Block 4: 28x28x128 - 14x14x256 nn.Conv2d(128, 256, kernel_size3, padding1), nn.BatchNorm2d(256), nn.ReLU(inplaceTrue), nn.Conv2d(256, 256, kernel_size3, padding1), nn.BatchNorm2d(256), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), ) self.classifier nn.Sequential( nn.Dropout(p0.5), nn.Linear(256 * 14 * 14, 512), nn.ReLU(inplaceTrue), nn.Dropout(p0.3), nn.Linear(512, num_classes) ) def forward(self, x): x self.features(x) x x.view(x.size(0), -1) x self.classifier(x) return x这个结构和常见的 VGG 风格骨架很接近。为什么不用更深的网络受限于数据集规模只有一万多张图片太深的网络更容易过拟合。为什么每层都是两个卷积叠加这个设计借鉴了 VGG 的思路——两个 3x3 卷积堆叠等价于一个 5x5 卷积的感受野但参数量更少、非线性更强。3.2 BatchNorm 放在哪个位置区别很大BatchNorm 层的摆位是个容易被忽视的细节。我见过很多新手把 BatchNorm 放在激活函数之后虽然代码也能跑但效果会打折扣。正确的顺序应该是卷积 - BatchNorm - ReLU。原因在于 BatchNorm 的作用是规范化卷积输出的分布让数据在进入 ReLU 之前保持一个稳定的尺度。如果先过 ReLU 再 BatchNormReLU 会把负数全部截断此时再做规范化信息已经被破坏了一部分效果自然不如前者。我定义网络时特意把BatchNorm2d放在了Conv2d和ReLU之间这是工程实践验证过的推荐顺序。另外inplaceTrue这个参数也值得注意它能让 ReLU 直接修改输入张量而不是创建新张量省一点内存。在小数据集上这个优化不明显但如果以后跑更大的模型内存省一点是一点。3.3 为什么我的网络最后不用全局平均池化熟悉 CV 的朋友可能知道ResNet 和 GoogLeNet 这些经典网络最后都用全局平均池化Global Average Pooling替代全连接层。为什么我这里仍然用展平加全连接的方式全局平均池化最大的好处是大幅减少参数量、降低过拟合风险但它有一个代价——直接把每个通道的所有空间信息压缩成一个标量丢弃了空间位置的细节信息。Food-11 里的食物图片很多时候关键判别信息恰恰在局部位置比如撒在甜品上的一颗草莓、披萨边缘的一圈芝士。全局平均池化太粗暴了把这些空间信息全抹掉了。所以我选择了展平加全连接的方式保留更多空间信息给分类器同时用 Dropout 来控制过拟合。这个选择在实验对比中确实比直接加全局平均池化高了两到三个百分点。当然这也不是绝对的如果你的数据集类别差异非常显著比如猫和飞机这种全局平均池化的劣势就没那么明显。4. 数据加载与预处理细节决定成败4.1 用 ImageFolder 快速加载数据集PyTorch 的torchvision.datasets.ImageFolder是专门为“文件夹结构即标签”这种数据组织方式设计的加载器Food-11 的目录结构可以直接喂进去省去手写 Dataset 的麻烦。完整的数据加载部分我是这样写的import torch from torch.utils.data import DataLoader from torchvision import datasets, transforms # 训练集数据增强 归一化 train_transform transforms.Compose([ transforms.RandomResizedCrop(224, scale(0.7, 1.0)), transforms.RandomHorizontalFlip(), transforms.RandomRotation(15), transforms.ColorJitter(brightness0.2, contrast0.2, saturation0.2), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) # 验证集/测试集只做缩放和归一化 eval_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(./food-11/training, transformtrain_transform) val_dataset datasets.ImageFolder(./food-11/validation, transformeval_transform) train_loader DataLoader(train_dataset, batch_size32, shuffleTrue, num_workers4, pin_memoryTrue) val_loader DataLoader(val_dataset, batch_size32, shuffleFalse, num_workers4, pin_memoryTrue)这里有几个值得展开的细节。4.2 数据增强的力度怎么把握RandomResizedCrop是我在食物分类任务中最喜欢的增强手段之一——它随机裁剪图片的一个区域并缩放到 224x224既模拟了不同拍摄距离的效果也为模型引入了丰富的尺度多样性。配合RandomRotation(15)模拟手机拍摄时轻微的角度偏转再用ColorJitter模拟不同餐厅灯光下的色差这个组合在 Food-11 上效果非常明显。但数据增强不是越多越好。旋转角度超过 30 度就会产生大量“悬空”的食物图片——真实场景中食物很少倒着出现在画面里这样的增强反而会把模型教歪。色彩抖动幅度也一样调太高会让食物看起来不像食物。建议从较小的参数开始观察验证集表现再逐步增强。另一个细节验证集和测试集千万不要做随机裁剪之类的增强操作。验证集的作用是如实评估模型在真实数据上的表现如果连验证集的图片都做了随机变换每次评估的结果都会不同你根本没法判断模型的真实水平。4.3 Normalize 的均值方差用哪个做迁移学习时均值方差必须用 ImageNet 统计值——mean[0.485, 0.456, 0.406]std[0.229, 0.224, 0.225]。因为你加载的预训练模型是在 ImageNet 上以这个标准化方式训练的输入数据必须匹配它的分布才能发挥最佳效果。手写 CNN 的时候严格意义上你可以自己统计 Food-11 的均值和方差来替换效果可能略好一点点。但为了后续和迁移学习对比时变量一致我统一用了 ImageNet 的统计值。这属于控制变量的思想——两组实验除了“是否使用预训练权重”这个变量外其他条件保持一致得出的结论才有说服力。4.4 num_workers 和 prefetch_factor 的合理配置num_workers4表示用 4 个进程并行加载数据pin_memoryTrue 可以加速数据从 CPU 内存到 GPU 显存的传输。如果你的机器有 8 核以上 CPUnum_workers可以开到 6-8但开太大反而会带来进程调度的开销得不偿失。这里有个常见坑在 Windows 系统上跑num_workers0时如果代码没有放在if __name__ __main__:保护块里会不断报错。如果你用 Windows 开发要么把训练代码包在 main 函数里要么把num_workers设为 0。我在 Linux 服务器上没这个问题但本地 Windows 笔记本上踩过印象很深。5. 训练流程搭建损失函数、优化器与训练循环的完整实现5.1 损失函数与优化器的选择逻辑Food-11 是一个标准的 11 分类问题损失函数用nn.CrossEntropyLoss()没有任何悬念。这里需要澄清一个新手常犯的错误理解CrossEntropyLoss 内部已经包含了 Softmax 操作所以网络最后一层不需要额外加 Softmax 激活。如果你在网络结构里加了 Softmax再把输出喂给 CrossEntropyLoss等于做了两次 Softmax数值会变得非常奇怪而且模型大概率不收敛。优化器方面我选了 AdamW 搭配余弦退火学习率调度。AdamW 是在 Adam 基础上修正了权重衰减的实现方式把权重衰减和基于梯度的自适应学习率解耦效果比传统 Adam 更稳定、更容易调参。import torch.nn as nn import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR model FoodCNN(num_classes11).cuda() criterion nn.CrossEntropyLoss() optimizer optim.AdamW(model.parameters(), lr1e-3, weight_decay1e-4) scheduler CosineAnnealingLR(optimizer, T_max30, eta_min1e-5)初始学习率我设成 1e-3这个值对 AdamW 来说不算激进。如果你用的是普通 SGD初始学习率通常会调到 1e-2 附近效果才好。优化器和学习率是配套的换了一个另一个也要跟着调整这是新手最容易忽略的地方。5.2 训练循环中的关键细节训练循环是每个 epoch 最核心的执行逻辑。一个标准的训练循环要处理的事情比看起来多得多前向传播、计算损失、反向传播、梯度更新这只是最表面的流程。真正决定模型质量的细节藏在这些步骤里面。第一个细节optimizer.zero_grad()必须在每批次开始时调用。PyTorch 的梯度是累积的不清零的话上个批次的梯度会和当前批次叠加。放在开头清零最安全放在反向传播前后清零也不是不行但顺序容易搞混。第二个细节loss.backward()只负责计算梯度真正更新参数的是optimizer.step()。这两个操作必须配对出现缺一个模型都不会正常训练。第三个细节模型训练和评估模式要切换。训练时调用model.train()让 BatchNorm 使用当前批次的统计量、Dropout 生效。评估时调用model.eval()让 BatchNorm 使用训练阶段累积的全局统计量、Dropout 关闭。我见过太多人漏掉这一步导致验证准确率忽高忽低还以为是模型的问题。第三个细节梯度裁剪。虽然 AdamW 已经相对稳定但训练初期 loss 偶尔还是会飙高尤其是遇到一些异常样本时。我习惯加上clip_grad_norm_(model.parameters(), max_norm1.0)把梯度范数限制在 1.0 以内能有效防止梯度爆炸导致的训练崩溃。完整训练循环如下你可以直接参考def train_one_epoch(model, train_loader, criterion, optimizer, epoch): model.train() total_loss 0.0 correct 0 total 0 for batch_idx, (images, labels) in enumerate(train_loader): images images.cuda() labels labels.cuda() optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() # 梯度裁剪防止梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() total_loss loss.item() _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() if batch_idx % 50 0: print(fEpoch [{epoch}] Batch [{batch_idx}/{len(train_loader)}] fLoss: {loss.item():.4f}) avg_loss total_loss / len(train_loader) accuracy 100.0 * correct / total print(fEpoch [{epoch}] Training Loss: {avg_loss:.4f}, Accuracy: {accuracy:.2f}%) return avg_loss, accuracy评估循环类似但更简单不需要反向传播和梯度更新只需要把模型设置为评估模式遍历验证集计算准确率即可def evaluate(model, val_loader, criterion): model.eval() total_loss 0.0 correct 0 total 0 with torch.no_grad(): for images, labels in val_loader: images images.cuda() labels labels.cuda() outputs model(images) loss criterion(outputs, labels) total_loss loss.item() _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() avg_loss total_loss / len(val_loader) accuracy 100.0 * correct / total print(fValidation Loss: {avg_loss:.4f}, Accuracy: {accuracy:.2f}%) return avg_loss, accuracy5.3 训练轮数与模型保存策略Food-11 上我从零训练的 CNN 大约在 25-30 个 epoch 达到最佳验证效果。前期 loss 下降很快后期逐渐平缓甚至开始反弹——反弹就是过拟合的前兆。模型保存我坚持“最优模型 最后一轮模型”双轨策略。每次验证集准确率创新高就保存一份best_model.pth训练结束后再存一份last_model.pth。因为有些时候验证集分数最高的模型不一定泛化能力最好留两份可以最后对比。best_acc 0.0 num_epochs 30 for epoch in range(1, num_epochs 1): train_loss, train_acc train_one_epoch( model, train_loader, criterion, optimizer, epoch ) val_loss, val_acc evaluate(model, val_loader, criterion) scheduler.step() # 保存最佳模型 if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), best_food_cnn.pth) print(f保存最佳模型验证准确率: {best_acc:.2f}%) # 打印当前学习率 current_lr optimizer.param_groups[0][lr] print(f当前学习率: {current_lr:.6f})6. 手写 CNN 的训练结果与瓶颈分析6.1 直接训练的效果观察我从零训练的 CNN 在 Food-11 上达到了大约 68%-72% 的验证准确率。这个成绩说不上差但绝不惊艳。观察训练过程中的损失曲线有一个非常明显的特征训练损失持续下降验证损失下降一段时间后开始横盘甚至回升。这说明模型正在进入过拟合阶段。一万多张图片对一个小型 CNN 来说不算多模型完全有能力把训练集的细节“背下来”但这对验证集没有帮助。我试过增加 Dropout 比例、加大权重衰减系数、缩小卷积核数量这些手段能把过拟合推迟一点点但无法从根本上改变趋势。6.2 错误分析模型到底错在哪我没有只盯着准确率数字看而是做了一次错误分析——把验证集的错误预测全部分类汇总看看到底是哪些类别被搞混了。结果非常有价值。模型最常搞混的类别组合是甜品类和面包类、水果类和蔬菜类、以及不同做法的肉类。这些都是视觉上高度相似的食物。比如一块撒了糖霜的蛋糕和一片厚切吐司如果不看包装和切面纹理人眼都容易看走眼更别说浅层特征提取能力有限的小型 CNN 了。还有一个发现模型在处理颜色单一、纹理不明显的食物时表现很差。比如白米饭、白面包、白色酱汁的意面这些食物几乎没有明显的颜色对比空间纹理也不丰富浅层卷积很难提取到判别性的特征。从这两类错误可以推断出限制手写 CNN 性能的本质原因数据量有限、模型容量受限、特征提取能力不足。这三个问题单靠调参解决不了需要从模型架构层面寻找新方案——这正是迁移学习登场的理由。6.3 调参过程中的失败经验这里分享几个我实际踩过、浪费了不少时间的坑。第一次我把初始学习率调到了 3e-3结果前两个 epoch 损失直接发散到 NaN。原因是数据增强后的某些图片可能包含极端亮度区域梯度更新幅度过大导致数值溢出。加上梯度裁剪后情况略好但依然不够稳定。把学习率降回 1e-3 之后才恢复正常。第二次我试图加大数据增强强度把旋转角度从 15 度加到 45 度色彩抖动也调大了不少。结果验证集准确率反而掉了两个百分点。原因是过度增强让训练数据严重偏离真实分布模型学会了一些根本不存在的“特征”。数据增强要克制够用就好。第三次我在最后一层全连接之前没有加 Dropout结果验证准确率比加了 Dropout 的版本低了约 4 个百分点。全连接层的参数量占比极高几乎承担了全部过拟合风险不加 Dropout 就是裸奔。这些经验说到底就是一句话从零训练小模型时最大的敌人不是模型容量不够而是过拟合来得太快。你需要的是克制——模型结构不要太深、增强不要太猛、学习率不要太激进。7. 迁移学习实战用预训练 ResNet 实现性能跃迁7.1 为什么迁移学习能碾压从零训练手写 CNN 的瓶颈已经很清楚了。迁移学习的核心思路是不从头学特征提取而是站在一个已经在大规模数据集上训练好的模型肩膀上只针对目标任务做一些调整。预训练模型在大规模数据集上已经学会了通用的视觉特征——边缘、纹理、颜色分布、形状结构这些特征是图像共通的食物图也不例外。我们只是把最后的分类层换掉让模型把已经学到的通用特征映射到 Food-11 的 11 个类别上。这就像一位精通基础绘画的画家转行画美食插画他不需要重新学习怎么构图、怎么调色只需要熟悉食物这个新题材就够了。从零训练的 CNN 一切都得自己摸索而迁移学习相当于给定了一个极好的初始化起点模型只需要在这个起点上做小小的调整就能达到很高的精度。7.2 ResNet 与 EfficientNet 的选型对比在 Food-11 这种小规模数据集上我推荐首选 ResNet50 或 ResNet18。为什么ResNet 的残差连接设计解决了深层网络的退化问题结构成熟、稳定、预训练权重容易获取而且模型参数量和数据规模匹配度好。EfficientNet 在 ImageNet 上的表现更优秀但它的预训练权重训练细节和适用场景跟 ResNet 有些差异。在 Food-11 这种体量上除非你想把模型压缩到极小尺寸做端侧部署否则 EfficientNet 的增益并不明显反而增加了很多调参复杂度。实际对比中我用 ResNet18 和 ResNet50 都做了实验。ResNet50 的最终准确率比 ResNet18 高约 1-2 个百分点但训练时间多了一半不止。在 Food-11 这种数据规模下我建议先用 ResNet18 跑通流程确认无误后再上 ResNet50 冲精度。PyTorch 官方在torchvision.models中提供了加载预训练权重的简洁 API用起来非常方便from torchvision import models # PyTorch 2.x 推荐用法 model models.resnet50(weightsmodels.ResNet50_Weights.IMAGENET1K_V1) # 查看 ResNet 最后的全连接层结构 print(model.fc) # 输出: Linear(in_features2048, out_features1000, biasTrue) # 替换最后一层适配 Food-11 的 11 类分类 num_features model.fc.in_features model.fc nn.Linear(num_features, 11)需要注意版本差异。老教程里常见的写法是models.resnet50(pretrainedTrue)这种写法在新版 PyTorch 中会报 FutureWarning将来可能会被移除。推荐用weights参数语义更清晰。7.3 微调策略冻结还是全量微调拿到预训练模型后有两种常见做法第一种是特征提取模式——把预训练模型的所有层冻结只训练最后替换的分类层。这种做法速度快、不容易过拟合但模型的容量被限制了它只能使用预训练模型已经学到的特征无法针对食物任务做任何特化调整。第二种是全部微调——所有层都参与训练只是初始学习率设得更小。这种做法能最大程度地适配 Food-11 的数据分布但训练时间长、过拟合风险高。我采用的是一种折中方案先冻结除分类层外的所有参数训练几个 epoch让随机初始化的分类层先学会在预训练特征上做分类然后解冻部分深层用更小的学习率全量微调。# 阶段一只训练分类层 for param in model.parameters(): param.requires_grad False # 最后全连接层参与训练 for param in model.fc.parameters(): param.requires_grad True optimizer optim.AdamW(filter(lambda p: p.requires_grad, model.parameters()), lr1e-3, weight_decay1e-4) # 训练5个epoch后进入阶段二 # 阶段二解冻最后两个残差层全量微调 for name, param in model.named_parameters(): if layer4 in name or layer3 in name or fc in name: param.requires_grad True optimizer optim.AdamW(filter(lambda p: p.requires_grad, model.parameters()), lr5e-5, weight_decay1e-4)这个两阶段微调策略的优势在于第一阶段让分类层快速收敛避免随机初始化的分类层在刚开始反向传播时产生过大的梯度扰动预训练特征的良好分布第二阶段以极小学习率慢慢调整深层特征让模型适应食物数据的特定分布又不至于把预训练学到的好东西冲掉。实际操作中我会额外在模型定义时给新的分类层加上nn.Dropout(0.3)配合更低的微调学习率双保险防过拟合。8. Food-11 全流程对比实验手写 CNN vs 迁移学习8.1 统一的训练配置与评估流程为了让对比结果有说服力两组实验我尽量保证所有变量一致——数据增强策略相同、损失函数相同、批次大小相同、评估流程相同。唯一的变量就是模型架构和是否加载预训练权重。我统一用每个 epoch 结束后在验证集上计算准确率作为评估指标最终取验证集表现最好的保存模型在测试集上做最终验证。训练轮数的设定上手写 CNN 我跑了 30 个 epoch迁移学习因为收敛更快只跑 20 个 epoch。# 统一的预测与评估函数 def test_model(model, test_loader): model.eval() correct 0 total 0 all_preds [] all_labels [] with torch.no_grad(): for images, labels in test_loader: images images.cuda() labels labels.cuda() outputs model(images) _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() all_preds.extend(predicted.cpu().numpy()) all_labels.extend(labels.cpu().numpy()) acc 100.0 * correct / total return acc, all_preds, all_labels8.2 最终结果对比两组实验的结论非常直观我把关键数字列在下方供你参考指标手写 CNNResNet50 迁移学习参数量约 800 万约 2500 万达到 60% 准确率的 epoch第 8 轮第 2 轮最佳验证准确率68% ~ 72%88% ~ 92%测试集最终准确率约 70%约 90%训练耗时单卡约 20 分钟约 35 分钟过拟合程度明显轻微数据说明了一切。迁移学习在提升准确率的同时还大幅缩短了收敛时间。训练耗时略长是因为 ResNet50 模型本身的参数量和计算量更大但考虑到准确率从 70% 跳到 90%这十几分钟的额外成本简直不值一提。8.3 错误模式的前后对比做完对比实验后我又做了一次错误分析。手写 CNN 最容易搞混的甜品类/面包类、水果类/蔬菜类组合在 ResNet50 上依然会出现错误但错误率显著降低。这说明预训练模型提取的特征比手写 CNN 的浅层特征更本质、更具判别力。不过一个有意思的现象是一些手写 CNN 能正确分类、ResNet50 反而分错的样本也存在大约占到全部错误样本的 10% 左右。这说明预训练模型并不是万能的——它从 ImageNet 学到的特征偏向自然图像动物、物体、场景对某些特定食物的细粒度分类未必比专门训练的小模型更细致。但总体来看这种反超样本的比例太小不足以改变迁移学习整体碾压的结论。这也让我坚定了后续优化的方向如果想让准确率再往上冲与其继续调 ResNet不如用集成方法把手写 CNN 和 ResNet 的预测结果做融合让两个模型的错误模式互补。这个方法我后来在其他项目上验证过效果不错不过那已经是题外话了。9. 训练过程中的常见问题与排查技巧实录9.1 loss 为 NaN 的排查与修复训练刚开始 loss 就变成 NaN是我在整个项目里遇到的最吓人的问题。排查思路是有顺序的——先从数据侧筛查再从模型侧找问题。数据侧检查图片是否损坏。有些下载的数据集里存在截断的 JPEG 文件加载后像素值异常可能导致梯度计算不稳定。我写了一个小脚本用 PIL 逐个打开所有图片验证完整性from PIL import Image import os def check_images(data_dir): bad_images [] for root, dirs, files in os.walk(data_dir): for f in files: if f.lower().endswith((.jpg, .jpeg, .png)): filepath os.path.join(root, f) try: img Image.open(filepath) img.load() except Exception as e: bad_images.append((filepath, str(e))) return bad_images模型侧检查是否有除零操作或 log(0)。我遇到过 BatchNorm 层在 batch size 为 1 或特征图尺寸为 1x1 时计算异常的情况。另外检查学习率是否过大——3e-3 以上的学习率配合 AdamW在某些数据分布下非常容易触发数值溢出。定位方法也很简单在训练循环里加打印观察第一个出现 NaN 的 batch 是哪一个然后检查对应图片和标签的数据情况。如果所有数据都正常问题基本可以锁定在模型或超参数上。9.2 验证准确率波动的迷惑行为迁移学习训练过程中我发现验证准确率在 epoch 之间波动很大有时甚至会出现 5 个百分点的大起大落。排查了数据增强对验证集的影响之后我发现真正的元凶竟然是 num_workers 的随机性。PyTorch 的 DataLoader 在使用多进程时每个 epoch 的打乱顺序会受系统随机数种子影响。如果训练循环里没有设置torch.manual_seed()每个 epoch 的数据加载顺序会变化导致 BatchNorm 层的统计量在不同批次间波动较大。解决方案是在训练脚本最开始固定所有随机数种子import random import numpy as np import torch def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark False set_seed(42)固定随机种子后实验的可复现性会好非常多。这在比赛和论文场景几乎是必须的日常工程开发至少也能让你对比不同超参数时有信心——验证集准确率的变化确实来自模型改动而不是数据顺序的随机波动。9.3 训练集准确率 99% 但验证集只有 70% 怎么办这是典型的过拟合信号。我在手写 CNN 训练过程中验证集表现最好的阶段训练集准确率已经超过 95%验证集却卡在 70% 附近上不去。这种情况我按优先级做了几件事第一步检查是否有数据泄漏。我确认了训练集和验证集的图片没有重复目录结构正确不存在同一张图片同时出现在两边的情况。如果这个不查清楚后面所有调优都是白费。第二步增强正则化强度。把 Dropout 比例从 0.3 提到 0.5同时把权重衰减系数从 1e-4 提到 5e-4观察过拟合曲线是否缓解。第三步引入更多数据增强。如果上述调整后训练和验证的差距依然巨大说明模型的泛化能力不足需要让训练数据更“多样化”来模拟真实分布。第四步简化模型结构。如果增强和正则化都试过依然过拟合那说明模型容量超过了数据能够支撑的范围需要砍掉一些层或者减少通道数。9.4 常见问题速查表症状可能原因解决方案loss 初始就是 NaN学习率过大 / 数据损坏降低学习率、检查图片完整性验证准确率忽高忽低随机种子未固定 / BatchNorm 模式错误固定随机种子、确保 eval 模式切换训练准确率很高但验证上不去过拟合增强正则化、增加数据增强、简化模型迁移学习收敛极慢只训练分类层 / 初始学习率过低确认冻结范围的合理性、适当调高学习率模型预测所有图都归为一类类别极度不均衡 / 最后一层梯度异常检查数据分布、验证分类层替换是否正确这些坑我在项目里几乎全踩了一遍每一行都是真金白银换来的经验。建议你把这张表存下来跑类似任务时遇到问题先对号入座。10. 经验总结与进阶方向这次完整走完“从零手写 CNN 到掌握迁移学习”的流程我的最大体会是手写 CNN 的意义不在于追求最高准确率而在于真正理解卷积网络的工作原理——感受野、通道扩展、卷积与池化的配合、过拟合的根源。这些理解后续做任何 CV 任务都是用得上、绕不开的知识基座。迁移学习的意义则在于提供一个工程上极其高效的起点——站在预训练模型的肩膀上用极低成本获得远超从零训练的效果。如果接下来你想继续深入我有几个推荐的进阶方向第一个方向是把准确率从 90% 再往上提。可以尝试更大的模型如 ResNet101或者引入数据增强里的高级玩法如 MixUp、CutMix、AutoAugment。这些技术在 Food-11 这种中等规模数据集上都有明确的增益。第二个方向是模型集成。把不同架构的模型预测结果做多数投票或加权融合通常能再提升 1-3 个百分点。代价是推理时间变长适合对精度要求高、对延迟不敏感的场景。第三个方向是可视化与可解释性。用 Grad-CAM 查看模型聚焦的图片区域你会直观地看到模型到底在看食物的哪个部分。我在调试阶段用过这个方法定位了不少错误样本的语义原因。第四个方向是部署落地。把训练好的模型通过 ONNX 导出、量化压缩部署到移动端或浏览器端。这个方向的工程价值很高也是验证你是不是真正理解模型的好方法。最后分享一个小技巧训练过程中不要只盯着准确率一个数字养成实时记录损失曲线和准确率曲线的习惯。我在项目里用 TensorBoard 记录每个 epoch 的 loss 和 acc一旦发现验证损失开始反弹立即停止训练回滚到最佳模型。这种“训练中干预”的习惯能帮你省下大量试错时间。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑