The hardware and bandwidth for this mirror is donated by METANET, the Webhosting and Full Service-Cloud Provider.
If you wish to report a bug, or if you are interested in having us mirror your free-software or open-source project, please feel free to contact us at mirror[@]metanet.ch.

Arbitrary p: Operational Cookbook for Multivariate Fits

Building, fitting, predicting, and reporting AMM models with p > 1 (Path 1)

José Mauricio Gómez Julián

2026-07-06


1. What this vignette covers

The canonical AMM decomposition fitted by Path 1 of gdpar generalises from a scalar individual parameter \(\theta_i \in \mathbb{R}\) to a vector \(\theta_i \in \mathbb{R}^p\):

\[\theta_i[k] = \theta_{\text{ref}}[k] + a_k(x_i) + b_k(x_i)\,\theta_{\text{ref}}[k] + \bigl(W_k(\theta_{\text{ref}}) - W_k(\theta_{\text{anchor}})\bigr)\,x_i, \qquad k = 1, \ldots, p,\]

stacked across \(k\), this is the vector form

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

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 package factorises the likelihood across coordinates (architectural Option B canonised in Phase F of Block 5.2):

\[p(y_i \mid \theta_i) = \prod_{k=1}^{p} D_k\bigl(y_{ik} \mid \theta_i[k]\bigr),\]

with cross-dimensional coupling carried exclusively by the modulating component \(W(\theta_{\text{ref}})\). The additive component \(a_k\) and the multiplicative component \(b_k\) depend only on the covariates \(x_i\) and therefore factorise per coordinate; declaring them per coordinate is the user’s responsibility and is what the multivariate API of amm_spec() enforces.

This vignette is the operational cookbook for \(p > 1\) fits. It documents:

  1. The four construction paths for a multivariate amm_spec: direct, with dimwise() + override(), with the chainable amm_build() helper, and the canonical serialised form via amm_save_spec() / amm_load_spec().
  2. The multivariate family gdpar_family_multi(), its auto-promotion semantics, and the homogeneous-family restriction of the current release.
  3. The modulating component on a multivariate spec: W_basis(..., p = ...) materialisation, the structural asymmetry between a / b and W, and the separable polynomial / B-spline default.
  4. The full call cycle of gdpar() for p > 1: outcome layout as a matrix column, parametrization = "auto" together with parametrization_aggregation, and the per-coordinate pre-flight report.
  5. Extracting predictions and coefficients: predict() returning a three-dimensional array, coef() returning the unified gdpar_coef class, and as.data.frame() for downstream dplyr / ggplot2 pipelines.
  6. A runnable end-to-end smoke fit with p = 2.
  7. A short sub-section showing how to read the pre-flight calibration report produced by inst/benchmarks/scripts/report_hit_rate_multi.R so that users can build their own scenario-driven calibration workflows.
  8. Known limitations and pointers to the planned multi-parametric extension (Block 8).

For the underlying canonical form, identifiability, and the cross-dimension condition C4-bis, see vignette("v01_amm_identifiability", package = "gdpar"). For the scalar parametrization toggle and the three-filter pre-flight diagnostic, see vignette("vop01_parametrization_toggle", package = "gdpar"). The asymptotic theory of Path 1 lives in vignette("v04_asymptotics_path1_bayesian", package = "gdpar").


2. Building a multivariate spec

A multivariate spec differs from the scalar spec in one structural axis only: the per-dimension components a and b are supplied via the dims argument of amm_spec(), not via the top-level a and b arguments. The package rejects mixing the two paths in either direction (amm_spec(p = 2L, a = ~ x1) and amm_spec(p = 1L, dims = ...) both abort with an informative error). The modulating component W remains a single top-level argument regardless of p, because it depends on the full \(\theta_{\text{ref}}\) vector and therefore couples the dimensions.

2.1. Direct construction with dimwise()

When every coordinate of \(\theta_i\) shares the same additive and multiplicative bases (the most common case), wrap the common formulas with dimwise() and pass the result as dims:

library(gdpar)

spec_uniform <- amm_spec(
  p    = 2L,
  dims = dimwise(a = ~ x1 + x2, b = ~ x1),
  W    = W_basis(type = "polynomial", degree = 2)
)
print(spec_uniform)
#> <amm_spec> AMM Level 2
#>   p (dim theta_i)    : 2
#>   dims (per-k a, b)  :
#>     k = 1 : a = ~x1 + x2 ; b = ~x1
#>     k = 2 : a = ~x1 + x2 ; b = ~x1
#>   W (modulating)     : W_basis(type = 'polynomial')

The printer reports the AMM level, p, the per-coordinate a and b formulas (resolved from the uniform template), and the modulating basis. The internal slot spec_uniform$dims is a length-p list whose entries each carry an a and a b formula; bare formula values passed to dims are rejected to prevent silent recycling.

2.2. Per-dimension overrides with override()

When one or more coordinates need a different additive or multiplicative basis, layer override() on top of dimwise():

spec_mixed <- amm_spec(
  p    = 3L,
  dims = dimwise(a = ~ x1 + x2, b = ~ x1) |>
           override(k = 2L, a = ~ x1) |>
           override(k = 3L, b = NULL),
  W    = W_basis(type = "polynomial", degree = 2)
)
print(spec_mixed)
#> <amm_spec> AMM Level 2
#>   p (dim theta_i)    : 3
#>   dims (per-k a, b)  :
#>     k = 1 : a = ~x1 + x2 ; b = ~x1
#>     k = 2 : a = ~x1 ; b = ~x1
#>     k = 3 : a = ~x1 + x2 ; b = NULL
#>   W (modulating)     : W_basis(type = 'polynomial')

The semantics of “unchanged” versus “disabled” is encoded by missing() inside override(): omitting the argument inherits from the base, while passing NULL explicitly disables the component for that coordinate only. Multiple override() calls compose; calling override() twice with the same k replaces the previous override for that index.

2.3. Chainable construction with amm_build()

For programmatic construction (loops, generators, serialisation pipelines), the chainable amm_build() helper exposes the same semantics as dimwise() + override():

spec_chain <- amm_build(p = 3L) |>
  amm_set_a_uniform(~ x1 + x2) |>
  amm_set_a(k = 2L, ~ x1) |>
  amm_set_b_uniform(~ x1) |>
  amm_set_b(k = 3L, NULL) |>
  amm_set_W(W_basis(type = "polynomial", degree = 2)) |>
  amm_set_x_vars(c("x1", "x2")) |>
  as_amm_spec()
print(spec_chain)
#> <amm_spec> AMM Level 2
#>   p (dim theta_i)    : 3
#>   dims (per-k a, b)  :
#>     k = 1 : a = ~x1 + x2 ; b = ~x1
#>     k = 2 : a = ~x1 ; b = ~x1
#>     k = 3 : a = ~x1 + x2 ; b = NULL
#>   W (modulating)     : W_basis(type = 'polynomial')
#>   x_vars             : x1, x2

The chain is functionally equivalent to the direct construction of Section 2.2; as_amm_spec() finalises the builder by validating coherence and forwarding to amm_spec() on the appropriate path. Overrides accumulated via amm_set_a() / amm_set_b() survive subsequent calls to amm_set_a_uniform() / amm_set_b_uniform(), mirroring the composition behaviour of dimwise() |> override().

2.4. Canonical serialisation: amm_save_spec() and amm_load_spec()

The canonical file format is a small key: value grammar with a mandatory version header. Round-trip is bit-exact for polynomial and B-spline bases; user-defined W bases are rejected (their evaluator is an arbitrary R function that cannot be canonised). Parsing is purely lexical; no source() or eval() runs on the file contents, so loading from untrusted locations is safe.

tmp <- tempfile(fileext = ".gdpar")
amm_save_spec(spec_uniform, tmp)
cat(readLines(tmp), sep = "\n")
#> # gdpar_spec_version: 0.1.0
#> p: 2
#> a: NULL
#> b: NULL
#> x_vars: NULL
#> W.type: polynomial
#> W.degree: 2
#> dims.1.a: ~x1 + x2
#> dims.1.b: ~x1
#> dims.2.a: ~x1 + x2
#> dims.2.b: ~x1

spec_roundtrip <- amm_load_spec(tmp)
identical(spec_uniform$level, spec_roundtrip$level)
#> [1] TRUE

The serialised form records constructor inputs only, not derived state. In particular, a W_basis that was materialised at a specific \(p\) via W_basis(..., p = ...) is serialised as the equivalent unmaterialised basis; reconstruction at gdpar() time re-applies materialize_W_basis() with the p of the loaded spec.


3. Multivariate families: gdpar_family_multi()

The multivariate family declares one univariate family per coordinate of \(\theta_i\). The current release of Path 1 restricts the per-coordinate families to a homogeneous set (all entries must share the same stan_id); heterogeneous families per coordinate are deferred to a later sub-phase.

3.1. Construction

Three input forms are accepted:

fam_mv1 <- gdpar_family_multi("gaussian", p = 2L)
print(fam_mv1)
#> <gdpar_family_multi> coord-wise factorization
#>   p              : 2
#>   homogeneous    : TRUE
#>   name           : gaussian
#>   link           : identity
#>   has_dispersion : TRUE
#>   did_status     : holds
#>   did_reference  : Block 1, Section 6.4 (Lemma 1B)
#>   param_specs    : mu (per_observation), sigma (population) per coord (x2)

fam_mv2 <- gdpar_family_multi(gdpar_family("poisson"), p = 3L)
print(fam_mv2)
#> <gdpar_family_multi> coord-wise factorization
#>   p              : 3
#>   homogeneous    : TRUE
#>   name           : poisson
#>   link           : log
#>   has_dispersion : FALSE
#>   did_status     : holds
#>   did_reference  : Block 1, Section 6.4 (Lemma 1B)
#>   param_specs    : mu (per_observation) per coord (x3)

fam_mv3 <- gdpar_family_multi(
  list(
    gdpar_family("gaussian"),
    gdpar_family("gaussian")
  ),
  p = 2L
)
print(fam_mv3)
#> <gdpar_family_multi> coord-wise factorization
#>   p              : 2
#>   homogeneous    : TRUE
#>   name           : gaussian
#>   link           : identity
#>   has_dispersion : TRUE
#>   did_status     : holds
#>   did_reference  : Block 1, Section 6.4 (Lemma 1B)
#>   param_specs    : mu (per_observation), sigma (population) per coord (x2)

The first form (name string) is the most common; the second wraps an existing gdpar_family object; the third supplies an explicit list, useful when you want to vary the link function across coordinates in a future heterogeneous-family release while keeping today’s homogeneous-family code working.

3.2. Auto-promotion

When gdpar() is called with amm$p > 1L and a univariate gdpar_family object, the function auto-promotes the family to gdpar_family_multi(family, p = amm$p) and emits an informational message (gdpar_family_promotion_message). The convenience saves typing in interactive use; production scripts that need silence can pass an explicit gdpar_family_multi or wrap the call in suppressMessages().

fit <- gdpar(
  formula = y ~ x1 + x2,
  family  = gdpar_family("gaussian"),  # auto-promoted to gdpar_family_multi
  amm     = amm_spec(p = 2L,
                     dims = dimwise(a = ~ x1 + x2)),
  data    = df
)
# Informational message emitted (class gdpar_family_promotion_message):
#   "Auto-promoted univariate family 'gaussian' to gdpar_family_multi
#    via gdpar_family_multi(family, p = 2) because amm$p > 1.
#    Pass an explicit gdpar_family_multi to silence."

3.3. Constraints

The current release enforces two coherence invariants that abort gdpar() before sampling:

A gdpar_family_multi supplied to a spec with amm$p NULL or 1 is rejected with gdpar_input_error; the reverse mismatch (univariate family with amm$p > 1L) triggers the auto-promotion described in Section 3.2.


4. The modulating component for p > 1

The asymmetry between per-dimension components (a, b) and the cross-dimension component (W) is what makes the canonical AMM form expressive beyond the separable sub-class. The package therefore reflects this asymmetry at the API level: dims for a and b, a single top-level W argument for \(W\).

4.1. Materialisation at a given p

The polynomial and B-spline bases are separable: each coordinate of \(\theta_{\text{ref}}\) contributes an independent block of basis functions, concatenated in coordinate order. The total basis dimension and per-coordinate block indices are computed by materialize_W_basis(). This can run either eagerly at construction time (when p is supplied) or lazily inside gdpar():

wb_poly_p2 <- W_basis(type = "polynomial", degree = 2, p = 2L)
print(wb_poly_p2)
#> <W_basis>
#>   type   : polynomial
#>   degree : 2
#>   dim    : 4
#>   p      : 2 (multivariate, separable)
#>   block_indices: 1-2, 3-4
wb_poly_p2$block_indices
#> [[1]]
#> [1] 1 2
#> 
#> [[2]]
#> [1] 3 4

wb_bs <- W_basis(type = "bspline", degree = 3, df = 4, p = 2L)
print(wb_bs)
#> <W_basis>
#>   type   : bspline
#>   degree : 3
#>   df     : 4
#>   dim    : 8
#>   p      : 2 (multivariate, separable)
#>   block_indices: 1-4, 5-8
wb_bs$block_indices
#> [[1]]
#> [1] 1 2 3 4
#> 
#> [[2]]
#> [1] 5 6 7 8

The block_indices slot is a length-p list whose k-th entry holds the row indices of the basis output that correspond to coordinate k of \(\theta_{\text{ref}}\). For polynomial bases of degree degree, every block has length degree; for B-spline bases, every block has length df (or length(knots) + degree if knots is supplied instead of df).

4.2. Splitting a separable basis with as_per_k()

For introspection and per-coordinate diagnostics, as_per_k() returns a length-p list of univariate W_basis objects describing the contribution of each coordinate:

subs <- as_per_k(wb_poly_p2)
length(subs)
#> [1] 2
subs[[1L]]$dim
#> [1] 2
subs[[2L]]$dim
#> [1] 2

For user-defined W bases (type = "user"), as_per_k() returns NULL and emits a warning: the package cannot infer separability from an arbitrary R evaluator. Callers that need a per-coordinate decomposition for a user basis must construct it explicitly.

4.3. Why W stays top-level

Declaring W per coordinate would silently restrict the model class to the separable sub-class:

\[W(\theta_{\text{ref}}) x = \sum_{k=1}^{p} W_k(\theta_{\text{ref}}[k])\,x \quad \text{(separable)},\]

losing the cross-dimensional coupling that gives the AMM hierarchy its expressive power. The polynomial and B-spline bases are separable by construction, but they materialise as a single \(W\) object whose internal block structure is recorded in block_indices. Replacing this single object by a length-p list of independent bases is what the package refuses at construction time. The non-separable extension (cross-coupling polynomials and Gaussian-process priors on \(W\)) is on the roadmap; the current API is forward-compatible.


5. Calling gdpar() for p > 1

5.1. Outcome layout

The outcome must be a matrix column of data with ncol(y) == p. Two equivalent constructions:

df$y <- cbind(y1, y2)              # matrix column added to existing df
# or
df <- data.frame(x1 = ..., x2 = ...)
df$y <- y_matrix                   # y_matrix is a numeric matrix n x p

A vector outcome aborts with an informative gdpar_input_error. A matrix with ncol(y) != amm$p aborts as well. The package never imputes missing values: any NA in the outcome matrix aborts before sampling.

5.2. Parametrization aggregation

For p > 1, the pre-flight diagnostic is run per coordinate (Path B’ applied to each \(\theta_{\text{ref}}[k]\) independently). The per-coordinate decisions are aggregated to a per-component decision via the new argument parametrization_aggregation:

Strategy Behaviour When to use
"any_ncp" (default) Component is CP only when every coordinate’s decision is CP. A single NCP flips the component-wide decision to NCP. Conservative. Default. NCP geometry is monotonically safer in the worst case (more warmup steps in high-info, fewer divergences in low-info).
"majority" Component is CP if the strict majority votes CP. Ties break toward NCP. When per-coordinate decisions are expected to be near-uniform and you want the dominant signal.
"per_k" No aggregation; each coordinate keeps its own CP/NCP at the Stan-template level via segment()-based priors (Phase H.2 of Block 5.2). When coordinates have markedly different information regimes; needs the per-k Stan template wiring active.

The argument is ignored for the univariate path (amm$p == 1L). The full per-coordinate report is stored at fit$parametrization$report as an object of class gdpar_preflight_report.

5.3. Full call signature for the multivariate path

fit <- gdpar(
  formula                     = y ~ x1 + x2,
  family                      = gdpar_family_multi("gaussian", p = 2L),
  amm                         = spec_uniform,
  data                        = df,                # df$y is n x 2 matrix column
  parametrization             = "auto",            # runs per-coord preflight
  parametrization_aggregation = "any_ncp",         # default; conservative
  iter_warmup                 = 1000L,
  iter_sampling               = 1000L,
  chains                      = 4L,
  seed                        = 42L
)

The user-facing arguments are the same as the scalar path; the multivariate dispatch is internal and based on amm$p > 1L. All scalar-path arguments (anchor, skip_id_check, adapt_delta, max_treedepth, refresh, verbose, seed, parametrization_a, parametrization_W) are honored identically on the multivariate path.


6. Inspecting the pre-flight report

The S3 class gdpar_preflight_report bundles per-coordinate, per-component CP/NCP decisions together with an aggregated per-component summary. Access it via the exported accessors preflight_per_dim() and preflight_global_decision(), or via the S3 methods print(), summary(), as.data.frame(), and format().

# After the fit:
rep <- fit$parametrization$report   # gdpar_preflight_report
print(rep)                           # default: level = "global"
print(rep, level = "dim")            # per-coordinate detail
print(rep, level = "both")           # global + per-coord

preflight_per_dim(rep)               # tidy data.frame, one row per (component, dim)
preflight_global_decision(rep)       # one row per component
as.data.frame(rep)                   # same as preflight_per_dim()
summary(rep)                         # named list with aggregates

The per-coordinate table has columns (component, dim, decision, decision_reason, n_divergent, div_pct, ebfmi_min, t_attribution, t_info_cp, t_info_ncp). The decision_reason column carries the active-filter code (filter_attribution, filter_ebfmi, filter_info_high, filter_info_low, filter_info_ambiguous_ncp, filter_info_undefined_ncp, absent_or_degenerate). The reason codes are consistent with the univariate guide; see vignette("vop01_parametrization_toggle", package = "gdpar") Section 4 for the canonical table.

The global table has columns (component, global_decision, agreement, method). The agreement column reports the share of effective per-coordinate decisions matching the aggregated global_decision (or the modal-decision frequency when method = "per_k"). An agreement of 1.0 flags uniform; values strictly above 0.5 flag mixed; values at or below 0.5 flag split.

When per-coordinate decisions for \(W\) are heterogeneous (mixed CP / NCP), an informational message of class gdpar_W_per_k_heterogeneous_message is emitted at fit time documenting that the current model uses a single global sigma_W[1] shared across blocks. The per-coordinate decisions are recorded in the report for auditability; the sampler honors only the aggregated cp_W. Block 8 (multi-parametric extension) may promote sigma_W to array[p] and honor per-coordinate \(W\) decisions.


7. Extracting predictions and coefficients

7.1. predict() returns a 3-D array

For multivariate fits, predict(fit, ...) returns a three-dimensional array of shape (S, n, p) with dimnames list(NULL, row_names, paste0("dim_", seq_len(p))). Three type modes and three summary modes mirror the scalar path:

arr <- predict(fit)                                 # type = "theta_i", summary = "draws"
dim(arr)                                            # (S, n, p)

arr_resp <- predict(fit, type = "response")         # per-coord inverse link applied
arr_se   <- predict(fit, summary = "mean_se")       # list of p data.frames
arr_q    <- predict(fit, summary = "quantiles")     # list of p data.frames

arr_new  <- predict(fit, newdata = df_new)          # reconstruct from posterior draws

The reconstruction on new data mirrors exactly the formula encoded in the multivariate Stan template:

\[\eta_{i,k} = \theta_{\text{ref}, k} + Z_{a,k}[i, \cdot] \cdot a_{\text{coef}, k} + Z_{b,k}[i, \cdot] \cdot c_{b, k} + \sum_{j=1}^{W_{\text{per\_k\_dim}}} \bigl(\theta_{\text{ref}, k}^j - \theta_{\text{anchor}, k}^j\bigr)\,W_{\text{raw}}[r_{k,j}, \cdot]\,\sigma_W\,X[i, \cdot]^{\top},\]

with \(r_{k,j} = (k - 1) W_{\text{per\_k\_dim}} + j\) and the \(\sigma_W\) multiplier present only when the modulating component was sampled in the non-centered parametrization (cp_W = FALSE). The internal helper performs the per-coordinate centering via the means recorded at fit time.

7.2. coef() returns a unified gdpar_coef object

The S3 class gdpar_coef provides a single representation for both scalar and multivariate fits. The structure is:

gdpar_coef
  $p               : integer, the dimension of theta_i
  $summary_stats   : character vector, c("mean", "q05", "q50", "q95")
  $theta_ref       : data.frame; cols (k, mean, q05, q50, q95) with p
                     rows when grouping is inactive (J_groups == 1);
                     cols (g, k, mean, q05, q50, q95) with J_groups * p
                     rows when grouping is active
  $a               : NULL (component absent) or list of length p; each
                     entry NULL (coord inactive) or data.frame
                     (term, mean, q05, q50, q95)
  $b               : same conventions as $a
  $W               : NULL or list of length p; each entry NULL or
                     data.frame (basis_idx, x_name, mean, q05, q50, q95)
  $mu_theta_ref    : NULL when grouping is inactive; data.frame with
                     cols (k, mean, q05, q50, q95) and p rows under
                     grouping (per-coord posterior summary of the
                     hyper-mean shared across groups)
  $sigma_theta_ref : NULL when grouping is inactive; data.frame with
                     cols (k, mean, q05, q50, q95) and p rows under
                     grouping (per-coord posterior summary of the
                     hyper-scale)
  $J_groups        : integer, the number of grouping levels (1 when
                     grouping is inactive)
  $group_levels    : NULL when grouping is inactive; character vector
                     of length J_groups with the original group labels
                     preserved from the data (the integer code in
                     $theta_ref$g maps to group_levels[g])

For scalar fits (p = 1), the per-component slots are length-1 lists, not bare data.frames. This unification is deliberate: downstream code does not need to bifurcate on p == 1 versus p > 1.

Under grouping (J_groups > 1; see vignette("vop03_grouped_anchors", package = "gdpar") for the public API and the canonical use cases), the theta_ref data.frame gains an integer column g running over the grouping levels, and the hyper-parameter slots mu_theta_ref / sigma_theta_ref are populated with per-coord posterior summaries (NULL otherwise). The original group labels are preserved verbatim in group_levels, so group_levels[theta_ref$g] recovers them.

In the multivariate path, the W slot honors cp_W: if cp_W = TRUE, the centered parametrisation already absorbs the scale and the slot reports W_raw directly; if cp_W = FALSE, the non-centered draws are multiplied by sigma_W per sample before computing quantiles, so the reported coefficients are on the natural modulating scale.

Three S3 methods aid inspection:

cf <- coef(fit)
print(cf)                                       # level = "global", default
print(cf, level = "coord")                      # per-coord means only
print(cf, level = "full")                       # per-coord with full quantiles

summary(cf)                                     # aggregated counts + theta_ref mean
format(cf)                                      # one-line representation

7.3. as.data.frame() for tidy pipelines

The flattener returns a long-tidy data.frame with columns (component, k, identifier, x_name, mean, q05, q50, q95), ready for dplyr and ggplot2:

df_coef <- as.data.frame(coef(fit))
head(df_coef)

# Using dplyr with explicit namespace to avoid library() in vignettes:
df_coef |>
  dplyr::filter(component == "a") |>
  dplyr::group_by(k) |>
  dplyr::summarise(mean_abs = mean(abs(mean)),
                   max_q95  = max(q95))

The identifier column carries the term for the a and b slots, the basis_idx (formatted as a string) for W, and NA for theta_ref. The x_name column is NA everywhere except for W rows.


8. End-to-end worked example

A minimal Gaussian smoke fit with p = 2, two coordinates of \(\theta_i\), two covariates entering both the additive and the modulating components.

library(gdpar)

set.seed(42L)
n <- 300L
df <- data.frame(
  x1 = rnorm(n),
  x2 = rnorm(n)
)
# True theta_ref = c(0.5, -0.5), beta_a 2x2, sigma_y = 0.3 per coord
true_theta_ref <- c(0.5, -0.5)
true_beta_a <- matrix(c(0.8, -0.6,
                        0.4,  0.7), nrow = 2L, byrow = TRUE)
y_mat <- matrix(NA_real_, nrow = n, ncol = 2L)
for (k in seq_len(2L)) {
  y_mat[, k] <- true_theta_ref[k] +
                true_beta_a[k, 1L] * df$x1 +
                true_beta_a[k, 2L] * df$x2 +
                rnorm(n, sd = 0.3)
}
df$y <- y_mat
str(df)
#> 'data.frame':    300 obs. of  3 variables:
#>  $ x1: num  1.371 -0.565 0.363 0.633 0.404 ...
#>  $ x2: num  -0.00462 0.76024 0.03899 0.73507 -0.14647 ...
#>  $ y : num [1:300, 1:2] 1.525 -0.281 1.063 0.816 0.713 ...

The spec uses a uniform additive basis across both coordinates (each \(\theta_i[k]\) depends on x1 and x2):

spec <- amm_spec(
  p    = 2L,
  dims = dimwise(a = ~ x1 + x2)
)
print(spec)
#> <amm_spec> AMM Level 1
#>   p (dim theta_i)    : 2
#>   dims (per-k a, b)  :
#>     k = 1 : a = ~x1 + x2 ; b = NULL
#>     k = 2 : a = ~x1 + x2 ; b = NULL
#>   W (modulating)     : NULL

The fit with parametrization = "auto" runs the per-coordinate pre-flight, aggregates with the conservative default "any_ncp", and proceeds to the long fit. Short iteration counts are used here for vignette responsiveness; production calibration should match the defaults of the calibration scripts (iter_warmup = 1000, iter_sampling = 1000, chains = 2-4).

fit <- gdpar(
  formula                     = y ~ x1 + x2,
  family                      = gdpar_family("gaussian"),  # auto-promoted
  amm                         = spec,
  data                        = df,
  parametrization             = "auto",
  parametrization_aggregation = "any_ncp",
  iter_warmup                 = 300L,
  iter_sampling               = 300L,
  chains                      = 2L,
  refresh                     = 0L,
  verbose                     = FALSE,
  seed                        = 42L
)
print(fit)
#> <gdpar_fit>
#>   path                 : bayes
#>   family               : gaussian (link = identity)
#>   AMM Level            : 1
#>   p (theta_ref dim)    : 2
#>   anchor               : [0, 0]
#>   observations         : 300
#>   identifiability_pass : TRUE
#>   converged            : FALSE
#>   rhat_max             : 1.011
#>   ess_bulk_min         : 326.2
#>   divergent_count      : 0

The print method reports the AMM level, p, the anchor vector, observation count, identifiability pass, and the convergence verdict together with R-hat / ESS / divergent summaries.

fit$parametrization$cp_a
#> [1] TRUE
fit$parametrization$cp_W
#> [1] FALSE
fit$parametrization$cp_a_per_k
#> [1] TRUE TRUE
fit$parametrization$cp_W_per_k
#> [1] FALSE FALSE

The pre-flight report exposes the per-coordinate decisions and the active filter for each:

rep <- fit$parametrization$report
print(rep, level = "both")
#> <gdpar_preflight_report>
#>   p (theta_ref dim) : 2
#>   aggregation       : any_ncp
#>   components        : a, W
#> 
#>   global decisions:
#>     component  global_decision  agreement  flag   
#>     a          CP               1.00       uniform
#>     W          absent            NA               
#> 
#> 
#>   per-coordinate decisions:
#>     component  dim    decision  decision_reason       t_info_cp  t_info_ncp
#>     a              1  CP        filter_info_high      41.5       57.2      
#>     a              2  CP        filter_info_high      30.8         44      
#>     W              1  absent    absent_or_degenerate    NA         NA      
#>     W              2  absent    absent_or_degenerate    NA         NA

Coefficient extraction and tidy flattening:

cf <- coef(fit)
print(cf, level = "coord")
#> <gdpar_coef>
#>   p                 : 2
#>   summary_stats     : mean, q05, q50, q95
#>   components active : a(2/2) b(0/2) W(0/2)
#> 
#>   theta_ref:
#>  k    mean     q05     q50     q95
#>  1  0.4799  0.4544   0.48   0.5073
#>  2 -0.5278 -0.5578 -0.5275 -0.4973
#> 
#>   coord k = 1:
#>     a:
#>  term    mean
#>    x1  0.7795
#>    x2 -0.6181
#> 
#>   coord k = 2:
#>     a:
#>  term   mean
#>    x1 0.4029
#>    x2  0.698

df_coef <- as.data.frame(cf)
head(df_coef, 8L)
#>   component  g k identifier x_name       mean        q05        q50        q95
#> 1 theta_ref NA 1       <NA>   <NA>  0.4799498  0.4543930  0.4799642  0.5073288
#> 2 theta_ref NA 2       <NA>   <NA> -0.5277693 -0.5577847 -0.5275476 -0.4972924
#> 3         a NA 1         x1   <NA>  0.7794671  0.7522783  0.7793699  0.8054150
#> 4         a NA 1         x2   <NA> -0.6181115 -0.6449905 -0.6179875 -0.5899020
#> 5         a NA 2         x1   <NA>  0.4029126  0.3701883  0.4030815  0.4350416
#> 6         a NA 2         x2   <NA>  0.6980163  0.6689458  0.6979309  0.7261308

Prediction with summary = "quantiles" returns a list of p data.frames, one per coordinate:

q_list <- predict(fit, type = "response", summary = "quantiles")
length(q_list)
#> [1] 2
head(q_list$dim_1, 4L)
#>          q05        q50        q95
#> 1  1.5039983  1.5532166  1.5952615
#> 2 -0.4689861 -0.4295067 -0.3930356
#> 3  0.7110963  0.7387112  0.7666663
#> 4  0.4807776  0.5195424  0.5549394
head(q_list$dim_2, 4L)
#>            q05         q50        q95
#> 1 -0.001862122  0.04888494  0.1043911
#> 2 -0.239240930 -0.19585643 -0.1544547
#> 3 -0.358338547 -0.32636237 -0.2939195
#> 4  0.223606238  0.26874689  0.3107521

Diagnostics access via the convenience function:

diagnostics(fit)
#> <gdpar_diagnostics>
#>   converged           : FALSE
#>   rhat_max            : 1.011
#>   ess_bulk_min        : 326.2
#>   ess_tail_min        : 221.9
#>   divergent_count     : 0
#>   treedepth_saturated : 0
#>   efmi_min            : 0.8772

The fit can be archived together with the spec via amm_save_spec(); the spec is the only object needed to re-build the design at load time (the data and the seed reproduce the rest).


9. Reporting parametrization decisions across scenarios

For workflows that calibrate the pre-flight against custom scenarios, the package ships two scripts in inst/benchmarks/:

The reporter is intended as a template; copy and adapt it for domain-specific scenario batteries.

9.1. Reading the CSV directly

csv_path <- system.file(
  "benchmarks", "results", "cp_ncp_hit_rate_multi.csv",
  package = "gdpar"
)
results <- utils::read.csv(csv_path, stringsAsFactors = FALSE)
str(results)

The CSV is long-tidy with one row per (scenario, p, k, component) combination. The column regime_truth is NA for borderline scenarios where no ground-truth parametrisation is defined a priori; hit is NA for those rows. The columns n_div_pred and n_div_alt are the divergent counts of the predicted and contrastive (alternative) fits.

9.2. Hit-rate aggregation

The reporter aggregates hits per component and per scenario, restricting to rows with a defined truth:

sub <- results[!is.na(results$regime_truth) & !is.na(results$hit), ]
hits_comp <- aggregate(hit ~ component, data = sub,
                        FUN = function(x) mean(as.logical(x)))
hits_sc <- aggregate(hit ~ scenario, data = sub,
                      FUN = function(x) mean(as.logical(x)))
print(hits_comp)
print(hits_sc)

The package ships a baseline run where the hit rate is 18 / 18 = 1.00 over the truth-defined rows of the canonical eight scenarios. In the contrastive comparison, n_div_alt >= n_div_pred holds for every scenario, confirming empirically that the pre-flight chose the parametrisation with fewer divergences.

9.3. Faceted plot

When ggplot2 is available, the reporter writes cp_ncp_hit_rate_multi.png (facets scenario ~ component, fill by hit / miss / borderline) and cp_ncp_div_pred_vs_alt.png (dodge per scenario, predicted vs alternative divergences). The relevant chunk is:

# Using ggplot2 with explicit namespace to avoid library() in vignettes;
# the actual reporter at inst/benchmarks/scripts/report_hit_rate_multi.R
# follows the same convention.
results$hit_label <- ifelse(
  is.na(results$hit), "borderline",
  ifelse(as.logical(results$hit), "hit", "miss")
)
ggplot2::ggplot(
  results,
  ggplot2::aes(x = factor(k), fill = hit_label)
) +
  ggplot2::geom_bar(width = 0.7) +
  ggplot2::facet_grid(scenario ~ component, scales = "free_x",
                       space = "free_x") +
  ggplot2::scale_fill_manual(values = c(
    "hit"        = "#2c7bb6",
    "miss"       = "#d7191c",
    "borderline" = "#fdae61"
  )) +
  ggplot2::labs(x = "k (coordinate)", y = "count", fill = "verdict") +
  ggplot2::theme_minimal()

Users adapting the reporter to their own scenarios should preserve the long-tidy CSV schema (scenario, p, k, component, regime_truth, regime_pred, hit, decision_reason, n_div_pred, n_div_alt, ebfmi_min, t_attr, t_info_cp, t_info_ncp) so that the aggregation code keeps working unchanged.

The companion vignette vop03_regression_testing documents a complementary tool: the four-layer comparator gdpar_golden_compare() that locks the posterior of a reference fit so that future runs detect any regression in the sampling-side output. The reporter of this section focuses on the decision of the pre-flight; the comparator focuses on the realised draws after the long fit.


10. PSIS-LOO via gdpar_loo()

The Stan template emits the per-observation log-likelihood as a generated quantity (log_lik[i, k] for p > 1, log_lik[i] for p = 1). The helper gdpar_loo() wraps these draws into the loo::loo() workflow and returns the standard psis_loo object with elpd_loo, its standard error, and the Pareto-\(k\) diagnostics.

10.1. Default aggregation: per-subject

For p > 1, the observational unit is the row (subject) of the input data. Following the coord-wise factorisation \(p(y_i \mid \theta_i) = \prod_k D_k(y_{ik} \mid \theta_i[k])\), the per-subject log-likelihood is \(\log p(y_i \mid \theta_i) = \sum_k \log p(y_{ik} \mid \theta_i[k])\). This is the default aggregation = "subject" and it matches the convention used by brms multivariate fits with set_rescor(FALSE), so the resulting elpd_loo values are directly comparable to per-coordinate competitors aggregated identically.

fit <- gdpar(
  formula  = y ~ x1 + x2,
  family   = gdpar_family_multi("gaussian", p = 2L),
  amm      = amm_spec(p = 2L, dims = dimwise(a = ~ x1 + x2, b = NULL)),
  data     = train_df,
  refresh  = 0L, seed = 42L
)
lo <- gdpar_loo(fit)
print(lo)
# elpd_loo, se_elpd_loo, Pareto-k summary as in loo::loo

10.2. Diagnostic aggregation: per-cell

aggregation = "cell" treats each pair \((i, k)\) as an independent observation, yielding PSIS-LOO over \(n \cdot p\) cells. It is useful when Pareto-\(k\) mass concentrates in a specific coordinate (a marginally identified component for that dimension), but it conflates subject-level and coordinate-level cross-validation: do not report cell-aggregated elpd_loo as comparable to subject-aggregated values from other methods.

lo_cell <- gdpar_loo(fit, aggregation = "cell")
sum(lo_cell$diagnostics$pareto_k > 0.7) # per-cell concentration

10.3. Pareto-\(k\) caveats

Pareto-\(k\) values above 0.7 signal that the PSIS approximation is unreliable for the affected observations. The standard refinements are loo::loo_moment_match() (cheap re-weighting) and loo::reloo() (per-observation re-fit, expensive). Both accept the gdpar_loo() output and the corresponding fit$fit cmdstanr object directly.

10.4. Experimental status

gdpar_loo() is flagged with @keywords experimental. The aggregation rule is stable; the signature may gain additional arguments in future versions (for example integrand for non-pointwise predictive quantities).


11. Known limitations

11.1. Separable W only

The package-provided \(W\) bases (polynomial and B-spline) are separable in the per-coordinate sense described in Section 4.3. Non-separable bases that cross-couple coordinates of \(\theta_{\text{ref}}\) require either W_basis(type = "user", basis_fn = ...) (with the caveat that as_per_k() returns NULL for user bases) or the future non-separable extension on the package roadmap.

11.2. Per-coordinate heterogeneous families are a future block (per-slot heterogeneity is implemented as of 8.3.7)

The release enforces that all per-coordinate families of gdpar_family_multi() share the same stan_id and link function. The package distinguishes two orthogonal kinds of family heterogeneity:

11.3. Single global sigma_W shared across blocks

The multivariate Stan template uses one sigma_W[1] shared across all \(W\) blocks. When the pre-flight per-coordinate decisions for \(W\) are heterogeneous, the package emits a gdpar_W_per_k_heterogeneous_message documenting that the sampler honors only the aggregated cp_W. The per-coordinate decisions remain in the report for auditability. Promotion of sigma_W to array[p] is scoped for Block 8 (multi-parametric extension) together with the related per-coordinate prior policy.

11.4. Multi-parametric extension is a future block

The factorisation \(p(y_i \mid \theta_i) = \prod_k D_k(y_{ik} \mid \theta_i[k])\) canonised here is the coord-wise option (Option B of Phase F). The alternative multi-parametric option (Option A: a single univariate outcome parametrised by the entire vector \(\theta_i\), e.g., Gaussian with \(\theta_i = (\mu_i, \log\sigma_i)\) in the distributional regression sense) is scoped for Block 8, after the coord-wise validation against TOP3 competitors (Blocks 6-7).

11.5. Pre-flight wall-time

The per-coordinate pre-flight adds roughly 30 % wall-time per gdpar() call (one compilation, one short fit with iter_warmup = iter_sampling = 200, two chains, adapt_delta = 0.95, max_treedepth = 10). In production pipelines where this cost is unacceptable, run parametrization = "auto" once during prototyping, read the resolved decisions from fit$parametrization, and pass them explicitly in subsequent calls via parametrization_a / parametrization_W.


12. References and cross-references

These binaries (installable software) and packages are in development.
They may not be fully stable and should be used with caution. We make no claims about them.