Make external likelihood terms proper Gaussians in the reported NLL - #147
Merged
Merged
Conversation
An external term is stored expanded as
-log L_ext = g^T x + 0.5 x^T H x
For a Gaussian prior N(mu, C) with H = C^-1 and g = -H mu, that is short
of the real thing by two additive scalars:
const = 0.5 mu^T H mu = 0.5 g^T H^-1 g
lognorm = 0.5 (k log 2pi - log det H)
Without `const` the term evaluates to -0.5 mu^T H mu at the prior mean
instead of 0, so every absolute NLL on a card carrying an external term
carries a card-dependent offset. In one real case (a 2-parameter lattice
prior) that offset was 62.4 units, enough to make a *penalised* fit look
like it had a lower NLL than its own unpenalised version -- which is
impossible, since adding a penalty can only raise the minimum.
Nothing about any fit changes: neither scalar depends on x, so the
minimum, gradient, Hessian, covariance, EDM and any NLL difference taken
within a single fit are all untouched. What changes is reported absolute
NLL -- nllvalreduced, nllvalfull, and the saturated chi2 built as
2 * nllvalreduced in rabbit_fit.py, which was wrong by 2 * const.
Where each scalar goes follows _compute_lc, which writes the native
constraint term in centered form `cw * 0.5 * (x - x0)^2` and gates only
its normalization on full_nll:
- `const` is part of the quadratic (it is what centers it), so it goes
into both the reduced and the full NLL.
- `lognorm` is the density normalization, so it goes into the full NLL
only. In one dimension it is exactly the `0.9189 + log(sigma)` that
_compute_lc already adds per constrained parameter -- so on a card
mixing native constraints with an external term, nllvalfull was
previously normalizing some priors and not others.
Both are derived automatically at load time when H is dense, which fixes
existing cards with no migration and no re-fitting. A sparse H needs a
sparse solve and log-determinant, neither of which belongs in a load
path, so those must be supplied at write time; such terms keep the old
behaviour with a warning. To that end add_external_likelihood_term()
gains `mean=`, which derives g = -H mu and const = 0.5 mu^T H mu from a
single matvec (no solve needed when mu is known) and is now the
recommended way to declare a Gaussian prior. `const=` / `lognorm=`
overrides are available for the raw-sparse case.
Degenerate cases are handled explicitly rather than raising, since the
g^T x + 0.5 x^T H x form can express things that are not priors: a
grad-only tilt has no minimum and gets nothing; a singular H uses the
pseudo-inverse and warns if g leaves its range (the term is then
unbounded below and no constant centers it); a non-positive-definite H
is not a density, so it is centered but not normalized, with a warning.
Add tests/test_external_nll_constant.py, which compares two cards
identical but for the term and asserts the loss difference equals the
analytic Gaussian at several points -- not just at the minimum. The
existing test_external_term.py asserts only postfit parameter values,
which by construction cannot see a constant.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
An external likelihood term is stored expanded, exactly as its docstring says:
For a Gaussian prior
N(mu, C)withH = C^-1andg = -H mu, the real thing is0.5 (x-mu)^T H (x-mu) + lognorm, and expanding it givesSo the stored form is short by
const = 0.5 mu^T H mu, and separately by the Gaussian normalisation. It evaluates to-0.5 mu^T H muat the prior mean instead of0.How this surfaced. On a card with a 2-parameter lattice prior the offset is 62.4 units. That made a penalised fit appear to have a lower NLL (4373.45) than its own unpenalised version (4433.12) — impossible, since adding a penalty can only raise the minimum. We had been correcting it by hand.
Blast radius
No fit changes. Neither scalar depends on
x, so the minimum, gradient, Hessian, covariance, EDM, impacts, scans and every NLL difference taken within a single fit are untouched. That is why fits have always been correct and only reported numbers were wrong.What was wrong:
nllvalreduced,nllvalfull, andchi2_val = 2.0 * nllvalreducedinrabbit_fit.py(the absolute saturated chi2), which was off by2 * const.Where each scalar goes
Following
_compute_lc, which writes the native constraint term in centered formcw * 0.5 * (x - x0)^2and gates only its normalisation onfull_nll:const0.5 mu^T H mu=0.5 g^T H^-1 glognorm0.5 (k log 2pi - log det H)lognormin one dimension is exactly the0.9189 + log(sigma)that_compute_lcalready adds per constrained parameter. So on a card mixing native constraints with an external term,nllvalfullwas previously normalising some priors and not others.Migration: none
Both scalars are derived at load time when
His dense, so existing cards are fixed with no regeneration and no re-fitting. Verified against a real card: the 62.408046 we had been adding by hand is now produced automatically, and the term evaluates to exactly 0 atmu.A sparse
Hwould need a sparse solve and log-determinant, neither of which belongs in a load path — those terms keep today's behaviour with a warning and must be given the scalars at write time. To that endadd_external_likelihood_term()gainsmean=, which derivesg = -H muandconst = 0.5 mu^T H mufrom a single matvec (no solve needed whenmuis known) and is the recommended way to declare a Gaussian prior.const=/lognorm=overrides exist for the raw-sparse case.Degenerate cases
The
g^T x + 0.5 x^T H xform can express things that are not priors, so these are handled explicitly rather than raising:Habsent) — a linear tilt with no minimum; nothing to centre, nothing addedconst = 0exactlyH— pseudo-inverse for the constrained subspace; warns ifghas a component outsiderange(H), where the term is unbounded below and no constant centres itH— not a density; centred but not normalised, with a warningTests
New
tests/test_external_nll_constant.pycompares two cards identical but for the term and asserts the loss difference equals the analytic Gaussian at several points, not just the minimum — the existingtest_external_term.pyasserts only postfit parameter values, which by construction cannot see a constant. Also covers themean=path, sparse storage, backward compatibility when the scalars are absent, the degenerate cases, and that gradient/Hessian are unchanged.Both the new suite and the existing
test_external_term.pypass.black/isort/flake8run with the CI flags in the CI container image are clean repo-wide.Note for reviewers
Absolute NLL values change for any card carrying an external term. That is the fix, but it will look like a regression to anyone diffing against previous output, so it may be worth a release note.
🤖 Generated with Claude Code
https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c