The hardware and bandwidth for this mirror is donated by dogado GmbH, the Webhosting and Full Service-Cloud Provider. Check out our Wordpress Tutorial.
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[@]dogado.de.

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.
Health stats visible at Monitor.