Per-parameter centered vs. non-centered parameterization¶
Hierarchical models can express a group-specific (random) effect in one of two equivalent ways:
- Centered: $\theta_g \sim \mathrm{Normal}(\mu, \sigma)$.
- Non-centered: $\theta_g = \mu + \sigma \, z_g$, with $z_g \sim \mathrm{Normal}(0, 1)$.
They imply the same model but sample very differently. Non-centered usually samples better when a group is only weakly informed by the data (it avoids Neal's funnel); centered can be better when a group is strongly informed. In a model with several parameters, the better choice can differ from parameter to parameter — so a single global switch is often too coarse.
HSSM surfaces bambi's per-parameter control, letting you pick the parameterization per parameter, or even per term.
Requirements. The per-parameter forms below (a
noncentereddict and the per-priornoncenteredfield) require bambi ≥ 0.19 (PR #983), which HSSM depends on. The plainnoncentered=True/Falseform works on any supported bambi.
Setup¶
import logging
import warnings
warnings.simplefilter("ignore")
logging.getLogger("hssm").setLevel(logging.ERROR) # quiet info/warnings for the demo
import bambi as bmb
import hssm
from hssm import Prior
hssm.set_floatX("float32")
print("hssm", hssm.__version__, "| bambi", bmb.__version__)
You supplied a model 'lba4', which is currently not supported in the ssm_simulators package. An error will be thrown when sampling from the random variable or when using any posterior or prior predictive sampling methods.
Setting PyTensor floatX type to float32.
Setting "jax_enable_x64" to False. If this is not intended, please set `jax` to False.
hssm 0.4.0 | bambi 0.19.0
data = hssm.load_data("cavanagh_theta")
data.head()
| participant_id | stim | rt | response | theta | dbs | conf | |
|---|---|---|---|---|---|---|---|
| 0 | 0 | LL | 1.21 | 1.0 | 0.656275 | 1 | HC |
| 1 | 0 | WL | 1.63 | 1.0 | -0.327889 | 1 | LC |
| 2 | 0 | WW | 1.03 | 1.0 | -0.480285 | 1 | HC |
| 3 | 0 | WL | 2.77 | 1.0 | 1.927427 | 1 | LC |
| 4 | 0 | WW | 1.14 | -1.0 | -0.213236 | 1 | HC |
We will detect the parameterization structurally, without sampling: bambi
creates a standard-normal <param>_..._offset random variable for each
non-centered group term (and a Deterministic equal to offset * sigma). A
centered term instead has a direct <param>_<group> random variable. So the
set of parameters that own an *_offset free RV is exactly the set of
non-centered parameters.
def noncentered_params(model):
"""Return HSSM parameters whose group term uses the non-centered form."""
names = [rv.name for rv in model.pymc_model.free_RVs]
return sorted({n.split("_")[0] for n in names if "_offset" in n})
A hierarchical model — the default¶
We give both v (drift rate) and a (boundary separation) a by-participant
random intercept. bambi's default is non-centered everywhere, so both
parameters get an *_offset.
hierarchical = [
{"name": "v", "formula": "v ~ 1 + (1|participant_id)"},
{"name": "a", "formula": "a ~ 1 + (1|participant_id)"},
]
model_default = hssm.HSSM(data=data, model="ddm", include=hierarchical, p_outlier=0.0)
noncentered_params(model_default) # -> ['a', 'v']
Model initialized successfully.
['a', 'v']
Choose per parameter with a dict¶
Pass noncentered as a dict keyed by HSSM parameter name. Here we make
v centered while keeping a non-centered. Only a keeps an *_offset.
model_mixed = hssm.HSSM(
data=data,
model="ddm",
include=hierarchical,
p_outlier=0.0,
noncentered={"v": False, "a": True},
)
noncentered_params(model_mixed) # -> ['a'] (v is now centered)
Model initialized successfully.
['a']
Override a single term with a per-prior noncentered¶
For the finest control, attach noncentered directly to a term's prior. It
overrides the model-level setting for just that term. The precedence is:
per-prior noncentered > model-level noncentered dict > default (True)
This works whether you specify the prior as a plain dict or as an
hssm.Prior object. Below, the model-level dict asks for centered v, but the
per-prior field wins and makes it non-centered.
prior = {
"1|participant_id": Prior(
"Normal",
mu=0.0,
sigma=Prior("HalfNormal", sigma=1.0),
noncentered=True, # overrides the model-level setting for this term
)
}
model_override = hssm.HSSM(
data=data,
model="ddm",
include=[{"name": "v", "formula": "v ~ 1 + (1|participant_id)", "prior": prior}],
p_outlier=0.0,
noncentered={"v": False}, # model says "centered"; the per-prior field wins
)
noncentered_params(model_override) # -> ['v']
Model initialized successfully.
['v']
Caveats¶
- Hierarchical terms only.
noncenteredaffects group-specific terms (e.g.... + (1|participant_id)). Setting it for a parameter with no group term is a silent no-op. - Unknown keys fail fast. A typo'd parameter name in the dict raises at construction, listing the valid names.
muis dropped under non-centered. The non-centered reparameterization isoffset * sigma; a non-zeromuon a groupNormalprior is ignored when that term is non-centered. Put the location on the commonInterceptinstead.- Bounded group priors. A bounded/truncated prior on a group term is
incompatible with bambi's group-hyperprior requirement regardless of
noncentered— unrelated to this feature, but worth knowing.
The unknown-key guard in action:
try:
hssm.HSSM(
data=data,
model="ddm",
include=[{"name": "v", "formula": "v ~ 1 + (1|participant_id)"}],
p_outlier=0.0,
noncentered={"vv": True}, # typo: there is no parameter "vv"
)
except ValueError as err:
print(err)
Unknown component name(s) in `noncentered`: ['vv']. Valid component names for this model: ['a', 't', 'v', 'z'].