054、SimOTA 最优传输分配:cost matrix到dynamic k到supplier-demander 的匈牙利思想 054、SimOTA 最优传输分配cost matrix到dynamic k到supplier-demander 的匈牙利思想从一次mAP卡在0.45的调试说起去年夏天调YOLOX的时候有个场景让我印象特别深——COCO上mAP死活卡在0.45换backbone、调学习率、改数据增强能试的都试了就是上不去。最后打开tensorboard看正样本分配情况发现每个gt平均只匹配到1.2个正样本而正常应该是3-5个。问题出在SimOTA的dynamic k计算上cost matrix里分类cost和回归cost的权重配比不对导致suppliergt给demanderanchor分配的样本数严重不足。这个坑让我意识到SimOTA不是简单的“算个cost然后topk”它背后是一整套最优传输的分配逻辑。今天我们就从cost matrix的构建开始一步步拆解SimOTA的完整流程。Cost Matrix不只是分类和回归的加权和SimOTA的cost matrix由三部分组成分类cost、回归cost、以及一个中心先验cost。很多人只写前两个第三个才是YOLOX能稳定收敛的关键。分类cost用的是Focal Loss的变体不是简单的BCE。YOLOX源码里这样写# 注意这里用预测的obj_score乘上类别概率再算focal losscls_costF.binary_cross_entropy(cls_preds.sigmoid(),gt_labels_onehot,reductionnone).sum(-1)# [n_gt, n_anchors]这里有个细节——cls_preds要先过sigmoid因为YOLOX的head输出是logits。如果你直接拿softmax算cost会偏大导致dynamic k选出来的正样本太少。我踩过这个坑当时把sigmoid写成了softmaxmAP直接掉了3个点。回归cost用的是IoU loss的负对数形式# 别这样写直接算L1 loss# reg_cost F.l1_loss(bbox_preds, gt_bboxes, reductionnone).sum(-1)# 正确做法用iou_loss越小表示匹配越好ioubbox_iou(bbox_preds,gt_bboxes,xywhFalse)reg_cost-torch.log(iou1e-7)# 加小epsilon防止梯度爆炸中心先验cost是YOLOX的trick强制让gt只跟它附近的anchor匹配# 计算每个anchor中心点到gt中心的距离center_disttorch.cdist(anchor_centers,gt_centers,p2)# [n_anchors, n_gt]# 只保留半径内的anchor半径外的cost设为无穷大in_radiuscenter_distradius center_costtorch.where(in_radius.T,0.0,100000.0)# [n_gt, n_anchors]最终cost matrix是这三项的加权和权重分别是3.0、5.0、1.0。这个比例是YOLOX作者调出来的如果你改数据集建议先保持这个比例只调回归cost的权重。Dynamic K不是简单的topk是供需平衡Dynamic K的核心思想是每个gt应该分配多少个正样本不是固定的3个或5个而是根据这个gt周围anchor的cost分布动态决定。具体做法是对每个gt先算它跟所有anchor的cost然后取cost最小的前n_candidate个anchorYOLOX里n_candidate10对这些anchor的cost求和再取平均最后取整得到k值。# 这里踩过坑直接对全部anchor取topk会引入大量低质量anchor# 正确做法先筛选出候选anchorn_candidate10_,topk_idxtorch.topk(cost,kn_candidate,dim1,largestFalse)# [n_gt, n_candidate]# 取这些候选anchor的cost均值dynamic_kstorch.clamp((cost.gather(1,topk_idx).sum(dim1)/n_candidate).int(),min1# 每个gt至少分配1个正样本)这里有个容易忽略的点dynamic_ks要clamp到至少1。如果某个gt周围全是高cost的anchor比如小目标在图像边缘均值可能小于1这时候如果不clamp这个gt就完全没有正样本了梯度传不回去。Supplier-Demander的匈牙利思想SimOTA本质上是一个简化版的匈牙利算法。匈牙利算法解决的是“n个工人分配n个任务”的二分图匹配问题而SimOTA解决的是“m个gtsupplier分配k个anchordemander”的供需问题。区别在于匈牙利算法要求一对一匹配SimOTA允许一对多一个gt匹配多个anchor和多对一多个gt匹配同一个anchor但通过cost排序解决冲突。具体实现分三步构建供需矩阵cost matrix就是供需矩阵行是gtsupplier列是anchordemander值表示匹配代价。动态确定供给量每个gt的供给量就是dynamic_ks表示这个gt需要多少个anchor。解决冲突当多个gt都想匹配同一个anchor时取cost最小的那个gt。# 冲突解决对每个anchor只保留cost最小的那个gt的匹配# 这里用了一个trick对cost matrix按行排序然后取每个anchor的最小costmatched_gt_idxtorch.argmin(cost,dim0)# [n_anchors]matched_costcost[matched_gt_idx,torch.arange(n_anchors)]# 对每个gt只保留它dynamic_ks个最小cost的anchorforgt_idxinrange(n_gt):kdynamic_ks[gt_idx]# 找到这个gt对应的所有anchorgt_maskmatched_gt_idxgt_idx gt_anchor_idxtorch.where(gt_mask)[0]# 按cost排序取前k个iflen(gt_anchor_idx)k:_,sorted_idxtorch.sort(matched_cost[gt_anchor_idx])drop_idxgt_anchor_idx[sorted_idx[k:]]matched_gt_idx[drop_idx]-1# 丢弃多余的匹配这个实现有个问题如果两个gt的dynamic_ks之和超过了总anchor数会导致部分gt分配不到足够的anchor。YOLOX的解决方案是在计算dynamic_ks之前先对cost matrix做一次全局归一化让不同gt的cost具有可比性。实际调试中的几个关键点1. cost matrix的数值范围问题分类cost和回归cost的数值范围差异很大。分类cost通常在0-10之间回归cost在0-5之间中心先验cost是0或100000。如果不做归一化中心先验cost会主导整个匹配过程导致只有中心附近的anchor被选中。解决方案对分类cost和回归cost做min-max归一化让它们都在0-1之间然后再加权。2. dynamic_ks的上下界YOLOX默认dynamic_ks在1到10之间。如果你的数据集目标很小比如WiderFace建议把上界调大到15-20因为小目标需要更多正样本才能稳定收敛。3. 训练初期的不稳定性训练刚开始时模型预测不准cost matrix几乎随机dynamic_ks会偏大因为所有cost都很大均值也大。这会导致每个gt匹配到很多低质量anchor梯度噪声大。我的做法前5个epoch固定k3等模型有一定判别能力后再启用dynamic k。个人经验性建议SimOTA不是银弹。如果你的数据集目标分布很均匀比如每个图片都有3-5个中等大小的目标用最简单的topk分配k3效果可能更好。SimOTA的优势在于处理目标数量变化大的场景比如一张图有0个目标另一张有50个。另外如果你在复现YOLOX时发现mAP比论文低1-2个点90%的概率是SimOTA的实现有问题。建议打开tensorboard监控每个gt的平均正样本数正常应该在3-5之间。如果小于2检查dynamic_ks的计算如果大于8检查cost matrix的权重配比。最后说一句不要迷信“最优传输”这个高大上的名字。SimOTA本质上就是一个带动态k的贪心匹配跟匈牙利算法比它牺牲了全局最优性换来了计算效率。在目标检测这种实时性要求高的任务里这个trade-off是值得的。