---
title: "**Intermediate AMM Specifications: B-spline `W` Bases and Heterogeneous Families per Slot**"
subtitle: "Tutorial for users moving past the polynomial / homogeneous baseline (Path 1)"
author: "**José Mauricio Gómez Julián**"
date: "`r Sys.Date()`"
output:
  rmarkdown::html_vignette:
    toc: true
    toc_depth: 3
vignette: >
  %\VignetteIndexEntry{Intermediate AMM Specifications: B-spline W Bases and Heterogeneous Families per Slot}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

```{r setup, include=FALSE}
knitr::opts_chunk$set(
  echo = TRUE, message = FALSE, warning = FALSE,
  collapse = TRUE, comment = "#>"
)
have_cmdstan <- requireNamespace("cmdstanr", quietly = TRUE) &&
  isTRUE(try(cmdstanr::cmdstan_version(), silent = TRUE) != "")
```

---

# **1. What this vignette covers**

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,$$

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,

allows two refinements that take a model out of the polynomial / homogeneous baseline and into the *intermediate* regime — that is, still a single-anchor canonical fit, but with a richer modulating geometry and per-slot heterogeneous families when the user runs distributional regression (`K > 1`). This vignette documents both refinements in the order in which a user typically encounters them.

1. **B-spline `W` bases** (sub-phase 8.3.8). The default `W_basis(type = "polynomial", dim = 2L)` is sufficient when the modulating effect $W(\theta_{\text{ref}})$ is smooth and low-curvature. For non-monotone or sharply varying effects, a B-spline basis preserves the AMM canonical form while giving local flexibility.
2. **Heterogeneous families per slot** (sub-phase 8.3.7). For distributional regression `K > 1`, the default homogeneous family (the same `gdpar_family` for every slot) can be replaced by a *named list* of `gdpar_family` objects, one per slot, when the location slot's likelihood admits multiple compatible auxiliary parametrizations (e.g., a Gaussian location with a Beta-distributed dispersion slot).

Both refinements are orthogonal: a fit may combine a B-spline `W` with heterogeneous families, or use one without the other.

For the API of `K > 1` distributional regression as a whole and for the residual / DHARMa workflow that complements these fits, see `vignette("vop05_distributional_K_dharma", package = "gdpar")`.

---

# **2. B-spline `W` bases**

## **2.1. When to switch from polynomial to B-spline**

`W(\theta_{\text{ref}})` is a univariate function of the anchor evaluated at each observation's posterior reference value. The polynomial basis of dimension $\dim_W$ writes

$$W(\theta_{\text{ref}}) = \sum_{j=1}^{\dim_W} W_j \,(\theta_{\text{ref}}^j - \theta_{\text{anchor}}^j),$$

which is smooth and parsimonious but globally rigid. For modulating effects that are *piecewise* smooth — for example, a saturating curve or a sigmoidal shift — a B-spline basis evaluates a local set of basis functions, each supported on a few neighbouring knots, and gives finer control over local curvature without inflating the global degree.

**Rule of thumb.** Start with `W_basis(type = "polynomial", dim = 2L)` (the default). Switch to B-spline when (i) posterior predictive checks (see `vignette("vop05_distributional_K_dharma")`) show systematic deviations of `W` away from a global low-degree polynomial, or (ii) you have prior knowledge that the modulating effect is non-monotone over the operative range of `theta_ref`.

## **2.2. API**

```{r bspline-api, eval=FALSE}
library(gdpar)

# Polynomial baseline (default)
W_poly <- W_basis(type = "polynomial", dim = 2L)

# B-spline with internal knots given explicitly, cubic by default
W_bs <- W_basis(
  type           = "bspline",
  knots          = c(-0.5, 0.0, 0.5),
  degree         = 3L,
  boundary_knots = c(-1.5, 1.5)
)
```

Three points of contract:

- `boundary_knots` is **mandatory** for B-spline bases (sub-phase 8.3.8, decision D4). It pins the domain of the basis evaluation; outside it, the R-side validator aborts. Stan-side evaluation is unconstrained, mirroring the conventional `splines::bs()` behaviour, but a fit that crosses the boundary at sampling time will return basis values that extrapolate. Set `boundary_knots` to cover the operative range of `theta_ref` with a small margin (e.g., 5 to 10% of the prior anchor's marginal SD on each side).
- `degree = 3L` is the default (cubic). `degree = 1L` gives piecewise linear basis functions; `degree = 2L` quadratic. Higher than cubic is rarely needed and increases the dimension of `W_raw` (which is $\dim_W = \text{length}(\text{knots}) + \text{degree} - 1$ for a clamped basis without boundary repetition).
- The Stan-side evaluator uses a Cox-de Boor recursion (sub-phase 8.3.8, decision D2) that runs once per draw and per observation; performance is comparable to a polynomial basis up to $\dim_W \approx 10$.

## **2.3. End-to-end example**

```{r bspline-example, eval=FALSE}
set.seed(2026L)
n <- 80L
theta_ref_true <- 0.5
# A sigmoidal modulating effect that the polynomial baseline cannot
# represent without raising dim_W substantially.
x_var <- runif(n, -2, 2)
W_true <- function(z) 1.2 / (1 + exp(-3 * z)) - 0.6
y <- theta_ref_true + 0.4 * (x_var - mean(x_var)) +
  W_true(theta_ref_true) * (x_var - mean(x_var)) * 0.7 +
  rnorm(n, sd = 0.2)
d <- data.frame(y = y, x = x_var)

library(gdpar)
fit_bs <- gdpar(
  y ~ a(x) + W(),
  data   = d,
  family = gdpar_family("gaussian"),
  W      = W_basis(type = "bspline",
                   knots = c(-1, 0, 1),
                   degree = 3L,
                   boundary_knots = c(-2.2, 2.2)),
  chains = 2L, iter_warmup = 400L, iter_sampling = 400L,
  refresh = 0L
)

co <- coef(fit_bs)
co
```

The `coef()` output reports the per-basis coefficient `W_raw[j, k]` summarised by posterior mean and 5%/50%/95% quantiles, exactly as in the polynomial case. Compare with a polynomial fit of the same `dim_W` to see whether the B-spline absorbs structure that the polynomial flattens.

## **2.4. Diagnostics and known limitations**

- **In-sample prediction** with `predict(fit_bs)` returns posterior draws of `theta_i` on the training data, evaluated through the B-spline basis.
- **New-data prediction** (`predict(fit_bs, newdata = ...)`) is implemented for `K = 1` polynomial `W`; B-spline new-data prediction is supported through the same `predict.gdpar_fit` API and reads the Stan-side `apply_W_basis_diff()` helper at fit time. For `K > 1` distributional regression, B-spline new-data prediction is queued for Session 8.4 and raises `gdpar_unsupported_feature_error`.
- **Boundary violation.** If the posterior of `theta_ref` drifts outside the `boundary_knots` range, the basis values extrapolate. The R-side validator inspects the prior's anchor mean ± 3 SD at construction time and warns if the range is too tight; the user can either widen `boundary_knots` or tighten the anchor prior.

---

# **3. Heterogeneous families per slot (`K > 1`)**

## **3.1. The default homogeneous case**

For distributional regression `K > 1`, `gdpar()` accepts a single `gdpar_family` and replicates the canonical per-slot parametrization across every slot. For Gaussian `K = 2`, slot 1 is the location (`mu` with identity link) and slot 2 is the dispersion (`sigma` with log link). The two slots share the same Gaussian likelihood family; only the slot-specific link differs.

```{r het-default-api, eval=FALSE}
fit <- gdpar(
  gdpar_bf(y ~ a(x1), sigma ~ a(x2)),
  data   = d,
  family = gdpar_family("gaussian"),
  chains = 2L, iter_warmup = 400L, iter_sampling = 400L
)
```

## **3.2. When to declare heterogeneous families**

The heterogeneous-per-slot path (sub-phase 8.3.7, decision D5) targets the use case where the *auxiliary slots* (slot $k > 1$) have a different parametric structure than the location slot. Two motivating examples:

- The location is Gaussian (an additive structure on $y$) but the dispersion is bounded in $(0, 1)$ and naturally Beta-distributed (e.g., when the dispersion is a normalised heterogeneity index).
- The location is Poisson (counts) but the dispersion is Gamma-distributed (a positive continuous spread parameter that arises in some count-overdispersion models).

The canonical decision D5 (sub-phase 8.3.7) also restricts heterogeneous specifications to *compatible families*: slot 1 ("location") must belong to the canonical primary `{gaussian, poisson, neg_binomial_2, beta, gamma, lognormal_loc_scale}` family-id set, and slot $k > 1$ must declare a family whose `support` is coherent with the slot's canonical role. Incompatible combinations (e.g., a Gaussian location with a Bernoulli dispersion) are rejected by `.gdpar_validate_heterogeneous_family_K` with `gdpar_input_error`.

The pattern name `lognormal_loc_scale` listed in the primary family-id set above is **not part of the enum of `gdpar_family(name)`**: the package registers it as a `K = 2` *custom-family pattern* (canonised in Sub-phase 8.3.4), accessed via `gdpar_family_custom_K(stan_lpdf_id = "lognormal_loc_scale", ...)`. See `vignette("vop05_distributional_K_dharma", package = "gdpar")` §2.4 for the literal recipe and `vignette("vop05_distributional_K_dharma", package = "gdpar")` §6 for the complementary `gdpar_family_custom()` (`K = 1`) constructor.

On the Stan side, the per-slot dispatch of the inverse link is implemented by the helper function `apply_inv_link_by_id(link_id, eta)` canonised in `inst/stan/amm_distrib_K.stan:228`. It maps the integer `link_id` carried per slot in the data block (`inv_link_id_per_slot`) to the corresponding inverse-link evaluation, so that each slot's linear predictor is transformed by its own `inv_link` without conditional R-side branching at compile time. This is the Stan-side mechanism that makes the heterogeneous-per-slot path of Sub-phase 8.3.7 transparent to the model template.

## **3.3. API**

```{r het-api, eval=FALSE}
fit_het <- gdpar(
  gdpar_bf(y ~ a(x1), sigma ~ a(x2)),
  data   = d,
  family = list(
    mu    = gdpar_family("gaussian"),
    sigma = gdpar_family("beta")
  ),
  chains = 2L, iter_warmup = 400L, iter_sampling = 400L
)
```

The named list keys must match the slot names of the `gdpar_formula_set` (the `bf()` constructor's left-hand side names, or the `param_specs` names of the location family). Mismatches raise `gdpar_input_error` with the expected slot names listed.

Sub-phase 8.3.7 supports heterogeneous specifications for `K = 2` in three combinations of practical interest:

- Gaussian location + Beta dispersion.
- Gaussian location + Gamma dispersion.
- Negative-binomial-2 location + Beta dispersion (the so-called *zero-inflated negative binomial with Beta-distributed dispersion*; see Greene 1994 for the source motivation in count regression with overdispersed compounding).

Other combinations are queued for sub-phase 8.4 (heterogeneous `K = 3+` families) and emit `gdpar_unsupported_feature_error` at construction time with a message pointing to Session 8.4 deudas.

## **3.4. End-to-end example**

```{r het-example, eval=FALSE}
set.seed(818L)
n <- 100L
x1 <- rnorm(n); x2 <- rnorm(n)
mu_true   <- 0.3 + 0.6 * (x1 - mean(x1))
sigma_eta <- 0.5 + 0.3 * (x2 - mean(x2))
# Beta-distributed sigma in (0, 1) via inverse-logit of sigma_eta
sigma_p <- 1 / (1 + exp(-sigma_eta))
y <- rbeta(n, shape1 = 2 + 5 * sigma_p, shape2 = 5 - 4 * sigma_p)
d <- data.frame(y = y, x1 = x1, x2 = x2)

fit_het <- gdpar(
  gdpar_bf(y ~ a(x1), sigma ~ a(x2)),
  data   = d,
  family = list(
    mu    = gdpar_family("beta"),
    sigma = gdpar_family("beta")
  ),
  chains = 2L, iter_warmup = 400L, iter_sampling = 400L,
  refresh = 0L
)

co <- coef(fit_het)
str(co, max.level = 2L)
```

`coef.gdpar_fit` for `K > 1` returns a named list of `gdpar_coef` objects, one per slot (decision E4.A, sub-phase 8.3.10). Each entry follows the standard scalar contract (`theta_ref`, `a`, `b`, `W` sub-components with posterior summaries) and can be coerced to a long-tidy data frame via `as.data.frame`.

## **3.5. Identifiability and information diagnostics**

The heterogeneous path runs the same identifiability diagnostics as the homogeneous one:

- **Pre-fit** (`identifiability_report` slot of `gdpar_fit`): C4-bis cross-slot Gram check, rank check of each slot's `Z_a` design, and slot-aware information ratio.
- **Post-fit** (`identifiability_post_fit` slot): information contraction per slot. A ratio below 0.1 emits `gdpar_information_error` warning; in $[0.1, 0.5)$ emits `gdpar_information_warning`; above 0.5 is silent.

When the dispersion slot's canonical kind is Beta or Gamma, the prior on its anchor `theta_ref_k[k]` is the canonical one of the *slot's family* (not the location's), via the placeholder `THETA_REF_PRIOR_BLOCK` resolved at codegen time. This is the decision D-ID per-slot of sub-phase 8.3.4 generalised to heterogeneous specifications.

---

# **4. Combining B-spline `W` with heterogeneous families**

The two refinements are orthogonal at the design level. A typical combined fit declares:

```{r combined-api, eval=FALSE}
fit_combo <- gdpar(
  gdpar_bf(y ~ a(x1) + W(), sigma ~ a(x2)),
  data   = d,
  family = list(
    mu    = gdpar_family("gaussian"),
    sigma = gdpar_family("beta")
  ),
  W      = W_basis(type = "bspline", knots = c(-1, 0, 1),
                   degree = 3L, boundary_knots = c(-2.5, 2.5)),
  chains = 2L, iter_warmup = 400L, iter_sampling = 400L
)
```

The `W` block is *globally shared* across slots (canonical decision: "Scope of W: global", handoff 28). The same `W_raw` and `sigma_W` enter the linear predictor of every slot, evaluated at the slot's own `theta_ref_k[k]` and anchored at the slot's `theta_anchor_K[k]`. This is by design and reflects the role of `W` as a structural coupler in the AMM canonical form; the alternatives (W-per-slot, W-only-in-mu) are queued as scope deudas for Session 8.4.

---

# **5. Known limitations and future work**

- **K > 1 with grouping** (`J_groups > 1`): both `coef.gdpar_fit` and `predict.gdpar_fit` with `newdata` raise `gdpar_unsupported_feature_error`; the in-sample path (`predict(fit, newdata = NULL)`) is supported. Session 8.4 will extend the per-slot extraction to per-group threading.
- **K > 1 with B-spline `W` on new data** (`predict(fit, newdata = ..., type = ...)`): the in-sample path supports B-spline through `apply_W_basis_diff()` in Stan; the R-side reconstruction on new data presently mirrors only the polynomial branch. Session 8.4 will lift this.
- **Heterogeneous `K = 3+`**: sub-phase 8.3.7 covers `K = 2` combinations only. `K = 3` extensions (Student-t with heterogeneous dispersion, Tweedie with heterogeneous power, mixtures with heterogeneous mixture weight) are queued for Session 8.4.

For the residual diagnostics that complement these intermediate specifications — including `gdpar_dharma_object()` and the three-layer G1 / G2 / G3 residual hierarchy — see `vignette("vop05_distributional_K_dharma", package = "gdpar")`.

---

# **References**

- Dunn, P. K., & Smyth, G. K. (1996). Randomized quantile residuals. *Journal of Computational and Graphical Statistics*, 5(3), 236--244.
- Greene, W. H. (1994). Accounting for excess zeros and sample selection in Poisson and negative binomial regression models. *NYU Stern Working Paper EC-94-10*.
- Wood, S. N. (2017). *Generalized Additive Models: An Introduction with R* (2nd ed.). Chapman and Hall/CRC.
