深度学习实战:食品分类任务中的半监督学习与CNN应用
1. 从零开始:为什么食品分类需要半监督学习?
大家好,我是老张,在AI和硬件领域摸爬滚打了十几年,今天想和大家聊聊一个既有趣又实用的项目:用深度学习给美食图片分类。你可能觉得,现在AI看图识物不是很成熟了吗?直接用个现成的模型不就行了?这话对,也不全对。在实际项目中,尤其是像食品分类这种场景,我们常常会遇到一个非常现实的问题:有标签的数据太少了。
想象一下,你要做一个能识别“宫保鸡丁”、“麻婆豆腐”、“佛跳墙”的智能点餐系统。你当然可以从网上爬取海量美食图片,但给每一张图片打上准确的标签,需要耗费巨大的人力成本和时间。你可能只有几千张带标签的图片,但手里却有几十万张无标签的图片。这时候,传统的监督学习就有点“巧妇难为无米之炊”了。而半监督学习,就是来解决这个“米”的问题的。它的核心思想是,让模型自己从海量无标签数据中“学习”知识,来辅助有标签数据的训练。
这就像教一个孩子认水果。你手头只有几个苹果、香蕉的实物(有标签数据),但你有一整本带各种水果图片但没文字说明的图册(无标签数据)。半监督学习的方法,就是先教会孩子认识这几个实物,然后让他自己去图册里找哪些图片像苹果、哪些像香蕉,并让他对自己“认出来”的图片(我们称之为“伪标签”)进行判断,只有当他非常确信时,这个“自学”的成果才会被采纳,用来进一步巩固他的认知。这样一来,孩子的见识(模型性能)就能远超仅靠几个实物学到的程度。
在食品分类任务里,我们用的关键技术就是 “伪标签”。模型先用有限的带标签数据训练一个基础版本,然后用这个基础模型去预测所有无标签图片,给出一个分类结果和对应的置信度。只有那些预测置信度超过我们设定阈值(比如0.95或0.99)的图片,我们才认为模型“猜”得很准,把这些图片和它们对应的预测标签,当作新的训练数据,重新喂给模型学习。这个过程可以迭代进行,模型就像滚雪球一样,利用的数据越来越多,性能也越来越好。
当然,这里面坑也不少。阈值设高了,能利用的无标签数据就少,提升有限;设低了,可能会引入大量错误标签,反而把模型带偏,这叫做“确认偏误”。所以,如何设计一个稳健的伪标签生成和利用机制,是半监督学习成功的关键。接下来,我们就一步步拆解,看看怎么用代码实现这个想法。
2. 实战第一步:数据准备与增广的艺术
拿到一个数据集,别急着上模型。好的数据预处理,能让你的模型训练事半功倍,尤其是对于图像任务。我们用的数据集是Food-11,包含11类食物,图片已经分好了训练集(含labeled和unlabeled)、验证集和测试集。我们的战场,主要就在training文件夹里。
2.1 读懂数据,构建Dataset
PyTorch里,我们通过继承 Dataset 类来定义自己的数据加载器。原始文章里的 food_Dataset 类就是这个作用。我在这里想强调几个容易踩坑的细节。
首先,图像读取与格式转换。我们用PIL库的 Image.open() 打开图片,并统一缩放到固定尺寸(比如224x224)。这里有个关键点:PIL打开的图片格式是 (H, W, C),即高度、宽度、通道数。但在PyTorch的卷积层里,期待的输入格式是 (C, H, W)。这个转换通常由 transforms.ToTensor() 来完成,它还会顺便把像素值从 [0, 255] 归一化到 [0.0, 1.0]。所以,在定义数据变换流水线时,顺序很重要。
from torchvision import transforms
# 训练集的数据增强流水线
train_transform = transforms.Compose([
transforms.ToPILImage(), # 假设输入是numpy数组,先转成PIL Image
transforms.RandomResizedCrop(224), # 随机裁剪并缩放
transforms.RandomHorizontalFlip(), # 随机水平翻转
transforms.RandomRotation(15), # 随机旋转
transforms.ColorJitter(brightness=0.2, contrast=0.2), # 随机调整亮度对比度
transforms.ToTensor(), # 转为Tensor并归一化
])
# 验证集不需要增强,只需最基础的转换
val_transform = transforms.Compose([
transforms.ToPILImage(),
transforms.Resize((256, 256)), # 先缩放到稍大尺寸
transforms.CenterCrop(224), # 再从中心裁剪,保证输入一致
transforms.ToTensor(),
])
为什么验证集要用 CenterCrop 而不是 Resize?因为 Resize 会直接拉伸图片,可能引入不自然的形变,影响模型对真实特征的判断。而 CenterCrop 能保持物体的原始比例,评估更公平。RandomResizedCrop 则是增强的利器,它让模型学会不管食物在图片的哪个角落、以多大比例出现,都能识别出来。
2.2 半监督数据集的动态构建
这是半监督学习的核心模块,对应原始文章的 semiDataset 类。它的工作流程是这样的:
- 接收一个无标签数据的DataLoader (
no_label_loader)、当前训练好的模型 (model)、设备 (device) 和置信度阈值 (thres)。 - 让模型在无标签数据上跑一遍推理(注意要用
torch.no_grad()关闭梯度计算,节省内存和算力)。 - 对每个样本的预测结果应用
Softmax,得到属于各个类别的概率分布。 - 取出概率最大的那个类别作为“伪标签”,并记录其最大概率值(置信度)。
- 只保留那些置信度高于阈值
thres的样本和它们的伪标签。 - 用这些“高置信度”样本构建一个新的Dataset,并应用训练集的数据增强。
这里我踩过一个坑:阈值的选择需要动态调整。在训练初期,模型本身就不准,如果阈值设得太高(如0.99),可能根本筛选不出多少伪标签数据,半监督学习起不到作用。我的经验是,可以设计一个简单的策略,让阈值随着训练轮数(epoch)增加而逐步提高。例如,前10轮用0.9,10-20轮用0.95,20轮之后再用0.99。这样能让模型在早期利用更多数据学习通用特征,后期再收紧标准,提升伪标签质量。
class SemiDataset(Dataset):
def __init__(self, no_label_loader, model, device, thres=0.95):
x, y = self._generate_pseudo_labels(no_label_loader, model, device, thres)
if len(x) == 0:
self.valid = False
else:
self.valid = True
self.X = np.stack(x) # 将列表中的图片堆叠成数组
self.Y = torch.LongTensor(y)
self.transform = train_transform # 使用和训练集一样的增强
def _generate_pseudo_labels(self, loader, model, device, thres):
model.eval()
softmax = nn.Softmax(dim=1)
confident_images = []
pseudo_labels = []
with torch.no_grad():
for batch_data, _ in loader: # 注意,无标签loader返回的标签是占位符,我们不用
batch_data = batch_data.to(device)
outputs = model(batch_data)
probs = softmax(outputs)
max_probs, preds = torch.max(probs, dim=1)
# 筛选高置信度样本
mask = max_probs > thres
if mask.any():
# 需要获取原始图像数据,这里假设loader的dataset能返回原始图像
# 实际操作中可能需要根据你的Dataset设计来调整
confident_images.extend([batch_data[i].cpu().numpy() for i in range(mask.size(0)) if mask[i]])
pseudo_labels.extend(preds[mask].cpu().numpy().tolist())
return confident_images, pseudo_labels
3. 模型搭建:从零构建CNN与迁移学习的选择
现在数据准备好了,我们来看看用什么模型来“吃”这些数据。原始文章给出了一个自己设计的CNN模型 myModel,也提到了使用预训练的VGG模型进行迁移学习。这里我们来详细对比一下这两种思路。
3.1 自己设计CNN:理解每一层的计算
自己搭建模型是理解CNN最好的方式。我们回顾一下 myModel 的结构:
- 输入:3x224x224 (RGB图像)
- Conv2d -> BatchNorm2d -> ReLU -> MaxPool2d:这个四件套是CNN的经典组合。卷积提取特征,批归一化加速训练并稳定过程,ReLU引入非线性,池化降低空间尺寸(减半)。
- 经过四层这样的结构后,特征图尺寸从224->112->56->28->14,通道数从3->64->128->256->512。
- 最后接一个全局池化或展平操作 (
x.view(x.size(0), -1)),将三维特征图拉成一维向量。 - 最后通过全连接层 (
nn.Linear) 映射到11个类别的输出上。
自己设计模型时,最需要计算的是每一层输出特征图的尺寸和参数量。这能帮你预估模型大小,防止内存溢出。公式很简单:
- 输出尺寸:
O = (I - K + 2P) / S + 1。I是输入尺寸,K是卷积核大小,P是填充,S是步长。比如输入224,卷积核3,填充1,步长1,输出还是224。 - 参数量:对于卷积层,是
(K_h * K_w * C_in + 1) * C_out。+1是偏置项。比如第一层nn.Conv2d(3, 64, 3, 1, 1),参数量就是(3*3*3 + 1) * 64 = 1792。全连接层的参数量是(输入维度 + 1) * 输出维度。
3.2 拥抱迁移学习:站在巨人的肩膀上
对于食品分类这种任务,我强烈推荐初学者直接使用迁移学习。原因很简单:我们用的Food-11数据集,在规模上无法与ImageNet(1400万张图片,1000类)相比。在ImageNet上预训练好的模型(如VGG、ResNet、EfficientNet),已经学会了提取通用图像特征(如边缘、纹理、形状)的强大能力。这些能力对于识别食物同样有效。
我们可以把这些预训练模型当作一个非常好的特征提取器,只替换掉最后的分类头(通常是全连接层),然后在我们自己的数据上进行“微调”。PyTorch的 torchvision.models 让这一切变得极其简单:
import torchvision.models as models
import torch.nn as nn
def get_pretrained_model(model_name='resnet18', num_classes=11):
# 加载预训练模型,并获取其特征提取部分
if model_name == 'resnet18':
model = models.resnet18(pretrained=True)
# 冻结所有卷积层的参数,只训练最后的全连接层(适用于数据量很少时)
# for param in model.parameters():
# param.requires_grad = False
# 替换最后的全连接层
num_ftrs = model.fc.in_features
model.fc = nn.Linear(num_ftrs, num_classes)
elif model_name == 'vgg16':
model = models.vgg16(pretrained=True)
num_ftrs = model.classifier[6].in_features
model.classifier[6] = nn.Linear(num_ftrs, num_classes)
elif model_name == 'efficientnet_b0':
model = models.efficientnet_b0(pretrained=True)
num_ftrs = model.classifier[1].in_features
model.classifier[1] = nn.Linear(num_ftrs, num_classes)
else:
raise ValueError("Unsupported model name")
return model
微调策略:如果带标签数据很少(比如只有几百张),可以先冻结所有预训练层的参数,只训练新换上的分类头。训练几轮后,再解冻部分或所有底层卷积层,用较小的学习率进行整体微调。这能有效防止在小数据集上过拟合。原始文章里直接调用 initialize_model 函数,背后也是类似的原理。实测下来,用ResNet18,即使只用有限的标签数据,也能轻松达到比自定义模型高得多的准确率。
4. 训练策略与性能优化:让半监督学习真正生效
模型和数据都齐了,怎么把它们高效地组织起来训练,并让半监督学习发挥最大效用,这里面有很多技巧。
4.1 训练循环的设计
原始文章的 train_val 函数是一个标准的训练验证循环,但针对半监督学习做了关键扩展。我将其核心逻辑梳理并优化如下:
- 初始化:将模型放到GPU上,定义记录损失和准确率的列表。
- 迭代训练:
- 监督训练阶段:用有标签的
train_loader正常训练一个epoch,计算损失并反向传播。 - 半监督训练阶段:如果存在由伪标签构成的
semi_loader,则用它再训练一个epoch。这里注意,很多新手会忘记在半监督训练后也执行optimizer.step()和zero_grad(),导致梯度累积出错。 - 验证阶段:用
val_loader评估当前模型性能,不计算梯度。
- 监督训练阶段:用有标签的
- 伪标签数据更新:每隔N个epoch(比如3个),用当前模型在无标签数据上生成新的伪标签,创建新的
semi_loader。一个重要的改进点是增加过滤:只将本次新产生的高置信度样本加入训练集,或者对同一张图片,只有当前预测置信度高于历史值时才更新其伪标签,这样可以减少噪声。 - 模型保存:保存验证集上性能最好的模型。
# 伪代码,展示核心循环逻辑
for epoch in range(total_epochs):
# 1. 监督训练
model.train()
for labeled_data, labeled_target in labeled_loader:
# 前向传播,计算损失,反向传播,优化器更新
...
# 2. 半监督训练(如果存在伪标签数据)
if semi_loader is not None:
model.train() # 注意保持训练模式
for pseudo_data, pseudo_target in semi_loader:
# 同样进行前向、损失计算、反向传播和更新
# 这里的 pseudo_target 是模型自己生成的“伪标签”
...
# 3. 验证
model.eval()
with torch.no_grad():
for val_data, val_target in val_loader:
# 计算验证损失和准确率
...
# 4. 动态更新伪标签数据集(例如每3个epoch)
if epoch % 3 == 0 and val_accuracy > some_threshold:
new_semi_loader = generate_pseudo_labels(unlabeled_loader, model, device, threshold)
# 可以在这里将新loader与旧的合并,或者替换
semi_loader = update_semi_loader(semi_loader, new_semi_loader)
4.2 损失函数与优化器调参
- 损失函数:多分类任务标配
nn.CrossEntropyLoss()。它内部已经集成了Softmax和对数计算,所以模型最后一层不需要再加Softmax。 - 优化器:AdamW是目前的主流选择,它比Adam加了权重衰减的正则化,效果通常更好。学习率
lr是关键参数。对于微调预训练模型,学习率通常要设小一点,比如1e-4到1e-5。对于从头训练,可以稍大,比如1e-3。 - 学习率调度:使用学习率衰减策略能显著提升模型最终性能。比如
torch.optim.lr_scheduler.CosineAnnealingLR或ReduceLROnPlateau(当验证损失不再下降时降低学习率)。我在食品分类任务上发现,配合余弦退火调度,模型收敛更稳定。
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-4)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs)
# 或者在每个epoch的验证步骤后
# scheduler.step(val_loss)
4.3 解决数据不平衡与过拟合
食品数据集很可能存在类别不平衡,比如“米饭”的图片远多于“佛跳墙”。这会导致模型偏向于多数的类别。除了原始文章提到的过采样、欠采样,在深度学习中更实用的方法是:
- 在损失函数中引入类别权重:
CrossEntropyLoss可以传入weight参数,给样本少的类别更大的权重。 - 使用Focal Loss:这种损失函数可以降低易分类样本的权重,让模型更关注难分类的样本。
过拟合是另一个常见问题。除了Dropout和BatchNorm,还可以:
- 更激进的数据增强:对食品图像,可以尝试
CutMix、MixUp等高级增强技术,它们能混合两张图片生成新的训练样本,正则化效果极佳。 - 标签平滑:在计算损失时,不直接使用0/1的硬标签,而是使用如
[0.1, 0, 0, ... 0.9]的软标签,可以防止模型对标签过于自信,提升泛化能力。 - 早停:持续监控验证集损失,当其在连续多个epoch不再下降时,就停止训练,防止在训练集上过拟合。
5. 结果分析与模型迭代:看懂训练曲线,选择合适模型
训练完成后,我们不仅要看最终的准确率数字,更要学会分析训练过程。
5.1 绘制并解读学习曲线
原始文章代码中已经包含了绘制损失和准确率曲线的部分。这是诊断模型问题的“听诊器”。
- 理想情况:训练损失和验证损失都平稳下降,最后趋于一个较低的值;训练准确率和验证准确率同步上升,最后接近且差距不大。
- 过拟合:训练损失持续下降,训练准确率持续上升,但验证损失在某个点后开始上升,验证准确率停滞甚至下降。这说明模型记住了训练集的噪声,而非一般规律。解决办法就是加强正则化(更多数据增强、Dropout、权重衰减)或减少模型复杂度。
- 欠拟合:训练损失和验证损失都很高,准确率上不去。这说明模型能力不足,或者训练不充分。可以尝试更复杂的模型、更长的训练时间、减小正则化强度。
- 半监督学习生效的迹象:在每次更新伪标签数据后(比如第3、6、9个epoch),你可能会看到训练损失有一个小幅度的上升(因为加入了新的、可能有噪声的数据),但随后验证准确率会有一个跃升。这是利用新知识带来的正向反馈。
5.2 模型对比与选择
原始文章对比了ResNet18、VGG、AlexNet和自定义模型。在我的多次实验中也验证了这一点:
- AlexNet:作为早期模型,参数量大且深层次少,在Food-11上表现一般,容易欠拟合。
- VGG:结构规整,但参数量巨大,训练慢,在数据量不是特别大的情况下,优势不明显。
- ResNet18:凭借残差连接,解决了深层网络梯度消失问题,在有限的标签数据上表现就很好,是性价比最高的选择。
- EfficientNet:谷歌提出的模型,通过复合缩放深度、宽度和分辨率,在精度和效率上取得了更好平衡。如果你追求更高的准确率,可以尝试
EfficientNet-B0或B1。
我的建议是:项目初期,无脑用 ResNet18 或 EfficientNet-B0 进行迁移学习微调。它们社区支持好,预训练权重质量高,能快速给你一个不错的基线。等整个半监督流程跑通后,再考虑尝试更复杂的模型或自定义结构。
5.3 超越准确率:更全面的评估
对于分类任务,尤其是类别可能不平衡时,不要只看一个“准确率”。应该计算每个类别的精确率、召回率和F1分数,生成混淆矩阵。这能帮你发现模型到底在哪些类别上容易混淆(比如把“炒饭”误判为“炒面”)。sklearn.metrics 库可以轻松实现这些计算。
from sklearn.metrics import classification_report, confusion_matrix
model.eval()
all_preds = []
all_labels = []
with torch.no_grad():
for data, target in val_loader:
data, target = data.to(device), target.to(device)
output = model(data)
pred = output.argmax(dim=1)
all_preds.extend(pred.cpu().numpy())
all_labels.extend(target.cpu().numpy())
print(classification_report(all_labels, all_preds, target_names=class_names))
# 打印出每个类别的精确率、召回率、F1-score
通过分析这些细节,你就能有的放矢地改进模型,比如针对混淆严重的类别,专门收集或生成更多数据,或者调整损失函数的类别权重。记住,模型迭代是一个循环的过程:分析结果 -> 发现问题 -> 改进数据/模型/训练策略 -> 重新训练 -> 再次分析。把这个流程走通,你的深度学习实战能力就真正上了一个台阶。
更多推荐
所有评论(0)