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 类。它的工作流程是这样的:

  1. 接收一个无标签数据的DataLoader (no_label_loader)、当前训练好的模型 (model)、设备 (device) 和置信度阈值 (thres)。
  2. 让模型在无标签数据上跑一遍推理(注意要用 torch.no_grad() 关闭梯度计算,节省内存和算力)。
  3. 对每个样本的预测结果应用 Softmax,得到属于各个类别的概率分布。
  4. 取出概率最大的那个类别作为“伪标签”,并记录其最大概率值(置信度)。
  5. 只保留那些置信度高于阈值 thres 的样本和它们的伪标签。
  6. 用这些“高置信度”样本构建一个新的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 函数是一个标准的训练验证循环,但针对半监督学习做了关键扩展。我将其核心逻辑梳理并优化如下:

  1. 初始化:将模型放到GPU上,定义记录损失和准确率的列表。
  2. 迭代训练
    • 监督训练阶段:用有标签的 train_loader 正常训练一个epoch,计算损失并反向传播。
    • 半监督训练阶段:如果存在由伪标签构成的 semi_loader,则用它再训练一个epoch。这里注意,很多新手会忘记在半监督训练后也执行 optimizer.step()zero_grad(),导致梯度累积出错。
    • 验证阶段:用 val_loader 评估当前模型性能,不计算梯度。
  3. 伪标签数据更新:每隔N个epoch(比如3个),用当前模型在无标签数据上生成新的伪标签,创建新的 semi_loader一个重要的改进点是增加过滤:只将本次新产生的高置信度样本加入训练集,或者对同一张图片,只有当前预测置信度高于历史值时才更新其伪标签,这样可以减少噪声。
  4. 模型保存:保存验证集上性能最好的模型。
# 伪代码,展示核心循环逻辑
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.CosineAnnealingLRReduceLROnPlateau(当验证损失不再下降时降低学习率)。我在食品分类任务上发现,配合余弦退火调度,模型收敛更稳定。
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,还可以:

  • 更激进的数据增强:对食品图像,可以尝试 CutMixMixUp 等高级增强技术,它们能混合两张图片生成新的训练样本,正则化效果极佳。
  • 标签平滑:在计算损失时,不直接使用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-B0B1

我的建议是:项目初期,无脑用 ResNet18EfficientNet-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

通过分析这些细节,你就能有的放矢地改进模型,比如针对混淆严重的类别,专门收集或生成更多数据,或者调整损失函数的类别权重。记住,模型迭代是一个循环的过程:分析结果 -> 发现问题 -> 改进数据/模型/训练策略 -> 重新训练 -> 再次分析。把这个流程走通,你的深度学习实战能力就真正上了一个台阶。

更多推荐