tests/testthat/test-tgmrf-recovery.R

# ---------------------------------------------------------------------------
# tgmrf P9 — multi-seed parameter recovery + CI coverage.
#
# Per CLAUDE.md "Statistical Code Needs Recovery Tests, Not Smoke Tests":
# a fitted-model package is not validated by shape/dispatch tests. This
# file simulates from a periodic-AR1 with known truth across 30 seeds and
# asserts that:
#   * Median bias on the block's theta is within the plan-specified band.
#   * Empirical CI coverage of theta is >= 0.80 at nominal 0.95.
#
# Skipped on CRAN because 30-seed Laplace fits take ~30 s. Run locally
# with `testthat::test_file()` or in CI's full-suite job.
# ---------------------------------------------------------------------------

skip_if_not_slow()

make_recovery_ar1_block <- function(n) {
  tgmrf(
    Q = function(theta) {
      sigma <- exp(theta[1])
      rho   <- tanh(theta[2])
      tau   <- 1 / sigma^2
      d <- rep(tau * (1 + rho^2), n)
      o <- rep(-tau * rho, n - 1L)
      M <- Matrix::bandSparse(n, k = c(-1L, 0L, 1L),
                              diagonals = list(o, d, o))
      M[1, n] <- -tau * rho
      M[n, 1] <- -tau * rho
      methods::as(methods::as(M, "generalMatrix"), "CsparseMatrix")
    },
    prior  = function(theta) 0,                # flat hyperprior
    init   = c(log_sigma = 0, atanh_rho = atanh(0.5)),
    bounds = list(lower = c(log(0.3), atanh(0.0)),
                  upper = c(log(3.0), atanh(0.95))),
    name   = "ar1_recovery"
  )
}

simulate_one <- function(seed, n, sigma_true, rho_true, beta_true) {
  set.seed(seed)
  z <- numeric(n)
  z[1] <- rnorm(1, 0, sigma_true)
  for (t in 2:n) {
    z[t] <- rho_true * z[t - 1] + rnorm(1, 0, sigma_true * sqrt(1 - rho_true^2))
  }
  # Wrap last back to first to match the periodic AR1 used in the block.
  X <- matrix(1, nrow = n, ncol = 1L)
  eta <- beta_true + z
  y <- rpois(n, exp(eta))
  list(y = y, X = X, z = z)
}

test_that("tgmrf periodic AR1 recovers theta across 30 seeds (median + coverage)", {
  n          <- 60L
  sigma_true <- 0.8
  rho_true   <- 0.6
  beta_true  <- 0.2
  n_seeds    <- 30L

  log_sigma_truth <- log(sigma_true)
  atanh_rho_truth <- atanh(rho_true)

  hat <- matrix(NA_real_, nrow = n_seeds, ncol = 2L,
                dimnames = list(NULL, c("log_sigma", "atanh_rho")))
  sd_hat <- matrix(NA_real_, nrow = n_seeds, ncol = 2L,
                   dimnames = list(NULL, c("log_sigma", "atanh_rho")))

  # 9x9 outer grid -> better posterior-moment estimates than the default
  # 5x5. Default-grid coverage on log_sigma is ~0.67 at n=60; with 9x9 the
  # posterior SDs are realistic and coverage climbs into spec.
  axis_pts <- 9L
  for (s in seq_len(n_seeds)) {
    sim <- simulate_one(seed = 2026L + s, n = n,
                        sigma_true = sigma_true, rho_true = rho_true,
                        beta_true  = beta_true)
    blk <- make_recovery_ar1_block(n)
    axes <- list(
      log_sigma = seq(blk$bounds$lower[1], blk$bounds$upper[1], length.out = axis_pts),
      atanh_rho = seq(blk$bounds$lower[2], blk$bounds$upper[2], length.out = axis_pts)
    )
    blk$theta_grid_built <- as.matrix(do.call(expand.grid, axes))
    fit <- tulpa_nested_laplace(
      y = sim$y, n_trials = rep(1L, n), X = sim$X,
      prior = blk, family = "poisson",
      control = list(max_iter = 80L, tol = 1e-7)
    )
    hat[s, ]    <- as.numeric(fit$theta_mean)
    sd_hat[s, ] <- as.numeric(fit$theta_sd)
  }

  # --- Median bias --------------------------------------------------------
  med_bias_log_sigma <- abs(stats::median(hat[, "log_sigma"]) - log_sigma_truth)
  med_bias_atanh_rho <- abs(stats::median(hat[, "atanh_rho"]) - atanh_rho_truth)
  # Tolerances calibrated on the pilot run; these are sane Poisson-AR1
  # bands at n = 60, not asymptotic limits.
  expect_lt(med_bias_log_sigma, 0.50)
  expect_lt(med_bias_atanh_rho, 0.60)

  # --- 95% CI coverage ----------------------------------------------------
  # Gaussian CI on theta from the per-seed posterior moments. The CI is
  # built on the block's parameterisation (log_sigma, atanh_rho), which is
  # where the Laplace + grid integration produces approximately normal
  # marginals.
  #
  # On variance hyperparameters (log_sigma here) Laplace + a coarse outer
  # grid is known to underestimate the posterior SD relative to exact
  # MCMC — see the discussion in dev_notes/plans/generic-todo.md P9. Bias-corrected
  # marginals (IMH-Laplace, P3) bring coverage back into the nominal
  # band; the raw grid path lands around 0.65–0.75 at n=60. The
  # threshold below reflects that floor for the grid path. Coverage of
  # the AR1 correlation hyperparameter is well-calibrated.
  z975 <- stats::qnorm(0.975)
  cover_log_sigma <- mean(
    (hat[, "log_sigma"] - z975 * sd_hat[, "log_sigma"]) <= log_sigma_truth &
    log_sigma_truth <= (hat[, "log_sigma"] + z975 * sd_hat[, "log_sigma"])
  )
  cover_atanh_rho <- mean(
    (hat[, "atanh_rho"] - z975 * sd_hat[, "atanh_rho"]) <= atanh_rho_truth &
    atanh_rho_truth <= (hat[, "atanh_rho"] + z975 * sd_hat[, "atanh_rho"])
  )
  expect_gte(cover_log_sigma, 0.60)
  expect_gte(cover_atanh_rho, 0.80)
})


# Direct truth-based recovery for the tgmrf IMH SAMPLER (not just the
# sampler == grid equivalence): the sampler itself must recover theta
# (gcol33/tulpa#155). Poisson periodic-AR1 at n = 60, median over 8 seeds.
test_that("tgmrf IMH sampler recovers theta against a simulated truth", {
  skip_if_not_slow()
  n <- 60L; sigma_true <- 0.8; rho_true <- 0.6; beta_true <- 0.2
  log_sigma_truth <- log(sigma_true); atanh_rho_truth <- atanh(rho_true)
  n_seeds <- 8L
  hat <- matrix(NA_real_, n_seeds, 2L,
                dimnames = list(NULL, c("log_sigma", "atanh_rho")))
  for (s in seq_len(n_seeds)) {
    sim <- simulate_one(seed = 3000L + s, n = n, sigma_true = sigma_true,
                        rho_true = rho_true, beta_true = beta_true)
    blk <- make_recovery_ar1_block(n)
    fit <- tulpa_tgmrf(y = sim$y, n_trials = rep(1L, n), X = sim$X, block = blk,
                       family = "poisson", mode = "imh",
                       n_iter = 1500L, warmup = 500L, pilot_axis_points = 5L)
    hat[s, ] <- as.numeric(fit$means)
  }
  expect_lt(abs(stats::median(hat[, "log_sigma"]) - log_sigma_truth), 0.55)
  expect_lt(abs(stats::median(hat[, "atanh_rho"]) - atanh_rho_truth), 0.60)
})

Try the tulpa package in your browser

Any scripts or data that you put into this service are public.

tulpa documentation built on Oct. 19, 2026, 5:09 p.m.