Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 6 additions & 3 deletions docs/how_to/specify_group_priors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
64 changes: 45 additions & 19 deletions src/hssm/param/regression_param.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.

Expand Down
6 changes: 3 additions & 3 deletions src/hssm/prior.py
Original file line number Diff line number Diff line change
Expand Up @@ -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-
Expand Down
76 changes: 49 additions & 27 deletions tests/unit/param/test_regression_param.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -932,45 +932,74 @@ 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)",
bounds=(0.0, 1.0),
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(
Expand Down Expand Up @@ -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")
Expand Down
78 changes: 78 additions & 0 deletions tests/unit/param/test_unmatched_group_prior_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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."""
Expand Down
Loading