简介:基于半监督学习训练YOLOv7的源码包,面向有一定深度学习基础、希望利用大规模未标注数据提升目标检测性能的开发者与研究人员。资源围绕伪标签生成、联合训练、一致性正则化、分阶段训练等典型半监督策略,提供了一套从数据加载、模型构建、损失函数到训练循环的完整可运行代码,非常适合用于学术实验、课程设计或工程落地参考。压缩包共34个文件,以26个Python脚本为主体,涵盖head/voc标注转换、kmeans锚框计算、semi_dataloader半监督数据读取、semi_training半监督训练、模型评估及预测等模块;另有4个xml配置、2个txt说明、1张示例jpg和工程配置文件,整体仅515KB,目录结构清晰、便于按需查阅。已有674人学习下载。通过阅读和运行源码,可深入理解伪标签的生成与更新机制、半监督损失函数的设定、如何结合有标签与无标签数据协同训练YOLOv7,并可直接基于该代码改造自己的检测任务,是学习和实践半监督目标检测的实用参考。
1. 这份源码要解决的,不只是把 yolov7 跑通
标注成本高到无法覆盖所有现场类别时,把未标注数据也塞进训练是最直接的止损思路。但市面上以“半监督学习训练 YOLOv7”为卖点的源码包,解压后往往是train.py、pseudo_label.py加一组 shell 脚本,真正决定效果的并不是模型结构加了什么,而是伪标签有没有回到损失函数。很多包的实现止步于“把未标注图放进 dataloader”,训练 loss 照常下降,验证 mAP 却稳定在监督基线上不动。原因是检测输出是集合预测,伪框不经过 anchor assigner 就不会形成匹配目标,更谈不上梯度反传。这篇按伪标签接入、数据流、阈值与验证四条线,把这类源码里常见的半监督训练改法理清楚,给出一套从解压到复现 mAP 提升的完整路径。
2. 半监督学习在 YOLOv7 里的核心前提:伪标签必须走完梯度通路
2.1 为什么混入未标注图不叫半监督
半监督学习落到目标检测,最常用的不是一致性正则,而是伪标签(pseudo label)。通俗讲:先让一个已训练的 teacher 模型对无标注图做推理,输出高置信度框,把它当成标签,再和真实标注混合训练 student。这里有一个容易忽略的前提:YOLOv7 的损失由边界框回归、cls、obj 三部分组成,所有目标都必须先经过正负样本分配,把预测框和真值框做形状与 IoU 匹配,才能进入各自损失项。伪标签如果只是打印到终端、写进日志,或者像分割任务那样把输出当 mask 直接求损失,对 YOLOv7 这种 anchor-based 检测器都无效。
基于半监督学习训练 YOLOv7,本质上是给模型增加了一条“自己监督自己”的梯度通路。无标注分支的梯度路径比有标注分支更脆弱,因为模型用自己的输出监督自己,一旦置信度阈值没有过滤掉错误框,错误会顺着 box、cls、obj 三个损失项同时被放大。这也是为什么同一个源码包,别人跑能涨三到五个点,你跑反而掉点的最常见原因:不是代码有问题,而是伪标签的质量和数量关系没有被处理好。
2.2 三种能进训练循环的伪标签接入方式
伪标签要真正参与训练,不能只在推理脚本里存一份 txt。按实现路径,可以把源码包里的方案归为三类。
方式一是离线预生成:先训练一个 teacher,用它对全部无标注图做一次推理,把高置信度结果写成 YOLO 格式的 txt,和真实标注一起参与后续训练。实现最简单,显存只多出一份推理开销,对原版train.py的改动也最小。缺点是伪标签只生成一次,student 能力超过 teacher 之后,标签质量成为模型上限,因此它更适合标注数据极少的冷启动阶段。
方式二是在线周期生成:每训练 N 个 epoch 用当前权重重新推理一遍无标注集,覆盖旧的伪标签目录。这相当于在训练循环里嵌入了一条反馈回路,标签质量能跟随模型提升,但每隔几轮就要跑一次全量推理,磁盘写入和训练中断都需要设计好。
方式三是端到端 teacher-student:每个 batch 内 teacher 和 student 同时前向,teacher 用no_grad()包裹,权重通过 EMA 从 student 复制,student 的梯度不回传到 teacher。伪标签不再落盘,而是直接在内存中完成匹配和损失计算。
| 接入方式 | 代码里找什么 | 伪标签更新时机 | 主要代价 | 适用场景 |
|---|---|---|---|---|
| 离线预生成 | inference.py或导出 txt 的循环 | 训练前一次 | 磁盘占用 | 冷启动、标注极少 |
| 在线周期生成 | write_pseudo、save_pseudo | 每 N 个 epoch | 推理耗时与磁盘 IO | 标签质量跟随模型 |
| 端到端 | teacher、ema_decay、detach | 每个 step | 显存翻倍 | 追求精度上限 |
端到端方式对显存最不友好,但伪标签新鲜度最高。需要注意它和 YOLOv7 自带的 EMA 参数平滑机制兼容良好,因为 YOLOv7 原本就用 EMA 维护推理权重,改造时可以直接复用它作为 teacher,不必额外定义模型副本。
2.3 拿到源码先做的三件事:找伪标签、追数据流、查 BN
解压源码后不要直接执行python train.py。第一步先在整个工程里搜关键词,确认半监督逻辑是否真实存在:
grep -rnE "pseudo|unlabel|teacher|ema" --include="*.py" .输出里出现ema不代表什么,YOLOv7 原版就有 EMA 参数平滑;真正要关注的是pseudo和unlabel。如果整个仓库里这两个词只出现在注释里,那这份源码大概率只是把无标注图混进了训练集,基本可以认定是假半监督。
第二步追数据流,看 dataloader 对无标注图做了什么处理。常见的问题写法如下:
# 部分半监督“改造”会这样返回空目标 class UnlabeledDataset(Dataset): def __getitem__(self, idx): img, _ = self.load_image(idx) return img, torch.zeros((0, 5))这段代码的问题在于:模型确实加载了无标注图,但传给损失函数的是一个空目标。YOLOv7 的损失函数会把“没有目标”当作负样本,模型在无标注图上学习的其实是“这张图里没有框”的先验。训练 loss 会正常下降,验证 mAP 却不会涨。真正有效的半监督实现,这里应该返回伪标签的坐标数组,或者返回伪标签索引让损失函数去查找对应结果。
第三步检查 BN 的运行状态。很多改造方案把无标注图和标注图塞进同一个 model 前向,teacher 和 student 共享 BN 层。由于无标注与标注数据往往来自不同时段或不同机位,分布存在偏差,BN 的 running stats 会被无标注分支拉偏,导致有标注分支的精度也一起下降。
提示:如果 teacher 和 student 是同一个模型实例,伪标签生成阶段记得用
model.eval(),student 训练阶段用model.train(),不要让无标注前向更新 BN 统计量。
3. YOLOv7 半监督训练的数据流:目录、标注与首次启动命令
3.1 目录约定:未标注目录不能放空 txt
半监督训练的目录结构比普通训练多出无标注分支和伪标签目录。常规做法是单独维护一份伪标签输出目录,不建议直接把伪标签写回原始 label 目录,否则点数训练过程中原始标注被污染:
semi_visdrone/ ├── labeled/ │ ├── images/ │ └── labels/ # 每行: class_id cx cy w h(归一化) ├── unlabeled/ │ └── images/ └── work/ └── pseudo_labels/ # teacher 生成的伪标签 txt 全部放这里YOLO 格式的标注文件每一行是类别编号加归一化中心点坐标和宽高。这里有一个隐蔽但破坏性很大的问题:未标注图片对应的 txt 文件,到底是“不存在”还是“存在但为空”,必须区分清楚。原版 YOLOv7 的 Dataset 在找不到对应 txt 时会跳过这张图;空 txt 则会把图片当作纯负样本来训练。半监督改造里,如果给未标注图统一生成了空 txt,模型等于在几百张图上反复学习“没有任何目标”,最终表现是验证集 recall 暴跌。
可以用一段脚本在训练前检查伪标签目录,确保既有文件也有内容:
find unlabeled/images -name "*.jpg" | while read f; do b=$(basename "$f" .jpg) if [ ! -f "work/pseudo_labels/$b.txt" ]; then echo "missing: $b" elif [ ! -s "work/pseudo_labels/$b.txt" ]; then echo "empty: $b" fi done脚本分别检查伪标签文件不存在和文件大小为零两种情况,这两类样本都不应该进入训练。实际处理中,无标注图中确实可能有大量不含目标的背景图,如果希望它们作为负样本参与学习,也应该是显式标注为0类或加一个“背景”类别,而不是留空文件让模型去猜。
3.2 半监督入口配置与数据 YAML
源码包如果真的是按半监督学习改造过的,数据 YAML 通常会增加labeled和unlabeled两段字段。常见写法如下:
# data/visdrone_semi.yaml labeled: images: labeled/images labels: labeled/labels unlabeled: images: unlabeled/images pseudo: work/pseudo_labels nc: 5 names: ['car', 'truck', 'person', 'bicycle', 'drone']配置加载后,训练脚本需要把未标注图片的 label 根目录切换成work/pseudo_labels,而不是读取labeled/labels。这里有个容易踩的坑:原版 YOLOv7 从 YAML 读取train、val字段,半监督改造后如果源码没有兼容labeled/unlabeled段,会直接报参数缺失错误。如果遇到这种情况,需要找到dataloader初始化函数,把labeled段映射为train,同时保留unlabeled段单独初始化第二个 dataloader。
伪标签目录的路径建议用相对路径加上path:前缀,避免每次换机器都要改配置。如果源码里写的是绝对路径,训练前要确认os.path.exists能通过,否则会静默读取失败并回退到空目标。
3.3 常见训练命令参数与首次运行检查
拿到能跑的源码后,典型的启动命令长这样:
python train.py \ --data data/visdrone_semi.yaml \ --cfg cfg/training/yolov7.yaml \ --weights yolov7.pt \ --batch-size 16 \ --img 640 \ --epochs 200 \ --labeled-batch 8 \ --unlabeled-batch 8 \ --pseudo-conf 0.6 \ --pseudo-iou 0.6 \ --teacher-ema 0.999 \ --unsup-loss-weight 0.5逐项说明这些参数的含义。--labeled-batch和--unlabeled-batch共同构成总 batch size,1:1 是最常用的配比,保证梯度里始终有足够的有标注信号。显存不够时优先砍unlabeled-batch,但不要砍到 0,否则退化成纯监督训练。--pseudo-conf 0.6是伪标签保留的置信度下限,调低能提升召回但引入噪声,调高更干净但可能丢掉小目标。--pseudo-iou 0.6是伪标签生成阶段 NMS 的 IoU 阈值,用来去除重叠框,它不等于训练时 anchor assigner 的匹配阈值。--teacher-ema 0.999控制 teacher 权重的更新速度,数据量少时用 0.99,数据量大时用 0.999。--unsup-loss-weight 0.5是无标注分支损失的缩放系数,伪标签噪声大时降到 0.3,质量稳定后可升到 0.8。
运行前几轮要重点看日志里是否有两个独立的 loss 项。正常的半监督训练应该能看到类似labeled_loss和unsup_loss的输出;如果日志里只有一个loss,说明无标注分支没有进入损失计算,需要回到 2.3 节检查数据流。
4. 伪标签置信度、NMS 阈值与半监督训练退化排查
4.1 conf_thres 与 iou_thres 的取值逻辑
伪标签生成阶段有两个最容易被混淆的参数:conf_thres和iou_thres。前者过滤低置信度框,后者是伪标签生成阶段 NMS 的 IoU 阈值,用于去除重复框。这两个参数只影响“哪些框能成为伪标签”,不参与训练时的 anchor 正负样本分配。
阈值怎么定,取决于训练阶段。冷启动阶段 teacher 本身不强,低置信度的伪标签也有召回价值,阈值可以放低到 0.4 到 0.5;训练中后期模型已经能输出较稳定的框,再把阈值升到 0.6 或 0.7,换取更高精度的伪标签。一个不区分阶段的固定阈值,往往会在前期抹掉大量可学习的召回信号,又在后期引入不必要的噪声。
| 训练阶段 | conf_thres | iou_thres | 目的 |
|---|---|---|---|
| 冷启动(前 50 epoch) | 0.4 - 0.5 | 0.5 | 优先保证伪标签召回 |
| 中期(50 - 150 epoch) | 0.5 - 0.6 | 0.5 - 0.6 | 召回和精度平衡 |
| 后期(150 epoch 之后) | 0.6 - 0.7 | 0.6 | 保留高可信伪标签 |
三个阈值要特别区分:伪标签生成阶段的conf_thres、NMS 的iou_thres、以及训练阶段 anchor assigner 内部的 IoU 匹配阈值。前两个出现在源码包的推理或伪标签生成脚本里,第三个由 YOLOv7 的匹配逻辑决定,改动位置完全不同。如果伪标签生成脚本里没有 NMS 过程,iou_thres参数实际上不生效,很多源码包只是把参数列在那里。
4.2 动态阈值与 per-class 阈值
固定阈值对小目标和不常见类别很不友好。小目标本身置信度分布偏低,统一用 0.6 过滤会把大量可用的伪框丢掉;频繁出现的类别置信度普遍偏高,又会让伪标签集中在少数类上。基于半监督学习训练 YOLOv7,比较稳妥的做法是统计每个类别的平均置信度,再据此生成 per-class 阈值。
def adaptive_per_class_threshold(conf, cls_ids, epoch, base=0.6, warmup=50): if epoch < warmup: return np.full(int(cls_ids.max()) + 1, base) mean_all = conf.mean() per_cls = np.array([ conf[cls_ids == c].mean() for c in np.unique(cls_ids) ]) ratio = np.clip(per_cls / mean_all, 0.5, 1.5) return np.clip(base * ratio, 0.3, 0.85)这段代码先算出所有伪框的平均置信度,再按类别分别计算平均置信度与全局均值的比例。置信度低于全局均值的类别,阈值自动下调以保留更多伪框;置信度偏高的类别,阈值相应上调,过滤掉低质量框。np.clip把比例限制在 0.5 到 1.5,避免某个极端类别把阈值拉得过低或过高。下限设 0.3 是在召回与噪声之间做的基本保险,低于这个值的伪框连位置都不可信,没有学习价值。
4.3 训练退化排查:mAP 不涨时看四个信号
训练 loss 正常下降但验证 mAP 不动,是半监督训练最典型的退化症状。优先排查四个信号。
第一个信号是unsup_loss是否真的非零。如果日志里压根没有这项,或者它一直为 0,说明伪标签通路是断的,回到 2.3 节查 dataloader。第二个信号是伪标注的类别分布是否极度倾斜,可以用一行命令统计:
awk '{print $1}' work/pseudo_labels/*.txt | sort | uniq -c | sort -rn如果某个类别占比超过 80%,且真实标注里这个类本身不是多数类,说明 teacher 产生了严重的伪标签偏置。这种情况下模型会被伪标签往局部最优方向拽,mAP 涨不动是必然。
第三个信号是 labeled 与 unlabeled 的 batch 配比。如果--labeled-batch 8 --unlabeled-batch 4,无标注分支的梯度贡献占比只有三分之一,半监督收益会被稀释,可以尝试改回 1:1 或让 unlabeled 占更高比例。第四个信号是 BN 污染导致的隐式退化。
提示:一半以上的“半监督不涨点”问题,最终都落在伪标签未进损失函数和 BN 统计被污染这两项上。
5. 验证半监督训练是否生效:三个对照实验与一个最小改进
5.1 对照一:清零无标注分支损失
使用半监督源码包训练到 30 轮左右,把unsup-loss-weight临时设为 0,或者在前向计算时让无标注分支的 loss 乘以 0,继续训练 20 轮。如果 mAP 几乎不变化,说明无标注分支原本就没有贡献有效梯度;如果 mAP 明显下降,说明伪标签确实在参与学习。这个实验成本最低,也最能快速识别“假半监督”。
5.2 对照二:扰动伪标签坐标
另一个更细的验证方式,是在伪标签生成阶段对框的坐标做轻微扰动,比如把每个框的中心点随机偏移 5 到 10 个像素,或者在宽高方向上缩放 5%。刷新的目标是“只扰动训练时的目标真值,不扰动输入图像”,因为对图像做增强只会改变学习难度,不会暴露伪标签是否被真实使用。如果扰动后 mAP 不变,说明伪标签虽然是落盘了,但模型对它的依赖非常弱,大概率是阈值设置过严,伪标签大多没有匹配到预测框。
5.3 最小改进:per-class 动态阈值
如果基线已经稳定,不需要动结构,可以先做 per-class 阈值改造。把 4.2 节的adaptive_per_class_threshold集成到伪标签生成脚本中,在保存结果时按类别取阈值过滤。改进点在于:长尾类别用更低的置信度保留召回,高频类别用更高阈值控制噪声。这个改造通常能把伪标签的总数量压下来,但 mAP 反而上升,因为它让模型把注意力放在了原本被忽略的类别上。
最后注意,改完阈值必须重新生成伪标签再继续训练,不要只改pseudo-conf参数却继续用旧的 txt。源码包里如果训练脚本会自动生成伪标签,确认生成开关处于打开状态;如果不会,手动跑一次推理脚本重建work/pseudo_labels,否则新阈值不会生效。
本文还有配套的精品资源,点击获取