YOLO v1到v5核心解析:损失函数、锚框与多尺度检测实践

📅 发布时间:2026/10/17 17:48:12
YOLO v1到v5核心解析:损失函数、锚框与多尺度检测实践
简介面向目标检测初学者和算法研究者的YOLO系列专题解读文档围绕YOLOv1至YOLOv5的完整演进脉络系统梳理算法核心原理、网络设计与实现细节。包内含1个docx文档约4MB以文字、公式结合代码注解形式呈现适合作为论文精读、源码复现与面试准备的伴读笔记。文档从YOLOv1的S×S网格划分、各网格预测B个边界框的置信度定义、xywh加置信度的五元组含义讲起逐步拆解损失函数中坐标误差与分类误差的权重处理并对比Fast YOLO的轻量卷积结构概述YOLOv3、YOLOv5在卷积层和特征融合上的改进思路及其对检测速度和精度的影响。实现层面文档对predict_boxes输出坐标的真实含义做了专门辨析说明中心点坐标是相对所属网格左上角的偏移、宽高是归一化后的开方帮助读者避开最常见的源码理解误区。已有2735人学习/下载读者可借助训练参数记录、网络结构对比和易错点提示快速打通从论文原理到工程实现的理解链路。1. 从 45fps 说起YOLO v1 ~ YOLO v5 论文解读这条路能省下几星期的调参时间某个项目里视频流小目标检测让我反复翻车模型在测试集上 mAP 不低一上真实视频就漏框调了一周数据增强没有任何起色。后来把 YOLO v1 ~ YOLO v5 的论文和实现细节重新读了一遍才明白这类问题并不玄学它早就在 v1 的 grid 设计、v2 的 anchor 引入和 v3 的多尺度 head 里埋好了。接下来的内容按这条时间线把每代模型到底改了什么、哪些细节能在开源代码里对上号以及新手最容易在哪些地方踩坑一条条拆开。读完你会清楚一件事现在的 YOLO 行为不是某个版本凭空造出来的而是四代设计决策叠出来的结果想改好自己手里的模型必须回到这些决策本身而不是在训练命令上瞎试。2. YOLO v1 的损失函数实现grid、bbox 置信度和三组系数怎样排布2.1 S×S grid 结构一次把检测拉回回归问题的大胆设计目标检测最直接的做法是滑动窗口加分类器速度慢窗口怎么滑全靠经验。YOLO v1 走的是另一条路把整张图切分成 S×S 个 grid cell每个 cell 同时输出 B 个候选框、一个物体置信度、还有 C 个类别概率。这等于告诉网络检测就是一次从图像到张量的回归前向一遍就把框直接吐出来不需要区域提议也没有二次分类。当时它在 Pascal VOC 上能跑出 45 fps比同期的两阶段方法实时性高一个量级代价是位置精度比较粗糙。理解 v1 只需要抓住一条主线它用整个 grid 结构换走了后续 anchor、多尺度特征的设计基础。没有 v1 的 S×S grid就没有 v2 在网格上放 anchor 这件事。输出张量的排布是这样的维度含义S × S把输入图划分成的网格数经典配置 S7B每个格子负责预测几个边框经典配置 B25每个边框包含 cx、cy、w、h、confidence 五个量C类别数Pascal VOC 上 C20网络输出形状是 S×S×(B×5C)前向结束后你拿到的不再是候选框列表而是一整张稠密特征。训练时要做的就是把每个 GT 框分配到对应 grid cell再转成同样形状的 target 张量去监督。2.2 损失函数三组系数为什么 coord5、noobj0.5、宽高要先开根号v1 损失可以拆成五个求和项中心点坐标损失、宽高开根号后的误差、有物体的置信度损失、没有物体的置信度损失、类别损失。论文里给了两个容易被人忽略的系数一个是坐标损失前面乘的 λ_coord5一个是无物体置信度损失前面的 λ_noobj0.5。为什么要这样调因为一张图大部分网格里根本没有物体如果无物体部分的置信度损失权重和正样本一样网络最优策略就是让所有格子置信度都预测为 0反正负样本多到足以淹没正样本的信号。这样模型训练早期就不会去尝试回忆目标训练会变得非常慢。λ_noobj0.5 就是在告诉网络没有物体的格子你也可以犯错但代价只有有物体格子的一半。坐标损失乘 5 则是相反的原因一张图里真正有物体的格子太少框回归的误差在总损失里占不到应有的分量如果不放大网络会优先把类别学对而忽略框的位置。宽高开根号处理是 v1 损失里一个很有意思的细节它让大框和小框的误差落在同一个尺度上。同样偏离 10 个像素对 50 像素宽的小框和 500 像素宽的大框语义完全不同先开根号再求误差小框的位置偏差会被放大大框的则被压缩相当于一种手工设计的尺度均衡。2.3 在 PyTorch 里把 v1 损失写成可跑代码理解完公式最好的验证方式就是自己写一次。我一般会这样落地import torch import torch.nn as nn class YOLOv1Loss(nn.Module): YOLO v1 原始损失S7、B2、C20 是论文里的经典配置 def __init__(self, S7, B2, num_classes20): super().__init__() self.S S self.B B self.C num_classes self.lambda_coord 5.0 self.lambda_noobj 0.5 def forward(self, pred, target): # pred: (N, S, S, B*5 C) # target: (N, S, S, B*5 C) 数据准备阶段已按同样格式排好 N pred.size(0) pred pred.view(N, self.S, self.S, self.B, 5 self.C) target target.view(N, self.S, self.S, self.B, 5 self.C) # 有目标的 box 槽位target[..., 4] 在严格实现里应填入 IoU box_mask (target[..., 4:5] 0.5).float() # 分类损失按格算一个格子任意一个 box 有目标就要算类别误差 cell_mask box_mask.max(dim-2, keepdimTrue).values # 中心点坐标误差 xy_loss self.lambda_coord * box_mask * ( (pred[..., 0:2] - target[..., 0:2]) ** 2 ).sum(dim-1, keepdimTrue) # 宽高先开根号再算平方误差 sqrt_pred torch.sqrt(pred[..., 2:4].clamp(min1e-6)) sqrt_target torch.sqrt(target[..., 2:4].clamp(min1e-6)) wh_loss self.lambda_coord * box_mask * ( (sqrt_pred - sqrt_target) ** 2 ).sum(dim-1, keepdimTrue) # 有目标格子的置信度损失 conf_loss_obj box_mask * ( (pred[..., 4:5] - target[..., 4:5]) ** 2 ) # 无目标格子的置信度损失乘 0.5 conf_loss_noobj self.lambda_noobj * (1 - box_mask) * ( pred[..., 4:5] ** 2 ) # 类别损失用平方误差实现分类学习 cls_loss cell_mask * ( (pred[..., 5:] - target[..., 5:]) ** 2 ).sum(dim-1, keepdimTrue) total ( xy_loss wh_loss conf_loss_obj conf_loss_noobj cls_loss ).sum() / N return total这里的核心技巧是 mask 匹配。box_mask标记出哪些 box 槽位真正负责物体cell_mask则把有物体的格子整体标记出来类别损失只算一次避免 B 个槽位重复计算把分类信号放大。严格按论文实现时target[..., 4]要先算出网络预测框和真实框的 IoU 再回填我上面的代码用 1 简化了训练时要换成 IoU 值。代码里 λ_coord 和 λ_noobj 就对应 2.2 里讲的两组系数。你如果自己复现 v1 训练会发现这个损失在训练初期非常不稳定原因是正样本太少、负样本太多即使做了 noobj 降权背景梯度仍然占主导。这也是后来 v2/v3 改用 anchor 和独立逻辑回归的重要原因。3. YOLO v2 和 v3 的锚框与多尺度 headv3 推理脚本一次讲清3.1 v2 带来的两个关键改动BatchNorm 和直接位置预测v2 论文里信息量很大但对后续实现影响最深的是两件事一是把 BatchNorm 加到卷积之后让训练收敛稳定了很多二是不再让网络直接回归全局坐标而是引入 anchor box 作为先验网络只预测相对 anchor 的偏移量。直接回归全局 cx、cy 有个问题不同尺寸物体在坐标空间的量纲差距太大网络学起来很难稳定。v2 改成先在每个 grid cell 上预置 k 个不同宽高的 anchor然后让网络输出四个偏移量 tx、ty、tw、th。中心点偏移经过 sigmoid 后加上网格坐标宽高则用 exp 放大 anchor 的基础尺寸。维度v1v2 之后位置回归直接输出归一化 cx、cy预测基于 anchor 的偏移量中心点范围全局 01sigmoid 限制在所在格内每个格子的框数固定 B2用聚类得到的 k 个 anchor输入尺寸448v2 用 416v3 常用 608这段演进的直接结果是anchor 尺寸选择开始影响模型性能。v2 里 k 值和小尺寸由聚类决定而不是人拍脑袋。后面所有 YOLO 版本都保留了这个机制区别只在于 anchor 怎么分配到不同尺度特征图。3.2 v3 的三个尺度13×13、26×26、52×52 各管什么v3 论文做的最大结构调整是把特征金字塔思想引入 YOLO。主干网络从不同深度抽出三张特征图小特征图语义强、分辨率低负责大目标大特征图分辨率高、细节多负责小目标。每个尺度上都配了三组 anchor总共九组。以常见的输入尺寸和 COCO 类别配置来看anchor 大致是这样分配的输出尺度下采样倍数anchor 尺寸目标类型13×1332(116,90)、(156,198)、(373,326)大目标26×2616(30,61)、(62,45)、(59,119)中目标52×528(10,13)、(16,30)、(33,23)小目标每个尺度预测的物体数是格子数乘 3。输入 416 时总输出框数为 13×13×3 26×26×3 52×52×3 10647 个候选框远远多于 v1 的 98 个。这也是为什么要做 NMS网络天生输出大量冗余框最后一步必须把重叠框合并掉。3.3 一个能跑通的最小 v3 推理代码从特征图到图像坐标v3 的推理和训练坐标解码是同一个函数理解它比理解训练更容易。下面这段代码把单个尺度的网络输出还原成图像坐标系下的 bboximport torch import torchvision.ops def decode_yolov3(feature, anchors, img_size640): feature: head 输出 (batch, (5 num_classes) * 3, H, W) anchors: 该头对应的三组 [w, h]单位是输入像素 batch, _, height, width feature.shape num_anchors len(anchors) # 先拆成 (batch, anchors, 5C, H, W)再换到最后两维 feature feature.view(batch, num_anchors, 5 80, height, width) feature feature.permute(0, 1, 3, 4, 2).contiguous() # 论文里的解码公式中心点过 sigmoid宽高做 exp xy torch.sigmoid(feature[..., 0:2]) wh torch.exp(feature[..., 2:4]) * torch.tensor( anchors, devicefeature.device ).view(1, num_anchors, 1, 1, 2) conf torch.sigmoid(feature[..., 4:5]) cls torch.sigmoid(feature[..., 5:]) # 建立网格坐标索引 grid_y, grid_x torch.meshgrid( torch.arange(height, devicefeature.device), torch.arange(width, devicefeature.device), indexingij, ) # 中心点 网格坐标 偏移再还原到输入图像尺寸 cx (xy[..., 0] grid_x.view(1, 1, height, width)) / width * img_size cy (xy[..., 1] grid_y.view(1, 1, height, width)) / height * img_size w wh[..., 0] * img_size h wh[..., 1] * img_size # 转成 x1, y1, x2, y2 boxes torch.stack( [cx - w / 2, cy - h / 2, cx w / 2, cy h / 2], dim-1 ) return boxes, conf, cls三个尺度的输出最后要拼在一张表里all_boxes, all_scores [], [] for feature, anchors in zip(features, anchors_list): boxes, conf, cls decode_yolov3(feature, anchors, img_size640) boxes boxes.view(-1, 4) cls cls.view(-1, 80) conf conf.view(-1) class_scores, class_ids cls.max(dim-1) scores conf.squeeze(-1) * class_scores all_boxes.append(boxes) all_scores.append(scores) all_boxes torch.cat(all_boxes) all_scores torch.cat(all_scores) mask all_scores 0.25 keep torchvision.ops.nms(all_boxes[mask], all_scores[mask], iou_threshold0.5)这里最容易出错的是把conf和cls分开用。最终用于阈值筛选的分数是物体置信度乘以类别概率不能只看类别概率否则空白背景上也会输出一类伪框。NMS 的 iou_threshold 一般取 0.40.5太低会压制相邻目标的框太高则输出大量重叠框。4. YOLOv4 的 Bag-of-Freebies 和 YOLOv5 的工程化调参从结构到配置 YAML4.1 v4 在结构上补了哪些CSPDarknet53、SPP、PANet 与 Mosaicv4 论文用一个词概括很准确Bag-of-Freebies也就是不改变模型主干只通过在训练集变换、正则化、损失函数上做加法来提升精度。真正落在结构上的改动并不大但每处都对训练稳定性有益。CSPDarknet53 是把基础残差块拆成两个分支一半走深路径一半走浅路径最后拼接好处是减少重复梯度计算降低显存占用。SPP 则是在某个特征层后做多个不同尺寸的池化再拼接扩大感受野让模型能看到更广的上下文。PANet 的作用是把高层语义信息向下融合的同时把浅层位置信息再往上传相当于双向特征融合。训练层面的 Mosaic 增强是 v4 最出名的工程 trick。它把四张图拼成一张图输入让模型在一个 batch 里看到更多不同尺度的物体也实际上提升了小目标训练样本的数量。要求不高时只开 Mosaic 就能观察到小目标召回改善。4.2 YOLOv5 的模型配置 YAMLdepth_multiple 和 width_multiple 怎样决定模型大小v5 经常被说成没有正式论文严格讲它确实更像一个工程实现库而不是新算法论文。所谓“论文解读”落到实际更多是读它的模型配置和训练策略。模型配置的核心是一份 YAML里面用数字控制网络深度和宽度# 一个典型的 v5 风格模型配置 nc: 80 depth_multiple: 0.33 # 模块内部重复层数的缩放系数 width_multiple: 0.50 # 每层输出通道的缩放系数 anchors: - [10,13, 16,30, 33,23] # P3 小目标 - [30,61, 62,45, 59,119] # P4 中目标 - [116,90, 156,198, 373,326] # P5 大目标 backbone: # from, number, module, args - [-1, 1, Conv, [64, 6, 2, 2]] - [-1, 1, Conv, [128, 3, 2]] - [-1, 3, C3, [128]]depth_multiple 控制每个 C3 模块里重复残差结构的数量width_multiple 控制每一层卷积的输出通道数。通道数被缩小后特征图的宽度和计算量会同时下降。不同规模模型基本就是这几组系数的组合模型规模depth_multiplewidth_multiple相对计算量n/s 小模型0.330.50低m 中模型0.670.75中l 大模型1.01.0高x 超大模型1.331.25最高这份 YAML 里的 anchors 顺序不能随便改它必须和 backbone 输出的三个特征尺度对应。如果把 P3 的锚框误放在 P5 层训练时小目标会被强行匹配到低分辨率特征图召回直接崩盘。训练脚本一般会要求 anchor 按尺度从细到粗排列顺序错了很难通过报错发现。4.3 把 v5 训练起来的最小命令数据配置、batch、学习率和 anchors 自适应准备数据时数据集 YAML 至少要包含训练集路径、验证集路径、类别数和类别名。常见约定是train: ./datasets/mydata/images/train val: ./datasets/mydata/images/val nc: 3 names: [person, car, cat]训练命令保持最小可跑的状态我一般会这样写python train.py \ --data datasets/mydata.yaml \ --weights yolov5s.pt \ --img 640 \ --batch-size 16 \ --epochs 100 \ --device 0batch size 和分辨率是最关键的两个参数。显存不够时优先降 batch size不要轻易把 640 改成 320因为训练分辨率会直接影响 anchor 匹配和目标尺度。学习率一般由脚本自动从预热阶段开始如果 loss 在训练后期明显震荡通常是 batch size 变大后学习率没有跟着调。v5 系模型通常带有 autoanchor 机制训练前根据当前数据集重新聚类 anchor 尺寸再输出一组更匹配的锚框。换数据集时不要直接跳过这一步尤其是目标尺度跟预设数据集差别大的场景。如果训练日志里出现 “anchors: 0.0” 之类异常多半是数据集中目标框没有被正确读取先修数据而不是去调网络参数。5. 从 v1 到 v5 的常见问题五个案例对照排查让你少踩两个星期坑5.1 现象加载预训练权重后模型直接报维度错误原因很直接类别数对不上。v5 的检测头输出通道由类别数决定80 类权重加载到 3 类数据集时head 层权重形状必然不一致有时报错在 backbone 最后几层。解决方式是训练脚本里分开加载backbone 复用预训练权重head 重新初始化。现象RuntimeError: size mismatch for model.24... 原因预训练权重中的 255 通道输出和自定义数据集的 3×(53) 不匹配 解决把 --weights 换成不带 head 的版本或者在加载时过滤 head 层这个坑对任何从 v1 之后带 anchor 的版本都适用。v1 因为直接输出分类概率类别数变化时同样会炸所以换数据集第一件事就是确认类别数是否同步到配置、数据 YAML 和权重三处。5.2 现象模型能检测到大目标小目标几乎全漏常见的尴尬局面是 mAP 看起来还行但实际视频里小目标一个都抓不到。先别怀疑增强策略去看 anchor。预训练模型自带的九组锚框是按 COCO 尺度聚类的如果项目里监控摄像头画面中目标普遍很小COCO 锚框的最小一组可能都比目标大好几倍匹配阶段直接失效。解决方法是打开 autoanchor 或手动用聚类脚本重算锚框。聚类不是跑一遍就结束要看每个尺度上聚出的宽高是否落在对应特征图的感受野范围内。小目标集中时聚类结果通常会把 P3 层的最小锚框压到几个像素级别比如从 (10,13) 变成 (4,6)这是正常现象。5.3 现象训练用 416、推理用 640小目标漏检反而更严重输入分辨率变大理论上小目标应该更容易被看见但实际可能更糟。原因是训练时锚框是按 416 下的目标尺度匹配的模型已经学会了在该尺度下的目标分布推理喂进 640 后目标在图像里的像素大小变了anchor 匹配逻辑却没有重新适应导致部分小目标落到错误的特征层级。解决方式有两种一是训练时开启多尺度训练让 batch 的输入在 320640 之间随机抖动模型不会过度依赖单一尺度二是推理前把 anchors 按输入尺寸做等比缩放。v5 的推理脚本通常会自动处理锚框缩放但如果是自己写的自定义推理要确认这一步没丢。5.4 现象置信度调到 0.25 还是输出几十个重叠框这通常不是模型问题而是 NMS 参数和候选框生成策略不匹配。YOLO 输出上万候选框全靠最后一步 NMS 把重叠框合并。如果把置信度阈值压得很低比如 0.001NMS 的输入框数量暴增容易超过默认算法的处理上限结果就是大量低质量框保留下来。解决方式是先按类做 NMS再做跨类去除。同一目标被多个类同时激活时跨类 NMS 能去掉重复框。iou_threshold 也要按任务调密集人群场景可以放宽到 0.6稀疏的大目标场景用 0.4 更干净。v1 时代的 NMS 其实没有这些区分因为候选框少得多这正是后来模型候选框变多带来的新坑。5.5 现象loss 下降很快但验证集框的位置乱跳loss 下降不一定代表模型学好了特别是自己改损失函数时很容易出现这种假象。把 v1 的 λ_coord 直接搬进 v5 训练坐标损失可能会被放大几倍模型为了压 loss 会把框缩成很小的区域因为这种预测能稳定拿到低误差。解决方式是先区分“训练 loss 下降”和“验证指标上升”是两件事。检查训练曲线里的 box_loss、cls_loss、obj_loss 三个分量如果 box_loss 出现锯齿状抖动优先恢复默认权重再去调整 IoU loss 的 alpha 参数。调任何损失权重前先跑一次默认配置作为基线没有基线的调参都是玄学。6. 验证技巧用 v1 的召回率判断法拆开看分类、定位和置信度读 v1 到 v5 最大的收获不是记住某个公式而是学会一套诊断模型的方法。v1 时代大家最看重的是在低置信度下模型还记得多少目标而不是只看最终 mAP。现在的评测体系里召回率被 mAP 盖住了导致很多人只调阈值不查根因。我拿到一个新模型会先做这样一件事把置信度阈值分别设成 0.001、0.1、0.25、0.5逐步观察检测结果和真实框的匹配数量for thr in [0.001, 0.1, 0.25, 0.5]: detections detector(images, conf_thresthr) recall compute_recall(detections, gt_boxes) precision compute_precision(detections, gt_boxes) print(f{thr:.3f} recall{recall:.3f} precision{precision:.3f})0.001 下的召回率可以近似理解为模型“最多能想起多少目标”反映的是特征表达和 anchor 匹配的极限。如果 0.001 下召回也只有 60%那问题不在阈值或 NMS而在训练数据、锚框或模型容量如果 0.001 召回很高但 0.25 下掉得厉害说明模型对目标置信度本身就不自信要回去看类别不平衡或数据标注噪声。另外一个实用的习惯是分目标尺寸看指标。小目标对位置误差极其敏感单个像素的标注偏移都会放大到好几个 box 面积的比例。v1 的宽高开根号处理已经暗示了这一点后来的版本即使换了损失函数小目标的定位问题也从来没有消失。所以验证时建议把真实目标按面积分成大、中、小三档分别统计 recall0.5 和 recall0.75而不是只报一个整体 mAP。我现在拿到任何 YOLO 系列模型测试的顺序永远是先跑最低阈值召回再按目标尺寸分组统计最后才调 NMS 和置信度阈值。这个顺序帮我避开了很多假装调参、实际无效的坑也是读 v1~v5 论文留下来的最有用的习惯希望帮到你。本文还有配套的精品资源点击获取