Skip to content

Unified constraint structure (cw/x0); ParamModel Gaussian priors#139

Merged
davidwalter2 merged 17 commits into
WMass:mainfrom
lucalavezzo:split/param-priors-blinding
Jun 23, 2026
Merged

Unified constraint structure (cw/x0); ParamModel Gaussian priors#139
davidwalter2 merged 17 commits into
WMass:mainfrom
lucalavezzo:split/param-priors-blinding

Conversation

@lucalavezzo

@lucalavezzo lucalavezzo commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

Based on #140 #143 #144 to be merged after those.

  • unified constraint structure over the full parameter vector — fitter.cw (1/σ²; 0 = free), x0 (centers), var_x0 (1/cw), all index-aligned with x = [POI, POU, NOI, SYST], replacing theta0/param_x0/param_cw/var_theta0. The loss constraint term, prefit variances, toys, and every impact path now read this one structure; the ±nparams offsets cancel, so it's a net code reduction. Breaking: fitter.theta0 is gone (use fitter.x0[fitter.param_model.nparams:]).
  • Gaussian priors on ParamModel parameters — opt-in, declared by the model (prior_sigmas/prior_means, no CLA). A prior is just a constraint on a param-block entry (cw=1/σ², x0=mean), so it flows through prefit variances, pulls/constraints, toys (frequentist fluctuates centers, bayesian samples values — fixes the old FIXMEs), and all global impacts (likelihood/gaussian/asym, where priors show up as _prior sources). Priors on POIs require allowNegativeParam=True (guarded).

@davidwalter2 davidwalter2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This looks overall very good but I have one design question, could we align the constraints mechanism with the thetas (as discussed offline)?

Comment thread rabbit/fitter.py Outdated
lucalavezzo and others added 8 commits June 12, 2026 16:27
--blindingGroup: regex-defined groups of parameters that share a single
deterministic blinding offset (seeded from the group regex string), so
relative pulls and differences between matched parameters stay
meaningful while absolute values remain blinded. Parameters matching
both --unblind and --blindingGroup are a configuration error and abort
the fit. match_regexp_params now returns the union of exact and regex
matches (deduplicated, order-preserving) instead of short-circuiting on
the first exact match, so mixed exact/regex expression lists behave
consistently.

--paramModelPriors: opt-in Gaussian priors on ParamModel parameters.
The Fitter reads optional prior_sigmas / prior_means attributes from
the ParamModel and adds the corresponding 0.5*(x-mu)^2/sigma^2 penalty
to the NLL constraint term; the applied priors are stored in the output
metadata. Off by default; without the flag all ParamModel params float
free as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per review on WMass#133 (parsing.py:252): there is no rabbit-side
CLA anymore. If a ParamModel declares prior_sigmas, the priors are
applied; whether and how to enable them (e.g. a token in the
--paramModel spec) is the model's own decision.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per review on WMass#133: param_prior_sigmas / param_prior_means
are stored once as TF constants holding the priors as declared (NaN
where no prior); the compute-safe masked forms are derived inside
_compute_lc, and the output metadata reads the arrays via .numpy().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CompositeParamModel summed npoi but concatenated params in model order,
so a POU-carrying model placed before a POI-carrying one leaked its POUs
into the composite's POI slice. Every npoi-sliced consumer then
misbehaved: get_poi() squared the POUs (allowNegativeParam=False
default) and, on real data, blinded them as "signal strength modifiers"
-- visible with --computeSaturatedProjectionTests + a custom npou>0
param model, where the saturated refit ran with squared+blinded theory
parameters (frozen ones unable to compensate), invalidating the
saturated p-value.

Fix: assemble the composite per-block, [poi(m1), poi(m2), ... | pou(m1),
pou(m2), ...], and reassemble each submodel's native [poi | pou] vector
in compute(). Legacy-valid orderings produce identical layouts.
Additionally:
- allowNegativeParam is derived from the POI-carrying submodels only
  (mixed flags raise: the fitter applies a single squaring transform to
  the POI block). load_models' any() over all models had the same trap.
- submodel prior_sigmas/prior_means propagate through the permutation
  (NaN = no prior), so a composite refit keeps the same prior penalty.
- param_impact_groups are merged (name-based, permutation-safe).
- is_linear no longer claims linearity for products of >1
  parameter-dependent factor or sqrt-stored POIs.
- blinding log message: "signal strength modifier" -> "parameter"
  (review request).

Tested in tests/test_composite_param_model.py (layout, compute slicing
vs manual evaluation, gradient flow, legacy-ordering invariance, flag
derivation/conflicts, prior+group propagation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ParamModel priors now use the same constraint structure as the nuisance
constraints: param_cw holds the per-parameter constraint weight
(1/sigma^2; 0 = free) and param_x0 the constraint center (the prior
mean) — the ParamModel-block analogues of indata.constraintweights and
theta0. Consequences:

- _compute_lc penalizes cw * 0.5 * (param - x0)^2, same form as the
  nuisance term.
- prefit variances are 1/cw uniformly, so priored params get proper
  prefit uncertainties (pulls/constraints work without special cases).
- Toys treat the prior like any auxiliary constraint: frequentist toys
  fluctuate param_x0 around its default with width sqrt(1/cw) exactly
  like theta0, and bayesian toys sample the priored param values from
  their priors. Previously priors were static in toys, silently
  underestimating the toy spread of anything correlated with a priored
  parameter. This also fixes the long-standing FIXMEs in bayesassign /
  frequentistassign: constraint centers now fluctuate around their
  defaults (not zero) with width sqrt(1/cw), and unconstrained entries
  are no longer randomized with unit width.
- Gaussian priors on POIs require allowNegativeParam=True; with the
  squared storage the penalty would silently apply to sqrt(poi), so the
  Fitter raises instead.

Verified on the test tensor: identical postfit optimum as the previous
implementation, prior width visible in the prefit variances, per-toy
fluctuation of the prior centers, and unchanged behavior for models
without priors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The prior centers (param_x0) are auxiliary observables like theta0, so
they now appear as global-impact sources, one column per priored param
(named <param>_prior, appended after the systs / syst groups in the
output axes):

- likelihood-based global impacts: _compute_global_impacts_x0 already
  differentiates the full constraint term; the ParamModel-block rows
  (nonzero where priors are declared) are now kept instead of
  discarded, in per-1-prefit-sigma units automatically via
  sqrt(d2lc/dx2) = 1/sigma. Their variance is subtracted from the
  residual data-stat term, which previously absorbed it.
- gaussian global impacts: _dxdvars additionally differentiates with
  respect to param_x0 and the impacts are dx/dparam_x0 * sigma.
- observable (histogram) impacts: both variants extended through
  _dndvars with the chain-rule term pdndx @ dxdparam_x0.
- profiled chi2: the residual covariance in _residuals_profiled gains
  the prior-center variance contribution.

Verified on the test tensor: the likelihood and Gaussian paths agree
exactly on the prior impact, and the variance closure
sum(sources^2) + stat^2 + binByBinStat^2 = sigma_tot^2 holds to all
printed digits. Output schemas are unchanged for fits without priors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
_compute_lc is now one expression: cw * 0.5 * (x - x0)^2 summed over the
full effective parameter vector [poi, model_nui, theta], with
cw = concat(param_cw, constraintweights) and x0 = concat(param_x0,
theta0). No special-casing of the ParamModel block — a model without
priors simply contributes cw = 0 entries. The full-NLL normalization is
generalized to 0.5*log(2 pi / cw) for constrained entries (identical to
the previous 0.9189*cw for the standard cw in {0, 1}).

var_param_x0 is promoted to an attribute (the analogue of var_theta0)
and the toy randomization of param_x0 is unconditional, mirroring
theta0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lucalavezzo lucalavezzo force-pushed the split/param-priors-blinding branch from 664d055 to c674aae Compare June 12, 2026 20:33
lucalavezzo and others added 6 commits June 12, 2026 17:07
The constraint structure is now held in two full-length objects,
index-aligned with x:

  cw      constraint weights (1/sigma^2 for priored ParamModel params,
          indata.constraintweights for systs; 0 = unconstrained)
  x0      constraint centers (prior means / theta0), a tf.Variable
  var_x0  prefit center variances (1/cw; 0 where free)

fitter.theta0, param_x0, param_cw, var_theta0 and var_param_x0 are
gone. Every consumer is migrated, in most cases with less code since
the +- nparams index offsets cancel:

- _compute_lc is one term: cw * 0.5 * (x - x0)^2.
- prefit_variance is a single tf.where over cw.
- frequentistassign / bayesassign are single full-vector expressions.
- _dxdvars / _dndvars differentiate w.r.t. x0 once; the dxdtheta0 /
  dxdparam0 pair and its plumbing through the gaussian global impacts
  collapse into a dxdx0 split helper.
- _residuals_profiled has a single x0-variance term.
- nonprofiled_impacts and global_asym_impacts operate on x0 directly
  (x0[idx] instead of theta0[idx - nparams]).
- the saturated-projection re-init preserves the toy-randomized centers
  via the x0 syst-block slice.

No behavior change: postfit results, impacts, closures and the
global-asym test suite are identical to the previous two-block
implementation. External code that accessed fitter.theta0 must use
fitter.x0[fitter.param_model.nparams:] instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An expression that names one parameter exactly can no longer also match
parameters whose names merely extend it (prefix matches could silently
unblind more than intended); families are matched with an explicit
pattern, e.g. alphaS.*. The parameters an --unblind expression resolves
to are now reported at INFO level.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The variation magnitude used 1/cw (the variance) instead of 1/sqrt(cw)
(the sigma); identical for the 0/1 tensor constraint weights, but wrong
for ParamModel priors where cw = 1/sigma^2 is genuinely non-unit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The saturated-projection re-init dropped --blindingGroup (falling back
to per-name blinding seeds) and reset the ParamModel block of x0 to its
defaults, losing toy-fluctuated prior centers. Both are now carried
through the composite [POIs | POUs] permutation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… default

- prior_sigmas/means and the cw/x0 numpy intermediates are built in
  self.indata.dtype (via as_numpy_dtype) rather than a hardcoded
  np.float64, matching how the rest of init_fit_parms respects the input
  dtype (davidwalter2 review on WMass#139).
- free (unpriored) entries of the ParamModel x0 block now default to the
  parameter own default instead of 0. cw = 0 makes this irrelevant to the
  likelihood, but nonprofiled_impacts reads x0 as a parameter natural
  center, so a frozen free param is now varied around its default rather
  than around 0. With no priors declared x0default now equals xdefault.
- fix a stale theta0 reference in the global_asym_impacts docstring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ored params in asym impacts

Item 5 (exact + cheaper): x0 enters the NLL only through cw*(x-x0)^2, so
the loss derivative w.r.t. any center with cw = 0 is identically zero.
_dxdvars now perturbs only the constraint sources (the nuisance block plus
priored params) via a scattered tangent and returns the response split into
(dxdtheta0, dxdparam0), shrinking the Jacobian from [npar, npar] to
[npar, nsyst (+ n_prior)]; the gaussian global-impacts helpers take the
pre-split pieces (dropping _split_x0_sources). _dndvars likewise drops its
x0 watch since the forward model has no x0 dependence (verified
d(yields)/dx0 = 0 exactly). Validated bit-identical to the previous code
on the test tensor: parms gaussian/likelihood impacts, observable impacts,
and the profiled-chi2 residual covariance all unchanged.

Item 6b: global_asym_impacts now scans priored ParamModel params as
sources too (full-x indexed, labelled <param>_prior), matching the source
set of gaussian_global_impacts_parms, with ParamModel impact groups folded
into the grouped envelopes. The per-source center shift is scaled by the
source prefit width sqrt(var_x0) (1 for unit-constrained nuisances, the
prior sigma for priored params), so a unit-sigma asym impact stays in the
same units as the gaussian one. On the test tensor the sig_prior asym
impact (-1.10e-2, +1.11e-2) reproduces the gaussian value (1.106e-2) to
<1%, the small spread being genuine non-Gaussianity; nuisance impacts are
unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lucalavezzo lucalavezzo changed the title param priors blinding Unified constraint structure (cw/x0); ParamModel Gaussian priors; blinding groups Jun 15, 2026

@davidwalter2 davidwalter2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The params and systs are not really unified in this PR, there are lot's of places where the two are split and/or merged together with specific treatment which isn't getting clear to my why it is needed. Is it possible to unify them better and work with one collection in most cases?

Can the modifications on blinding groups and the bugfix on composite param models each one be moved into separate PRs?

Comment thread rabbit/fitter.py Outdated
)
self.param_prior_active = True
self.param_prior_mask = tf.constant(mask, dtype=tf.bool)
self.param_prior_sigmas = tf.constant(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

These are duplicated in ParamModel and fitter class which calls for trouble, we should probably only define them in only one of them.

Comment thread rabbit/impacts/global_asym_impacts.py Outdated
)
t_lws = time.perf_counter()
dxdtheta0_tf, _, _ = fitter._dxdvars()
dxdtheta0_tf, dxdparam0_tf, _, _ = fitter._dxdvars()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think theta0 and param0 should be treated on equal footing, so here instead of returning the two dxdtheta0_tf and dxdparam0_tf collection return one dxdx0_tf

Comment thread rabbit/fitter.py Outdated
# _x0_constraint_source_idxs. The resulting columns are ordered
# [theta block | priored params] and split below.
src_idxs = self._x0_constraint_source_idxs()
n_src = self.indata.nsyst + (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This looks very error prone, what do we gain from sticking together two collections, I think it is much cleaner to work with a single vector self.x0 which replaces the previous self.theta0 and contains all params and systs.

Comment thread rabbit/impacts/global_impacts.py Outdated
# ParamModel prior penalties (rows in the ParamModel block, nonzero where
# the model declares priors). Per-1-sigma units come out automatically
# since sc = sqrt(d2lc/dx2) = sqrt(cw) = 1/sigma.
impacts_x0 = _compute_global_impacts_x0(x, compute_lc_fn, cov_dexpdx)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

When params and systs are treated on equal footing I think it should be possible to work with impacts_x0 without splitting them into the two groups, or am I missing something?

Per davidwalter2 review: treat the ParamModel params and the systs on equal
footing instead of carrying / splitting two collections.

- Fitter builds a single x0_source_idxs (the constrained centers, ordered
  [nuisance block | priored params]) and var_x0_sources, derived from cw.
- _dxdvars / _dndvars return ONE dxdx0 / dndx0 over those sources instead of
  the (dxdtheta0, dxdparam0) pair; the helper methods _x0_constraint_source_idxs
  and _x0_source_vars are gone.
- the likelihood global impacts gather impacts_x0 over source_idxs in one
  step (no theta-block slice + separate prior gather); the gaussian impacts
  take one dxdx0 / varx0 and compute the per-source impact uniformly as
  dxdx0 * sqrt(var) (systs have var = 1, priored params carry their sigma).
- the only remaining nuisance-vs-param boundary is a local slice when
  assembling the grouped axis in its historical order [syst groups | stat |
  bbb | priors], driven by n_param_sources.
- global_asym_impacts warm-start maps each source to its single dxdx0 column.
- drop the duplicated param_prior_sigmas / _means / _mask Fitter attributes;
  priors live only on the model, and the output metadata reads them there.
  param_prior_active is now derived from cw.

Validated bit-identical to the previous code on the test tensor: likelihood
and gaussian global impacts (parms + observables), and the asym sig_prior
impact still matches the gaussian one to <1%. Source axis unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Integrates the merged sibling PRs into the priors/unification branch:
  WMass#140 fit-ops (minimizer flags, externalPostfit), WMass#142 scalePoiScan,
  WMass#143 CompositeParamModel layout fix, WMass#144 blinding groups.

Conflicts (both trivial):
  - fitter.py: blinding_group=options.blindingGroup (main's direct form;
    the SimpleNamespace test fixtures already carry blindingGroup=[]).
  - helpers.py: kept main's cleaned-up version (dropped the explanatory
    comment removed on main).
Also dropped tests/test_composite_param_model.py, which the merge
re-introduced from this branch but was deliberately removed on main.

Validated: lint clean; pytest (15) + the five CI script-tests pass; the
ParamModel-prior closure is unchanged (gaussian sig_prior 1.105769e-02,
asym (-1.097e-2,+1.114e-2)); parsing.py is identical to main so all of
WMass#140/WMass#142/WMass#144's CLAs are present.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lucalavezzo

Copy link
Copy Markdown
Collaborator Author

Thanks @davidwalter2 I tried to improve a bit the unification after discussing. Everything should be more folded into a single cw / x0 / var_x0. Can you take a look to see if this is closer to what had in mind?

@lucalavezzo lucalavezzo changed the title Unified constraint structure (cw/x0); ParamModel Gaussian priors; blinding groups Unified constraint structure (cw/x0); ParamModel Gaussian priors Jun 23, 2026

@davidwalter2 davidwalter2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

A few more comments

Comment thread bin/rabbit_fit.py
Comment thread bin/rabbit_fit.py Outdated
# what was applied without parsing the rabbit log.
if getattr(ifitter, "param_prior_active", False):
pm = ifitter.param_model
sigmas = np.asarray(pm.prior_sigmas, dtype=np.float64)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Should this not rather be tf objects instead of np? And use ifitter.indata.dtype here and below?

Comment thread rabbit/impacts/global_asym_impacts.py Outdated
x_nom + dxdtheta0[:, i] * shift, the Gaussian-approximation new
minimum for the shifted theta0. Should drastically reduce the
number of optimizer iterations on near-Gaussian nuisances.
x_nom + dxds[:, source] * shift, the Gaussian-approximation new

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

should it not be dxdx0 rather than dxds?

t_lws = time.perf_counter()
dxdtheta0_tf, _, _ = fitter._dxdvars()
dxdtheta0_np = dxdtheta0_tf.numpy()
dxdx0_tf, _, _ = fitter._dxdvars()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

just call it dxdx0 since tf is the default used elsewhere

Comment thread rabbit/impacts/global_impacts.py Outdated
bin_by_bin_stat_mode,
systgroupidxs,
impacts_theta0_sq,
impacts_sources_sq,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

should that not be impacts_x0_sq?

Comment thread rabbit/fitter.py Outdated
else:
self.x.assign(
tf.concat([self.param_model.xparamdefault, self.theta0], axis=0)
tf.concat(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Is there now a self.x0default attribute defined above that let's you get rid of this if-else block?

Comment thread rabbit/fitter.py Outdated
],
axis=0,
)
defaults = tf.concat(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Same comment as above, can self.x0default be used instead?

Comment thread rabbit/fitter.py Outdated
],
axis=0,
)
self.var_x0_sources = tf.gather(self.var_x0, self.x0_source_idxs)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think we should get rid of this attribute and use self.var_x0 everywhere instead, even if we carry some zeros with us, they should not matter

Comment thread rabbit/fitter.py
return pd2ldbeta2

def _dxdvars(self):
# Response of the postfit minimum to a unit shift of each constraint

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Instead of working with this reduced set of source attributes I suggest to replace the self.theta0 from the previous implementation with self.x0 even if we have parameters without priors that we carry for no reason, I think it is better to have code simplicity versus overly specific logic

Comment thread rabbit/fitter.py Outdated
Per davidwalter2's latest comments, simplify to a single full-length x0
instead of carrying a reduced 'source' set:

- _dxdvars / _dndvars differentiate w.r.t. the whole self.x0 (no scatter /
  perturbation, no x0= loss argument); columns for unconstrained centers
  (cw = 0) are exactly zero and carried along.
- the global impacts work with the full impacts_x0 directly:
  impacts_x0_sq = square(impacts_x0), var_x0 = reduce_sum, no gather; the
  per-source axis is now the whole parameter list (matching the traditional
  impacts). gaussian per-source = dxdx0 * sqrt(var_x0) (one expression).
- grouping is one operation over full-x column-index lists (syst groups
  shifted by nmodel_params, ParamModel impact groups in the param block) so
  param and syst sources combine uniformly; the grouped global axis now uses
  the param-impact groups (like the traditional grouped impacts).
- drop x0_source_idxs / var_x0_sources; param_prior_idxs kept only for the
  asym scan. xdefaultassign / bayesassign use self.x0default.
- nits: drop the param-model-specific prior comment; meta priors use
  indata.dtype; rename dxds -> dxdx0 in global_asym_impacts.

Validated: lint clean, pytest (15) + the five CI script-tests pass, and the
ParamModel-prior impacts are unchanged (likelihood = gaussian source[sig]
= 1.105769e-02, asym (-1.097e-2, +1.114e-2)).

NOTE for review: the global-impact source axis is now the full parameter
list (was systs + <param>_prior); priored params appear under their own
name with unconstrained params as exact-zero columns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@davidwalter2 davidwalter2 merged commit 948a94a into WMass:main Jun 23, 2026
23 checks passed
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.

2 participants