这次课的内容是半监督食物分类系统的上半部分。我带着这套方案在某个食堂结算项目上试跑了一轮,核心思路其实不复杂:先训练一个基于调整后ResNet的分类模型,再用伪标签和无标签数据把这个模型的精度往上推。很多人做实际菜品分类时都卡在标注成本上,半监督这条路恰好能解决这个问题。这篇文章适合手里有大量菜品图像、但没人力逐张标注的团队参考,也适合深度学习学到 CNN 和训练流程之后,想往半监督方向深入的同学。上篇主要覆盖数据准备、网络结构调整和半监督训练闭环,目标检测只做前置衔接,分类主干的细节全部展开讲。
1. 项目背景与整体设计思路:为什么做半监督食物分类
1.1 半监督食物分类要解决的痛点
菜品识别是一个比通用图像分类更别扭的场景。日常生活里我们习以为常的一道菜,在视觉模型看来可能千变万化。同样是番茄炒蛋,有的饭店番茄切块,有的饭店番茄切丁;有的是汤汁型,有的偏干炒。更别提红烧肉这种百家百味的类别了,颜色深浅、肥瘦比例、盘子里的配菜装饰都会改变模型对它的判断。
我做这个项目最初的动机,就是接触到一个就餐结算场景,需要把每个人托盘里的菜品识别出来并估算热量。需求听起来很简单,但真正上手之后发现最大的瓶颈不是模型结构,而是数据标注。菜品种类动辄上百种,很多类别之间视觉差异极小,比如凉拌黄瓜和拍黄瓜、宫保鸡丁和辣子鸡丁,非专业人员标注的一致性本身就很成问题。把一张图交给标注员,告诉他是“红烧肉”,他可能在肥肉多的样本上犹豫很久。这种情况下的标注成本,比通用物体识别高好几个档次。
现实困境是:有标注数据永远不够,没标注数据堆积如山。食堂摄像头每天产生大量图像,后厨和餐台也在不断上新菜,等新菜被人工标注进训练集,新品活动可能都结束了。半监督学习在这里的价值就是把这些“躺着睡觉”的无标签图利用起来,让模型先借助少量有标签数据学到一个基础能力,再通过置信度筛选的方式给自己造标签,不断扩大有效训练集。
所以整套系统的目标定为:用大约一千多张有标签图,配合七千多张无标签图,训练出一个接近全监督水平的菜品分类模型。这个思路放在课程里,就是“半监督食物分类系统”这个名字的来源。
1.2 为什么选ResNet而不是更大的模型
选主干网络的时候,我身边不少朋友第一反应是上 Vision Transformer 或者 Swin Transformer,毕竟这几年它们刷榜刷得厉害。但我最后还是选了 ResNet 作为基础骨架,理由有三个,都挺现实的。
第一是数据量撑不起大模型。半监督场景下,虽然有标签加无标签的总数据量可能上万,但真正给模型提供强约束的仍然是那一千多张有标签图。ViT 这类结构对数据的需求比 CNN 大得多,在小数据量上很容易欠拟合;更麻烦的是,伪标签本身带噪声,Transformer 对噪声的拟合能力太强,容易把错误标签“背下来”,导致训练后期南辕北辙。
第二是推理速度。食堂结算通道的摄像头是实时的,每帧图像做完目标检测之后,同一个画面里可能出现多个餐盘、多个菜品区域,这些区域都要依次过分类模型。ResNet-18 推理一张图几毫秒,同一帧里跑十几个框也毫无压力;如果用大 ViT,延迟会明显上升,而且部署时对显存的要求也更高。
第三是生态成熟度。ResNet 从提出到现在,预训练权重、部署工具链、量化方案都非常成熟,上课讲起来思路也清晰。课程里要传达的是半监督的机制,不是比拼刷榜分数,选一个大家耳熟能详的主干,能让注意力集中在伪标签、置信度阈值、损失函数这些真正关键的点上。
| 主干方案 | 参数规模 | 数据需求 | 半监督适配性 | 部署成本 |
|---|---|---|---|---|
| 调整后 ResNet-18 | 约 9M | 较低,适合本场景 | 强,伪标签噪声影响可控 | 低,CPU可跑 |
| 原版 ResNet-50 | 约 25M | 中等 | 中,尾部类别容易过拟合 | 中 |
| ViT-Base | 约 86M | 高,需要海量数据 | 弱,噪声标签易被过度拟合 | 高 |
| Swin-T | 约 28M | 中高 | 中,需较多调参 | 中高 |
表格里“调整后 ResNet”这个词很重要。我并不是直接拿官方 ResNet 就用,而是针对菜品分类的类别相似度、长尾分布做了几处结构改动,后面第三章会详细拆解。
1.3 系统链路拆解:检测、裁剪、分类三层
整套系统并不是单纯的一个分类模型,而是“检测 + 裁剪 + 分类”的级联结构。一张真实场景的图片里可能有一整桌菜,也可能是一大碗混合食物,直接整图分类会引入大量背景噪声,类别概率也会被不同菜品区域互相干扰。
流程上先走目标检测模块,从原图中框出各个菜品区域。检测这一步我用的是现成的目标检测模型,具体结构细节打算放到下篇展开,上篇只需要保证它能输出可靠的边界框。拿到边界框之后,把框内图像裁剪出来,做短边resize和中心裁剪,统一成 224×224 的分类输入尺寸。然后这些裁剪图进入半监督分类模型,输出每个菜品区域的类别。
为什么这个系统可以叫多模态识别?因为在真实项目里,除了摄像头图像,往往还有菜单文本、价格标签、下单记录这些辅助信息。比如说某道菜在菜单上的名字出来了,分类模型的候选类别范围就可以大幅缩小,准确率自然会上升。不过上篇只做纯视觉链路,文本辅助的逻辑后面再单独说。
数据流可以描述为:原图 → 目标检测框 → 裁剪图 → 数据增强 → 分类模型 → 类别概率。半监督模块介入的地方就在分类模型这一层,无标签图同样经过检测和裁剪,再进入伪标签生成流程。
2. 数据准备与预处理:先把数据这条腿补上
2.1 数据集划分与类别长尾分布
课程模拟项目里用的是一套演示数据集,包含 12 个常见菜品类别,我挑几个有代表性的类别:番茄炒蛋、宫保鸡丁、麻婆豆腐、红烧肉、清蒸鱼、干煸豆角、蛋炒饭、面条、饺子、凉拌黄瓜、冬瓜汤、拍黄瓜。为什么特意放凉拌黄瓜和拍黄瓜,因为它们视觉上高度相似,能很好地检验模型对细粒度差异的捕捉能力。
有标注数据一共 1400 张,无标注数据 7200 张,测试集单独留 600 张。这里有个细节,有标注数据的类别分布并不是均匀的。番茄炒蛋、红烧肉这类热门菜样本数量多,加起来占了有标注数据的接近 40%;而冬瓜汤、拍黄瓜这类样本少的类别,有标签可能只有 50 张左右。这个长尾分布是真实业务里非常典型的情况,相关团队如果只靠有监督训练,尾部类别的准确率会非常难看。
半监督学习在这里就有意思了。虽然无标签数据没有标注,但它携带了真实场景中的类别分布信息。模型对一张无标签图预测出高置信度结果之后打出伪标签,本质上是在用自己的认知去“扩展”尾部类别的训练样本。不过这也带来一个隐患,如果模型本身就偏向头部类别,伪标签也会跟着偏,尾部类别可能永远等不到一个高置信度预测。针对这个问题,我为尾部类别设计了专门的类别加权采样策略,后面第五章会讲。
关于数据划分,有一点值得单独强调:无标签数据的来源和测试集要尽量同分布。如果不注意,拿网络上风格差异很大的图片当无标签数据,模型的预测置信度可能很高,但放进测试集一测发现完全不对。半监督的优势只有建立在“无标签数据来自目标场景”这一前提下才成立。
2.2 图像预处理与增强策略设计
预处理环节我沿用了 ImageNet 训练的标准流程,均值是 0.485, 0.456, 0.406,方差是 0.229, 0.224, 0.225。裁剪后的菜品区域先做短边 resize 到 256 像素,再从中心或者随机位置裁剪出 224×224 的大小给模型训练,测试时统一用中心裁剪。
增强策略是半监督训练里的重头戏。我把增强分成弱增强和强增强两档,这个概念来自 FixMatch,也是让模型从无标签数据里稳定学习的关键。
弱增强包括随机水平翻转、随机裁剪和轻微的颜色抖动。强增强我在课程里用的是 RandAugment 的简化版本,把旋转、亮度调整、对比度拉伸、cutout 这些操作随机叠加到两到三个。逻辑很简单:对同一张无标签图做两次增强,弱增强版本喂给模型生成伪标签,强增强版本再喂给模型计算一致性损失。如果模型对这两种增强方式的预测保持一致,说明它学到了真正稳定的类别特征,而不是记住了某个背景色、某个盘子纹理。
实操里有一条容易踩的坑:强增强的强度不能一开始就拉满。我最初把 RandAugment 的幅度参数设得很大,结果前几个 epoch 无监督损失一直很高,模型学到的特征被颜色失真干扰。后来把增强幅度降了一档,并配合训练进度逐步提升,情况就正常了。避免模型被增强后的失真样本带偏,最好的办法是给强增强也设置一个 ramp-up 过程。
2.3 标注文件规范与数据检查
有标签数据我用的是 JSON 格式来组织,因为后续要方便做伪标签文件的合并,层级关系清楚一点对程序处理更友好。每条记录包含一个唯一的 image_id、对应的图片路径、label_id 以及检测框坐标,框坐标用 xyxy 格式,也就是左上角和右下角两个点。如果一张图里有多个菜品区域,就拆成多条记录,每条记录对应一个框。
无标签数据的组织更简单,只需要 image_id 和图片路径。伪标签生成之后,我会额外生成一个 pseudo_label.json,里面存模型预测的类别 id 和对应的最大 softmax 概率。这样做的好处是,调试的时候可以直观地看到某个无标签样本被打了什么标签、置信度是多少,不用每次重新推理一遍。
数据检查这个环节很容易被忽略,但它的重要性甚至超过模型结构。我们当时对全部图像做了三个维度的筛选:清晰度过滤,删除运动模糊严重或分辨率过低的图;多菜同盘过滤,检测框内同时出现两种以上菜品时直接弃用;光线异常过滤,比如强逆光导致菜品颜色完全失真。做完这三步,数据质量带来的精度提升比任何结构改动都明显。
3. 调整后的ResNet网络结构:分类主干的关键改动
3.1 标准ResNet在菜品数据上的问题
直接用官方 ResNet-18 跑有监督基线的时候,我观察到两个现象:训练集准确率快速冲到 98% 以上,但测试集准确率在 68% 左右就上不去了;尾部类别比如拍黄瓜的召回率低得可怜,很多被误判成凉拌黄瓜。这两个现象指向同一个根源:模型过拟合了有标签样本的浅层特征。
ImageNet 上的自然图片和菜品图片分布差异很大,自然图片的类别之间存在明显的语义边界,比如狗和猫、卡车和轿车。而菜品图片恰恰相反,类别之间的边界极其模糊,有时候一碗面条的形态和一份炒面的形态差距,比同一碗面条的两张不同角度照片还要大。标准 ResNet 的最后一层是 512 或 2048 维特征直接映射到类别数,这种结构在类别间距离大的数据集上没问题,但在菜品这种细粒度场景里,模型容易学成“记样本”而不是“学特征”。
另外标准 ResNet 没有针对类别不平衡做任何设计,头部类别的大量样本把特征空间挤得满满的,尾部类别的特征被压缩到一个小角落。半监督训练一旦开启,模型对头部类别的高置信度预测会进一步挤占尾部类别,造成错误伪标签的滚雪球效应。
3.2 结构调整细节与参数量对比
调整后的 ResNet 围绕三个目标展开:降低过拟合、增强细粒度特征、稳定半监督训练。
第一个改动是在主干网络 backbone 部分,保留 ResNet-18 的前三个 stage 不动,把 stage4 的 block 数量从 2 减到 1,并且把第一个 stage 的通道数从 64 适当降低到 48。这样做的直接效果是减少尾部特征图的容量。ResNet 的关键语义能力大多集中在前三个 stage,stage4 的容量太高反而会把细节纹理过度编码,容易过拟合。通道减下来之后,尾部类别的特征不会被头部类别完全压制。
第二个改动是全连接头。我认为这是影响最大的一处。原版 ResNet 在全局平均池化之后直接接一个类别数的全连接层,中间没有瓶颈结构,相当于把特征直接“压扁”成类别得分。我在池化之后加了一个 512 维的 embedding 层,中间用 ReLU 激活,再接 Dropout,最后才接到 12 类的分类头。embedding 层的意义在于,让模型先学到菜品的一个紧凑向量表示,再进行分类,这个表示对光线、角度、餐具干扰有更强的鲁棒性。
第三个改动是在每个残差块的输出上加了一个轻量的通道注意力。这个思路借鉴了 SE Net,但在课程里我没有把通道注意力做到完整模块,只是对 stage3 和 stage4 的残差输出做了简单的全局平均池化、两层全连接和 sigmoid 门控,然后再与原始特征相乘。之所以不全部加,是怕训练难度上升太多,半监督本身已经引入了噪声。
下面是原版和调整后的结构参数对比:
| 模块 | 原版 ResNet-18 | 调整后 ResNet-18 |
|---|---|---|
| Stem | 7×7, 64 | 7×7, 48 |
| Stage1 | 2×[3×3, 64] | 2×[3×3, 48] |
| Stage2 | 2×[3×3, 128] | 2×[3×3, 128] |
| Stage3 | 2×[3×3, 256] | 2×[3×3, 256] |
| Stage4 | 2×[3×3, 512] | 1×[3×3, 512] |
| 分类头 | 512→12 | 512→512→Dropout→12 |
| 参数总量 | 约 11.18M | 约 8.96M |
参数量下降了一千多万,但准确率反而有提升,原因就是前面说的,模型不需要记住那么多不必要的纹理。Dropout 我选 0.3 而不是更常见的 0.5,因为预训练权重的先验知识很强,dropout 太高会打断模型对迁移知识的利用。
3.3 预训练权重加载与冻结策略
半监督训练里预训练权重的作用比普通有监督更重要,因为标签数量不足,模型更需要一个通用的视觉先验。我是用 ImageNet 上预训练好的 ResNet 权重做初始化,但删掉了最后的全连接层,只加载卷积部分的参数,分类头从零开始训练。
加载时有几个容易出问题的细节。第一是这个预训练权重的 stem 通道数是 64,我的 stem 改成了 48 通道,直接把权重加载到第一个卷积层会报维度不匹配。处理方法是做一次投影:把 stem 层权重的前 48 个输出通道直接取出来,后面的通道丢弃。这个操作虽然简单,但确实能保住大部分预训练信息。
第二是关于 BN 层。如果原权重的 BN 层均值方差统计量已经跑得比较准,而我们要在菜品数据上重新跑,最好在训练初期让 BN 重新适应当前的数据分布。我的做法是前 5 个 epoch 冻结 stem 和 stage1 的所有参数,只训练后面的 stage 和分类头,让模型先在浅层特征尽可能稳定的前提下把分类任务学会。5 个 epoch 之后解冻浅层,用较低学习率微调。
下面这段代码演示了加载预训练权重时怎么处理 stem 通道数不匹配的问题,以及如何按名称冻结部分层。
def load_pretrained_weights(model, ckpt_path, freeze_prefix=("stem", "stage1"), freeze_epochs=5): # 读取权重 state_dict = torch.load(ckpt_path, map_location="cpu") if "state_dict" in state_dict: state_dict = state_dict["state_dict"] model_dict = model.state_dict() # 逐个key迁移,忽略分类头和stem尾部通道不匹配的情况 new_dict = {} for k, v in state_dict.items(): if k in model_dict and v.shape == model_dict[k].shape: new_dict[k] = v elif k == "stem.0.weight": # stem卷积层从64通道截取前48通道 new_dict[k] = v[:48] # 其他维度不匹配的层直接放弃加载,比如分类头 model_dict.update(new_dict) model.load_state_dict(model_dict) # 前freeze_epochs个epoch冻结指定的前缀层 for name, param in model.named_parameters(): if name.startswith(freeze_prefix): param.requires_grad = False return model冻结策略在半监督里的额外价值是稳定前期伪标签的生成。如果一开始就全量微调,模型可能因为浅层特征剧烈变化而产生不稳定的预测,伪标签的质量也会跟着波动,影响后续训练。
4. 半监督训练流程实现:伪标签与损失函数怎么落地
4.1 伪标签生成流程与置信度阈值设置
伪标签是半监督系统中的发动机。每一步由当前模型对无标签数据做预测,取出最大 softmax 概率,如果超过某个置信度阈值,就把这个样本连同预测类别当作有标签数据放进训练集。
置信度阈值的选择直接影响模型的上限。阈值太高,比如 0.99,伪标签质量极高,但覆盖的样本太少,无标签数据的利用率很低;阈值太低,比如 0.6,大量错误标签混进来,模型会被噪声带偏。我跑下来的经验是先在 0.95 的阈值下做引导,等有监督损失明显下降、模型有一定判别能力之后,降到 0.85 扩大伪标签数量。这个“先严后宽”的策略,比固定一个阈值更稳定。
伪标签不是每步迭代都重新生成的。每 10 个 epoch 对全部无标签数据重新推断一次,更新伪标签文件,然后在这 10 个 epoch 里重复用这批伪标签训练。这个设计是为了减少训练开销,同时也能避免模型在同一个 epoch 内自我强化过猛导致崩溃。
伪标签生成的代码可以写成下面这个样子,核心是对无标签 DataLoader 做一次完整的推断。
def generate_pseudo_labels(model, unlabeled_loader, threshold=0.95, device="cuda"): model.eval() pseudo_labels = [] with torch.no_grad(): for imgs, img_ids in unlabeled_loader: imgs = imgs.to(device) probs = torch.softmax(model(imgs), dim=1) max_probs, preds = torch.max(probs, dim=1) for i in range(len(imgs)): if max_probs[i].item() >= threshold: pseudo_labels.append({ "image_id": img_ids[i], "label_id": preds[i].item(), "confidence": max_probs[i].item() }) return pseudo_labels这里有一个重要经验:伪标签文件要记录 confidence,后续训练时可以用 confidence 作为样本权重,或者再做一次阈值过滤。这样每次更新阈值时不用重新跑全量推断,直接过滤旧的伪标签文件即可。
4.2 有监督损失与无监督损失怎么组合
半监督训练的损失函数由两部分组成。有标签样本走标准的交叉熵损失;无标签样本走一致性损失,逻辑是FixMatch的简化版本。
具体来说,对同一张无标签图做两次增强,一次弱增强、一次强增强。弱增强版本先经过模型得到预测,取最大概率和对应类别,当最大概率大于阈值时,把这个类别当作伪标签;然后用强增强版本做输入,模型对强增强图的预测要和伪标签计算交叉熵。这样等于要求模型在不同的扰动下保持一致判断。
总损失写成:L = L_sup + λ * L_unsup。λ是平衡权重,课程里设成 0.6。这个值不是拍脑袋定的,我做过一组对比实验,λ从 0.2 到 1.0,发现 0.6 附近测试集准确率最高,低于 0.3 时无监督部分对训练几乎没贡献,高于 0.8 时噪声影响明显增大。
置信度 mask 的写法是,把低于阈值样本的损失置零,避免低置信度伪标签干扰训练。不是让模型在所有无标签样本上强行保持一致,而是只挑选模型“有把握”的样本进行自训练。这也是 FixMatch 和早期伪标签方法最大的区别。
下面这段代码把损失计算和 mask 的操作串在一起,可以放在训练循环里直接使用。
def semi_loss(model, labeled_x, labeled_y, unlabeled_x_w, unlabeled_x_s, threshold=0.95): # 有标签分支 logits_labeled = model(labeled_x) loss_sup = F.cross_entropy(logits_labeled, labeled_y) # 无标签分支 logits_w = model(unlabeled_x_w) logits_s = model(unlabeled_x_s) with torch.no_grad(): prob_w = torch.softmax(logits_w, dim=1) max_probs, pseudo_label = torch.max(prob_w, dim=1) mask = (max_probs >= threshold).float() loss_unsup = F.cross_entropy(logits_s, pseudo_label, reduction="none") loss_unsup = (loss_unsup * mask).mean() total_loss = loss_sup + 0.6 * loss_unsup return total_loss, mask.mean().item()实现的时候要注意一个细节:生成伪标签的那次前向传播不能计算梯度,所以用了 with torch.no_grad() 包起来。强增强分支的预测则正常回传梯度。如果不加 no_grad,模型会同时修改两个分支的参数,损失容易被拉爆。
4.3 训练超参数配置与迭代策略
训练配置沿用了我平时做图像分类偏好的组合:优化器用 AdamW,初始学习率 1e-3,权重衰减 5e-4,采用 5 个 epoch 的 warmup 后接 cosine 退火,训练总轮数 90 个 epoch。batch 的组成方式是有标签 32 张、无标签 64 张,每个 step 里两部分数据同时进入模型。
这里有一个容易被忽略的重点:无标签的 batch 并不一定每张图都被用来计算损失,只有那些置信度超过阈值的样本才贡献损失。所以在统计 loss 的时候,经常看到无监督损失比有监督损失小很多,这是正常的,不需要强行调大 λ。
EMA 教师模型我也加上了。训练过程中维护一份模型权重的指数滑动平均,每步更新时 teacher 的权重按照 0.99 的比例融合当前学生模型的权重。推断和生成伪标签时用 teacher,训练时用 student,这样能明显降低伪标签的波动。这个技巧来自 Mean Teacher,在伪标签方法里效果同样显著。
训练预热的 burn-in 机制也很关键。前 10 个 epoch 我只计算有监督损失,不开无监督损失,让模型先稳定下来。10 个 epoch 之后再打开无监督分支,否则模型一开始预测完全不准,伪标签几乎没有可信度,反而会拖累训练。
整体训练参数可以整理成一张表,方便后续复现时对照检查:
| 参数 | 数值 | 说明 |
|---|---|---|
| 有标签 batch size | 32 | 每个 step 的光标数据量 |
| 无标签 batch size | 64 | 每个 step 的无标签数据量 |
| 基础学习率 | 1e-3 | AdamW,配合 warmup |
| 训练轮数 | 90 | 每 10 轮重新生成伪标签 |
| burn-in | 10 轮 | 前 10 轮只算有监督损失 |
| 伪标签阈值 | 0.95→0.85 | 30 轮后下调 |
| EMA 权重 | 0.99 | 步进式更新教师模型 |
| λ | 0.6 | 无监督损失权重 |
这套配置跑下来,8GB 显存的显卡可以正常训练,不需要额外做梯度累积。如果显存更小,可以考虑把无标签 batch 减半,再用梯度累积补足,这一条放到第五章展开。
5. 训练过程实录与问题排查
5.1 训练曲线和最终指标对比
我把实验分成了三组做对照。第一组是有监督基线,只拿 1400 张有标签数据训练调整后的 ResNet;第二组是加入半监督训练,用 7200 张无标签数据生成伪标签;第三组是在第二组基础上,把检测框裁剪链路完整接入,输入真正来自检测模块的裁剪图。
有监督基线的测试集准确率是 68.2%。加入半监督之后,准确率直接拉到了 81.7%,提升了 13.5 个百分点,这个提升幅度比换更大的网络明显得多。第三组在接入检测裁剪链路后继续涨到 84.5%,说明检测框对分类输入的质量有贡献,但半监督仍然是整个系统的主力。
从训练曲线看,前 10 个 epoch 只有有监督损失,损失下降比较快。第 10 个 epoch 打开无监督损失之后,总损失有一个小幅反弹,这是正常的,因为新增的损失项在初期还没被模型适应。大约 15 个 epoch 之后,损失重新进入平滑下降区间。伪标签的覆盖数量也随时间变化:第 10 轮大约能选中 3000 张,第 30 轮阈值下调后覆盖量升到 5500 张,训练后期稳定在 6000 张以上。
分类别看,头部类别的提升不如尾部类别明显,这一点很符合半监督的定位。番茄炒蛋的准确率只提升了两三个百分点,因为它本来就不缺样本;但拍黄瓜从 41% 提升到 72%,冬瓜汤从 35% 提升到 65%,这种尾部类别才是半监督收益最大的地方。
类别层面还有一个有趣的现象:凉拌黄瓜和拍黄瓜之间的混淆显著减少。原因是无标签数据提供了更多不同做法的样本,模型有机会学到这两类之间的细微差别,比如切块形状、调味颜色深浅。如果只有 1400 张有标签图,模型根本没见过足够多的形态变化。
5.2 检测框质量对分类结果的影响
接入检测框之后,我意识到分类模型的精度不仅仅取决于分类模块本身,还和前级的检测框质量绑在一起。框太大,会把盘沿、桌面、旁边餐具这些背景信息带进分类输入;框太小,会把菜品的边缘部分截掉,尤其像红烧肉这种大块头菜品,截掉一块可能就改变了整体形态特征。
实操中我是这样调优的:先固定检测模型不动,专注调整分类输入。发现当检测框只保留菜品主体、把菜汤和部分盘沿去掉时,分类准确率提升约两个点。为了系统性地利用检测框信息,我还做了一种数据增强:训练时对裁剪图做随机的边界缩放,模拟不同检测框置信度带来的大小偏差。测试时严格用检测框坐标裁剪,这种训练和测试分布的匹配带来了额外的稳定性。
还有一个容易被忽略的问题:同一张原图可能有多个检测框误检到同一个菜品。比如一个盘子里的红烧肉被检测模型拆成两个框,分类结果自然也是重复的。这个问题的标准解法是分类置信度筛选和 NMS 一起去重。如果两个框的类别相同、IoU 大于 0.6,保留分类得分更高的那个。这一条虽然写起来只要几行代码,但在真实项目里能减少不少重复上报。
这套链路中,“整图分类”和“裁剪图分类”我做过对比,裁剪图分类准确率明显更高。整图输入包含的背景噪声太多,尤其一张桌子多个菜品时,模型很难聚焦到目标区域。所以后续所有实验都采用了“检测框裁剪”这一条路径。
5.3 高频问题排查与避坑技巧
半监督训练踩过的坑比有监督多得多,这里把最典型的五个问题整理出来,每个都给出表现、原因和处理方式。
问题一:训练早期损失震荡,测试集准确率上下跳动超过两个点。常见原因是伪标签噪声在早期太多。对策是开 burn-in,前 10 轮不计算无监督损失,同时把初始阈值调高到 0.95。这两个措施叠加后,震荡明显缓解。
问题二:尾部类别伪标签数量始终很少。表现是无监督 loss 的 mask 覆盖率很高,但尾部类别的准确率没有提升。原因是模型对尾部类别的置信度普遍偏低,无法越过阈值。对策是给尾部类别做训练样本加权,或者对尾部类别的预测概率做温度缩放,降低它们进入伪标签的门槛。我实验里用了温度缩放,效果比单纯加权更平滑。
问题三:显存不够。8GB 显存上跑 32+64 的 batch 需要开混合精度,如果显存只有 6GB,先把无标签 batch 降到 32,然后用梯度累积每隔两步更新一次参数。实测下来,显存占用降了约 30%,准确率只掉了 0.4 个百分点,属于完全可接受的范围。
问题四:有标签 L_sup 都收敛了,但把 λ 调高之后测试集反而变差。这是典型的伪标签噪声压过了有监督信号。处理方式是降低 λ,同时检查伪标签的平均置信度。如果平均置信度低于 0.85,说明模型整体还是不够自信,需要先把模型训练得更强再开启半监督。
问题五:BN 层的统计量在无标签 batch 上波动异常。因为无标签 batch 里混入了大量增广图,同一 batch 的分布可能比较震荡。一个有效的做法是使用 SyncBN,或者在每个 epoch 结束时用一小批有标签数据重新校准 BN 的 running mean 和 running variance。后者实现起来更轻量,大多数项目都够用。
| 问题 | 表现 | 原因 | 对策 |
|---|---|---|---|
| 早期震荡 | loss 和 acc 波动大 | 伪标签噪声过强 | burn-in + 高阈值 |
| 尾部类别无伪标签 | mask 覆盖率高但尾部差 | 尾部置信度不足 | 温度缩放 + 类别加权 |
| 显存不足 | OOM 或训练卡死 | batch 太大 | 降无标签 batch + 梯度累积 |
| 调高 λ 反而变差 | 测试集下降 | 噪声压过有监督信号 | 降 λ + 检查平均置信度 |
| BN 漂移 | 验证集指标跳动 | 无标签 batch 分布震荡 | 每轮用有标签校准 BN |
这些坑的共性规律是:半监督系统的崩溃往往不是从训练发散开始,而是从一个看似无害的指标波动开始,如果不追踪伪标签的平均置信度和覆盖率,很难定位问题根源。我在整个调试过程中收益最大的习惯,就是把伪标签的平均置信度、覆盖率、有效样本数这三个指标像训练 loss 一样记录下来,每个 epoch 看一眼。它们比 loss 更能说明半监督训练是否健康。
我个人在实际操作中的体会是,别一上来就追求最好的半监督算法。把伪标签和置信度筛选这条基础链路跑通,再往里面加一致性正则、EMA、温度缩放,每一步带来的收益都看得见摸得着。上篇到这里已经覆盖了数据、网络结构和训练闭环,目标检测模型的训练细节以及整个系统的部署优化,我整理完再单独写一篇。