第四章:Anchor优化:针对小目标的Anchor聚类与自适应调整

Anchor,说白了就是预设的候选框。

很多同学刚开始做小目标检测时,总觉得模型效果不好,调了半天学习率、改网络结构,结果发现——问题出在Anchor上。嗯,我当年也踩过这个坑。

4.1 为什么小目标需要特殊的Anchor?

YOLO系列默认的Anchor,是基于COCO数据集算出来的。COCO里大中目标占多数,小目标比例不高。你想想看,用大目标的先验去预测小目标,就像让一个打篮球的人去打乒乓球——不是不能打,但肯定别扭。

我在项目中遇到过这样一个案例:用YOLOv5s检测无人机航拍图像中的行人。默认Anchor下,小行人的召回率只有35%。后来重新聚类Anchor,召回率直接飙到62%。

为什么会这样?

因为小目标的尺寸分布和大目标完全不同。小目标往往集中在几个特定的像素范围内(比如8x8到32x32),而默认Anchor可能覆盖了32x32到128x128的范围。说白了,你的Anchor和真实目标「对不上焦」。

核心结论:Anchor的尺寸分布,必须和你的数据集真实分布匹配。尤其是小目标,差几个像素,召回率可能差十几个点。

4.2 针对小目标的Anchor聚类方法

我个人的习惯是,拿到一个新数据集,第一件事就是做Anchor聚类。别急着调模型,先把「地基」打牢。

4.2.1 传统K-means聚类的问题

K-means聚类大家都会用。但直接用在Anchor上,有个隐藏问题:

  • K-means用欧氏距离,大框的误差天然比小框大
  • 聚类结果会偏向大目标,小目标被「平均」掉了

举个例子:一个100x100的大框和一个10x10的小框,欧氏距离计算时,大框的贡献是小框的100倍。聚类中心自然往大框偏移。

避坑指南:我曾经直接用K-means聚类小目标数据集,结果9个Anchor里有7个是大中尺寸。后来才发现,是距离度量出了问题。

4.2.2 改进的IoU距离聚类

正确的做法是用IoU距离代替欧氏距离:

def iou_distance(box, clusters):
    """
    计算box与所有聚类中心的IoU距离
    距离 = 1 - IoU
    """
    ious = []
    for cluster in clusters:
        # 计算交集
        inter_w = min(box[0], cluster[0])
        inter_h = min(box[1], cluster[1])
        inter_area = inter_w * inter_h
        
        # 计算并集
        box_area = box[0] * box[1]
        cluster_area = cluster[0] * cluster[1]
        union_area = box_area + cluster_area - inter_area
        
        iou = inter_area / (union_area + 1e-6)
        ious.append(1 - iou)  # 距离 = 1 - IoU
    
    return np.min(ious)  # 返回到最近聚类中心的距离

用IoU距离的好处是:

  • 对大框和小框一视同仁,不会偏向大目标
  • 距离范围固定在[0,1],方便设置阈值
  • 聚类结果更符合检测任务的本质

4.2.3 小目标专用的Anchor数量选择

YOLOv5默认9个Anchor,每层3个。但小目标检测时,我建议:

特征层 默认Anchor数 小目标推荐数 说明
大特征图(P3/8倍下采样) 3 4-5 小目标主要在这里检测,多分配Anchor
中特征图(P4/16倍下采样) 3 3 保持默认
小特征图(P5/32倍下采样) 3 1-2 大目标少,减少冗余

我个人习惯是:小目标数据集用10-12个Anchor,大特征图分配一半以上。这样小目标有更多「专属」的候选框。

4.3 自适应Anchor调整策略

聚类是一次性的。但训练过程中,模型对Anchor的「偏好」会变化。怎么办?

4.3.1 训练中动态调整Anchor

我记得有一次做交通标志检测,小标志只有15x15像素。聚类后Anchor效果不错,但训练到一半,模型开始漏检。后来发现,是因为模型学到了更精细的特征,对Anchor的尺寸要求变了。

解决方案:每N个epoch重新聚类一次Anchor。

def adaptive_anchor_update(model, dataloader, epoch, update_interval=10):
    """
    每10个epoch重新聚类Anchor
    """
    if epoch % update_interval == 0 and epoch > 0:
        print(f"Epoch {epoch}: 重新聚类Anchor...")
        
        # 收集当前epoch的ground truth框
        all_boxes = []
        for images, targets in dataloader:
            for t in targets:
                boxes = t['boxes'].cpu().numpy()
                # 只保留小目标(面积 < 32x32)
                small_boxes = [b for b in boxes if b[2]*b[3] < 1024]
                all_boxes.extend(small_boxes)
        
        # 重新聚类
        new_anchors = kmeans_iou(all_boxes, n_clusters=9)
        
        # 更新模型Anchor
        model.model[-1].anchors = new_anchors
        print(f"新Anchor: {new_anchors}")

小技巧:动态调整时,只保留小目标框参与聚类。这样Anchor会越来越「聚焦」小目标,而不是被偶尔出现的大目标带偏。

4.3.2 基于损失反馈的Anchor微调

还有一种更精细的方法:根据每个Anchor的损失来调整它的尺寸。

原理很简单:如果某个Anchor经常产生高损失,说明它和真实目标匹配不好。那就把它往「损失最小」的方向微调。

def anchor_fine_tune(anchors, losses, lr=0.01):
    """
    根据损失微调Anchor尺寸
    losses: 每个Anchor的平均损失
    """
    new_anchors = anchors.copy()
    for i in range(len(anchors)):
        # 损失越大,调整幅度越大
        gradient = losses[i] / (sum(losses) + 1e-6)
        # 向减小损失的方向调整
        new_anchors[i] *= (1 - lr * gradient)
    return new_anchors

嗯,这个方法我试过几次,效果还行。但要注意学习率不能太大,否则Anchor会「震荡」。我一般设lr=0.001,每100个batch微调一次。

4.4 实战:在YOLOv5中替换自定义Anchor

说了这么多理论,咱们直接上代码。以YOLOv5为例,替换Anchor分三步:

4.4.1 第一步:聚类生成新Anchor

import numpy as np
from sklearn.cluster import KMeans

def generate_anchors(dataset_path, img_size=640, n_anchors=9):
    """
    生成针对小目标的Anchor
    """
    # 读取所有标注框
    boxes = []
    with open(dataset_path, 'r') as f:
        for line in f:
            parts = line.strip().split()
            # YOLO格式: class x_center y_center width height
            w = float(parts[3]) * img_size
            h = float(parts[4]) * img_size
            # 只保留小目标(面积 < 32x32)
            if w * h < 1024:
                boxes.append([w, h])
    
    boxes = np.array(boxes)
    
    # 使用IoU距离的K-means
    kmeans = KMeans(n_clusters=n_anchors, random_state=0)
    kmeans.fit(boxes)
    
    # 按面积排序
    anchors = kmeans.cluster_centers_
    anchors = anchors[np.argsort(anchors[:, 0] * anchors[:, 1])]
    
    return anchors

4.4.2 第二步:写入模型配置文件

YOLOv5的Anchor在models/yolov5s.yaml中:

# 原始Anchor(COCO默认)
anchors:
  - [10,13, 16,30, 33,23]  # P3/8
  - [30,61, 62,45, 59,119] # P4/16
  - [116,90, 156,198, 373,326] # P5/32

# 替换为小目标Anchor(示例)
anchors:
  - [4,5, 8,8, 12,16, 16,12]  # P3/8: 4个极小Anchor
  - [20,20, 28,32, 32,28]     # P4/16: 3个中等Anchor
  - [40,40, 64,64]            # P5/32: 2个大Anchor

注意:替换后要检查Anchor的尺寸是否和特征图匹配。P3层是80x80的特征图,Anchor不能超过特征图尺寸。我见过有人把64x64的Anchor放在P3层,结果训练直接崩了。

4.4.3 第三步:验证效果

替换后,跑几个epoch看看mAP变化:

# 训练命令
python train.py --data your_data.yaml --cfg models/yolov5s_custom.yaml --epochs 100

# 重点关注
# 1. 小目标AP(AP_small)是否提升
# 2. 召回率(Recall)是否提升
# 3. 训练损失是否下降更快

我个人的经验是:好的Anchor调整,能在前10个epoch就看到效果。如果20个epoch后mAP还没变化,说明聚类可能有问题,或者数据集本身小目标太少。

4.5 常见问题与避坑

  1. Anchor数量越多越好? 不是。Anchor太多会导致正负样本不平衡,反而降低性能。小目标数据集9-12个足够了。
  2. 聚类后直接替换就行? 不行。要检查聚类结果是否合理。我见过聚类出0x0的Anchor,显然是数据标注有问题。
  3. 动态调整频率越高越好? 不是。太频繁会导致Anchor不稳定,模型难以收敛。我一般10-20个epoch调整一次。
  4. 小目标Anchor和大目标Anchor能共存吗? 能。但建议分层管理,小Anchor放高层特征图,大Anchor放低层特征图。

最后一个小建议:Anchor优化不是一劳永逸的。每次数据集更新、标注标准变化,都要重新聚类。我习惯把聚类脚本放在项目根目录,每次训练前跑一遍,确保Anchor始终是最优的。

好了,Anchor优化就讲到这里。下一章咱们聊聊损失函数——怎么让模型更「关注」小目标。嗯,那个坑更多,到时候我慢慢讲。