Training code review — 2026-09-13¶
Status on
dev(2026-09-18). This review was written againstmain's manualTrainer, which no longer exists — training runs on Lightning now. The fixes below were re-applied to the Lightning path, and two of them turned out to be live defects there too: the Mosaic box-size bug and the BatchNorm pollution during validation. Regression tests are intests/test_training_audit_fixes.py. What changed in the re-application:
- Fix 1 (validation NMS threshold) was already correct on
dev:YoloNASLightningModuledefaults toconf_threshold=0.001. The kill chain it describes —best.ptgated onmAP_50 > 0.0, early stopping on a stuck mAP — died with the manual callbacks; Lightning'sModelCheckpointmonitorsval/mAPwhen annotations are present andval/lossotherwise.- Fix 2 (validation letterbox) was still live in
cli/train_cmd.pyand is now fixed.- Gradient clipping, listed under "Smaller" below, is now on by default:
--grad-clip 10.0onyolonas train,grad_clipin the recipes.The "Still open" section at the end is still open, and still gates any published mAP number.
Reviewed at b027a79 on main, before deciding whether to rent a GPU for a from-scratch
COCO run. Scope: training/, data/, head/dfl.py, inference/postprocess.py,
cli/train_cmd.py.
Everything below is a defect that does not raise: training runs, loss decreases, and the result is quietly wrong — or the run dies for a reason that looks like a bad model.
Status: the blocker cluster and the Mosaic defect are fixed. tests/test_target_roundtrip.py
adds 12 tests: 7 of them fail on the pre-fix code (verified by reverting the fixes and
re-running); the other 5 pass either way and exist to pin the encode/decode conventions that the
round-trip proved correct, so a later refactor cannot quietly break them. Full suite: 176 passed,
1 skipped. The "still open" items at the end are not done.
Fixed¶
1. Validation NMS ran at the inference confidence threshold¶
trainer.py called postprocess(pred_bboxes, pred_scores) with no arguments, taking
conf_threshold=0.25 from inference/postprocess.py.
COCO mAP integrates the full precision/recall curve, which requires the low-confidence tail
(reference implementations use ~0.001 with max_det=100). At 0.25 the curve is truncated:
AP is understated and mAR badly so.
The failure that actually matters is from-scratch. For the first tens of epochs no
prediction exceeds 0.25, so postprocess returns empty for every image and mAP reads
exactly 0.0 — indistinguishable from a broken run. That arms a kill chain:
best.ptis gated onmAP_50 > self.best_map, andbest_mapstarts at 0.0 → never saved.EarlyStoppingCallbackwatchesval_metrics/mAPwith_best_map = -1.0. The first validation sets it to 0.0; every later 0.0 fails> 0.0 + min_delta→_waitincrements.configs/train.yamlshipsearly-stopping-patience: 5withval-freq: 10, so a healthy run stops at epoch 60 with no checkpoint.
Fix: _validate now passes conf_threshold=0.001, max_detections=300. The NMS IoU is left
at the existing 0.7 — it was not part of this defect, and the value super-gradients evaluates
YOLO-NAS at could not be verified here (super-gradients is not installed and no recipe is on
disk). Worth checking against the upstream recipe before publishing an mAP number. torchmetrics
caps detections at 100 via its own max_detection_thresholds.
Test: TestValidationPostprocessingKeepsTheTail — asserts a randomly-initialised model has
no box above 0.25, that the default threshold therefore yields zero detections, and that
_validate submits a non-empty detection set to the metric. Asserting on the mAP value would
not catch this: torchmetrics reports 0.0 for zero predictions, which looks like an untrained
model.
2. Validation cropped instead of letterboxing¶
cli/train_cmd.py had LetterboxResize commented out in both pipelines and used
CenterCrop(size=input_size) for validation.
Measured on the pinned albumentations 1.4.24:
CenterCrop(640) on a 375x500 image -> CropSizeError:
Crop size (height, width) exceeds image dimensions: (640, 640) vs (375, 500)
CenterCrop(640) on a 640x960 image -> 3 GT in, 1 GT out
(both edge objects deleted, only the centre one survives)
COCO val2017 images are mostly 640 or less on the long side, so at input_size=640 the first
validation pass raises and kills the run — after val-freq: 10 epochs of GPU time have
already been paid for. On a rented pod that is the whole experiment.
On larger images it does not raise, it just deletes edge objects from image and ground truth
alike, and the number stops being comparable to yolonas eval, which letterboxes.
Fix: val_transforms = Compose([LetterboxResize(target_size=input_size), Normalize()]),
matching eval_cmd.py and the inference path.
3. Validation ran the model in training mode¶
_validate called eval_model.train(), documented as safe because it sits inside
torch.no_grad(). That is true for gradients and false for buffers.
- BatchNorm in train mode normalises by batch statistics, so the reported mAP depends on the validation batch size and is not what the exported model produces.
- BatchNorm in train mode updates
running_mean/running_var. Buffer writes are not autograd operations, sono_graddoes not stop them. Every validation overwrote the EMA model's BN statistics with validation-set statistics — and_save_checkpointthen wrote those contaminated buffers intobest.ptandlast.pt.
The only reason for train() was that NDFLHeads.forward returns raw predictions (needed for
the validation loss) only when self.training.
Also a DDP deadlock waiting to happen. convert_sync_batchnorm runs before ModelEMA
deep-copies the model, so the EMA model holds SyncBatchNorm, and _validate runs on rank 0
only. SyncBN in train mode issues collectives while ranks 1..N are in the next epoch's forward
issuing their own. Invisible at num-gpus: 1; it appears on the first multi-GPU pod.
Fix: added NDFLHeads.return_raw_outputs. _validate now sets it, stays in eval()
throughout, and clears it afterwards. torch.jit.is_tracing() still short-circuits first, so
export is unaffected.
Test: TestValidationDoesNotMutateModel — snapshots every running_mean/running_var in
the model (and separately in the EMA model), runs _validate, and asserts bit-equality.
4. Mosaic emitted every box at half size¶
data/transforms.py. After the four-image placement loop, targets are normalized to the
2s × 2s canvas. The image is then cropped to s × s, and the centres were renormalized by 2 —
but the widths and heights were not:
targets[:, 1] = targets[:, 1] * 2 - crop_x / s # centre x — correct
targets[:, 2] = targets[:, 2] * 2 - crop_y / s # centre y — correct
# columns 3 and 4 (w, h) were never rescaled
Measured with four 640×640 source images each holding one 320 px box:
Exactly half. Concentric boxes at half width and half height have IoU 0.25 with the truth, so the model would be trained to under-predict extent on every mosaic'd sample.
Mosaic is not currently wired into train_cmd.py, so this was latent. It matters anyway:
mosaic is the core of the super-gradients YOLO-NAS recipe (Mosaic → RandomAffine → Mixup → HSV →
HFlip → PaddedRescale) and there is no competitive COCO number without it.
Fix: scale targets[:, 3:5] by 2 alongside the centres, and clip boxes to the crop window
(the old code kept a box whose centre was inside but never clipped its extent, so coordinates
could leave [0, 1] and reach the loss as GT outside the image).
Test: TestMosaicPreservesBoxSize. The one that catches the halving is
test_uncropped_box_keeps_exact_size: across 50 draws, at least one box must land fully inside
the crop and keep its exact pixel size. Its sibling test_box_pixel_size_is_unchanged only
guards against boxes growing, so the halving slips past it by design — noted in the test so
nobody later trusts the wrong one. test_boxes_stay_inside_the_frame covers the missing clip.
5. The ground truth had never been round-tripped through the decoder¶
There was no test taking GT boxes, encoding them as the loss encodes them, decoding them as the
head decodes them, and checking the result. tests/test_loss.py's test_forward_backward only
asserted the loss runs. Nothing covered the assigners, Mosaic, or Mixup.
The chain that needed proving:
| step | code |
|---|---|
| GT xywh normalized → pixel xyxy | loss.py forward |
| pixel xyxy → grid-unit ltrb | loss.py _bbox2dist, after / pos_stride |
| ltrb → DFL bin targets | loss.py DFLLoss.forward |
| logits → distances | dfl.py softmax · proj_conv |
| distances → pixel xyxy | dfl.py _batch_distance2bbox · stride_tensor |
Result: it round-trips. TestDFLRoundTrip builds the logits that exactly represent the DFL
targets, decodes them the way the head does, and gets IoU > 0.99 against the original boxes.
Channel layout is coord-major consistently on both sides, (l, t, r, b) ordering agrees, the
stride normalisation agrees, and the clamp to reg_max - 0.01 matches upstream.
This is the check that costs nothing and rules out the most expensive class of bug. It should have existed before any of the above was worth investigating.
Still open¶
Not fixed. Ordered by what would bite a COCO run first.
Train, validation and deployment see three different geometries¶
| stage | transform | scale distribution |
|---|---|---|
| train | RandomResizedCrop(scale=(0.05, 0.8), ratio=(0.75, 1.33)) then RandomAffine(scale=(0.5, 1.5)) |
5–80% of image area blown up to 640, aspect distorted; the full frame is never seen |
| val | LetterboxResize (now) |
full frame, aspect preserved |
| deploy | LetterboxResize |
full frame, aspect preserved |
Train and eval now disagree on object-size prior. This pipeline is a small custom-dataset recipe
— configs/train.yaml is num_classes: 3, input-size: 320 — not a COCO recipe. Rewriting it
toward the super-gradients shape is a prerequisite for a meaningful COCO number, and is a design
decision, not a bug fix.
Two smaller issues in the same pipeline:
RandomChannelSwapis not a channel swap. It wrapsA.ChannelShuffle(p=0.5), a uniform random permutation of all three channels (6 outcomes). The docstring claims "swap BGR channel order to RGB (and vice-versa)"; super-gradients'DetectionRGB2BGRreverses the order and nothing else. On COCO, colour is class-discriminative (stop sign, banana, fire hydrant, traffic light). Either rename it or make itimage[:, :, ::-1]with probabilityp.- No
min_visibility/min_areaon_BBOX_PARAMS.min_width=2/min_height=2let a box that has been 99% cropped away survive as a 2-pixel sliver still labelled "person".
iscrowd ground truth is dropped instead of ignored¶
data/coco.py skips crowd annotations entirely. Correct for training, wrong for evaluation:
COCO marks crowd regions ignore, not background. A detection that correctly fires inside a
crowd region has no GT to match and scores as a false positive, deflating AP by roughly a point.
torchmetrics.detection.MeanAveragePrecision accepts an iscrowd key in the target dicts for
exactly this.
Left undone because the clean version changes the target tensor from [N, 5] to [N, 6] and
ripples through collate.py and the loss. Worth doing before publishing any mAP number.
Subset and label-space traps¶
Both bite specifically on a reduced-size smoke run:
cat_id_to_labelis derived per-dataset from categories that appear in annotations, andtrain_dataset/val_datasetbuild it independently. On full COCO both yield the same 80 entries. On a 10k-image subset where a class has no instances in one split, every label after it shifts by one — train and val end up in different label spaces, silently.num_classesis then auto-detected from the train mapping. Force the canonical 80-class mapping when subsetting.- Subsets must stride, not slice.
self.idsis sorted and COCO image IDs cluster by collection order, soids[:10000]is a biased sample; useids[::12].
Smaller¶
- No gradient clipping anywhere. With AMP and from-scratch initialisation this is the usual
source of a run that NaNs in the first epoch.
clip_grad_norm_afterscaler.unscale_. best.ptis selected onmAP_50while early stopping watchesval_metrics/mAP. Two criteria; pick one (COCO convention ismAP).scheduler.step()runs every batch, including non-stepping ones.total_stepsis in batches too, so the cosine shape is self-consistent, butwarmup_steps=1000means 1000 batches — at the shippedgradient_accum: 16that is only 62 optimizer updates of warmup. This also produces thelr_scheduler.step() before optimizer.step()warning in the test suite.- A batch with zero GT contributes zero loss. When every image in the batch has no
annotations the loss returns
cls_logits.sum() * 0.0, skipping the negative classification term. Per-image empties are fine (the assigner produces all-zero targets and VFL supervises them). Only reachable withignore-empty: falseand rare at batch ≥ 8. cudnn.benchmarkis commented out. Fixed input size; free throughput on a rented GPU.TaskAlignedAssignerdoes not deduplicatemask_posbefore the soft-score scatter, so an anchor matched to two GTs of different classes gets a positive soft score on both. super-gradients resolves to one. Negligible in practice.cv2.imreadreturnsNonefor a missing or corrupt file and fails later with an unrelated error. Oneif image is None: raisesaves an hour on a rented pod.
What passes¶
Stated explicitly, because these are the checks that usually fail:
- Every head is supervised. All three
NDFLHeadsoutputs are concatenated and both the classification and regression branches feed the loss. No dead head, no untrained channels sitting inside the latency measurements. - Nothing is pixel-meaned. All three loss terms are normalized by
assigned_scores_sum, matching super-gradients. (This is an anchor-free set-prediction loss, not a sparse heatmap, so the usual sparse-target failure does not apply — and the normalisation is right anyway.) - No learned loss weighting, so nothing can chase a badly-scaled term. The weights are the fixed super-gradients values (1.0 / 2.5 / 0.5).
- Latency is already measured on target devices —
docs/benchmarks/latency_matrix.md. eval_sizeis never set by the model builders, so anchors are always regenerated from the actual feature maps: no stale-anchor bug under multi-scale or a changed input size.ModelEMA.updateis safe despite lacking@torch.no_grad():state_dict()returns detached tensors, so the in-place ops build no graph and leak no memory.- Optimizer param groups are correct — BatchNorm and bias excluded from weight decay, including frozen-parameter handling.
Prediction for the smoke run¶
Written down before spending anything, so the result is a diagnosis and not just a number.
With the fixes above and the augmentation pipeline left as-is, a from-scratch
yolo_nas_son a 10k-image strided COCO subset at 640, batch 16, AdamW 2e-4, ATSS warmup 4 epochs should showmAP_50 > 0.01by epoch 10 and a monotone rise through epoch 30. The absolute value will be poor — subset, no mosaic, mismatched train/val geometry — and that is expected. What is being tested is that the gradient signal reaches the boxes at all.If
mAP_50is still exactly 0 at epoch 20, the problem is not the budget. Runtests/test_target_roundtrip.pyfirst.
iou_lossshould fall below ~1.0 (GIoU loss of 1.0 means IoU ≈ 0) within the first epoch. If it plateaus near 2.0, boxes are being decoded at the wrong scale.
Budget, for reference¶
YOLO-NAS-S is ~33 GFLOPs forward at 640 → ~100 GFLOPs/image for training. 118k images × 300 epochs ≈ 3.5 EFLOP. At a realistic 20–40 TFLOPS achieved that is 25–50 A100-hours for the smallest variant: roughly $40–80 on a spot 4090-class GPU, 2–4× that for L.
A COCO-only run will not reach Deci's 47.5 mAP for S — that number depends on Objects365 pretraining, pseudo-labelling and distillation. Expect low 40s, by analogy with PP-YOLOE-S (43.0) and YOLOv8s (44.9).
COCO annotations are CC-BY 4.0, so from-scratch on COCO is licence-clean. Objects365 has its own restrictions; do not use it as pretraining.