Skip to content

fix: masked BinnedNLL gradient - #1137

Merged
henryiii merged 2 commits into
developfrom
fix/cost-bugs-1132
Sep 2, 2026
Merged

fix: masked BinnedNLL gradient#1137
henryiii merged 2 commits into
developfrom
fix/cost-bugs-1132

Conversation

@henryiii

@henryiii henryiii commented Jun 11, 2026

Copy link
Copy Markdown
Member

🤖 AI text below 🤖

The masked renormalization correction in BinnedNLL._grad summed the model gradient over both axes, which gave one scalar for all parameters. It must give one correction per parameter. Any masked BinnedNLL fit that used the analytic gradient got silently wrong results.

The mask selects along the first axis only, so for a multi-dimensional histogram the correction must also sum over the remaining bin axes, then reshape to broadcast over the prediction.

The existing test_BinnedNLL_mask uses a one-parameter 1D model, for which the sums agree, so the bug was not seen. The new tests compare against a numerical gradient with two parameters in 1D and with a masked 2D histogram.

Reported in #1132.

@henryiii
henryiii force-pushed the fix/cost-bugs-1132 branch 2 times, most recently from c09efff to 156f4bb Compare June 15, 2026 12:46
@henryiii

Copy link
Copy Markdown
Member Author

🤖 AI text below 🤖

Rebased onto develop. While verifying CI I found the coverage job still failing on test_CostSum_visualize — but it's a separate, pre-existing break on develop (not fixed by #1140): matplotlib ≥ 3.11 now raises ValueError: Figure N already exists when plt.subplots(num=fig.number) reuses the current figure. Since this PR already touches that exact line for the squeeze=False fix, I folded in clear=True here. Verified locally (built the wheel, all test_cost.py::*visualize* pass; clear=True preserves the set_figwidth call and yields the correct ax shape).

@henryiii

Copy link
Copy Markdown
Member Author

@HDembinski This is needed to get the rest of the PRs to go green (the clear=True fix above), so reviewing this next would be good.

(Note: "draft" means I have not reviewed it yet, I'm taking these out of draft when I review; you can review at any time)

@henryiii
henryiii marked this pull request as ready for review June 15, 2026 14:22
@HDembinski

Copy link
Copy Markdown
Member

Since this is AI generated, these should be individual focused PRs, not this hard to review collection of fixes.

@henryiii

Copy link
Copy Markdown
Member Author

Let me pull out the one that helps with CI, then I'll split the rest up.

@henryiii

Copy link
Copy Markdown
Member Author

(Fully doing one per fix would have hit the CI pretty hard, but I can break them up one at a time if that helps)

@henryiii

This comment was marked as low quality.

@henryiii

Copy link
Copy Markdown
Member Author

(Need to figure out how to stop Claude from commenting every rebase here, normally it doesn't do that)

@henryiii
henryiii marked this pull request as draft June 16, 2026 04:46
@henryiii
henryiii force-pushed the fix/cost-bugs-1132 branch 2 times, most recently from a69c1f6 to ae5b41a Compare July 27, 2026 14:28
@henryiii henryiii changed the title fix: cost function bugs (masked BinnedNLL gradient, pulls masks, visualize) fix: gradient of masked BinnedNLL with two or more parameters Jul 27, 2026
@henryiii henryiii changed the title fix: gradient of masked BinnedNLL with two or more parameters fix: masked BinnedNLL gradient Jul 27, 2026
henryiii added a commit that referenced this pull request Aug 29, 2026
…1148)

🤖 _AI text below_ 🤖

Split out of #1137.

A model that returned a non-float sequence took an early return before
the shape check, so a wrong shape passed silently. The value is now
converted to float and falls through to the shape check.
henryiii added a commit that referenced this pull request Sep 1, 2026
🤖 _AI text below_ 🤖

Split out of #1137.

`scaled_pdf` must scale by the number of data points. For multivariate
data of shape `(D, N)`, `np.prod(data.shape)` gives `D * N`, so the
number density was too large by the factor `D`. `data.shape[-1]` is
correct for 1D and multivariate data.

Reported in #1132.
henryiii added a commit that referenced this pull request Sep 1, 2026
🤖 _AI text below_ 🤖

Split out of #1137.

`plt.subplots(1, 1)` returns a bare Axes, not an array, so indexing it
raised a `TypeError` when the sum contained exactly one component with a
`visualize` method. The fix passes `squeeze=False`.

Also removes the `**kwargs` paragraph from the docstring. The method
does not accept `**kwargs`.

Reported in #1132.

Co-authored-by: Eduardo Rodrigues <eduardo.rodrigues@cern.ch>
henryiii added a commit that referenced this pull request Sep 1, 2026
🤖 _AI text below_ 🤖

Two bugs in the pull computation, split out of #1137.

- An index-array mask was negated with `~`, which is bitwise NOT for
integers, so the wrong bins became NaN. The new helper
`_exclusion_from_mask` handles boolean masks and index masks.
- The user mask replaced the `err == 0` mask instead of combining with
it, so bins with zero error gave ±inf pulls instead of NaN.
`LeastSquares.pulls` now also gives NaN for zero-error points when no
mask is set (before: ±inf), consistent with `BinnedCost`. The `pulls`
docstring now states this.

Reported in #1132.
@henryiii
henryiii marked this pull request as ready for review September 1, 2026 18:04

@ikrommyd ikrommyd left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please consider the comment for the 2D case. It's correct for 1D already. I would have thought of checking this myself tbh so fable is probably right here. If it's right, you can add the change with a 2 line comment explaining it + a test for the 2D case.

Comment thread src/iminuit/cost.py Outdated
if ma is not None:
psum = np.sum(p[ma])
pg = pg / psum - p * np.sum(pg[:, ma]) / psum**2
pg = pg / psum - p * np.sum(pg[:, ma], axis=1)[:, np.newaxis] / psum**2

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is correct for 1D but fable informs me that this is still wrong for 2D masked gradients. It suggested this change but I haven't out the bath myself.

Suggested change
pg = pg / psum - p * np.sum(pg[:, ma], axis=1)[:, np.newaxis] / psum**2
corr = np.sum(pg[:, ma].reshape(pg.shape[0], -1), axis=1)
pg = pg / psum - p * corr.reshape((-1,) + (1,) * p.ndim) / psum**2

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 AI text below 🤖

Confirmed and applied. BinnedNLL.mask selects along the first axis only, so for a 2D histogram pg[:, ma] keeps the trailing bin axis and sum(axis=1) left one correction per column instead of one per parameter. It broadcast without an error, so the result was silently wrong.

Added test_BinnedNLL_2D_mask_grad, which compares a masked 2D gradient to the numerical gradient. It fails on the old code (all 4 elements mismatch) and passes now.

The renormalization correction summed the model gradient over both axes,
which gave one scalar for all parameters. It must be summed over the bin
axis only, so that each parameter gets its own correction.

The existing mask test uses a one-parameter model, for which the two
sums agree, so the bug was not seen. The new test compares against a
numerical gradient with two parameters.

Reported in #1132.

Assisted-by: ClaudeCode:claude-opus-5
The mask selects along the first axis only, so the renormalization
correction must also sum over the remaining bin axes and reshape to
broadcast over the prediction.

Assisted-by: ClaudeCode:claude-opus-5

@ikrommyd ikrommyd left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be good now. Thanks!

@henryiii
henryiii merged commit 78ffc81 into develop Sep 2, 2026
9 checks passed
@henryiii
henryiii deleted the fix/cost-bugs-1132 branch September 2, 2026 15:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants