☰
半监督学习在食品图像分类中的应用:从伪标签到FixMatch
2026/10/17 3:05:57 网站建设 项目流程

1. 为什么食品分类偏偏需要半监督:被标注成本卡住的项目

先说我做这个项目的起因。当时要为一个餐饮场景做一个食品识别模块,目标是能识别托盘上常见的主食、荤菜、素菜、汤品和水果,大概二十多个类别。刚立项的时候大家天真地以为这就是个普通图像分类任务,下载一个公开食品数据集,微调一下ResNet,搞定。结果不到一周就被现实抽了一巴掌:公开数据集里的“食品”和实际食堂出餐的食品完全是两回事。

公开数据集的图,大多是西式快餐、单人摆盘、光线均匀、背景干净。而实际场景里是什么样?中餐菜品本身就没有严格一致的形态,同一个“红烧肉”在不同窗口、不同师傅手里,颜色可以差出好几个色号;打菜阿姨随手一勺,土豆烧牛肉里面土豆和牛肉的比例每天都不一样;餐盘里菜和菜还会串汁,茄子染红了旁边的米饭。这些情况在公开数据集里根本找不到。更要命的是,生产环境的图片还涉及隐私合规问题,不能直接拿到公开平台去标注,你得自己在现场采集、自己洗数据、自己找标注资源。

这就是第一个核心矛盾:标注成本过高。二十几个类别如果要做得好,至少需要每个类别一千张以上带标注的图,二十多类就是两万多张。找第三方标注公司,一张按类别和复杂度计价,粗略一算,这个项目还没开始,钱先烧掉一截。而且中餐菜品边界模糊,标注员得反复看专家给的标注规范,比如“土豆烧牛肉里土豆占多少比例算荤菜”“炒蛋里混了番茄算番茄炒蛋还是蛋炒番茄”。这种主观判断强的标注,一致性极差,返工率很高。

当时团队里有人提了一句:咱们攒了那么多历史监控片段,能不能让模型自己从里面学点东西?这句话直接把我们引到了半监督学习的路上。

所谓半监督,简单说就是:少量有标签数据(比如人工精标了三千张)加上大量无标签数据(比如从历史图片里筛出来的两万张),一起参与训练。模型的训练信号一部分来自人工标注的“标准答案”,另一部分来自无标签数据自身的结构信息。对食品分类这种场景来说,半监督几乎是量身定做的——因为食品图像的“分布”极其丰富,但“人工标注”极其昂贵,恰恰是半监督最擅长的领域。

做这个系统的整个周期大概花了一个半月,不算长,但中间踩的坑、推翻的方案、最后沉淀下来的经验,我觉得比做一个普通的有监督分类项目值钱得多。这篇文章就从头到尾复述一遍,讲清楚每个环节为什么这么设计,哪些地方容易翻车,以及最后实测出来的真实收益。

2. 数据工程先行:食品图像的特殊性和清洗策略

很多人一听“半监督”,第一反应是找个算法库跑起来,把无标签数据怼进去就完事。大错特错。半监督的上限不是由算法决定的,而是由无标签数据的质量决定的。无标签数据不干净,怎么训都是白搭。

2.1 采集环节怎么拍

我们的图像来源主要有三个:现场定点拍摄、历史记录片段抽帧、以及一小部分员工手机随手拍。前两个是主力。现场定点拍摄就是为了覆盖真实出餐状态,需要固定角度、固定高度的机位,分别拍“出餐窗口刚打好的一盘”“就餐者吃到一半的餐盘”“收盘处的残盘”。这三种状态的差异非常大,刚出餐的菜品完整、轮廓清晰,吃到一半的餐盘有遮挡、有混合,残盘则基本不可用。但不可用的残盘图也得留着,它是有价值的负样本,能让模型学到“这种状态不需要正确分类”。

抽帧最容易翻车的地方是模糊帧。监控片段里大量存在运动模糊、对焦不准、遮挡过半的帧,直接抽出来进训练集,会把模型带偏。我当时的处理策略是:

  • 抽帧间隔至少5秒,避免连拍相似帧导致的冗余;
  • 用Laplacian方差做一遍模糊筛除,低于经验阈值的直接丢弃;
  • 再跑一遍目标检测,裁出餐盘区域,过滤掉没有完整餐盘入镜的图。

这一步筛下来,两万张原始抽帧大概只剩一万三千张能用的。不要觉得浪费,半监督学习里无标签数据的价值密度比数量重要得多。十万张模糊图不如一万张清晰图有信息量。

2.2 食品类目边界怎么定

类目定义是所有后续工作的地基,这一步必须花时间理清楚。我们最终定成了22个类,覆盖常见主食、荤菜、素菜、蛋类、汤品和水果。定类目时有几个体会:

第一,类别宁少勿多。这22类里有些类别外形相似度很高,比如“炒土豆丝”和“凉拌土豆丝”,颜色和形态差异其实不小,但油光和装盘方式会让模型混淆。如果细分到“某种做法的土豆丝”,标注难度和模型难度的增加是同时的,不如合并为“土豆丝”一个大类。

第二,要有“其他”类。食堂总有你没想到的菜,比如临时加的玉米排骨汤、端午发的粽子。如果模型没有“其他”这个出口,会把所有看不清的东西硬塞到某一类里,分类错误率会明显上升。这个“其他”类最初占的比例控制在全体的5%左右,后面随着类别稳定慢慢调低。

第三,标注规范要写成图文对照文档,而不是一段文字描述。比如“糖醋里脊”的标注规范,不能只说“裹糊油炸后淋糖醋汁的猪肉条”,要配上至少三张不同角度、不同光照的样图,标明哪些算、哪些不算。注意区分“近期研发的新菜品”和“常见的家常菜谱”——很多公开数据集把两者混在一起,导致模型对某些菜式的偏见很大。

2.3 无标签数据怎么洗

无标签数据也不能直接扔给模型。我的经验是,半监督学习对噪声的容忍度比监督学习高一点,但只高一档,不是无限高。具体清洗流程:

  • 首先去掉所有文字信息过多的图。食堂窗口上有LED菜牌,抽帧画面里经常出现“鱼香肉丝”“番茄炒蛋”等文字。如果模型靠文字来做分类而不是靠食材视觉特征,一旦换一个没有文字的场景,模型马上就露馅。这类文字图必须要么裁掉文字区域,要么整张丢弃。
  • 其次去重。用感知哈希算法感知相似度,阈值0.9以上的只保留一张。这一步很重要,因为连拍抽帧很容易产生大量几乎一样的图,如果不做去重,模型会严重偏向高频出现的构图。
  • 最后做类别均衡性预估。不需要精确标注,只需要用“粗分类模型”先给无标签数据打个底,看看各类别数量分布是否极端失衡。如果某类占了40%以上,还得做下采样,防止半监督训练被大类别统治。

整个数据工程阶段耗时大约两周,这个投入是值得的。后面训练阶段的种种顺利,很大程度上就是靠这两周的硬功夫撑起来的。

3. 半监督学习的三种主流打法:从伪标签到一致性正则

数据准备好了,接下来是算法选型。

半监督图像分类近年来确实成熟了很多,主流方法可以归成三脉:伪标签法(Pseudo-Labeling)、一致性正则(Consistency Regularization)、以及把两者结合起来的混合范式(比如FixMatch)。我把这三种都跑过一遍,下面挨个说清楚它们的直觉和坑。

3.1 伪标签法:让模型给自己出题

伪标签的思路非常直白:先用少量有标签数据训练一个初步模型,然后用这个模型去预测无标签数据,把置信度高的预测结果当成伪标签,混进训练集继续训练,迭代多轮。

这个方法在食品分类上有天然的优势:食品图像通常特征比较鲜明——颜色、纹理、形状在同类别内差异有但可控,模型对某些类别的预测置信度可以做到很高。比如“白米饭”这种类,纹理均一、形状固定,模型很快就能以超过0.99的置信度给出伪标签,这些伪标签的可信度其实不亚于人工标注。

但伪标签法的坑在于错误会滚雪球。如果初始模型在某个类别上系统性犯错(比如把“炒鸡蛋”认成“南瓜饼”),那么高置信度的伪标签也会延续这个错误,下一轮训练模型会把这种错误当成正确答案不断强化。这个现象叫“确认偏置”(confirmation bias),做半监督的人基本都吃过它的亏。

解决偏置的办法有几个:阈值要卡得高(我一般设0.95起步);无标签数据的增强要做得强(让模型看到同一个样本的不同形态);以及每轮迭代时,有标签数据的loss权重不能太低,否则人工标注信息会被淹没在大规模伪标签里。

3.2 一致性正则:教模型“稳一点”

一致性正则的思路更优雅一些。它不需要模型给自己出伪标签,而是要求:同一个无标签样本,在两种不同的数据增强下,模型的预测分布应该保持一致。

直觉上讲,一张红烧肉的图,你把它稍微旋转、调暗、加一点噪声,它还是红烧肉。模型对这两种变化后版本的预测,应该高度一致。如果模型在增强A下认为是“红烧肉”,在增强B下认为是“回锅肉”,说明模型对这块肉的理解不稳定,这就是需要修正的学习信号。

一致性正则的好处是它对伪标签的噪声天然有更强的鲁棒性——它不要求模型产生一个“正确”的硬标签,只要求模型“前后一致”,这就避免了错误标签被当成正确答案反复强化的风险。缺点嘛,就是训练会比较慢,因为增广计算量翻倍,而且如果初始模型很弱,一致性的约束也很难教会模型真正的类别语义。

3.3 FixMatch:把两条路合并起来

FixMatch是当前半监督图像分类的经典基线,OpenReview上的口碑和复现效果都不错。它的核心做法分为两支:

  • 对无标签样本做弱增强(如随机水平翻转、平移),用模型预测出伪标签;
  • 只有伪标签置信度超过阈值(比如0.95)才保留;
  • 再对这个无标签样本做强增强(如RandAugment),要求模型在强增强下的预测和伪标签一致。

这里的逻辑很有意思:弱增强下的预测相对可靠,用来生成“目标”;强增强下的预测相对困难,用来制造“挑战”。模型既要保持稳定(强增广下依然分类正确),又要保持稳健(低置信度的不学)。这套机制让模型在无标签数据上的学习效率非常高,一度是半监督分类的SOTA级方法。

我选择FixMatch作为系统的核心训练范式,但没有直接用它的默认配置,而是针对食品数据做了几处调整。默认的RandAugment强度范围太激进,对于食品分类会出现过度扭曲导致语义失真(比如把粥的纹理增强没了);另外默认置信度阈值0.95在类别少、特征明显的食品任务上偏保守,我最后调到了0.92左右。

4. 从算法到系统:整个管线的搭建细节

算法选好了,接下来的问题是把它变成一个能稳定训练、可复现、可迭代的系统。这一步不要觉得简单,我见过太多人算法代码能跑,但一到真实数据上就崩得不明不白。

4.1 整体架构和任务切分

我的管线分成了四个模块:数据模块、训练模块、评估模块、导出模块。

数据模块负责读图、做增强、生成批次(有标签批次和无标签批次分开);训练模块实现半监督损失的前向与反向传播,并维护EMA(指数移动平均)模型来稳定训练;评估模块每次epoch结束后在纯人工标注的验证集上算指标;导出模块负责把训练好的模型转换成部署格式。

这四个模块之间只通过数据流交互,互不调用函数。这个设计对半监督训练特别关键,因为半监督训练的稳定性评估天然比监督学习更困难——你永远要警惕模型在无标签数据上“看起来在变好”但其实在走偏。评估模块必须是一个独立、干净、只依赖人工标注的“裁判”,不能让训练模块的任何逻辑污染它。

4.2 核心训练循环的设计

给一个核心训练的伪代码思路,方便你理解整体逻辑:

for epoch in range(total_epochs): model.train() for batch_labeled, batch_unlabeled in loader: # 有标签分支:标准交叉熵 x_l, y_l = batch_labeled logits_l = model(x_l) loss_sup = CrossEntropy(logits_l, y_l) # 无标签分支:FixMatch x_w = weak_augment(batch_unlabeled) x_s = strong_augment(batch_unlabeled) with torch.no_grad(): pred_w = softmax(model(x_w)) pseudo_label = pred_w.argmax(dim=1) confidence = pred_w.max(dim=1).values mask = confidence >= threshold logits_s = model(x_s) loss_unsup = CrossEntropy(logits_s, pseudo_label, reduction='none') loss_unsup = (loss_unsup * mask).sum() / max(mask.sum(), 1) loss = loss_sup + lambda_u * loss_unsup loss.backward() optimizer.step() ema_model.update(model)

有两点体会比较深。

第一,lambda_u(无标签损失的权重)在整个训练过程中可以保持一个恒定值(如1.0),但初始的若干个epoch(我用的warmup是5个epoch)里无标签分支的权重要从0附近逐渐升到1.0。这是因为初始模型太弱,生成的伪标签错误率偏高,如果一开始就让无标签损失占据太大比重,会让模型在错误的伪标签上过拟合。这个warmup策略对稳定性的提升非常显著。

第二,验证集必须抽出来单独放。半监督训练里验证集不能用无标签数据里的任何一张,否则会严重虚高模型指标。因为FixMatch的伪标签生成和模型自身强相关,验证集一旦混入无标签数据,相当于考试抄了平时作业。

4.3 实验设计:如何证明半监督真有效

我需要回答一个关键问题:半监督到底比纯监督多带来了多少收益?如果不做对比实验,你根本说服不了自己,更不用说说服别人。

我的实验设计分三组:

  • 基准组:只用3000张有标签数据,标准的监督学习训练;
  • 半监督组:3000张有标签 + 13000张无标签,FixMatch训练;
  • 全监督理论上限:把无标签数据全部人工标注,再用所有数据做监督训练(这组不用全部标完,抽样标2000张估算)。

结果后面第五节详说,这里只讲实验设计时的一个教训:三组实验必须在完全一致的数据预处理、增强参数、训练总步数下跑,否则指标差异无法归因到“半监督”这个方法上。我第一次跑的时候就吃了这个亏,半监督组用了双倍epoch,结果指标好看了但完全没法跟基准组做严谨对比,白白重跑了一遍。

4.4 增量学习应对新菜类别

食堂会周期性更新菜单,新菜品不断出现。如果每次都要重新全量训练,工程代价太高。我采用的策略是:新菜出现时,手动采集并标注新类别的少量样本(通常50到100张),然后连同旧数据一起做小规模微调。此时半监督的价值再次体现——旧菜类别的无标签数据可以持续参与训练,避免模型在新菜上获得能力的同时遗忘旧菜的知识。

增量微调需要用较低的学习率(比正常训练低10倍),并且在新类和旧类的采样比例上做均衡(新类稍偏高),否则模型会偏向新类而把旧类压过头。这个部分做得好,系统上线后的维护成本会大幅降低。

5. 实测结果与踩坑记录

这一节把数字和坑摆出来,真实项目里最值钱的往往不是方案本身,而是那些在方案执行过程中炸出来的雷。

实验组标签数量无标签数量Top-1准确率
基准监督3,000082.4%
弱半监督(伪标签)3,00013,00086.1%
FixMatch半监督3,00013,00089.7%
全监督参照5,000(抽样估算)090.2%

从表里能看到几个信息:FixMatch比基准监督高了7.3个百分点,比简单伪标签法高了3.6个百分点;而用少量标签+大量无标签的FixMatch,已经逼近了全监督的上限。这说明食品分类这个场景,半监督确实能挖出无标签数据里相当多的有效信息。

但我必须提醒一句:别把这类结果当成一个可以稳定复现的固定数值。准确率的提升幅度对数据集、类目数、标签数量占比高度敏感。如果初始标签数量只有500张,FixMatch带来的收益会更大(可能超过10个百分点),但训练也要脆弱得多,稍不留意就崩。

5.1 最容易踩的坑:伪标签噪声失控

训练到第10个epoch左右,我遇到了第一个严重问题:验证集准确率开始周期性抖动,大约每3个epoch出现一次尖峰式下降,随后又慢慢回升。一开始我以为是学习率周期的问题,排查了半天,最后发现是无标签分支里一个不小心引入的bug——Confidence阈值我设成了>=而不是>,让置信度恰好等于0.92(这个值来自上一个实验配置)的样本也进入了伪标签集合,而这些样本处恰好卡在类别边界,错误率极高。

修好这个低级错误之后,准确率曲线明显平滑了。这类“条件边界写错”的问题在代码审查里极难直接看出来,因为只差一个等号,而且多数时候不影响结果,偏偏到了某个临界值就爆发。

5.2 训练不稳定:EMA和增强的调节策略

半监督训练还有一个常见表现——loss在后期出现突然的尖峰,无标签分支的loss在某个step突然翻倍,然后整个模型跟着震荡。出现这种情况的原因通常是强增强的参数太激进,把某些样本增强到了语义丢失的程度(比如把皮蛋瘦肉粥在Randaugment的cutout操作下切掉了瘦肉区域,模型完全无法识别)。

解决办法是给RandAugment的强度设置一个上限,并做“半增强”:强增强和弱增强之间再加一个中等增强分支,让模型有一个渐进的学习路径。这个三档增强策略是我们后期调参数时发现的最有效稳定化方案。

另外,EMA(指数移动平均)在食品分类任务上很有用。直接使用当前模型的权重去生成伪标签,对于增强版本的预测波动大;用EMA模型(权重是历史上多个时刻的平均)来生成伪标签,相当于让多位不同训练阶段的“评审”投票,稳定性好得多。我最终的配置是EMA decay=0.998,每5个step更新一次。

5.3 类别不均衡:无标签数据也要讲公平

食堂菜谱有很明显的“头部效应”:米饭、番茄炒蛋、土豆丝可能占了出餐量的40%,而鱼、虾、排骨这类菜品出餐占比很低。如果不做处理,模型会在少数类上学得很差——Top-1整体准确率看着还行,但一按类别看,少数类准确率可能只有50%。

处理办法是双重采样:有标签数据在构建批次时,按类别平衡采样,保证每个类别在一个epoch内出现的次数相当;无标签数据则不追求严格平衡(因为大多数无标签数据类别未知),只是做整体上下采样把头部类的高频构图压下去。做了这一步之后,少数类别的准确率从65%提升到82%,整体准确率也同步涨了一些。

5.4 一个反直觉的发现:无标签数据不是越多越好

这一点我花了不少时间才接受:两万张无标签数据训练出来的模型,比一万三千张无标签数据训练的模型并没有显著提升,甚至在某些类别上略有下降。

原因在于无标签数据的覆盖度,而不是总量。多出来的那七千张图,大多来自相似的光线条件和相同的菜品时段,它们带来的信息熵很低,反而拉大了计算开销并让模型在特定构图上过拟合。后来我把无标签数据按场景、时间段做了分层采样,优先保证覆盖不同窗口、不同光照、不同出餐状态,模型效果才真正上了台阶。

建议做食品半监督的同学,选无标签数据时不要只看总量,先做一个简单的多样性分析(每个样本embedding后聚类,看簇的分布),保证覆盖度够广再投入训练。

6. 从工程到落地:部署形态和几个值得长期保留的习惯

系统最后部署成了一个离线推理服务,输入是一张餐盘照片,输出是“菜品种类+置信度”。部署时又遇到两类问题:一是推理端的图像输入尺寸和训练端不一致(训练用了288x288,部署设备为了性能改成了224x224),导致精度下降约1.5个百分点。这个后来通过训练时就同时用多个尺寸做随机裁剪解决——多尺度训练,部署端任意选一个尺寸都不会大幅掉点。

二是模型导出时为了兼容推理框架,做了层融合(比如把BatchNorm融合进卷积层),这个操作本身没问题,但要注意融合后必须用验证集重新跑一遍指标,确认数值一致。有一次融合后精度没变,但有少量样本的置信度翻转了,排查了很久发现是BatchNorm的running_mean被导出的顺序搞错了。

最后说几个我觉得值得长期保留的习惯:

  • 每次实验的完整配置(数据增强参数、阈值、lambda_u、随机种子)坚决落库,不写在草稿里。半监督实验的复现难度比监督学习高很多,同一个配置换了随机种子结果都可能波动两三个点,不做实验记录基本上就是在浪费自己的时间。
  • 每个epoch把验证集上的混淆矩阵存下来。只看准确率曲线的话,你很难发现“米饭被分到粥”这类系统性的小错误正在悄悄恶化。
  • 对无标签数据的伪标签质量做持续监控。每轮训完,随机抽200张高置信度的伪标签图片,肉眼扫一遍,确认模型没有在某个类别上产生系统性的错误标签。这个“人工抽检”的成本极低,但能避免很多在指标上看不到的隐患。

做这个半监督食品分类系统的过程,最大的感触是:算法本身不是瓶颈,数据和工程才是。FixMatch这套方法论文里写得清清楚楚,开源实现也很多,但真正决定项目成败的,是数据清洗够不够细、训练稳定性控制得好不好、以及有没有一套可靠的评估体系。如果你也在做类似的图像分类项目,手头恰好有一堆没有标注的图片,我觉得完全可以沿着这个路线试一次半监督——别指望它像魔法一样把所有问题都解决,但数据量够了、清洗到位了,它确实能帮你省下大把标注费,把分类准确率推到接近全监督的上限。

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

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

立即咨询