From 446ef4b0c201305d88c8b7e3c9a371d9d5cac919 Mon Sep 17 00:00:00 2001 From: Alexander Fengler Date: Mon, 7 Sep 2026 00:59:05 -0400 Subject: [PATCH] fix: require explicit bounded group prior decisions (#1269) --- docs/changelog.md | 2 +- docs/how_to/specify_group_priors.md | 9 ++- src/hssm/param/regression_param.py | 64 ++++++++++----- src/hssm/prior.py | 6 +- tests/unit/param/test_regression_param.py | 76 +++++++++++------- .../param/test_unmatched_group_prior_graph.py | 78 +++++++++++++++++++ 6 files changed, 182 insertions(+), 53 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 9367910bc..a338f8826 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -24,7 +24,7 @@ This version includes the following changes: 10. **New built-in model: `angle_extended`** (#1298) -- the angle model with drift bounds widened to (-6, 6) for designs producing strong evidence. Bounds and parameter order asserted against the `ssm-simulators` registry; the LAN passed a 240-fit parameter-recovery sweep with zero coverage or bias failures (report published beside the artifact). Raises the `ssm-simulators` floor to `>=0.14.0`, the first release containing the model. -11. **Safe priors now preserve a unique unmatched group-only population location and reject ambiguous or unrepresentable specifications** ([#1225](https://github.com/lnccbrown/HSSM/issues/1225)). When HSSM generates a prior for a group term with no exact common Formulae counterpart, the group distribution owns the population location. HSSM therefore preserves its location-bearing hierarchy and centers that generated term even when the model or component requested non-centering, because Bambi's current non-centered construction omits `mu`. For previously generated unmatched Normal terms under default non-centering, this intentionally changes the likelihood by reconnecting the location that Bambi had discarded. Exact common/group matches remain zero-mean deviations and honor the requested parameterization ([#1224](https://github.com/lnccbrown/HSSM/issues/1224)). Repeated unmatched expressions now fail when safe generation has no unique owner. Explicit priors are never rewritten, but HSSM raises before Bambi when a group prior cannot be represented faithfully and warns about buildable centered specifications with multiple free owners of one location. Identity-linked group-only intercepts retain their response-scale hierarchy for every identity spelling ([#1232](https://github.com/lnccbrown/HSSM/issues/1232)); transformed links use a hierarchy on the linear-predictor scale. Follow-ups cover upstream location-aware non-centering ([#1268](https://github.com/lnccbrown/HSSM/issues/1268), [Bambi #1003](https://github.com/bambinos/bambi/issues/1003)), bounded identity group priors ([#1269](https://github.com/lnccbrown/HSSM/issues/1269)), and broader numeric regression-term semantics ([#1271](https://github.com/lnccbrown/HSSM/issues/1271)). +11. **Safe priors now preserve a unique unmatched group-only population location and reject ambiguous or unrepresentable specifications** ([#1225](https://github.com/lnccbrown/HSSM/issues/1225)). When HSSM generates a prior for a group term with no exact common Formulae counterpart, the group distribution owns the population location. HSSM therefore preserves its location-bearing hierarchy and centers that generated term even when the model or component requested non-centering, because Bambi's current non-centered construction omits `mu`. For previously generated unmatched Normal terms under default non-centering, this intentionally changes the likelihood by reconnecting the location that Bambi had discarded. Exact common/group matches remain zero-mean deviations and honor the requested parameterization ([#1224](https://github.com/lnccbrown/HSSM/issues/1224)). Repeated unmatched expressions now fail when safe generation has no unique owner. Explicit priors are never rewritten, but HSSM raises before Bambi when a group prior cannot be represented faithfully and warns about buildable centered specifications with multiple free owners of one location. Identity-linked group-only intercepts retain their response-scale hierarchy for every identity spelling ([#1232](https://github.com/lnccbrown/HSSM/issues/1232)); transformed links use a hierarchy on the linear-predictor scale. For generic models, safe generation now fails closed when such an identity-linked intercept has finite response bounds, rather than inventing an unqualified unbounded hierarchy ([#1269](https://github.com/lnccbrown/HSSM/issues/1269)). Explicit priors remain authoritative, and exact/black-box HDDM likelihoods retain their calibrated natural-support hierarchies. Follow-ups cover upstream location-aware non-centering ([#1268](https://github.com/lnccbrown/HSSM/issues/1268), [Bambi #1003](https://github.com/bambinos/bambi/issues/1003)) and broader numeric regression-term semantics ([#1271](https://github.com/lnccbrown/HSSM/issues/1271)). ### 0.4.0 diff --git a/docs/how_to/specify_group_priors.md b/docs/how_to/specify_group_priors.md index 722379769..3d8291e4b 100644 --- a/docs/how_to/specify_group_priors.md +++ b/docs/how_to/specify_group_priors.md @@ -108,9 +108,12 @@ Use a support-respecting transformed link when it matches the model: should not receive response-scale bounds. If an identity link is scientifically required, choose a centered hierarchical -family with appropriate natural support when possible, and remember that this -constrains that coefficient rather than the entire predictor. HSSM's likelihood -bounds still apply to the assembled parameter value. +family with appropriate natural support explicitly, and remember that this +constrains that coefficient rather than the entire predictor. HSSM now fails +closed instead of generating its generic unbounded safe hierarchy for a unique +group-only intercept on a finitely bounded identity scale. Exact and black-box +HDDM likelihoods retain their separately calibrated natural-support hierarchies. +HSSM's likelihood bounds still apply to the assembled parameter value. For the underlying scale and location logic, continue with [Link functions and safe priors](../tutorials/link_functions.ipynb). For the general prior interface, diff --git a/src/hssm/param/regression_param.py b/src/hssm/param/regression_param.py index 146936d33..0e5ecb8a0 100644 --- a/src/hssm/param/regression_param.py +++ b/src/hssm/param/regression_param.py @@ -301,25 +301,7 @@ def _make_safe_priors( ) else: # treat the term as any other group-specific term - if ( - _is_identity_link(self.link) - and self.bounds is not None - and any(np.isfinite(bound) for bound in self.bounds) - ): - _logger.warning( - "The generated group-only intercept for parameter " - "%s is on the response/parameter scale under the " - "identity link, but its coefficient prior does not " - "apply finite HSSM bounds %s due to a current " - "Bambi limitation. Likelihood-level parameter " - "bounds still apply. A support-respecting " - "transformed link instead uses an unconstrained " - "predictor scale; bound-aware identity group " - "priors are " - "tracked in HSSM #1269.", - self.name, - self.bounds, - ) + self._reject_unqualified_bounded_group_default(name, is_ddm) prior = get_prior( "group_intercept", self.name, @@ -384,6 +366,50 @@ def _make_safe_priors( safe_priors.update(self.prior) self.prior = safe_priors + def _reject_unqualified_bounded_group_default( + self, term_name: str, is_ddm: bool + ) -> None: + """Require an explicit decision for unsupported bounded group locations. + + HSSM's generic safe group-intercept hierarchy is unbounded. Applying it + to a bounded parameter through an identity link would therefore invent a + response-scale default that has not passed the bounded-hierarchy + qualification. Exact/black-box HDDM likelihoods keep their separately + calibrated natural-support hierarchies; explicit user priors never reach + this generated-default path. + """ + has_finite_bound = self.bounds is not None and any( + np.isfinite(bound) for bound in self.bounds + ) + if not _is_identity_link(self.link) or not has_finite_bound: + return + + if is_ddm: + _logger.warning( + "The generated group-only intercept for parameter %s retains " + "the calibrated HDDM natural-support hierarchy under the " + "identity link. Its coefficient prior does not apply the exact " + "finite HSSM bounds %s due to a current Bambi limitation; " + "likelihood-level parameter bounds still apply.", + self.name, + self.bounds, + ) + return + + raise ValueError( + "Safe priors cannot generate a qualified default for group-only " + f"intercept {term_name!r} on bounded parameter {self.name!r}. The " + f"identity link places this coefficient on the response scale with " + f"HSSM bounds {self.bounds}, while the generic group hierarchy is " + "unbounded. Choose a support-respecting transformed link (for " + "example `log` for positive parameters or `gen_logit` for a finite " + "interval), or supply an explicit centered hierarchical group " + "prior and accept responsibility for its support. Set " + "`prior_settings=None` only when specifying the complete prior " + "policy explicitly. HSSM does not apply coefficient bounds to a " + "complete additive predictor." + ) + def _validate_generated_group_locations(self) -> None: """Reject ambiguous safe defaults for repeated group-only expressions. diff --git a/src/hssm/prior.py b/src/hssm/prior.py index 8b6dcdb0f..2fbc57394 100644 --- a/src/hssm/prior.py +++ b/src/hssm/prior.py @@ -269,9 +269,9 @@ def get_default_prior( * common_intercept: Bounded Normal prior (N(mean(bounds), 0.25)). * common: Normal prior (N(0, 0.25)). * group_intercept: Normal prior N(N(0, 0.25), Weibull(1.5, 0.3)). Under a - transformed link this correctly lives on an unbounded predictor scale. Finite - coefficient bounds for identity-linked group-only intercepts are not yet - supported; see HSSM #1269. + transformed link this correctly lives on an unbounded predictor scale. Safe + generation fails closed for a finitely bounded, identity-linked group-only + intercept because this generic hierarchy does not respect response support. * group_specific: Normal prior N(N(0, 0.25), Weibull(1.5, 0.3). This function is taken from bambi.priors.prior.py and modified to handle hssm- diff --git a/tests/unit/param/test_regression_param.py b/tests/unit/param/test_regression_param.py index bfa23f49a..f6ea6cd98 100644 --- a/tests/unit/param/test_regression_param.py +++ b/tests/unit/param/test_regression_param.py @@ -908,7 +908,7 @@ def test_hddm_safe_group_only_intercept_uses_preset_identity(cavanagh_test): @pytest.mark.parametrize( - ("link", "expect_bounds_warning"), + ("link", "expect_error"), [ pytest.param(None, True, id="omitted-identity"), pytest.param("identity", True, id="string-identity"), @@ -932,10 +932,10 @@ def test_hddm_safe_group_only_intercept_uses_preset_identity(cavanagh_test): ), ], ) -def test_group_only_bounds_warning_is_identity_specific( - cavanagh_test, caplog, link, expect_bounds_warning +def test_bounded_group_only_default_requires_explicit_identity_decision( + cavanagh_test, link, expect_error ): - """Do not warn about response bounds on a transformed predictor scale.""" + """Fail closed only when a generated group location lives on bounded scale.""" param = RegressionParam( name="v", formula="v ~ 0 + (1 | participant_id)", @@ -943,34 +943,63 @@ def test_group_only_bounds_warning_is_identity_specific( link=link, ) + if expect_error: + with pytest.raises(ValueError) as error: + param.make_safe_priors(cavanagh_test, {}, is_ddm=False, noncentered=False) + message = str(error.value) + assert "cannot generate a qualified default" in message + assert "'1|participant_id'" in message + assert "bounded parameter 'v'" in message + assert "(0.0, 1.0)" in message + assert "support-respecting transformed link" in message + assert "explicit centered hierarchical group prior" in message + else: + param.make_safe_priors(cavanagh_test, {}, is_ddm=False, noncentered=False) + assert "1|participant_id" in param.prior + + +@pytest.mark.parametrize( + "bounds", [None, (-np.inf, np.inf)], ids=["no-bounds", "unbounded"] +) +def test_group_only_default_guard_requires_finite_bounds(cavanagh_test, bounds): + """Keep the generic group default when response support is unbounded.""" + param = RegressionParam( + name="v", + formula="v ~ 0 + (1 | participant_id)", + bounds=bounds, + link="identity", + ) + param.make_safe_priors(cavanagh_test, {}, is_ddm=False, noncentered=False) - messages = [ - record.message for record in caplog.records if "HSSM #1269" in record.message - ] - assert bool(messages) is expect_bounds_warning - if expect_bounds_warning: - assert len(messages) == 1 - assert "Likelihood-level parameter bounds still apply" in messages[0] + assert "1|participant_id" in param.prior @pytest.mark.parametrize( - "bounds", [None, (-np.inf, np.inf)], ids=["no-bounds", "unbounded"] + "prior_key", ["1|participant_id", "group_specific"], ids=["exact", "wildcard"] ) -def test_group_only_bounds_warning_requires_finite_bounds( - cavanagh_test, caplog, bounds +def test_bounded_group_only_default_guard_preserves_explicit_prior( + cavanagh_test, prior_key ): - """Do not claim omitted bounds when no finite response bound exists.""" + """Leave exact and wildcard centered hierarchies authoritative.""" + explicit = bmb.Prior( + "Normal", + mu=bmb.Prior("Normal", mu=0.5, sigma=0.2), + sigma=bmb.Prior("HalfNormal", sigma=0.1), + noncentered=False, + ) param = RegressionParam( name="v", formula="v ~ 0 + (1 | participant_id)", - bounds=bounds, + prior={prior_key: explicit}, + bounds=(0.0, 1.0), link="identity", ) - param.make_safe_priors(cavanagh_test, {}, is_ddm=True, noncentered=False) + param.make_safe_priors(cavanagh_test, {}, is_ddm=False, noncentered=True) - assert not any("HSSM #1269" in record.message for record in caplog.records) + assert param.prior == {prior_key: explicit} + assert param.prior[prior_key] is explicit @pytest.mark.parametrize( @@ -1084,15 +1113,8 @@ def test_make_safe_priors(cavanagh_test, caplog, param_name, bounds, is_ddm): bounds=bounds, ) - param_no_common_intercept.make_safe_priors(cavanagh_test, {}, is_ddm=False) - - assert any("limitation" in record.msg for record in caplog.records) - assert "Intercept" not in param_no_common_intercept.prior - group_intercept_prior = param_no_common_intercept.prior["1|participant_id"] - group_slope_prior = param_no_common_intercept.prior["theta|participant_id"] - - _check_group_prior(group_intercept_prior) - _check_group_prior(group_slope_prior) + with pytest.raises(ValueError, match="cannot generate a qualified default"): + param_no_common_intercept.make_safe_priors(cavanagh_test, {}, is_ddm=False) # Change back after testing hssm.set_floatX("float32") diff --git a/tests/unit/param/test_unmatched_group_prior_graph.py b/tests/unit/param/test_unmatched_group_prior_graph.py index 43f83cf48..91f5f5307 100644 --- a/tests/unit/param/test_unmatched_group_prior_graph.py +++ b/tests/unit/param/test_unmatched_group_prior_graph.py @@ -28,6 +28,16 @@ def _group_only_data() -> pd.DataFrame: return pd.DataFrame(rows) +def _choice_only_data() -> pd.DataFrame: + """Return deterministic choice data for the generic bounded-prior path.""" + return pd.DataFrame( + { + "response": [-1, 1, 1, -1, 1, -1, -1, 1], + "participant_id": ["p0"] * 4 + ["p1"] * 4, + } + ) + + def _build_ddm(parameter: str, formula: str, noncentered=True) -> hssm.HSSM: """Build a small analytical DDM without sampling or init-value processing.""" fixed = {"v": 1.0, "a": 1.5, "z": 0.5, "t": 0.2} @@ -103,6 +113,74 @@ def _build_group_only_intercept( ) +def _build_bounded_generic_group_only( + *, link=None, prior=None, prior_settings="safe" +) -> hssm.HSSM: + """Build the generic positive softmax hierarchy used by the release guard.""" + specification: dict[str, object] = { + "name": "beta", + "formula": "beta ~ 0 + (1 | participant_id)", + } + if link is not None: + specification["link"] = link + if prior is not None: + specification["prior"] = {"1|participant_id": prior} + + return hssm.HSSM( + data=_choice_only_data(), + model="softmax_inv_temperature_2", + include=[specification], + logit1=1.0, + p_outlier=0.0, + prior_settings=prior_settings, + noncentered=True, + process_initvals=False, + initval_jitter=0.0, + ) + + +def test_generic_bounded_identity_group_default_fails_before_bambi(): + """Reject the unqualified generated hierarchy at HSSM construction time.""" + with pytest.raises(ValueError) as error: + _build_bounded_generic_group_only() + + message = str(error.value) + assert "cannot generate a qualified default" in message + assert "bounded parameter 'beta'" in message + assert "(0.0, inf)" in message + + +def test_generic_bounded_transformed_group_default_builds_with_finite_start(): + """Keep safe predictor-scale priors and usable initialization under a log link.""" + model = _build_bounded_generic_group_only(link="log") + point = model.pymc_model.initial_point() + + assert model.params["beta"].prior["1|participant_id"].name == "Normal" + assert np.isfinite(model.pymc_model.compile_logp()(point)) + assert find_disconnected_free_rvs(model.pymc_model) == [] + + +@pytest.mark.parametrize("prior_settings", ["safe", None]) +def test_generic_bounded_identity_accepts_explicit_natural_support_hierarchy( + prior_settings, +): + """Keep an explicit positive centered hierarchy under either prior policy.""" + prior = bmb.Prior( + "Gamma", + mu=bmb.Prior("Gamma", mu=1.0, sigma=0.5), + sigma=bmb.Prior("HalfNormal", sigma=0.25), + noncentered=False, + ) + model = _build_bounded_generic_group_only( + prior=prior, prior_settings=prior_settings + ) + point = model.pymc_model.initial_point() + + assert model.params["beta"].prior["1|participant_id"] is prior + assert np.isfinite(model.pymc_model.compile_logp()(point)) + assert find_disconnected_free_rvs(model.pymc_model) == [] + + @pytest.mark.parametrize("noncentered", [True, False]) def test_generated_group_only_slope_is_connected_centered_term(caplog, noncentered): """Retain the generated free location without an offset or orphan node."""