Per-group hierarchical anchors: Operational Guide


1. What this vignette covers

Block 6.5 promotes the population-level reference \(\theta_{\text{ref}}\) of the AMM canonical decomposition

\[\theta_i = \theta_{\text{ref}} + a(x_i) + b(x_i) \odot \theta_{\text{ref}} + W(\theta_{\text{ref}})\,x_i\]

from a single global scalar (Block 6 default) to a per-group vector \(\theta_{\text{ref}}[g]\), \(g = 1, \ldots, J_{\text{groups}}\), sampled hierarchically from \(\mathrm{Normal}(\mu_{\theta_{\text{ref}}}, \sigma_{\theta_{\text{ref}}})\) with both hyperparameters estimated. The model under grouping is

\[\theta_i = \theta_{\text{ref}}[g_i] + a(x_i) + b(x_i) \odot \theta_{\text{ref}}[g_i] + W(\theta_{\text{ref}}[g_i])\,x_i, \qquad \theta_{\text{ref}}[g] \sim \mathrm{Normal}(\mu_{\theta_{\text{ref}}}, \sigma_{\theta_{\text{ref}}}),\]

where \(\odot\) denotes the Hadamard (elementwise) product, coherent with the canonical notation of vignette("v00_framework_overview", package = "gdpar") §8.2 and vignette("v01_amm_identifiability", package = "gdpar") §3.3.

The user activates the grouping via the one-sided formula argument group of gdpar(). Default group = NULL reduces bit-exactly to the Block 6 scalar regime, so existing fits and tests do not change.

This vignette documents:

  1. The group API and what changes inside the model.
  2. The pre-flight condition (C7) that prevents structural aliasing of the per-group anchor with a or b.
  3. The coef() schema under grouping: per-group anchors plus the population-level hyperparameters.
  4. The predict() semantics for newdata that contains levels unseen at fit time.
  5. The relationship with the parametrization toggle of vop01_parametrization_toggle.

2. API

2.1. The group argument

group is a one-sided formula identifying a single variable in data:

fit <- gdpar(
  formula = y ~ x1 + x2,
  family  = gdpar_family("gaussian"),
  amm     = amm_spec(a = ~ x1 + x2),
  data    = df,
  group   = ~ school    # promotes theta_ref to per-school anchor
)

Constraints accepted by the resolver .resolve_group_argument():

  • One-sided formula with exactly one variable name. Examples: ~ school, ~ region. Expressions such as ~ school + region are rejected; if you need an interaction, materialize it as a column beforehand: df$cell <- interaction(df$school, df$region); gdpar(..., group = ~ cell).
  • The variable must exist in data, contain no NA values, and have at least one level. Internally it is coerced to a factor; the factor levels are stored in fit$group_info$levels and used later by predict() to map newdata.
  • The resolver emits an informational warning of class gdpar_grouping_warning when at least one level has fewer than 5 observations: the hierarchical anchor is then dominated by shrinkage and per-group summaries should be interpreted with caution.

2.2. What changes inside the model

  • Stan-side declaration. vector[J_groups] theta_ref (univariate) or array[J_groups] vector[p] theta_ref (multivariate), with mu_theta_ref and sigma_theta_ref allocated only when use_groups = 1. The model branch is the standard hierarchical prior theta_ref[g] ~ Normal(mu_theta_ref, sigma_theta_ref) with weakly informative hyperpriors (default normal(0, 2.5) on mu_theta_ref and student_t(3, 0, 1) on sigma_theta_ref).
  • Derived quantities. The b_coef = c_b / theta_ref derived quantity is not reported when use_groups = 1, because it would be ambiguous per group (different denominator per row). The structurally identified c_b is sampled and reported instead; users can derive b_coef[j, g] = c_b[j] / theta_ref[g, k] post-hoc per group if they need to.
  • Prior schema. gdpar_prior(sigma_theta_ref = "student_t(3, 0, 1)") is the new hyperprior knob; the existing theta_ref prior now applies to mu_theta_ref under grouping (and stays applied to the scalar theta_ref when group = NULL).

3. Pre-flight condition (C7)

When group is supplied, the pre-flight check .check_group_aliasing_c7() runs in addition to the standard Block 1 identifiability check. It enforces that neither a nor b contains columns that are linearly dependent with the group indicator \(G\). The check has two layers:

  1. Constant-per-group detection. Any column of \(Z_a\) or \(Z_b\) that is constant within every level of group is rank-deficient with \(G\) and creates a perfect alias with \(\theta_{\text{ref}}[g]\). This catches the most frequent mistake: including factor(group) (or any deterministic function of group) in a or b.
  2. Joint QR rank check. For the centered design block \(Z\), form \([G \mid Z]\), normalize column-wise, and verify \(\mathrm{rank}([G \mid Z]) = \mathrm{ncol}([G \mid Z])\). This catches indirect aliases that the per-column check misses (a non-trivial linear combination of \(Z\) columns matching a column of \(G\)).

On violation, gdpar() aborts with gdpar_input_error naming the offending columns. The user either removes them from the AMM spec or drops the group argument.

Defense in depth: the standard Block 1 identifiability check (gdpar_check_identifiability()) frequently catches the direct factor(group) case before C7 even runs, because \(Z_a\) centered is rank-deficient with the implicit intercept that the model carries via \(\theta_{\text{ref}}\). C7 covers the residual subtler aliases.

See vignette("v01_amm_identifiability", package = "gdpar") Section 6.6.2 for the formal statement and proofs.


4. Coefficient summary under grouping

coef(fit) returns an object of class gdpar_coef with the following slots when use_groups = 1:

  • theta_ref: data.frame with columns (g, k, mean, q05, q50, q95). One row per (group, coordinate). In the univariate case (p = 1), k is constant at 1. The factor levels are stored in fit$group_info$levels for the mapping g <-> name.
  • mu_theta_ref: data.frame with columns (k, mean, q05, q50, q95). One row per coordinate. The population-level mean of the per-group anchors.
  • sigma_theta_ref: data.frame with columns (k, mean, q05, q50, q95). Same shape. The population-level scale.
  • a, b, W: unchanged from Block 6.

Under use_groups = 0 (default), the theta_ref data.frame keeps the Block 6 columns (k, mean, q05, q50, q95) without g, preserving backward compatibility bit-exactly.

as.data.frame(coef(fit)) returns a long-tidy table that always includes the g column (NA when grouping is inactive) plus rows tagged component = "mu_theta_ref" and component = "sigma_theta_ref" when present.

Example (untested):

cf <- coef(fit)
cf$theta_ref          # one row per (g, k)
cf$mu_theta_ref       # population-level mu
cf$sigma_theta_ref    # population-level sigma
as.data.frame(cf)     # long-tidy: component, g, k, identifier, x_name, mean, q05, q50, q95

The print method shows the population-level hyperparameter blocks first, followed by the per-group anchors and the per-component summaries.


5. Cross-group prediction

predict(fit, newdata = new_df, ...) under a grouped fit resolves new_df[[var_name]] against fit$group_info$levels to obtain the per-row group index:

  • Known levels. The corresponding posterior column theta_ref[, g] (or theta_ref[, g, k] in multivariate) is read draw by draw and used as \(\theta_{\text{ref}}\).
  • Unseen levels. The function emits a gdpar_predict_unseen_group_warning naming the offending levels and, for each affected row, draws \(\theta_{\text{ref}}\) from the marginal prior predictive \(\mathrm{Normal}(\mu_{\theta_{\text{ref}}}[s], \sigma_{\theta_{\text{ref}}}[s])\) per posterior draw \(s\) (and per coordinate \(k\) in multivariate). The prediction is therefore well-defined but its uncertainty includes the population-level variance, not just the within-group variance.

The newdata data.frame must contain the same grouping variable. If absent, predict() aborts with gdpar_input_error.

The b-block of the predictor uses c_b directly under grouping (consistent with the Stan model that does not report b_coef per group). Mathematically the formula is the same as in Block 6 with Z_b %*% c_b replacing Z_b %*% b_coef * theta_ref, since c_b = theta_ref * b_coef by construction.

Example (untested):

new_df <- df[1:10, ]
new_df$school <- factor(c(rep("school_A", 8), rep("new_school_X", 2)),
                        levels = c(levels(df$school), "new_school_X"))
# predict() emits a single warning naming "new_school_X" and uses the
# prior predictive marginal for those two rows:
pred <- predict(fit, newdata = new_df, summary = "draws")

6. Interaction with the parametrization toggle

The parametrization toggle (parametrization, parametrization_a, parametrization_W) discussed in vignette("vop01_parametrization_toggle", package = "gdpar") is orthogonal to grouping: it controls the CP/NCP geometry of the hierarchical scales sigma_a and sigma_W on the basis coefficients, not the hierarchical scale sigma_theta_ref on the per-group anchors. The per-group anchor is always sampled from a standard random-intercept hierarchy without an additional CP/NCP toggle.

In practice, when a fit shows divergences under grouping, the diagnostic order is:

  1. Confirm pre-flight (C7) is satisfied (otherwise the divergences are structural, not geometric).
  2. Inspect fit$parametrization$meta for the per-component CP/NCP decision; switch to the alternative parametrization with the standard toggle if sigma_a or sigma_W is the culprit.
  3. If the divergences persist and concentrate on theta_ref[g], consider tightening gdpar_prior(sigma_theta_ref = ...), since with very few observations per group the random-intercept funnel can be severe regardless of the per-component parametrization.

7. Reproducibility checklist

  • The group argument is part of the call recorded in fit$call. Re-running the call with the same seed and the same data (including the same factor levels for the grouping variable) reproduces the fit bit-exactly.
  • fit$group_info stores the resolved metadata: var_name, levels, and group_id (the integer vector of length n). The mapping g <-> name is therefore portable across sessions through the saved gdpar_fit object.
  • coef(fit)$group_levels returns the same character vector, useful for plotting per-group anchors with the original labels.

8. See also

  • vignette("v01_amm_identifiability", package = "gdpar") Section 6.6.2 for the formal statement of (C7).
  • vignette("v03_special_cases", package = "gdpar") Section 4.1 for the random-intercept canonization.
  • vignette("vop01_parametrization_toggle", package = "gdpar") for the CP/NCP toggle on the hierarchical scales of a and W.