半监督YOLO目标检测:教师-学生框架与伪标签训练实战
简介这份资源是面向深度学习与计算机视觉方向研究者、算法工程师及高年级学生的半监督YOLO目标检测框架旨在解决标注数据稀缺场景下目标检测精度不足的问题。压缩包共25个文件以19个Python脚本为核心辅以3个YAML配置、1个Markdown说明文档及示例图片等整体约127KB结构紧凑便于快速上手。框架围绕train_ssod.py组织半监督训练流程通过伪标签生成与迭代训练将少量标注样本与大量未标注数据结合并配套教师学生模型、EMA、损失函数、伪标签器及数据增强、可视化、指标评估等模块训练配置与日志输出目录划分清晰。目前已有59人学习下载适合希望复现半监督检测方案、理解SSOD训练机制或在此基础上二次开发的读者参考。1. 半监督 YOLO 目标检测框架标注只做三成mAP 还能往上顶手里有一批工业质检或遥感图像标注了几百张就顶不住了剩下的几千张原图躺在硬盘里吃灰——这大概是目标检测落地时最真实的卡点。全监督 YOLO 要吃大量精标框标一张图少则几十秒多则几分钟人力成本直接劝退。半监督目标检测的思路就是用少量已标注数据当种子让模型自己对未标注图像生成伪标签再挑出高置信度的伪标签回灌训练如此往复把无标注数据的价值榨出来。这个框架打包的正是这套流程核心是 YOLO 检测器加一套伪标签生成与筛选机制适合已经跑通过 YOLOv5/v8 基础训练、想进一步压低标注成本的人。它不解决从零学 YOLO的问题但如果你正卡在标注预算和精度之间这套东西值得拆开看。2. 半监督 YOLO 的骨架教师-学生到底怎么分工2.1 为什么是教师-学生而不是直接自训练最朴素的做法是拿已标注数据训一个模型让它给未标注图打标签再混在一起重训。这条路能走但有个致命问题模型自己的错误会被当成真值反复强化越训越偏。半监督框架普遍采用教师-学生结构来缓解这个偏差——教师模型不直接参与梯度回传它的权重是学生模型权重的指数移动平均EMA。学生每步更新后教师按一个衰减系数慢慢跟随这样教师输出比学生更稳伪标签的噪声被平滑掉一部分。具体到 YOLO 场景教师和学生通常是同一个检测网络比如 YOLOv8n 或 YOLOv5s区别只在权重更新方式。教师前向推理未标注图像输出框和类别置信度经过置信度阈值和 NMS 筛选后剩下的框作为伪标签监督学生。学生同时也在已标注数据上做常规监督损失。总损失大致是L_total L_sup(labeled) λ * L_unsup(unlabeled, pseudo_labels)λ 是无监督损失的权重一般从 0 开始 warmup 到目标值避免训练初期伪标签质量太差把模型带偏。2.2 伪标签筛选阈值、NMS 和类别平衡伪标签质量直接决定半监督能不能涨点。框架里通常有三道筛子第一道是置信度阈值。教师输出的每个框带一个类别置信度低于阈值的直接丢。常见起点是 0.5但类别不均衡时不能一刀切——稀有类别可以降到 0.3常见类别提到 0.7。第二道是 NMS非极大值抑制。教师对同一目标可能吐出多个重叠框NMS 的 IoU 阈值一般设 0.5~0.6太松会留下重复框太紧会误杀密集小目标。第三道是类别平衡。如果未标注集里某个类别天然稀少伪标签会更少形成马太效应。有的实现会按类别统计伪标签数量对稀缺类别单独放宽阈值。下面是一段伪标签生成的简化逻辑用 Python 写假设教师模型已经加载好import torch from torchvision.ops import nms def generate_pseudo_labels(teacher, images, conf_thres0.5, iou_thres0.55): teacher: 教师模型eval 模式 images: 未标注图像 batch, shape [B, 3, H, W] conf_thres: 置信度阈值 iou_thres: NMS 的 IoU 阈值 返回: 每张图的伪标签列表 [(boxes, scores, labels), ...] teacher.eval() with torch.no_grad(): preds teacher(images) # 输出 [B, N, 5C]前4是xywh第5是obj后面是类别 pseudo_labels [] for pred in preds: # 拆出框、目标置信度、类别分数 boxes pred[:, :4] obj_conf pred[:, 4] cls_scores pred[:, 5:] cls_conf, cls_ids cls_scores.max(dim1) scores obj_conf * cls_conf # 最终置信度 # 第一道置信度过滤 mask scores conf_thres boxes, scores, cls_ids boxes[mask], scores[mask], cls_ids[mask] # 第二道NMS keep nms(boxes, scores, iou_thres) pseudo_labels.append((boxes[keep], scores[keep], cls_ids[keep])) return pseudo_labels逻辑说明教师模型在no_grad下前向不产生梯度。最终置信度用目标置信度乘类别分数这是 YOLO 系列的标准做法。NMS 用 torchvision 的实现输入框格式是 xyxy如果你的模型输出 xywh 需要先转换。参数方面conf_thres和iou_thres是最需要调的两个后面避坑章节会展开。2.3 训练循环EMA 更新与损失加权教师权重更新用 EMA公式是θ_teacher α * θ_teacher (1 - α) * θ_student。α 通常取 0.99~0.999越大教师越稳但跟随越慢。训练初期学生还没学好α 可以设大一点让教师保持稳定后期可以适当降低让教师跟上学生的进步。无监督损失权重 λ 的 warmup 策略也关键。常见做法是前 1~2 个 epoch λ0纯监督预热之后线性升到目标值比如 1.0 或 3.0。如果一上来就给大 λ伪标签噪声会直接污染学生。class EMAUpdater: def __init__(self, teacher, student, alpha0.999): self.teacher teacher self.student student self.alpha alpha def update(self): for t_param, s_param in zip(self.teacher.parameters(), self.student.parameters()): t_param.data.mul_(self.alpha).add_( s_param.data, alpha1 - self.alpha) # 训练循环片段 for epoch in range(num_epochs): # λ warmup前2个epoch为0之后线性升到1.0 if epoch 2: lambda_unsup 0.0 else: lambda_unsup min(1.0, (epoch - 2) / 5.0) for images_l, targets_l in labeled_loader: # 监督分支 loss_sup student(images_l, targets_l) # 无监督分支 images_u next(iter(unlabeled_loader)) pseudo generate_pseudo_labels(teacher, images_u) loss_unsup student(images_u, pseudo) total_loss loss_sup lambda_unsup * loss_unsup total_loss.backward() optimizer.step() ema_updater.update()这段代码把监督和无监督两个分支串起来。注意generate_pseudo_labels每步都调一次教师前向计算开销不小实际训练时未标注 batch 可以比标注 batch 小一些或者隔几步才生成一次伪标签缓存起来用。λ 的 warmup 步数不是固定的数据越脏、伪标签越不可靠warmup 就该越长。3. 把框架跑起来数据组织、配置与启动3.1 数据目录怎么摆半监督训练需要两套数据有标注集和无标注集。目录结构建议这样组织避免路径混乱dataset/ ├── labeled/ │ ├── images/ │ │ ├── 001.jpg │ │ └── ... │ └── labels/ │ ├── 001.txt │ └── ... ├── unlabeled/ │ └── images/ │ ├── 101.jpg │ └── ... └── data.yaml标注格式沿用 YOLO 的 txt每行class_id x_center y_center width height坐标归一化到 0~1。无标注集只需要 images 目录不需要 labels。data.yaml里要同时声明两套路径path: ./dataset train: labeled/images val: labeled/images # 验证集也从标注集里划 unlabeled: unlabeled/images nc: 3 names: [defect_a, defect_b, defect_c]这里nc和names必须和标注文件里的 class_id 严格对应错一个数字训练就全乱。验证集建议从标注集里切 10%~20% 出来不要用无标注数据做验证否则指标没有意义。3.2 关键配置项逐个说框架的配置文件通常包含模型、训练、半监督三块。挑几个最容易设错的配置项含义建议起点调整方向conf_thres伪标签置信度阈值0.5伪标签少就降噪声大就升iou_thresNMS 的 IoU 阈值0.55密集目标降到 0.5ema_alpha教师 EMA 衰减0.999训练不稳降到 0.99lambda_unsup无监督损失权重1.0伪标签质量差降到 0.5warmup_epochsλ 预热轮数5数据脏就加到 10unlabeled_ratio无标注 batch 比例1.0显存不够降到 0.5conf_thres和lambda_unsup是一对联动参数。阈值高、λ 大训练激进但容易崩阈值低、λ 小稳但涨点慢。我一般先用conf_thres0.5, lambda_unsup0.5跑一轮看 loss 曲线稳了再往上加。3.3 启动训练与日志观察启动命令通常长这样python train_semi.py \ --data dataset/data.yaml \ --weights yolov8n.pt \ --epochs 100 \ --batch-size 16 \ --conf-thres 0.5 \ --lambda-unsup 1.0 \ --warmup-epochs 5 \ --device 0参数说明--weights指定预训练权重半监督也从预训练起步别从随机初始化开始--batch-size是标注分支的 batch无标注分支按unlabeled_ratio缩放--device 0指定第一块 GPU多卡用0,1。训练日志里重点盯三个量监督 loss 是否稳定下降、无监督 loss 是否发散、伪标签平均数量是否骤降。无监督 loss 突然飙升通常意味着教师输出了大量低质量伪标签这时候要么降 λ要么升 conf_thres。伪标签数量骤降可能是教师模型退化检查 EMA 的 α 是不是设太大了。4. 避坑指南半监督训练里最容易翻车的五件事4.1 伪标签置信度阈值设太高无标注数据等于没用现象训练跑完mAP 和纯监督比几乎没涨日志里伪标签数量每张图不到 1 个。原因conf_thres设了 0.7 甚至更高教师模型本身还没训好输出置信度普遍偏低大部分框被过滤掉无标注数据实际参与训练的信号极少。解决先把阈值降到 0.3~0.4观察伪标签数量。如果每张图能稳定产出 3~5 个框再逐步往上调。阈值不是越高越好它和教师当前能力匹配才有意义。4.2 EMA 衰减系数太大教师跟不上学生现象训练中期学生 loss 在降但验证集 mAP 停滞甚至倒退教师输出的伪标签越来越旧。原因ema_alpha设了 0.9999教师权重几乎不动学生已经学到新特征了教师还在用几百步前的状态生成伪标签两者脱节。解决把 α 降到 0.99~0.999 区间。经验值是训练步数越多 α 可以越大一万步以内用 0.99十万步以上再考虑 0.999。4.3 无监督损失权重一上来就拉满现象第一个 epoch 无监督 loss 就爆炸总 loss 震荡不收敛。原因lambda_unsup初始就是 1.0 甚至更高warmup 没做或太短伪标签噪声直接主导了梯度。解决前 2~5 个 epoch 把 λ 设为 0纯监督预热之后线性升到目标值。数据越脏 warmup 越长。别嫌慢这一步省了后面全是后悔药。4.4 标注集和无标注集类别分布严重不一致现象某些类别在标注集里有无标注集里几乎没有训练后这些类别的召回率反而下降。原因教师对无标注集里没见过的类别无法生成伪标签学生从无监督分支学到的全是其他类别的信号形成类别偏置。解决检查两套数据的类别分布尽量让无标注集覆盖所有类别。如果做不到对稀缺类别单独放宽conf_thres或者在采样时给含稀缺类别的图像更高权重。4.5 验证集混入了无标注数据现象验证 mAP 虚高但换一批真实测试图效果差很多。原因验证集路径指向了无标注目录或者标注集和无标注集有重叠图像模型在验证时看到了训练中伪标签监督过的图。解决验证集严格从标注集里切且和训练用的标注图不重叠。训练前用脚本比对两套数据的文件名哈希确认没有交集。这个坑不显眼但会让所有指标失去参考价值。5. 进阶技巧用伪标签置信度做课程学习跑通基础流程后想让半监督真正榨出更多精度可以试试按伪标签置信度做课程学习。核心思路是训练早期只用高置信度伪标签随着教师变强逐步放低阈值让学生从易到难地吸收无标注数据。这比固定阈值更符合学习规律实测在遥感小目标和工业缺陷检测上能多涨 1~3 个点 mAP。实现上不复杂把conf_thres做成随 epoch 衰减的函数def get_conf_thres(epoch, total_epochs, start0.7, end0.4): 置信度阈值从 start 线性降到 end epoch: 当前轮数 total_epochs: 总轮数 if epoch total_epochs: return end ratio epoch / total_epochs return start - (start - end) * ratio # 在训练循环里替换固定阈值 for epoch in range(num_epochs): conf_thres get_conf_thres(epoch, num_epochs, start0.7, end0.4) pseudo generate_pseudo_labels(teacher, images_u, conf_thresconf_thres) # ... 后续训练逻辑参数上start建议设 0.6~0.7end设 0.35~0.45。衰减曲线用线性就行余弦衰减也可以但收益不明显。注意end不要低于 0.3再低伪标签噪声会盖过信号。配合课程学习还可以做一个简单的验证每个 epoch 结束后统计伪标签的类别分布和平均框数画成曲线。如果某个类别伪标签数突然掉到 0说明教师对该类别失去信心需要检查标注集里这个类别的样本量是不是太少。我一般会在训练脚本里加一段日志每 5 个 epoch 打印一次各类别伪标签数量比只看总 loss 有用得多。还有一个容易忽略的点半监督训练对学习率比纯监督更敏感。因为无监督分支的梯度噪声更大学习率太高会让 EMA 教师跟着抖。常见做法是把初始学习率降到纯监督的 0.5~0.7 倍配合余弦退火。如果发现验证 mAP 波动超过 2 个点先降学习率再调其他参数。从那以后我每次跑半监督都强制先跑一轮纯监督 baseline把 mAP 记下来再跑半监督对比。没有 baseline 的涨点都是玄学有了对照才知道伪标签到底有没有干活。希望帮到你。本文还有配套的精品资源点击获取