☰
基于ResNet18迁移学习的20类食物图像分类实战
2026/10/17 11:49:39 网站建设 项目流程

项目实战:基于 ResNet18 迁移学习的 20 类食物图像分类

食物图像分类这个方向,听起来不算高深,但真上手做一遍,你会发现坑比想象中多。从数据整理到模型微调,从训练调参到结果分析,每一步都有大量的细节在影响最终效果。这篇文章就把我完整跑通的一个 20 类食物图像分类项目摊开来讲,模型选的是 ResNet18,核心手段是迁移学习,框架用的 PyTorch。无论你是刚学完 CNN 想找个实际项目练手,还是已经在做图像分类但一直卡在训练效果不理想的阶段,这篇文章都能给你一份可以直接照着跑的完整方案,顺便把那些网上教程很少提到的坑一并播清楚。

先说结论:用 ResNet18 做迁移学习,20 类食物数据集上达到 90% 以上的验证集准确率,并不需要多么复杂的操作。关键在于把数据管好、把迁移学习的策略想清楚、把训练细节抠到位。下面我按自己做项目的实际顺序展开,从模型选型讲到最后的结果分析,全程给出代码和处理思路,希望能帮你少走点弯路。

1. 模型选型与迁移学习思路:为什么锁定 ResNet18

1.1 ResNet18 的结构特点与选型理由

ResNet 系列提出已经有几年了,但到今天依然是图像分类任务里最稳的骨架之一。ResNet18 的"18"指代带有权重层数总量,具体来说包含 17 个卷积层和 1 个全连接层。模型基础模块是残差块,每个块内部有两条路径:一条是常规的卷积、BN、ReLU 堆叠,另一条是恒等映射(identity shortcut)。两者在输出端相加,相当于让网络拥有了一条"直达通道",这样深层的梯度可以更顺畅地回传到浅层,有效缓解深层网络的退化问题。

选 ResNet18 而不是 VGG16 或者 ResNet50,主要考虑三点。

第一,参数量适中。ResNet18 的参数量大约在 1170 万左右,实际占用显存很小,大部分消费级显卡甚至只用 CPU 也能完成推理。训练阶段 batch_size 设到 32 或 64 都没有压力,这对新手调试非常友好。

第二,残差结构和 BN 层使得模型对学习率的容忍度相对较好。VGG 系列在微调时稍不注意就会梯度爆炸或者收敛极慢,ResNet 因为恒等映射的存在,训练过程平稳很多。

第三,ResNet18 在 ImageNet 上的预训练权重非常成熟,而且这是迁移学习的最佳起点。它提取的底层特征——边缘、纹理、颜色块——在食物图像上有很强的通用性,我们只需要在高层特征上做适配就行。

如果任务换成 100 类甚至 1000 类细粒度分类,我会建议升级到 ResNet50 或者更大模型,但对于 20 类食物分类这种粗粒度场景,ResNet18 已经有一点点"杀鸡用牛刀"的意思了,完全没有必要用更大的网络。

1.2 迁移学习的核心逻辑:为什么要用预训练权重

食物图像分类的难点在于类间相似性,比如炸鸡和炸鱼、薯条和薯角,颜色和纹理都很接近。这时候如果从零开始训练一个 ResNet18,要让它学到有区分力的特征,通常需要几十万张图片和大量训练时间。但我们绝大多数人都没有这个数据量。

迁移学习的思路是:先在 ImageNet 这种千万级数据集上把模型训练一遍,让网络学会通用的视觉特征,然后再把这份"知识"搬到食物分类任务上。在迁移时,底层的卷积核基本不需要改动,因为边缘、纹理这类低级特征在食物数据集上同样适用;需要改动的只是网络最后的分类层,让它学会怎么结合这些特征来区分 20 个食物类别。

打个比方,这就好比一个已经会品鉴辣椒、香料和油脂的老厨师,你让他改学做川菜,他只需要学"怎么把这些味道组合成回锅肉、水煮鱼",而不需要从头学炒菜的基本功。这也是为什么迁移学习在数据量有限的场景下效果远好于从零训练的根本原因。

实际操作中,加载预训练权重的做法很简单:

import torchvision.models as models # 加载预训练模型,weights 参数在较新版本的 torchvision 中可用 model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1)

如果你用的是旧版本 torchvision,可以用model.load_state_dict(torch.load('resnet18-5c106cde.pth')),效果一样。

1.3 冻结与微调:迁移学习的关键策略

迁移学习并不是把预训练权重一加载就完事,这里有一层更精细的操作,叫做"冻结与解冻"。

常见的策略是:把 ResNet18 的卷积层(features)全部冻结,只训练最后一层全连接层(新替换的分类层)。这样做的好处是训练速度快、显存消耗小,而且不用担心灾难性遗忘。缺点是模型只能在高层特征上做线性组合,表达能力受限,遇到类间差异比较小的食物时可能不够用。

我的做法是分两阶段训练。第一阶段冻结所有卷积层,只训练新的全连接层,跑 5~8 个 epoch,让分类层先收敛;第二阶段解冻最后几个残差块,用更小的学习率对整个网络做微调,通常再跑 10 个 epoch 左右。这种"先冻结后解冻"的方式,既稳住了初始训练,又给了模型足够的适应能力。

冻结层的方法也很直白:

# 冻结所有卷积层 for param in model.parameters(): param.requires_grad = False # 替换分类层 num_features = model.fc.in_features model.fc = nn.Linear(num_features, 20) # 新分类层的参数需要梯度(其实因为全连接层在冻结代码之后替换,所以默认 requires_grad=True)

需要注意的是,如果你冻结了所有层之后再替换 fc 层,那么 fc 层的参数默认是requires_grad=True,而卷积层参数是requires_grad=False,符合预期。如果后面要做第二阶段的解冻,只需把最后几个残差块的参数requires_grad置为 True 即可:

# 解冻最后两层的残差块 for name, param in model.named_parameters(): if 'layer4' in name: param.requires_grad = True

这里还有个容易踩的坑:如果模型里有 Dropout 或者 BN 层,冻结模型后推理模式必须记得调用model.eval(),否则 BN 层在训练模式下的统计量更新逻辑会影响结果。后面我会在踩坑章节详细说。

2. 数据集整理与预处理:20 类食物图片的前期准备

2.1 数据目录结构与数据集划分

做图像分类,第一步绝不是写模型,而是把数据管理好。我的数据集是 20 个食物类别,每类大约 500~800 张图片,整体规模在 1 万张上下。数据来源不统一,有些类别偏多,有些偏少,这很正常。

我用的目录结构是这样的:

food20/ ├── train/ │ ├── burger/ │ │ ├── burger_001.jpg │ │ └── ... │ ├── pizza/ │ ├── salad/ │ └── ... (共20个类别) ├── val/ │ ├── burger/ │ └── ... └── test/ ├── burger/ └── ...

训练集、验证集、测试集严格按类别分文件夹,这样torchvision.datasets.ImageFolder可以直接读取并根据文件夹名生成标签,省去手工写标签的麻烦。

划分比例我用的 70% 训练、15% 验证、15% 测试。注意测试集要单独留出来,且整个训练过程中完全不参与任何调整,包括数据增强、学习率选择等都不应该用测试集来试。验证集才是用来调参的,这一点很多人会搞混。

如果数据量更少,可以考虑 K 折交叉验证,但 1 万张图片做简单划分已经足够稳定,没必要折腾。

2.2 数据增强策略与归一化参数

食物图片通常来自网络,分辨率、拍摄角度、光照条件差异很大。为了让模型学到更鲁棒的特征,需要对训练集做数据增强。我的增强策略如下:

from torchvision import transforms train_transform = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.7, 1.0)), transforms.RandomHorizontalFlip(), transforms.RandomRotation(15), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2), 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]) ])

这里有几个细节值得展开说。

RandomResizedCrop的scale=(0.7, 1.0)是经验值。如果你把下限设到 0.08,图片会被裁得极小,虽然增强了数据多样性,但食物分类里很多特征(比如盘子里的摆盘、食材的边缘)在极小裁剪下会丢失语义,不利于模型学习。实际测试中 scale 下限设为 0.7 效果更好。

ColorJitter我调得比较轻,只动亮度、对比度、饱和度,不加色调(Hue)。如果加了色调,食物的颜色会被扭曲得非常厉害,原本炸鸡是金黄色的,可能被调成紫色,这种增强对食物分类弊大于利。

归一化参数用的是 ImageNet 的 mean 和 std,这是因为预训练模型是在 ImageNet 上训练的,输入数据的分布如果和预训练时一致,特征提取会更顺利。这里千万不要用数据自己的 mean 和 std 去替换。

验证集和测试集只做缩放和中心裁剪,不做随机增强,保证评估结果的稳定性和可对比性。

2.3 数据加载与标签映射

用ImageFolder加载很简单:

from torchvision.datasets import ImageFolder train_dataset = ImageFolder(root='food20/train', transform=train_transform) val_dataset = ImageFolder(root='food20/val', transform=val_transform) test_dataset = ImageFolder(root='food20/test', transform=val_transform) train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=4) val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False, num_workers=4) test_loader = DataLoader(test_dataset, batch_size=32, shuffle=False, num_workers=4)

num_workers的设置根据机器性能调整,Windows 上建议设为 0 避免 DataLoader 相关的报错,Linux 服务器上设 4~8 都行。shuffle=True每个 epoch 打乱训练顺序,防止模型记住数据的排列规律。

数据集不平衡的问题也需要留意。我做的类别中,"米饭类"的图片明显比其他类别多。这时候有两个选择:一是收集更多数据,二是调整采样器。如果你不想精调每个类别的权重,最简单的办法是统计各类别数量,然后用WeightedRandomSampler让每个 batch 采样的类别分布相对均衡。但增加采样器之后训练收敛速度会变慢,且带来一定过拟合风险,所以我最终选择保留自然分布,只在评估阶段用加权 F1-score 来反映真实能力。

3. 训练配置与完整实现:超参数、优化器与训练循环

3.1 优化器与学习率设定

迁移学习中,优化器的选择非常关键。我对这个项目最终用的是 SGD + momentum,而不是 Adam。原因在于:Adam 虽然收敛快,但容易跑到一个泛化性较差的局部最优点;SGD 收敛稍慢,但配合良好的学习率调度,往往能取得更高的验证集准确率,并且对微调来说更可控。

具体配置如下:

import torch.optim as optim # 分两组参数:新分类层用稍大学习率,预训练卷积层用小学习率 fc_params = list(map(id, model.fc.parameters())) base_params = filter(lambda p: id(p) not in fc_params, model.parameters()) optimizer = optim.SGD([ {'params': base_params, 'lr': 0.001, 'weight_decay': 1e-4}, {'params': model.fc.parameters(), 'lr': 0.01, 'weight_decay': 1e-4}, ], momentum=0.9)

这里我把新替换的全连接层和预训练卷积层分开设置学习率,新分类层的学习率是基座层学习率的 10 倍。原因是:预训练层已经有了很好的权重,不需要大步伐调整;而新分类层是从随机初始化开始的,它必须在前期快速学习到特征组合方式。

我在实际项目中不做第一阶段的完全冻结,而是采用这种"小学习率微调全部层"的策略。只要学习率设得足够小,整个模型一起微调的效果往往比先冻结再解冻更好,也能少调一次参。这个结论在不同数据集上可能不同,但我个人在食物分类上多次对比后发现,直接微调反而更省心。

学习率调度器我选的是ReduceLROnPlateau,当验证集 loss 连续 3 个 epoch 不再下降时,学习率自动乘以 0.1:

scheduler = optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode='min', factor=0.1, patience=3, verbose=True )

3.2 损失函数与训练循环

20 类分类任务,损失函数就是标准的交叉熵损失,PyTorch 里直接nn.CrossEntropyLoss()。没有需要特别设计的地方。

训练循环的核心代码如下:

import torch import torch.nn as nn criterion = nn.CrossEntropyLoss() device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model.to(device) best_val_acc = 0.0 num_epochs = 15 for epoch in range(num_epochs): # 训练阶段 model.train() running_loss = 0.0 correct = 0 total = 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() running_loss += loss.item() * images.size(0) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() train_loss = running_loss / total train_acc = correct / total # 验证阶段 model.eval() val_loss = 0.0 val_correct = 0 val_total = 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) val_loss += loss.item() * images.size(0) _, predicted = torch.max(outputs, 1) val_total += labels.size(0) val_correct += (predicted == labels).sum().item() val_loss = val_loss / val_total val_acc = val_correct / val_total scheduler.step(val_loss) print(f"Epoch {epoch+1}/{num_epochs} | Train Loss: {train_loss:.4f} | Train Acc: {train_acc:.4f} | Val Loss: {val_loss:.4f} | Val Acc: {val_acc:.4f}") # 保存最优模型 if val_acc > best_val_acc: best_val_acc = val_acc torch.save(model.state_dict(), 'best_food20_model.pth')

这里有几个容易出错的地方。一是 PyTorch 在model.eval()和model.train()之间切换一定要彻底,特别是在使用 BN 层的模型中,如果不切换会导致验证结果异常。二是每次optimizer.zero_grad()必须在反向传播之前,否则梯度会在参数更新后继续累积。三是保存模型用state_dict()而不是整个模型对象,这样加载时更灵活,也避免模型结构代码变更后无法加载旧模型的问题。

3.3 batch size 与 epoch 数量的选择

batch size 我最终定在 32。如果你显存充足,64 也可以,但要注意大 batch 在 BN 层中会影响统计量计算的稳定性,因为 BN 是在单个 batch 内计算均值和方差的。batch size 越大,BN 的统计量越稳定,但 batch size 越小则梯度的随机性越大,反而起到一定的正则化效果。对于 1 万张图片的训练集,batch size 32 在速度和稳定性的平衡上是比较合适的。

epoch 数量不是一个可以提前确定的参数。我会先定 15 个 epoch 作为初始预算,然后观察验证集 loss 的变化曲线。使用ReduceLROnPlateau之后,学习率降低会带来一波新的下降,所以训练到 15~20 个 epoch 是很正常的节奏。我见过有些人一跑就是 50 个 epoch,其实如果验证集 loss 已经连续多个 epoch 不再改善,就应该提前终止(Early Stopping),否则只是浪费时间。

4. 评估指标与结果分析:不只是看准确率

4.1 准确率之外,还要看混淆矩阵

训练完成后,第一件事是看测试集上的整体准确率,但这远远不够。对于 20 类分类这种任务,准确率只是一个宏观指标,真正有价值的信息藏在混淆矩阵和每个类别的 Precision、Recall、F1 里。

下面这段代码可以快速生成混淆矩阵:

import numpy as np from sklearn.metrics import confusion_matrix, classification_report model.load_state_dict(torch.load('best_food20_model.pth')) model.eval() model.to(device) all_preds = [] all_labels = [] with torch.no_grad(): for images, labels in test_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) _, preds = torch.max(outputs, 1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.cpu().numpy()) cm = confusion_matrix(all_labels, all_preds) print(classification_report(all_labels, all_preds, target_names=train_dataset.classes))

我跑出来的分类报告显示:20 个类别的平均准确率在 92% 左右,但"Fried Chicken"和"French Fries"这两个类别的混淆十分明显,F1 都在 85% 上下。这说明模型对于"炸鸡"和"薯条"这种纹理相近的食物确实是存在区分困难的。后面我会专门讲这个问题怎么排查。

混淆矩阵可视化的思路是,把矩阵转成热力图,横轴是预测类别,纵轴是真实类别,对角线越亮说明效果越好。如果你发现某个非对角线的点特别亮,那基本就能确定是哪两个类别在互相干扰了。

4.2 错误样本分析:看看模型为什么分错

只看混淆矩阵还不够,我还会把预测错误的样本挑出来,每一类各挑几张,存成一张大图直接可视化。这样做的价值在于,你能亲眼看到模型的"困境"。

我曾经就发现一个有意思的现象:模型把一张铺在白色餐盘上的米饭分类成"寿司"。原因很直接——因为数据集中"寿司"类里有很多图片是白色背景、米粒紧实的形状,而"米饭"类则是很多装在碗里的中部凸起的形态。模型只学到了形状特征,而没学到"是否裹着海苔、是否有配料"这些更本质的区别。

这种错误样本分析,才是真正能指导改进方向的一环。不要只看数字就以为万事大吉,数字背后隐藏着模型的偏见。

4.3 改进方向:类别混淆怎么解决

针对 "炸鸡 vs 薯条" 或者 "米饭 vs 寿司" 这种混淆,有几种解决手段可供选择。我的经验是先从数据下手,让数据变得更加易于区分:

  • 增加这两类样本的数量,特别是它们在不同角度、不同光照下的照片。
  • 可以稍微调整这两个类别对应的scale参数,让随机裁剪倾向于保留食物主体的整体轮廓。
  • 如果真的有必要,也可以用类别权重损失函数,但类别权重会放大对少数类的关注,可能影响其他类别的表现。

如果数据层面效果不明显,再考虑模型层面的改进:微调时解冻更多的残差块,或者尝试用细粒度分类常用的 "Focus on Key Features" 思路——例如在分类头前面额外加一个注意力模块。但不要一开始就上这些复杂方案,先把数据问题解决掉,往往能事半功倍。

5. 常见问题与踩坑实录:PyTorch 迁移学习避坑手册

5.1 BN 层在训练和评测模式下的陷阱

这个坑几乎每个新手都会踩,我也栽过跟头。PyTorch 的 BatchNorm 在model.train()和model.eval()两种模式下的行为是完全不同的:训练时 BN 会计算当前 batch 的均值和方差,并更新全局统计量;验证时 BN 使用全局统计量,不再更新。

如果在验证时忘记调用model.eval(),BN 会继续用当前 batch 的统计量,导致验证结果轻微不稳定,而且你会看到每次跑验证的结果都略有差异,不容易发现但又极其烦人。

解决方式很简单:训练循环里用完model.train(),在验证和测试前一定要调用model.eval()。这是迁移学习项目里最容易被忽视的一个细节点。

5.2 学习率过高导致 loss 直接爆炸或震荡

迁移学习的"微调"二字,重心在"微"上。预训练模型已经处于一个比较好的解空间,如果学习率设得过大,一轮迭代就会把已经学好的特征权重破坏掉,训练 loss 从刚开始的 0.5 瞬间变成 5.0 且长时间难以恢复。

我通常的做法是,卷积层的学习率不超过 0.001,全连接层不超过 0.01。如果你发现第一个 epoch 的 loss 从初始值开始不降反升,立刻把学习率除以 10 再试。判断学习率是否合适的另外一个简单方法是:打印第一个 epoch 前几个 batch 在参数更新前后的 loss 变化,如果 loss 突然飙升,说明学习率偏大。

5.3 验证集准确率不提升的排查思路

如果你训练了很多 epoch,验证集准确率却纹丝不动,先别急着加训练时长,按下面顺序逐项排查:

可能原因现象特征解决方案
数据问题训练 loss 也很高,但不下降检查图片是否损坏、标签是否错位、增强是否过于激进
学习率过低loss 缓慢下降但幅度极小把学习率提高 10 倍再试
正则化过强训练 loss 下降,验证 loss 不变甚至上升降低 weight_decay 或者去掉过强的增强
BN 模式错误验证集结果不稳定,时高时低确保验证阶段用model.eval()
分类层替换出错输出类别数不是 20检查model.fc = nn.Linear(num_features, 20)是否生效

这里面比较常见的是第一条,尤其是数据增强过于激进,洗掉了太多原始视觉信息,导致模型在训练集上也无法学到关键特征。

5.4 加载预训练权重时的版本兼容问题

新版本 torchvision 推荐用weights参数加载预训练权重,但如果你从网上下载了一个.pth文件再用load_state_dict加载,有可能遇到 key 的名称不匹配问题,前缀表明模型结构在保存时和当前代码定义的不一致。

一个稳妥的解决方法是打印出权重文件的 keys 和你当前模型的 keys,逐一比对。纯字符不同的情况(比如前缀module.是否包含)可以用下面的方式去掉:

from collections import OrderedDict state_dict = torch.load('resnet18-5c106cde.pth') new_state_dict = OrderedDict() for k, v in state_dict.items(): name = k.replace('module.', '') new_state_dict[name] = v model.load_state_dict(new_state_dict, strict=False)

5.5 训练环境和显存限制下的经验

如果你的机器显存只有 4GB 甚至更少,batch size 32 可能已经吃不消了。我的建议是优先尝试 batch size 16,并保持训练流程不变。ResNet18 对 batch size 的敏感性没有一些大模型那么高,只要 BN 层统计量能稳定,16 的 batch 也能跑到不错的精度。如果连 16 都不行,那就得把输入尺寸从 224 降到 192 或 160,这样模型对显存的需求会大幅降低,只是最终精度会略有损失。

我经常遇到有人一上来就 batch size 64,结果 OOM 报错,然后恨不得用单张图片去训练。其实一个务实的做法是:先让代码在小 batch 上跑通,确保流程无误,再逐步增大 batch size,直到显存快要不够了为止。

6. 进一步优化:提升分类精度的几个实用技巧

6.1 使用余弦退火学习率

如果你嫌ReduceLROnPlateau的调度太"手动",可以试试余弦退火。它在整个训练过程中让学习率按余弦曲线从较高值平滑降到零,非常适合在有限 epoch 内训练效果达到较好状态。配合 warmup 的话,迁移学习的稳定性会进一步加强。

scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=15, eta_min=1e-6)

这个方案更适合批量调参的场景,当你已经确定基本超参数、只是想让模型更接近"最优"时,余弦退火的效果会非常亮眼。但要注意T_max要跟总 epoch 数匹配,否则学习率会在中途就降到最低,后面的 epoch 完全在空转。

6.2 用更大的输入分辨率

224 是所有预训练模型的默认分辨率,但这并不代表它是最优的。食物图像中有些细节(比如食材表面的纹路、酱汁的分布)在 224 分辨率下可能已经被压缩得难以辨别。我把输入分辨率提升到 256 之后,测试集准确率提升了约 1.2 个百分点,代价是训练时间增加了约 30%。

如果你的训练时间充足,不妨做一组对比实验:把RandomResizedCrop(224)改成RandomResizedCrop(256),保持其他参数不变,看看验证集准确率是否有提升。如果有提升,说明模型受限于输入分辨率;如果反而下降了,说明模型泛化能力在分辨率升高后出现饱和,要学会接受当前方案。

6.3 模型集成与 TTA

模型集成是稳定提升精度的常见技巧,但对新手来说有点"重"。我更推荐先尝试 TTA(Test-Time Augmentation):测试时对同一张图片做多次变换(水平翻转、轻微旋转等),把多次预测结果取平均作为最终分类结果。TTA 的好处是不需要改训练过程,只改测试过程,代码也很容易实现。缺点是推理时间会变成原来的 3~5 倍。

def predict_with_tta(model, img, device): model.eval() transforms_list = [ transforms.Compose([transforms.Resize(256), transforms.CenterCrop(224)]), transforms.Compose([transforms.Resize(256), transforms.CenterCrop(224), transforms.RandomHorizontalFlip(p=1)]), ] outputs = [] with torch.no_grad(): for t in transforms_list: x = t(img).unsqueeze(0).to(device) outputs.append(torch.softmax(model(x), dim=1)) final = torch.mean(torch.cat(outputs, dim=0), dim=0) return final.argmax().item()

6.4 类别权重与 Focal Loss

如果某些类别的图片数量明显偏少,CrossEntropyLoss的默认行为会让模型偏向样本多的类别。此时可以给 loss 加上类别权重,权重设成与样本数量成反比:

class_counts = np.bincount(train_dataset.targets) class_weights = 1.0 / class_counts class_weights = class_weights / class_weights.sum() * len(class_counts) class_weights = torch.tensor(class_weights, dtype=torch.float32).to(device) criterion = nn.CrossEntropyLoss(weight=class_weights)

Focal Loss 是另一个方向,专门针对难分类样本。但食物分类场景里,类别分布不像目标检测那样极端不平衡,Focal Loss 带来的提升通常有限,而且超参数需要额外调整,建议把精力放在数据增强和数据补充上,而不是一开始就引入复杂 loss。

6.5 可视化特征图和 Grad-CAM

最后再提一个非常实用的工具——Grad-CAM。它能绘制模型在分类时重点关注的区域热力图,让你理解模型到底在"看"什么。我在做错误样本分析时,用 Grad-CAM 看到了模型把"米饭"判成"寿司"时,热点区域集中在米饭的中心位置,而不是海苔和配料。这说明模型确实走了一条不正确的判断路径。

如果你想复现 Grad-CAM,可以用pytorch-grad-cam这个库。用法简单,只需要传入模型和目标层即可:

from pytorch_grad_cam import GradCAM from pytorch_grad_cam.utils.image import show_cam_on_image target_layer = model.layer4[-1] cam = GradCAM(model=model, target_layers=[target_layer])

可视化结果会非常直观地告诉你模型在图片上关注什么、没关注什么。这是一个在撰写项目总结和做汇报时非常加分的环节,强烈建议试试。

7. 我的几点经验体会与后续扩展想法

这个项目让我印象最深的,是"数据决定上限、模型逼近上限"这句话的含金量。同样的 ResNet18,调了半天模型结构,不如先花两天时间把数据整理干净、把增强策略调合理。食物分类里很多看似模型能力不足的问题,归根结底是数据多样性不够或者标签噪声太大。所以如果你决定复现这个项目,我诚恳建议把最多的时间花在数据准备上,而不是一开始就纠结 ResNet50 还是 ResNet101。

另外一个体会是,迁移学习虽然门槛低,但其中的"微调"细节非常考验经验。学习率的分组设定、BN 层的模式切换、评估时的归一化一致性,这些细节不会直接写在论文里,却是实际训练中决定成败的地方。很多人说迁移学习是"拿来主义"、简单无脑,真正动手做过一遍之后,你会明白它其实是"熟练活儿"。

这套流程跑完之后,后续还可以做下面几个方向的扩展。比如把 20 类扩展到 40 类甚至 100 类,看看 ResNet18 的容量是否还够用;或者把分类模型换成 MobileNetV3 来做移动端部署,验证模型压缩对精度的影响;再或者把二维图像分类升级到视频帧序列分类,对一段视频里的食物进行实时识别。这些都是在这一个项目基础上很自然的延伸,也能让你的技术栈变得更完整。我强烈建议你先把 20 类的整个流程彻底吃透,再开始折腾这些进阶方向,会比直接上手复杂任务从容得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询