Skip to content

[Minuit2] Fix analytical Hessian/G2 transform for parameters with limits#22700

Merged
guitargeek merged 1 commit into
root-project:masterfrom
guitargeek:issue-22692
Jun 26, 2026
Merged

[Minuit2] Fix analytical Hessian/G2 transform for parameters with limits#22700
guitargeek merged 1 commit into
root-project:masterfrom
guitargeek:issue-22692

Conversation

@guitargeek

Copy link
Copy Markdown
Contributor

When a parameter has limits, Minuit2 minimizes in an internal coordinate q that is a non-linear function of the external parameter. Converting a user-provided external Hessian (or G2) to internal coordinates therefore requires, on the diagonal, an extra term involving the second derivative of the transformation:

H_int(i,i) = (dx/dq)^2 * H_ext(i,i) + (d^2x/dq^2) * g_ext(i)

AnalyticalGradientCalculator::Hessian() and ::G2() only applied the first-order Jacobian factor (dx/dq)^2 and dropped the (d^2x/dq^2) * g_ext(i) term. The numerical path does not have this problem because it differentiates directly in internal coordinates, so the term is included implicitly.

This was introduced in 888a767 ("[math][minuit2] First implementation in Minuit2 of mNHesse using external Hessian calculator"), which added the external->internal Hessian/G2 transform.

At the minimum the external gradient is ~0, so the missing term vanishes and the final errors are correct. But it is non-zero everywhere else, in particular at the seeding point. When a parameter starts close to a limit (dx/dq small) and far from the solution (g_ext large), the dropped term dominates: the seed G2/covariance built by MnSeedGenerator is corrupted and Migrad can take a catastrophic first step, silently converging to a wrong result that is still reported as valid. Relying on the numerical Hessian for the same fit works fine.

Fix this by adding the missing curvature term:

  • add D2Int2Ext() to Sin/SqrtLow/SqrtUp parameter transformations and a dispatching MnUserTransformation::D2Int2Ext() (returns 0 without limits, so unlimited fits are unchanged)
  • add (d^2x/dq^2) * g_ext(i) to the diagonal in AnalyticalGradientCalculator::Hessian() and both branches of ::G2()

The off-diagonal entries are unchanged: the transformation is per-parameter (diagonal), so mixed second derivatives only get the (dx/dq_i)(dx/dq_j) factor.

Add a regression test (Minuit2.AnalyticalHessianLimitTransformation). It does not rely on the end-to-end Migrad convergence behaviour, which is chaotic and compiler/optimization dependent for the reproducer, but instead checks the analytical internal Hessian/G2 against a central finite difference of the internal gradient at a near-limit, non-minimum point. Without the fix the G2 value is off by several orders of magnitude and has the wrong sign.

Closes #22692.

🤖 Done with the help of AI.

When a parameter has limits, Minuit2 minimizes in an internal coordinate
q that is a non-linear function of the external parameter. Converting a
user-provided *external* Hessian (or G2) to internal coordinates
therefore requires, on the diagonal, an extra term involving the second
derivative of the transformation:

```
H_int(i,i) = (dx/dq)^2 * H_ext(i,i) + (d^2x/dq^2) * g_ext(i)
```

`AnalyticalGradientCalculator::Hessian()` and `::G2()` only applied the
first-order Jacobian factor `(dx/dq)^2` and dropped the `(d^2x/dq^2) *
g_ext(i)` term. The numerical path does not have this problem because it
differentiates directly in internal coordinates, so the term is included
implicitly.

This was introduced in 888a767 ("[math][minuit2] First
implementation in Minuit2 of mNHesse using external Hessian
calculator"), which added the external->internal Hessian/G2 transform.

At the minimum the external gradient is ~0, so the missing term vanishes
and the final errors are correct. But it is non-zero everywhere else, in
particular at the seeding point. When a parameter starts close to a
limit (dx/dq small) and far from the solution (g_ext large), the dropped
term dominates: the seed G2/covariance built by MnSeedGenerator is
corrupted and Migrad can take a catastrophic first step, silently
converging to a wrong result that is still reported as valid. Relying on
the numerical Hessian for the same fit works fine.

Fix this by adding the missing curvature term:
 * add `D2Int2Ext()` to Sin/SqrtLow/SqrtUp parameter transformations and
   a dispatching `MnUserTransformation::D2Int2Ext()` (returns 0 without
   limits, so unlimited fits are unchanged)
 * add `(d^2x/dq^2) * g_ext(i)` to the diagonal in
   `AnalyticalGradientCalculator::Hessian()` and both branches of
   `::G2()`

The off-diagonal entries are unchanged: the transformation is
per-parameter (diagonal), so mixed second derivatives only get the
`(dx/dq_i)(dx/dq_j)` factor.

Add a regression test (Minuit2.AnalyticalHessianLimitTransformation). It
does not rely on the end-to-end Migrad convergence behaviour, which is
chaotic and compiler/optimization dependent for the reproducer, but
instead checks the analytical internal Hessian/G2 against a central
finite difference of the internal gradient at a near-limit, non-minimum
point. Without the fix the G2 value is off by several orders of
magnitude and has the wrong sign.

Closes root-project#22692.

🤖 Done with the help of AI.
@github-actions

github-actions Bot commented Jun 26, 2026

Copy link
Copy Markdown

Test Results

    22 files      22 suites   2d 11h 32m 35s ⏱️
 3 873 tests  3 872 ✅ 0 💤 1 ❌
76 550 runs  76 549 ✅ 0 💤 1 ❌

For more details on these failures, see this check.

Results for commit dfa5a9e.

♻️ This comment has been updated with latest results.

@lmoneta lmoneta 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.

LGTM!
Thank you Jonas for finding and fixing this important issue.
I have now a doubt about the Hessian matrix transformation (int to ext and viceversa) that is implemented in MnUserTransformation::Int2extCovariance and Ext2IntCovariance. Should we also include the second derivatives of the transformation for the diagonal term?
There is no need for the off-diagonal since the Hessian of the transformation is diagonal.

@guitargeek

Copy link
Copy Markdown
Contributor Author

Good question! I'll investigate.

@guitargeek guitargeek merged commit 5db334e into root-project:master Jun 26, 2026
59 of 64 checks passed
@guitargeek guitargeek deleted the issue-22692 branch June 26, 2026 09:46
@guitargeek

Copy link
Copy Markdown
Contributor Author

/backport to 6.40

@root-project-bot

Copy link
Copy Markdown

Preparing to backport PR #22700 to branch 6.40 requested by guitargeek

@root-project-bot

Copy link
Copy Markdown

Something went wrong when assigning the PR or setting labels @guitargeek please see the logs

@root-project-bot

Copy link
Copy Markdown

This PR has been backported to branch 6.40: #22713

@AdrianDBS

Copy link
Copy Markdown

Thanks for the quick fix!
Do you plan to backport this to 6.36 as well? If it aligns with your support plans, I would very much appreciate it.

@guitargeek

Copy link
Copy Markdown
Contributor Author

Yes, that's the plan. I just think it can't be done by the bot because of diverging history, so I wanted to do in manually later. But let's try

@guitargeek

Copy link
Copy Markdown
Contributor Author

/backport to 6.36

@root-project-bot

Copy link
Copy Markdown

Preparing to backport PR #22700 to branch 6.36 requested by guitargeek

@root-project-bot

Copy link
Copy Markdown

Something went wrong with the creation of the PR to backport to 6.36: @guitargeek please see the logs

@root-project-bot

Copy link
Copy Markdown

This PR has been backported to

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Providing analytical Hessian reduces robustness of minimization to initial values in Minuit2

5 participants