feat(multilabel): noise-aware losses with an optional per-tag prior - #4
feat(multilabel): noise-aware losses with an optional per-tag prior#4narugo1992 wants to merge 4 commits into
Conversation
dbv4 labels are high-precision and low-recall, and the omission rate varies
enormously between tags -- a human audit of 150 images puts `1girl`-class tags
near zero and composition tags around three quarters. Plain BCE therefore feeds
the model a large, systematic, tag-dependent stream of false negatives.
Adds `animetimm/multilabel/losses.py` with three losses behind `--loss`, which
defaults to `bce` so the existing training path is unchanged:
bce an exact reproduction of BCEWithLogitsLoss(reduction='none')
asl Asymmetric Loss (Ridnik et al., ICCV'21)
pasl Partial-ASL with class-aware selective treatment, adapted from
Ben-Baruch et al., CVPR'22
All three return an unreduced (B, C) tensor and accept soft targets, so mixup
and the existing per-tag weighting keep working.
The per-tag negative focusing parameter can be driven by a label-reliability
column, resolved from either entry point: `--loss-prior-file` for an external
parquet, or `--loss-prior-column` for a column carried by the dataset's own
tags parquet. Neither present means a flat gamma, so this stays opt-in.
`metrics.py` also unifies the F-measure parameter on beta. `f1score` squared it
while `_curves` did not, and a test pinned that disagreement as a quirk; since
this is the first non-unit use of it, both now mean beta and `alpha` remains as
an alias. Per-tag beta vectors are accepted.
Ablation harness in tools/, 67 new tests, 130 passing. Design doc and measured
results in docs/superpowers/specs/.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… results The in-run grid is 0.3/0.4/0.5 and both F1s peak outside it in opposite directions -- micro at 0.20 for BCE and 0.70-0.75 for the ASL family. Reading micro at macro's threshold makes asl_matched look like it collapsed (0.416 vs its real 0.515 at 0.725), so the results table now carries both thresholds. threshold_curve_probe.py also rules out the bf16-vs-fp32 suspicion: the in-run eval scores the prepared module under autocast while posthoc_eval is fp32, but on a 0.025 grid every micro/macro difference is within 0.003. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…inding Per-tag gamma replicates and strengthens (+0.0242 -> +0.0312 on omission recovery). Constant-gamma ASL vs BCE on the same contrast goes from +0.0231 significant to -0.0011 not significant, and is significantly worse than BCE on the pure-disagreement contrast -- asymmetry buys convergence speed on rare positives, not noise robustness. ASL's dbv4 macro-F1 advantage is real and does not narrow with budget, so it still ships, just not as a noise measure. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
The 12,000-step confirmation run finished and it changes the case for this PR, so the summary above needs amending. Details and audit data in #3; the short version: What still stands. Per-tag gamma, against the same-mean constant-gamma control, on the 86 human-confirmed omissions vs 606 confirmed absences: +0.0312 AUC [+0.0138, +0.0501] at 12,000 steps, up from +0.0242 [+0.0079, +0.0430] at 5,000. Replicated twice, effect grew with budget. What does not. My "ASL over BCE is a large, three-way-consistent win" was two claims fused. The dbv4-facing half is real and does not narrow with budget (macro-F1 gap 0.1405 → 0.1465; 3.8× in the rarest frequency decile). The human-ground-truth half is not: on omission recovery Consequence for this PR. The code is unchanged and the tests are unchanged; what changes is what the docs claim. Both |
… one default The shared default of 2.0 is the *reliable end* of pasl's per-tag interpolation. Passing --loss asl without an explicit value therefore trained a much weaker asymmetry than the 4.0 that was ablated, silently -- the run looks fine, it is just a different experiment. Default is now None and resolves to 4.0 for asl, 2.0 for pasl; an explicit value still wins. 3 tests, suite is 133 passing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Implements the losses described in #3, behind a flag that defaults to the current behaviour.
What this adds
animetimm/multilabel/losses.py— three losses selected by--loss:bce— an exact reproduction ofBCEWithLogitsLoss(reduction='none'); the default, so the existing training path is byte-for-byte unchanged unless asked otherwiseasl— Asymmetric Loss (Ridnik et al., ICCV'21)pasl— Partial-ASL with class-aware selective treatment, adapted from Ben-Baruch et al., CVPR'22All three return an unreduced
(B, C)tensor and accept soft targets, so mixup and the existing per-tag weighting and reduction keep working untouched.One deliberate departure from the P-ASL paper: there, labels are explicitly annotated / un-annotated and the class-prior gate can drop a whole class's un-annotated entries. Here every non-positive is an assumed negative, so dropping a tag's negatives wholesale would mean never learning when the tag is absent. The per-tag signal is instead a continuous interpolation of the negative focusing parameter between
gamma_negandgamma_unann; hard dropping is reserved for the per-sample top-k gate. The ablation supports that call — see below.Two entry points for the per-tag prior, both optional:
Explicit
--loss-prior-filewins over a column; neither present means a flat gamma. The column path is the longer-term home for this — it makes the prior travel with the dataset instead of as a side file.metrics.py: the F-measure parameter is unified on beta.f1scoresquared itsalphawhile_curvesdid not, and a test pinned that disagreement as a known quirk. Since this is the first non-unit use of the parameter in the codebase, both now mean beta;alphastays as an alias, and the test is repinned with a comment recording that the change is deliberate. Per-tag beta vectors are now accepted, which is what theβ_t = 1/r_twork in #3 needs.tools/— the ablation harness:loss_ablation.py(one arm per invocation, identical seed and data order across arms),posthoc_eval.py(uniform re-scoring with a 0.05–0.95 threshold sweep, per-pair audited scores kept for paired bootstrap),report_ablation.py, and the launcher scripts. Paths that cannot be checked in — the survey repo, the audited thumbnails, the scratch directory — come fromDBSURVEY_ROOT,DBSURVEY_AUDIT_IMAGESandABLATION_OUT.docs/superpowers/specs/2026-08-13-per-tag-noisy-label-training-design.md— the design, the six arms and why each exists, the three evaluation harnesses, and the measured results.Results
Full numbers are in #3 and in the design doc. The short version:
Tests
67 new tests across
test/multilabel/test_losses.pyandtest/multilabel/test_fbeta.py; suite is 130 passing.Coverage worth calling out:
bceis asserted equal toBCEWithLogitsLossto floating-point tolerance; the ASL clip path is checked against an independently derived closed form; gradients are checked finite at logits of ±30 with clip on, which is where the naivelog(1 - p + clip)formulation overflows; soft targets are checked to interpolate; andTestTrainIntegrationexercises both prior entry points end to end, including the precedence rule and the flat-gamma fallback.Compatibility
--lossdefaults tobceand every new flag is inert without it, so existing training commands, configs and resumed runs behave exactly as before. The only behavioural change outside the opt-in path is thealpha→betaunification inmetrics.py, which is a no-op at the default value of 1.Closes nothing yet — #3 has open decisions (which objective to train for, whether
key_metricmoves to a corrected beta, where the reliability column should live) that should be settled before this drives a production run.