﻿---
title: "Random slopes and the free random-effect covariance"
output: rmarkdown::html_vignette
vignette: >
  %\VignetteIndexEntry{Random slopes and the free random-effect covariance}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

```{r setup, include = FALSE}
knitr::opts_chunk$set(collapse = TRUE, comment = "#>", fig.width = 6, fig.height = 4)
set.seed(20260716)
```

```{r cran-gate, include = FALSE}
# The model fits below are evaluated when the article is built locally, in CI
# and for the pkgdown site (all of which set NOT_CRAN). CRAN's check farm gives
# the whole check a ten-minute budget, which these fits do not fit inside, so
# there the code is shown without being run.
EVAL_FITS <- identical(Sys.getenv("NOT_CRAN"), "true")
knitr::opts_chunk$set(eval = EVAL_FITS)
```

```{r load, message = FALSE}
library(tulpa)
```

## The covariance is the quantity, not a nuisance

A random-intercept model, `y ~ x + (1 | g)`, has a single variance
component: how much the groups differ in their baseline. A random-slope
model, `y ~ x + (1 + x | g)`, has three: the intercept variance, the
slope variance, and the correlation between them. That correlation is
often the scientific question -- do groups that start high also respond
more steeply? -- so it should be *inferred*, with its own uncertainty,
not fixed at a point estimate.

`tulpa()` treats the whole random-effect covariance `Sigma` as the
inferred object. When a term carries slopes it does not condition on a
plug-in `Sigma`; it integrates over it.

## Simulate a correlated random-slope data set

```{r sim}
G   <- 60L                 # groups
npg <- 12L                 # observations per group
N   <- G * npg
grp <- rep(seq_len(G), each = npg)
x   <- rnorm(N)

# True Sigma: sd 0.7 (intercept), 0.5 (slope), correlation 0.4.
Sigma <- matrix(c(0.7^2,            0.4 * 0.7 * 0.5,
                  0.4 * 0.7 * 0.5,  0.5^2), 2)
u <- t(t(chol(Sigma)) %*% matrix(rnorm(2 * G), 2))   # G x 2 group effects
eta <- 0.2 + 0.5 * x + u[grp, 1] + u[grp, 2] * x
y   <- rpois(N, exp(eta))
d   <- data.frame(y = y, x = x, g = factor(grp))
```

## Fit: the covariance is integrated, not plugged in

A `(1 + x | g)` term makes `tulpa()` route the Laplace path through the
nested-Laplace integration over `Sigma` (`tulpa_re_cov_nested()`): a
CCD grid in log-Cholesky coordinates, centred and rotated at the
marginal-likelihood mode, with a weakly-informative PC + LKJ hyperprior.
Each derived quantity -- the standard deviations `sigma_1`, `sigma_2`
and the correlation `rho_12` -- is summarised *after* integration, as a
weighted quantile of the joint posterior, so a skewed component is not
collapsed to its mode.

```{r fit}
fit <- tulpa(y ~ x + (1 + x | g), data = d, family = "poisson",
             mode = "laplace")
fit$posterior[, c("parameter", "median", "ci_lo", "ci_hi")]
```

The posterior medians track the truth (`sigma_1 = 0.7`, `sigma_2 = 0.5`,
`rho_12 = 0.4`), each with a credible interval rather than a bare number.

## Exact debias for small, low-count groups

The nested Laplace is fast and accurate when the per-group likelihood is
close to Gaussian. For binary or low-count data in *small* groups the
Laplace under-disperses `Sigma` -- it pulls the variance components low.
The exact counterpart, a Metropolis-within-Gibbs sampler with a
conjugate inverse-Wishart draw for `Sigma`, corrects that bias. Ask for
it with `control$re_cov = "gibbs"`:

```{r gibbs, eval = FALSE}
fit_gibbs <- tulpa(y ~ x + (1 + x | g), data = d, family = "poisson",
                   mode = "laplace",
                   control = list(re_cov = "gibbs",
                                  n_iter = 2000L, warmup = 1000L))
```

Both fits return the same accessors: `fit$posterior` holds the `Sigma`
summary, and `coef(fit)` / `summary(fit)` report the fixed effects. The
choice between them is the engine's design in miniature -- a cheap
deterministic approximation, with an exact sampler available exactly
where the approximation is biased.
