)
1. 為什么我選擇從零手寫一遍YOLO檢測頭先說結論如果你已經能熟練調用現(xiàn)成的檢測框架跑通推理卻說不清一張 640×640 的圖進去之后為什么輸出是 8400 個候選框、為什么正樣本只有寥寥幾個、為什么 loss 里有一項幾乎不下降——那這篇內容就是寫給你的。我打算把一次完整的實驗過程攤開講用 PyTorch 把一個 YOLO 風格的單階段目標檢測模型從數據管道、骨干網絡、檢測頭、標簽分配、損失函數一路搭到訓練收斂和推理可視化。這里說的“YOLO”不是指某個具體版本的官方實現(xiàn)而是指單階段、Anchor-based、輸出為網格化預測張量這一整類檢測范式的核心骨架。選它做實驗的理由很實在相比兩階段方法它沒有區(qū)域建議網絡那一層整條鏈路是端到端可微的任何一個環(huán)節(jié)寫錯都會立刻體現(xiàn)在 loss 曲線上調試反饋非常直接相比純分類任務它又多出了坐標回歸、正負樣本分配、NMS 后處理這幾塊真正的“工程難點”正好是檢驗自己是否真的理解檢測模型的分水嶺。適合誰看有 PyTorch 基礎、寫過 Dataset 和訓練循環(huán)、能看懂卷積和矩陣運算但對“檢測模型到底怎么訓起來”還停留在調包階段的同學。全文的代碼都是可運行的片段級實現(xiàn)我刻意避開了過度封裝參數含義全部寫進注釋你可以直接抄進自己的實驗倉庫。至于我為什么堅持手寫而不是直接改開源倉庫——因為改別人的代碼時你永遠在猜作者為什么這么寫自己寫一遍錯一次就永遠記住了。2. 先想清楚輸入輸出約定再動手寫第一行代碼動手搭網絡之前我強制自己先在紙上寫清楚三件事輸入張量的形狀、輸出張量的形狀、以及兩者之間的語義映射。這一步偷懶后面 80% 的 bug 都源于此。很多人一上來就抄 backbone結果訓了兩天發(fā)現(xiàn) loss 不降回頭一查是 anchor 順序和輸出通道的排布對不上。這種錯誤不會報異常只會安靜地浪費你的時間。2.1 網格化預測張量的形狀為什么是 S×S×(B×(5C))先定義清楚符號。把輸入圖像統(tǒng)一縮放到 640×640網絡以 32 倍下采樣輸出特征圖于是得到 20×20 的網格。每個網格單元負責預測 B 個邊界框每個框帶 5 個量中心點相對網格的偏移 x、y寬高 w、h以及一個 objectness 置信度。再加 C 個類別的條件概率通道數就是 B×(5C)。以單尺度、B3、C80 的配置為例輸出張量形狀是 1×20×20×3×85。但我更推薦把三個尺度拼起來看stride 8 得到 80×80stride 16 得到 40×40stride 32 得到 20×20把它們全部展平并按網格順序拼接總數是 80×80×3 40×40×3 20×20×3 19200 4800 1200 25200 個預測。這個數字很重要它就是你后面標簽分配時要處理的候選總量也是你評估顯存占用的依據。補充說明一個容易混淆的點不同實現(xiàn)里“(5C)”和“(C5)”的排布順序不一樣有的是[x, y, w, h, obj, cls...]有的是[obj, cls..., x, y, w, h]。這純粹是工程約定但一旦定了就必須全鏈路統(tǒng)一從標簽編碼、損失計算到解碼推理任何一處反了都會導致模型學到一個完全錯位的映射。我的習慣是把它寫成常量并只在一處引用# 每個 anchor 的預測向量長度4 個坐標 1 個 objectness C 個類別 NUM_CLASSES 80 NOOBJ 4 1 NUM_CLASSES # 85 # 通道索引約定全局唯一定義禁止在別處硬編碼數字 IDX_XYWH slice(0, 4) IDX_OBJ 4 IDX_CLS slice(5, 5 NUM_CLASSES)這套 slice 常量看起來啰嗦但我在排查一次“類別分數全部趨同”的問題時就是靠全局替換 slice 才確認不是索引錯位導致的省了至少半天。2.2 坐標為什么要預測偏移量而不是絕對值這是我在第一次實驗里踩得最深的坑。如果直接讓網絡回歸框中心的絕對像素坐標訓到中期 loss 會卡在一個較高的平臺反復震蕩。原因是絕對坐標的取值范圍是 0~640而卷積輸出的數值分布天然集中在較小的區(qū)間網絡為了輸出大數值必須把權重推到很大的量級梯度尺度失衡。正解是讓網絡只預測相對網格左上角的偏移量并用 sigmoid 壓到 0~1解碼時再加回網格坐標寬高則預測相對于 anchor 尺度的對數縮放比。這樣每個輸出單元的回歸目標都落在 0~1 或接近 0 的小范圍內數值穩(wěn)定得多。解碼公式我固定成下面這樣寫在decode函數里訓練和推理共用同一份def decode(pred, anchors, stride, grid_size): # pred: [B, A, H, W, 5C]A 為該尺度 anchor 數 device pred.device gy, gx torch.meshgrid( torch.arange(grid_size, devicedevice), torch.arange(grid_size, devicedevice), indexingij) grid torch.stack((gx, gy), dim-1).float() # [H, W, 2] xy (torch.sigmoid(pred[..., 0:2]) grid) * stride # 解碼到輸入尺度 wh torch.exp(pred[..., 2:4]) * anchors.view(1, -1, 1, 1, 2) obj torch.sigmoid(pred[..., 4:5]) cls torch.sigmoid(pred[..., 5:]) return torch.cat([xy, wh, obj, cls], dim-1)注意meshgrid的indexing參數必須顯式寫成ij否則在較新版本的 PyTorch 里會產生警告而且在寬高不相等的特征圖上會靜默給出轉置后的網格坐標全錯卻不會報錯。我因為這個排查過一整晚。寬高用exp而不是 sigmoid是因為尺度縮放比理論上無上界用指數映射可以覆蓋任意大小目標同時讓梯度形式保持簡潔。3. 數據管道比模型更容易出錯的地方我現(xiàn)在的經驗是檢測任務的調試時間大概七成花在數據和標簽上。模型結構寫錯了頂多 shape 報錯數據寫錯了是“能跑、loss 也降、但 mAP 上不去”最難定位。所以這一節(jié)我把數據管道拆得細一些。3.1 圖像與框的同步增強最容易寫錯的是翻轉后的 x 坐標檢測的增強和分類最大的區(qū)別所有幾何變換必須同時作用于圖像和標注框而且框的表示形式在變換前后要來回轉換。我采用內部統(tǒng)一的中間格式(cx, cy, w, h)歸一化到 0~1因為縮放、翻轉在這個格式下計算最簡潔只在最終編碼時才轉成像素單位。水平翻轉的坑在于框中心 x 變換后應該是1 - cx但寬不變。而如果你順手把 x1、x2 也做了1 - x再重排結果雖然等價代碼里卻很容易忘記重排導致 x1 x2。我的做法是統(tǒng)一走中心點格式翻轉只改一個數def hflip(img, boxes): img img[:, ::-1, :].copy() boxes boxes.copy() boxes[:, 0] 1.0 - boxes[:, 0] # 只改中心 x寬高不變 return img, boxes另外隨機縮放和隨機裁剪之后那些面積被裁掉大半的框必須過濾。我用的閾值是保留可見面積占原面積 30% 以上、且裁剪后寬高都不小于 4 像素的框。閾值不是玄學是基于實測低于這個比例的目標在特征圖上已經不足一個感受野的中心區(qū)域強行保留只會給回歸分支注入噪聲標簽。3.2 一個 batch 內如何組織多尺度標簽多尺度輸出意味著標簽也要按尺度分別準備。我的Dataset.__getitem__返回的是原始圖像張量和未編碼的歸一化框 類別編碼放到collate_fn里批量做理由是編碼過程依賴 anchor 配置而這些配置是全局的放在 batch 級別做可以順便完成 padding 對齊。collate 階段的處理順序我固定為取 batch 內最大寬高做 letterbox 填充保持長寬比灰色 114 填充記錄每張圖的縮放比和填充偏移然后按三個尺度分別計算每個 GT 落在哪個網格、與哪幾個 anchor 的 IoU 超過閾值。這里的 DOIIoU 閾值我設成 0.5 起步但對小目標尺度放寬到 0.4因為小目標匹配到的 anchor 天然 IoU 偏低卡太死會導致正樣本極度稀疏。下面這個表格是我實測的 anchor 配置針對自建數據集以行人和車輛為主調過兩輪供參考輸出尺度stride特征圖anchor 尺寸像素適用目標小880×80(12,16), (19,24), (28,36)小于 32px 的目標中1640×40(38,52), (56,76), (82,110)32~96px 的目標大3220×20(116,150), (160,210), (240,320)大于 96px 的目標anchor 不是隨便給的我的做法是先對自己的訓練集跑一遍 K-means距離用 1-IoU把聚類中心按面積排序后三等分再微調讓相鄰尺度的比例接近 2 倍。直接用公開數據集的默認 anchor 到自己數據上召回率通常會掉幾個點。4. 骨干網絡與頸部結構的選擇邏輯結構選型這件事我的判斷標準只有一個在目標硬件的推理預算內保證足夠的下采樣倍數和足夠的跨尺度融合能力。檢測需要大感受野來定位大目標也需要高分辨率特征圖來定位小目標這兩者靠單一尺度無法同時滿足所以多尺度輸出是必需項不是可選優(yōu)化。4.1 從 Darknet 風格殘差塊講起的最小可用骨干我不建議一開始就上很復雜的結構。實驗初期我的骨干只用了“卷積 殘差塊堆疊”的最小組合每個 stage 由一個 stride2 的下采樣卷積開頭后接 N 個殘差單元通道數按 32→64→128→256→512 遞增。這套結構的好處是每一層的形狀都能手算出來出問題一眼能定位。殘差塊內部我用 1×1 降維 3×3 提特征的瓶頸結構并在 3×3 卷積后接 BatchNorm 和 LeakyReLU負斜率 0.1。選 LeakyReLU 而不是 ReLU 的原因很實際檢測任務里大量回歸目標的梯度在負半軸也有貢獻ReLU 在負區(qū)直接截斷會讓回歸收斂變慢。這個差異我在對比實驗中觀察到過換成 ReLU 后坐標 loss 的下降速度明顯滯后。class Bottleneck(nn.Module): def __init__(self, cin, cout): super().__init__() cmid cout // 2 self.conv1 nn.Conv2d(cin, cmid, 1, biasFalse) self.bn1 nn.BatchNorm2d(cmid) self.conv2 nn.Conv2d(cmid, cout, 3, padding1, biasFalse) self.bn2 nn.BatchNorm2d(cout) self.act nn.LeakyReLU(0.1, inplaceTrue) self.short (nn.Identity() if cin cout else nn.Conv2d(cin, cout, 1, biasFalse)) def forward(self, x): y self.act(self.bn1(self.conv1(x))) y self.bn2(self.conv2(y)) return self.act(y self.short(x))顯存方面提醒一句訓練時把torch.backends.cudnn.benchmark打開輸入尺寸固定的話能白撿一成左右的速度但如果你的 dataloader 會輸出不同尺寸的圖比如沒做統(tǒng)一 letterbox這個開關反而會導致反復重選算法速度更慢。4.2 頸部融合上采樣加拼接而不是直接相加三個尺度的特征要互相“看見”彼此的信息否則小尺度分支缺語義、大尺度分支缺細節(jié)。我采用的路徑是自頂向下從 stride 32 的特征圖開始1×1 卷積把通道壓到統(tǒng)一值我設 256然后雙線性上采樣 2 倍與 stride 16 的同通道特征沿通道維拼接再過一組 3×3 卷積融合重復一次到 stride 8。用拼接而不是相加是因為拼接保留了不同來源特征的獨立性讓后續(xù) 1×1 卷積自己去學如何加權相加則強制假設兩路特征語義對齊這個假設在淺層往往不成立。代價是通道數翻倍帶來的顯存開銷所以我在拼接后緊跟一個 1×1 卷積把通道壓回去實測能省下約 15% 的顯存而不掉精度。提示上采樣我用F.interpolate(..., modenearest)而不是轉置卷積。轉置卷積容易在特征圖上產生棋盤格偽影對小目標的定位有肉眼可見的負面影響而且參數更多。最近鄰插值雖然“粗糙”但在檢測任務里配合后續(xù) 3×3 卷積完全夠用。5. 檢測頭、標簽分配與損失函數整個實驗的核心我把這一節(jié)放在最中間因為這里是 YOLO 類模型真正區(qū)別于普通回歸網絡的地方。頭部結構本身很簡單幾個卷積難的是如何決定哪個預測該對哪個 GT 負責以及如何為不同質量的預測賦不同權重。5.1 檢測頭的三種輸出分支與解耦設計每個尺度上我接一個檢測頭結構是先 3×3 卷積把頸部輸出壓到中間通道我取 256然后分出三個并行的 1×1 卷積分支分別輸出坐標4 通道、objectness1 通道、類別C 通道。這就是所謂的解耦頭。為什么要解耦因為分類需要的是語義不變性同一個物體平移、縮放后類別不變回歸需要的是位置敏感性。讓同一組卷積核同時服務這兩個目標存在內在沖突。我在實驗中做過對照耦合頭訓到第 30 輪時類別 loss 和坐標 loss 會互相拉扯表現(xiàn)為其中一個下降另一個就抬頭換成解耦頭后兩條曲線都能單調下降。代價是參數量增加不多完全值得。單尺度檢測頭的實現(xiàn)如下注意輸出通道順序要和前面定義的常量嚴格一致class DetectHead(nn.Module): def __init__(self, cin, num_anchors, num_classes): super().__init__() self.stem nn.Sequential( nn.Conv2d(cin, 256, 3, padding1, biasFalse), nn.BatchNorm2d(256), nn.LeakyReLU(0.1, inplaceTrue)) self.reg nn.Conv2d(256, num_anchors * 4, 1) self.obj nn.Conv2d(256, num_anchors * 1, 1) self.cls nn.Conv2d(256, num_anchors * num_classes, 1) def forward(self, x): x self.stem(x) # 輸出統(tǒng)一調整為 [B, A, H, W, C] 便于后續(xù)按 anchor 維處理 b, _, h, w x.shape reg self.reg(x).view(b, -1, 4, h, w).permute(0, 1, 3, 4, 2) obj self.obj(x).view(b, -1, 1, h, w).permute(0, 1, 3, 4, 2) cls self.cls(x).view(b, -1, self.num_classes, h, w).permute(0, 1, 3, 4, 2) return reg, obj, cls坐標分支我不加任何激活原始輸出直接交給 loss配合前面說的 sigmoid/exp 在解碼時處理objectness 和類別分支同理sigmoid 在 loss 內部配合 BCEWithLogits 使用避免數值不穩(wěn)定。這是一個非常容易出錯的細節(jié)如果在頭部就套了 sigmoid再用 BCEWithLogits 就相當于做了兩次變換loss 會一直偏高且梯度異常。5.2 正負樣本分配我用的簡化版動態(tài)分配策略早期的 YOLO 只按“GT 中心落在哪個網格”來分配正樣本一個 GT 一個正樣本監(jiān)督信號太稀疏。我現(xiàn)在的做法是對每個 GT找出中心點落在其內部或附近的網格計算這些網格上所有 anchor 與該 GT 的 IoU凡是 IoU 超過正閾值的都作為正樣本IoU 低于負閾值的作為負樣本中間的忽略不計入 loss。關鍵在于引入中心先驗一個網格即使 IoU 夠高如果它的中心離 GT 中心太遠也不應該算正樣本否則會讓模型學到“從遠處預測物體”的壞習慣。我的過濾條件是中心距離落在 GT 寬高的一半范圍內。這個約束加上之后訓練初期 loss 的抖動明顯減小。分配過程我用一個 mask 張量實現(xiàn)形狀與輸出對齊1 表示正樣本、0 表示負樣本、-1 表示忽略def assign_targets(gt_boxes, anchors, strides, grid_sizes, pos_thr0.5, neg_thr0.4): # 返回每個尺度上的 mask 與回歸目標此處展示單尺度核心邏輯 masks, targets {}, {} for s, (anc, st, gs) in enumerate(zip(anchors, strides, grid_sizes)): # 展開該尺度所有預測框中心為網格中心尺寸為 anchor cx (torch.arange(gs).float() 0.5) * st cy (torch.arange(gs).float() 0.5) * st # ... 構造 [gs*gs*A, 4] 的候選框與每個 GT 算 IoU iou pairwise_iou(candidates, gt_boxes) # [N_cand, N_gt] best_iou, best_gt iou.max(dim1) mask torch.full_like(best_iou, -1.0) # 默認忽略 mask[best_iou neg_thr] 0.0 # 負樣本 mask[best_iou pos_thr] 1.0 # 正樣本候選 # 中心先驗過濾網格中心必須落在 GT 內部 mask[~inside_center(candidates, gt_boxes[best_gt])] -1.0 masks[s], targets[s] mask, build_reg_target(candidates, gt_boxes[best_gt]) return masks, targets代碼里pairwise_iou和build_reg_target是輔助函數分別算兩兩 IoU 和把 GT 編碼成相對偏移量。我在實現(xiàn)pairwise_iou時犯過一個經典錯誤直接用x1 cx - w/2這種形式計算但沒有對坐標做 clamp導致超出圖像邊界的框算出負寬度IoU 出現(xiàn)負值進而讓一批本該是負樣本的候選被誤判為忽略。修復方法是在算面積前統(tǒng)一clamp(min0)。5.3 三項損失的權重配比與不收斂的排查經驗損失由三部分組成坐標回歸 loss、objectness 的二元交叉熵、類別的二元交叉熵。坐標我用 CIoU 形式因為它同時考慮重疊面積、中心距離和長寬比收斂更快后兩項用帶 logits 的 BCE。權重配比是這套實驗里我最不愿意給“標準答案”的部分因為強依賴數據分布。我踩坑后穩(wěn)定下來的一組起點是坐標 7.5、objectness 1.0、類別 0.5。給坐標更高的權重是因為定位精度直接決定 mAP 的上限objectness 保持 1.0 是因為正負樣本極度不均衡如果權重給太高負樣本的 loss 會淹沒正樣本類別權重壓到 0.5 是因為在正樣本已經很少的情況下類別分支的梯度容易過大。另外必須處理正負樣本比例。實測下一個 batch 里正樣本可能只有幾十個而負樣本上萬。我用的策略是只對負樣本中 objectness 預測值最高的前 K 個計算 lossK 取正樣本數的 3 倍其余負樣本直接忽略。這一步聽起來粗暴但它比直接給負樣本乘個小權重更有效因為它聚焦在“模型最困惑的負樣本”上。判斷訓練是否正常我只看三個信號一是坐標 loss 應該在 10 輪內降到初始值的三成以下二是 objectness loss 的下降曲線要平滑如果出現(xiàn)周期性尖峰多半是某個 batch 的標簽編碼有問題三是正樣本數量均值應該穩(wěn)定在一個區(qū)間內如果隨 epoch 劇烈波動說明標簽分配的中心先驗在邊界目標上失效了。我曾遇到 objectness loss 每 5 個 epoch 準時跳一次的問題最后定位到是 dataloader 的 shuffle 與采樣器組合導致某個類別的樣本被集中投放改回標準隨機采樣就平了。6. 訓練循環(huán)、顯存控制與推理后處理網絡和數據都跑通之后剩下的工作是從“能訓”到“訓得動、訓得好”。這一節(jié)的每一條都是我在有限顯存條件下被逼出來的直接照做能省很多試錯時間。6.1 學習率調度、混合精度與梯度累積的組合我用 AdamW 起步初始學習率 1e-3 配 weight decay 5e-4前 3 個 epoch 做線性 warmup之后余弦退火到 1e-5。warmup 不是可有可無的檢測頭是隨機初始化的前幾百步梯度尺度很大直接上高學習率會把坐標分支推到一個 bad local minimum表現(xiàn)為坐標 loss 長期卡在高位不下來?;旌暇任矣胻orch.cuda.amp但loss 計算部分強制在 float32 下進行。原因是 objectness 的 BCE 在 fp16 下容易溢出尤其是當某個 batch 里出現(xiàn)極端難負樣本時。寫法上我用 autocast 包裹前向loss 前用float()轉換scaler torch.cuda.amp.GradScaler() for imgs, gts in loader: imgs imgs.cuda(non_blockingTrue) with torch.cuda.amp.autocast(dtypetorch.float16): reg, obj, cls model(imgs) # 關鍵損失計算退回到 fp32保證數值穩(wěn)定 loss compute_loss(reg.float(), obj.float(), cls.float(), gts) scaler.scale(loss).backward() scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm10.0) scaler.step(optimizer) scaler.update() optimizer.zero_grad(set_to_noneTrue)梯度裁剪的閾值我設 10.0實測下來只有當標簽編碼出現(xiàn)異常時才會觸發(fā)所以它其實充當了一個免費的異常探針如果你發(fā)現(xiàn)裁剪頻繁觸發(fā)別急著調大閾值先去查標簽。顯存不夠時用梯度累積每 4 個 micro-batch 做一次step等效 batch 翻 4 倍。這里有一個細節(jié)必須配對處理——BN 的統(tǒng)計量在小 batch 下會失真我改用SyncBatchNorm或者在凍結 BN 統(tǒng)計量的前提下累積否則等效 batch 放大了但 BN 看到的仍是小 batch精度對不上。6.2 NMS 的實現(xiàn)細節(jié)與置信度閾值的標定方法推理階段把三個尺度的解碼結果拼成一個[N, 5C]的大張量先按 objectness 過濾閾值 0.05 起步寧低勿高因為后續(xù) NMS 還會再篩再依次做按類別的 NMS。我的 NMS 實現(xiàn)里最容易寫錯的邊界是IoU 計算要用到裁剪后的坐標且不同類別的框不互相抑制。def nms(boxes, scores, iou_thr0.45): # boxes: [N, 4] 格式為 xywh坐標已還原到原圖尺度 x1, y1 boxes[:, 0] - boxes[:, 2] / 2, boxes[:, 1] - boxes[:, 3] / 2 x2, y2 boxes[:, 0] boxes[:, 2] / 2, boxes[:, 1] boxes[:, 3] / 2 areas (x2 - x1).clamp(min0) * (y2 - y1).clamp(min0) order scores.argsort(descendingTrue) keep [] while order.numel() 0: i order[0].item() keep.append(i) xx1 torch.clamp(x1[order[1:]], minx1[i]) yy1 torch.clamp(y1[order[1:]], miny1[i]) xx2 torch.clamp(x2[order[1:]], maxx2[i]) yy2 torch.clamp(y2[order[1:]], maxy2[i]) inter (xx2 - xx1).clamp(min0) * (yy2 - yy1).clamp(min0) iou inter / (areas[i] areas[order[1:]] - inter 1e-7) order order[1:][iou iou_thr] return keepIoU 閾值我最后定在 0.45。這個值不是拍腦袋密集場景比如人群設 0.5 以上會殘留大量重復框而設 0.3 又會把相鄰的兩個真實目標合并掉。我的標定方法是拿驗證集畫一張“置信度閾值-召回率”曲線找召回率曲線開始陡降前的拐點通常落在 0.25~0.35 之間而 NMS 的 IoU 閾值則固定 0.45兩者不要混在一起調否則你無法判斷性能變化歸因于哪一個。6.3 調試檢測模型時我常用的三個可視化手段第一是把標簽畫回原圖。訓練前必須做這一步我見過太多因為歸一化坐標和像素坐標混用導致框全部擠在左上角的案例而 loss 依然在降因為模型確實在學習這個錯誤分布。第二是把預測框在訓練中期畫出來哪怕框很亂只要它開始聚集在目標附近就說明坐標分支方向對了。第三是按尺度分別統(tǒng)計正樣本數如果某個尺度長期是 0說明 anchor 尺寸和你的數據分布完全錯配那個尺度的檢測頭永遠學不會。有一次我把這三個手段串起來排查了一個典型問題mAP 卡在 0.2 上不去可視化發(fā)現(xiàn)所有預測框都比真實框小一圈。原因是我在編碼時用了log(w_gt / w_anchor)解碼時卻用了exp(pred) * anchor的寬高順序搞反了把 w 對到了 h 的 anchor 上。這類錯誤不可能從 loss 數值看出來只能靠可視化。7. 幾個必須提前知道的邊界與經驗最后分享幾條我認為比代碼本身更值錢的經驗。第一不要在小數據集上追求多尺度。我最初在只有 1200 張圖的數據集上直接開三個尺度訓練結果最小尺度分支一直是零正樣本白白多算了 40% 的前向開銷。數據量小的時候先用單尺度stride 16把整條鏈路驗證通再逐步加尺度效率高得多。第二objectness 的分支比類別分支更容易出問題優(yōu)先調它。很多“mAP 上不去”的表象根因是 objectness 的校準不好——模型給了目標很高的類別分數但 objectness 很低NMS 前就被閾值濾掉了。檢查方法是單獨統(tǒng)計正樣本上的 objectness 均值正常情況下它應該隨訓練穩(wěn)步上升到 0.8 以上。第三關于驗證集的選擇我堅持按場景而非隨機劃分。隨機劃分會讓同一段連續(xù)視頻或被同一組采集條件下的圖片同時出現(xiàn)在訓練集和驗證集里驗證 mAP 虛高 5~10 個點是常有的事。按場景、按采集批次、甚至按時間劃分得到的數字才代表真實泛化能力。常見癥狀大概率根因快速驗證方式loss 完全不降標簽編碼與輸出通道順序不一致單 batch 過擬合測試看能否記住 4 張圖坐標 loss 平臺震蕩直接回歸絕對坐標未用相對偏移檢查解碼公式的縮放項某尺度無正樣本anchor 尺寸與數據分布錯配統(tǒng)計各尺度的正樣本數框普遍偏小或偏移anchor 寬高順序與編碼時不一致可視化預測框與 GT 疊加訓練后期 mAP 回落學習率未退火或正樣本過擬合打印每輪正樣本數與 lr我還想強調一個調試習慣用 4 張圖做單 batch 過擬合測試。把這個小實驗跑通loss 降到接近 0預測框和 GT 幾乎重合就等于證明了數據管道、編碼、損失、解碼這整條鏈路自洽。凡是鏈路級別的 bug都會讓這個測試失敗而鏈路一旦自洽剩下的問題都只是超參和容量問題排查范圍小得多。這個習慣幫我省掉的調試時間比我讀過的所有文檔加起來都多。