fix(models): guard keypoint loss and its shared ref_wh composition against non-finite predictions - #1336
Conversation
…ainst non-finite predictions
Codecov Report✅ All modified and coverable lines are covered by tests. ❌ Your project check has failed because the head coverage (85%) is below the target coverage (95%). You can increase the head coverage or adjust the target coverage. Additional details and impacted files@@ Coverage Diff @@
## develop #1336 +/- ##
=======================================
Coverage 85% 85%
=======================================
Files 111 111
Lines 13918 13947 +29
=======================================
+ Hits 11809 11843 +34
+ Misses 2109 2104 -5 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
This PR hardens GroupPose keypoint training against non-finite predictions (NaN/Inf) by preventing them from corrupting both loss values and gradients, including a critical upstream composition in LWDETR.forward() where keypoint deltas are multiplied by shared box reference tensors.
Changes:
- Updated
compute_l1_keypoint_lossto sanitize non-finite inputs before loss ops (so local backward formulas never see NaN/Inf) and to safely handle non-finitetarget_areasandexp()overflow in the Gaussian NLL path. - Sanitized
outputs_keypoints_deltainLWDETR.forward()before the* ref_wh + ref_xycomposition to prevent poisoning gradients of shared reference tensors. - Added targeted regression tests covering forward-value finiteness, gradient finiteness (per-channel),
exp()overflow, non-finite target areas, and the LWDETR ref gradient path.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
src/rfdetr/models/heads/keypoints.py |
Adds robust NaN/Inf guarding (forward + backward) for all four keypoint loss terms, including overflow/area edge cases. |
src/rfdetr/models/lwdetr.py |
Sanitizes keypoint deltas before composing with shared reference tensors to protect shared gradients. |
tests/models/heads/test_keypoint_heads.py |
Adds regression tests for non-finite predictions, gradient poisoning, exp() overflow, and non-finite target areas. |
tests/models/test_lwdetr_keypoints.py |
Adds an end-to-end regression test ensuring NaN keypoint deltas don’t poison ref_unsigmoid gradients. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- Sanitize two-stage encoder keypoint predictions before shared reference composition and outer consumers - Add NaN/Inf regression coverage for bbox modes, finite preservation, and encoder gradients --- Co-authored-by: Codex <codex@openai.com>
ref_wh composition against non-finite predictions
…against non-finite predictions (#1336) - Sanitize two-stage encoder keypoint predictions before shared reference composition and outer consumers - Add NaN/Inf regression coverage for bbox modes, finite preservation, and encoder gradients --------- Co-authored-by: Jesús Royeth <JESUSROYETH@users.noreply.github.com> Co-authored-by: jirka <6035284+borda@users.noreply.github.com> Co-authored-by: Codex <codex@openai.com>
What does this PR do?
compute_l1_keypoint_losshas four loss terms. Three of them (location_loss,findable_loss,visible_loss) excluded invalid keypoints by multiplying the per-keypoint loss by a float mask. Under IEEE 754,0.0 * nan == nan, not0.0, so a masked-out position with a non-finite prediction still poisoned the sum.location_losshad anisfinitecheck that looked like it should catch this, but the multiply defeated it the same way.Switching to
masked_fill(mirroring the pattern this function already used correctly fornll_keypoints) fixes the forward value, but that alone isn't always enough for the gradient. Every loss op's own backward formula still evaluates its original input locally. Forbinary_cross_entropy_with_logitsthat'ssigmoid(x) - y, so a non-finitexgives a non-finite local gradient regardless of the output mask, and the correctly-zeroed upstream gradient times that non-finite local gradient is0.0 * nan == nanagain, one level down — a single non-finitefindable/visibleprediction still poisoned every parameter gradient reachable from the keypoint head, even though the loss value came out finite. (F.l1_loss's local gradient issign(pred - target), andtorch.sign(nan)happens to return0.0in the PyTorch version this was tested on, solocation_loss's own gradient was already clean withmasked_fillalone — verified directly, not assumed. Applied the same input-guard there anyway for consistency across all four terms rather than relying on that implementation detail holding across PyTorch versions/devices.)The Gaussian NLL term has the same hazard through
dx/dy/the Cholesky params: it already callednan_to_numon its finalnll_raw, but that only cleans the exact node it's applied to, not the chain of ops (u0,u1,maha2) leading up to it, where the non-finite value re-enters the backward formula the same way.Fixed by swapping in a safe placeholder (
torch.where) for the non-finite input before each loss op, on top of the existingmasked_fill/nan_to_numon the output — verified with a direct backward pass: 7 prediction channels × 4 loss terms, 28 combinations, all give a finite gradient with the fix and (for the combinations that actually break) a non-finite one without it.Three more gaps in the same spirit, found and fixed in review:
torch.whereguards above only protect ops insidecompute_l1_keypoint_loss. In the real GroupPose training path (use_grouppose_keypoints=True),lwdetr.pybuildspred_keypoints[..., :2]asoutputs_keypoints_delta[..., :2] * ref_wh + ref_xybefore calling this function —ref_wh/ref_xycome fromref_unsigmoid, shared with the box head. A non-finiteoutputs_keypoints_deltastill poisonsref_wh's gradient the same0.0 * nan == nanway, one level earlier, because that multiply's own backward (d(a*b)/db == a) evaluates the original (non-finite)aregardless of how clean the gradient flowing out of this function is. Fixed bynan_to_num-ingoutputs_keypoints_deltaat the source inlwdetr.py, before the multiply — verified with a fullLWDETR.forward()pass (mocked backbone/transformer, realkeypoint_embed) injecting a NaN and checkingref_unsigmoid.grad.finite_uncertaintyonly checks the raw (pre-exp) Cholesky params, so e.g.raw_log_l11 = 100.0passes it, thenexp(100)overflows toinf— several ops before the existingisfinite(u0)/isfinite(maha2)check that excludes it from the forward sum. By thenu0/maha2are already built from the overflowed value, so their own backward (d(u0**2)/d(u0) == 2*u0 == inf) poisons x/y/Cholesky gradients the same way. Fixed by refining the mask after computingu0/u1/maha2once, then redoing that chain withtorch.where-sanitized inputs beforeexp/multiply/square see them.target_areasstill producednaninlocation_loss/nll_loss.valid_areacorrectly drops those keypoints fromlocation_loss_mask/gaussian_loss_mask, andmasked_fillcorrectly zeroes their numerator — but both terms then divide byarea.clamp_min(area_eps)directly, andtorch.clamp_min(nan, eps)leavesnanunchanged (comparisons againstnanare alwaysFalse).0.0 / nan == nansurvives the mask. Fixed by sanitizingareaitself (torch.where(valid_area, area, 1.0)) before either division.All three follow TDD (failing test confirmed against the pre-fix code via
git stash, passing after).Related Issue(s): None, no existing report found.
Type of Change
Testing
Test details:
tests/models/heads/test_keypoint_heads.py: two parametrized tests from the first pass (forward value, 3 cases; gradient, 7 cases — one per prediction channel, summing all four losses and callingbackward()), plus two new ones for the gaps above (exp()overflow of a finite log-precision input; non-finitetarget_areas). All four written/confirmed failing against the pre-fix code first (git stash). Full file now 30/30 (18 pre-existing + 12 new: 3 forward-value cases + 7 gradient cases + theexp()-overflow case + the non-finite-target_areascase), including the pre-existing NLL-gradient-vs-reference-formula test, confirming no behaviour change in the normal finite case.tests/models/test_criterion.py(12/12, same package, not a direct consumer) also passes unchanged.tests/models/test_lwdetr_keypoints.py: one new test building a realLWDETR(mocked backbone/transformer, realkeypoint_embed), injecting a NaN via a forward hook onkeypoint_embed, and checkingref_unsigmoid.gradafterbackward(). Confirmed failing against the pre-fixlwdetr.py(git stash), passing with the fix. Full file 7/7.Broader sanity check after all three fixes (not the full 3854-item suite — no need to re-pay that cost for an unrelated part of the tree):
tests/models/+tests/models/heads/together, 194 passed, 3 skipped (gpu-marked).Full CPU suite per
ci-tests-cpu.yml, from the prior review pass: ran to completion (3780 passed + 72 skipped + 2 failed = 3854 items, single worker, 188s). Both failures are intests/models/test_evaluate.py, a file that doesn't import or exercisekeypoints.py. Re-ran both in isolation; the first failed again from an externalSIGTERM— confirmed againstjournalctl -u claude-watchdog.service(machine-level systemd log, independent of this session): a real entry at22:43:32killspytest tests/models/test_evaluate.py::test_train_then_from_checkpoint_then_evaluateatcpu=2281%, matching the number cited inrepro/NOTAS-verificacion.mdexactly. The second failure (test_sequential_evaluate_calls_do_not_leak_state) has no equivalent isolated confirmation — it only ran as part of the original full-suite pass.Checklist
Not applicable for docs: no public API changed.