Nothing
# Joint multi-block nested-Laplace recovery sweep (dev_notes/plans/accuracy.md Phase 3).
#
# Statistical validation, not a smoke test. Per dev_notes/plans/accuracy.md sec. Win 1 Validation
# Plan, sweeps recovery across three alpha regimes -- {0.0, 1.0, 2.0} -- on a
# BYM2 + AR1 + IID two-arm joint model (binomial occupancy + beta cover).
#
# Simulator:
# arm1 (binomial): eta1 = X1 beta1 + sigma * w[s_idx_1]
# + ar[t_idx_1] + iota[o_idx_1]
# arm2 (beta): eta2 = X2 beta2 + alpha * sigma * w[s_idx_2]
# + ar[t_idx_2] + iota[o_idx_2]
# Shared latent fields:
# w = sqrt(rho_b) * phi + sqrt(1 - rho_b) * theta (BYM2-style)
# ar = AR1(rho_ar, marginal precision tau_ar)
# iota = IID N(0, sigma_iid^2)
# Truth (held fixed across regimes): sigma = 0.6, rho_b = 0.7, tau_ar = 10,
# rho_ar = 0.8, sigma_iid = 0.3. Truth on alpha varies per regime.
#
# CI semantics: per dev_notes/plans/accuracy.md Win 2, alpha is summarized via weighted
# empirical 2.5 / 97.5 quantiles on the joint hyperparameter grid
# (`theta_ci_lo` / `theta_ci_hi`), not mean +/- 1.96 sd. Sigma is reported
# from per-block Laplace-at-mode Gaussian moments (`block_moments[[1]]$mean`,
# `$sd`) -- sigma is identified well enough away from its boundary for
# Gaussian SE to be honest.
#
# Per-regime acceptance (calibrated for N1 = N2 = 500, 25 sites, alpha grid
# c(0, 0.5, 1, 1.5, 2, 2.5)):
# alpha = 2.0 (strong copy):
# * median |alpha_hat - 2| / 2 < 0.35
# * empirical 95% CI coverage >= 0.80
# alpha = 1.0 (moderate copy):
# * median |alpha_hat - 1| / 1 < 0.45
# * empirical 95% CI coverage >= 0.80
# alpha = 0.0 (no copy -- boundary win vs rgeneric):
# * median |alpha_hat| < 0.40
# * median(alpha_median) < 0.30
# * CI_lo touches 0 in >= 0.85 of seeds
# Sigma (across all regimes):
# * median |sigma_hat - 0.6| / 0.6 < 0.35
#
# AR1 tau / rho are not asserted: at n_years = 10 plus competing BYM2 / IID
# variance the AR1 marginal is identification-limited (pilot shows tau biased
# by ~100% with grid mean near the upper edge).
skip_if_not_slow()
# Chain adjacency (25 sites). Same shape as the alpha-ridge regression test;
# keeps BYM2 scale_factor = 1 calibration roughly correct on the spatial
# component.
.chain_adj_rec <- function(n_s) {
nbr <- lapply(seq_len(n_s),
function(s) setdiff(c(s - 1L, s + 1L), c(0L, n_s + 1L)))
n_neighbors <- vapply(nbr, length, integer(1))
list(
adj_row_ptr = as.integer(c(0L, cumsum(n_neighbors))),
adj_col_idx = as.integer(unlist(nbr)) - 1L,
n_neighbors = as.integer(n_neighbors),
n_spatial_units = n_s
)
}
# Joint multi-block simulator. Per-regime alpha controls the strength of the
# copy of the BYM2 field into the positive arm.
.sim_joint_multi_rec <- function(seed,
N1 = 500L, N2 = 500L,
n_sites = 25L, n_years = 10L,
n_obs = 30L,
sigma = 0.6, alpha = 1.5,
rho_b = 0.7,
tau_ar = 10, rho_ar = 0.8,
sigma_iid = 0.3,
betaO = c(-0.5, 0.5),
betaP = c(0.2, -0.3),
phi_b = 25) {
set.seed(seed)
phi_f <- rnorm(n_sites); phi_f <- phi_f - mean(phi_f)
theta_f <- rnorm(n_sites); theta_f <- theta_f - mean(theta_f)
w_unit <- sqrt(rho_b) * phi_f + sqrt(1 - rho_b) * theta_f
sd_ar <- 1 / sqrt(tau_ar)
inn_sd <- sd_ar * sqrt(1 - rho_ar^2)
ar_t <- numeric(n_years)
ar_t[1L] <- rnorm(1, 0, sd_ar)
for (t in 2:n_years) {
ar_t[t] <- rho_ar * ar_t[t - 1L] + rnorm(1, 0, inn_sd)
}
iota_o <- rnorm(n_obs, 0, sigma_iid)
s_idx_1 <- sample.int(n_sites, N1, replace = TRUE)
s_idx_2 <- sample.int(n_sites, N2, replace = TRUE)
t_idx_1 <- sample.int(n_years, N1, replace = TRUE)
t_idx_2 <- sample.int(n_years, N2, replace = TRUE)
o_idx_1 <- sample.int(n_obs, N1, replace = TRUE)
o_idx_2 <- sample.int(n_obs, N2, replace = TRUE)
X1 <- cbind(1, rnorm(N1))
X2 <- cbind(1, rnorm(N2))
eta1 <- as.numeric(X1 %*% betaO) +
sigma * w_unit[s_idx_1] +
ar_t[t_idx_1] + iota_o[o_idx_1]
eta2 <- as.numeric(X2 %*% betaP) +
alpha * sigma * w_unit[s_idx_2] +
ar_t[t_idx_2] + iota_o[o_idx_2]
y1 <- rbinom(N1, 1L, plogis(eta1))
mu2 <- plogis(eta2)
y2 <- rbeta(N2, mu2 * phi_b, (1 - mu2) * phi_b)
y2 <- pmin(pmax(y2, 1e-6), 1 - 1e-6)
list(
arm_occ = list(
y = as.numeric(y1), n_trials = rep(1L, N1), X = X1,
re_idx = rep(0, N1), n_re_groups = 0L, sigma_re = 1.0,
family = "binomial", phi = 1.0
),
arm_pos = list(
y = y2, n_trials = rep(1L, N2), X = X2,
re_idx = rep(0, N2), n_re_groups = 0L, sigma_re = 1.0,
family = "beta", phi = phi_b
),
s_idx_1 = s_idx_1, s_idx_2 = s_idx_2,
t_idx_1 = t_idx_1, t_idx_2 = t_idx_2,
o_idx_1 = o_idx_1, o_idx_2 = o_idx_2
)
}
# Per-seed fit helper. Returns a flat list of recovery quantities so the
# regime loop can collect across seeds without nested indexing.
.fit_one_seed_multi <- function(seed, alpha_true,
sigma_grid, alpha_grid, rho_grid,
tau_grid, rho_ar1_grid, sigma_iid_grid,
adj, n_years, n_obs,
scale_factor = 1.0,
prior_sigma = NULL) {
sim <- .sim_joint_multi_rec(seed,
n_sites = adj$n_spatial_units,
n_years = n_years, n_obs = n_obs,
alpha = alpha_true)
# A fully supplied (tau, rho) pair is read cell for cell, so the AR1
# tensor is spelled out; the copied BYM2 block crosses its own axes.
ar1_cells <- expand.grid(tau = tau_grid, rho = rho_ar1_grid,
KEEP.OUT.ATTRS = FALSE)
prior <- list(
list(type = "bym2",
n_spatial_units = adj$n_spatial_units,
adj_row_ptr = adj$adj_row_ptr, adj_col_idx = adj$adj_col_idx,
n_neighbors = adj$n_neighbors, scale_factor = scale_factor,
sigma_grid = sigma_grid, rho_grid = rho_grid,
spatial_idx = list(sim$s_idx_1, sim$s_idx_2)),
list(type = "ar1", n_times = n_years,
tau_grid = ar1_cells$tau, rho_grid = ar1_cells$rho,
temporal_idx = list(sim$t_idx_1, sim$t_idx_2)),
list(type = "iid", n_units = n_obs,
sigma_grid = sigma_iid_grid,
obs_idx = list(sim$o_idx_1, sim$o_idx_2))
)
fit <- suppressWarnings(
tulpa_nested_laplace_joint(
responses = list(occ = sim$arm_occ, pos = sim$arm_pos),
prior = prior,
copy = list(block = 1L, arm = "pos", alpha_grid = alpha_grid),
prior_sigma = prior_sigma,
control = list(adaptive_grid = FALSE, max_iter = 60L, tol = 1e-5)
)
)
bm1 <- fit$block_moments[[1L]]
bm3 <- fit$block_moments[[3L]]
# Multi-block joint_grid columns are prefixed `b<N>.` (B blocks); the
# copy block here is block 1, so `b1.alpha` is the prefixed axis name
# used by theta_median / theta_ci_lo / theta_ci_hi.
list(
sigma_hat = bm1$mean[["sigma"]],
sigma_sd = bm1$sd[["sigma"]],
alpha_mean = bm1$mean[["alpha"]],
alpha_sd = bm1$sd[["alpha"]],
alpha_med = fit$theta_median[["b1.alpha"]],
alpha_lo = fit$theta_ci_lo[["b1.alpha"]],
alpha_hi = fit$theta_ci_hi[["b1.alpha"]],
sigma_iid_mean = bm3$mean[["sigma"]],
sigma_iid_sd = bm3$sd[["sigma"]],
log_marg_ok = all(is.finite(fit$log_marginal[!cells_dropped(fit)])),
sim = sim
)
}
# --------------------------------------------------------------------------- #
# Phase 3: 30-seed recovery sweep across alpha in {0, 1, 2}. #
# --------------------------------------------------------------------------- #
test_that("(sigma, alpha) reparam recovers across alpha in {0, 1, 2} on BYM2+AR1+IID (30 seeds per regime)", {
adj <- .chain_adj_rec(25L)
n_years <- 10L
n_obs <- 30L
seeds <- 9001:9030
n_seeds <- length(seeds)
# Single shared hyperparameter grid that brackets each alpha truth value.
# Cell count = 3 * 6 * 2 * 3 * 2 * 3 = 648.
sigma_grid <- c(0.3, 0.6, 1.0)
alpha_grid <- c(0, 0.5, 1.0, 1.5, 2.0, 2.5)
rho_grid <- c(0.5, 0.8)
tau_grid <- c(4, 10, 25)
rho_ar1_grid <- c(0.5, 0.85)
sigma_iid_grid <- c(0.15, 0.3, 0.6)
regimes <- list(
list(name = "alpha=0", alpha_true = 0.0),
list(name = "alpha=1", alpha_true = 1.0),
list(name = "alpha=2", alpha_true = 2.0)
)
sigma_truth <- 0.6
for (r in regimes) {
sigma_hat <- numeric(n_seeds)
alpha_mn <- numeric(n_seeds)
alpha_md <- numeric(n_seeds)
alpha_lo <- numeric(n_seeds)
alpha_hi <- numeric(n_seeds)
for (i in seq_along(seeds)) {
f <- .fit_one_seed_multi(
seeds[i], alpha_true = r$alpha_true,
sigma_grid = sigma_grid, alpha_grid = alpha_grid,
rho_grid = rho_grid, tau_grid = tau_grid,
rho_ar1_grid = rho_ar1_grid, sigma_iid_grid = sigma_iid_grid,
adj = adj, n_years = n_years, n_obs = n_obs
)
expect_true(f$log_marg_ok,
info = sprintf("%s seed %d: non-finite log_marginal",
r$name, seeds[i]))
sigma_hat[i] <- f$sigma_hat
alpha_mn[i] <- f$alpha_mean
alpha_md[i] <- f$alpha_med
alpha_lo[i] <- f$alpha_lo
alpha_hi[i] <- f$alpha_hi
}
# Sigma: same contract across regimes.
rel_sigma <- median(abs(sigma_hat - sigma_truth) / sigma_truth)
info_s <- sprintf("%s: median rel|sigma_hat - 0.6| = %.3f",
r$name, rel_sigma)
expect_lt(rel_sigma, 0.35, label = info_s)
# Alpha: regime-dependent.
if (r$alpha_true == 0) {
abs_mn <- median(abs(alpha_mn))
abs_md <- median(alpha_md)
boundary_frac <- mean(alpha_lo <= 0 + 1e-8)
info_a <- sprintf(
"%s: median |alpha_mean|=%.3f, median(alpha_med)=%.3f, frac(CI_lo<=0)=%.2f",
r$name, abs_mn, abs_md, boundary_frac)
expect_lt(abs_mn, 0.40, label = info_a)
expect_lt(abs_md, 0.30, label = info_a)
expect_gte(boundary_frac, 0.85, label = info_a)
} else {
rel_a <- median(abs(alpha_mn - r$alpha_true) / r$alpha_true)
cov_a <- mean(alpha_lo <= r$alpha_true &
alpha_hi >= r$alpha_true)
thr_rel <- if (r$alpha_true == 1.0) 0.45 else 0.35
info_a <- sprintf(
"%s: median rel|alpha_mean - %.1f|=%.3f, empirical CI cov=%.2f",
r$name, r$alpha_true, rel_a, cov_a)
expect_lt(rel_a, thr_rel, label = info_a)
expect_gte(cov_a, 0.80, label = info_a)
}
}
})
# Posterior mean / SD of sigma = 1/sqrt(precision) from INLA, by transforming
# the precision marginal and integrating the result.
.sigma_from_prec_marginal <- function(fit, nm) {
mg <- fit$marginals.hyperpar[[nm]]
sm <- INLA::inla.tmarginal(function(t) 1 / sqrt(t), mg)
z <- INLA::inla.zmarginal(sm, silent = TRUE)
c(mean = z$mean, sd = z$sd)
}
# --------------------------------------------------------------------------- #
# Phase 3: INLA cross-check on the well-identified middle of the alpha range. #
# #
# Re-fit the same joint two-arm data at alpha = 1 with INLA's bym2 + ar1 + #
# iid latents. INLA's BYM2 uses scale.model = TRUE (geomean of marginal #
# variance = 1) so we pass scale_factor = compute_bym2_scale(adjacency) on #
# the tulpa side for convention compatibility. Compares across-seed mean #
# posterior moments on sigma, alpha, sigma_iid -- the cleanly identified #
# hyperparameters. Skipped on: #
# - CRAN (INLA isn't on CRAN, and the joint INLA fit takes 5-10s / seed) #
# - any machine without INLA installed. #
# #
# rho_b (BYM2 mixing) and AR1 hyperparameters are not asserted: their #
# posteriors are dominated by prior identification at this data size #
# (n_years = 10, single noisy realization of an iid simulator) and the two #
# engines genuinely disagree on the within-prior pull. #
# --------------------------------------------------------------------------- #
test_that("INLA joint fit agrees with tulpa at alpha = 1 (5-seed cross-check)", {
skip_if_not_installed("INLA")
adj_sparse <- .chain_adj_rec(25L)
n_sites <- adj_sparse$n_spatial_units
n_years <- 10L
n_obs <- 30L
seeds <- 9001:9005
adj_dense <- matrix(0L, n_sites, n_sites)
for (s in seq_len(n_sites - 1L)) {
adj_dense[s, s + 1L] <- 1L
adj_dense[s + 1L, s] <- 1L
}
sf <- compute_bym2_scale(adj_dense)
sigma_grid <- c(0.3, 0.6, 1.0)
alpha_grid <- c(0, 0.5, 1.0, 1.5, 2.0, 2.5)
rho_grid <- c(0.5, 0.8)
tau_grid <- c(4, 10, 25)
rho_ar1_grid <- c(0.5, 0.85)
sigma_iid_grid <- c(0.15, 0.3, 0.6)
keys <- c("sigma", "alpha", "sigma_iid")
tul_mean <- matrix(NA_real_, length(seeds), length(keys),
dimnames = list(NULL, keys))
tul_sd <- tul_mean
inla_mean <- tul_mean
inla_sd <- tul_mean
for (i in seq_along(seeds)) {
f <- .fit_one_seed_multi(
seeds[i], alpha_true = 1.0,
sigma_grid = sigma_grid, alpha_grid = alpha_grid,
rho_grid = rho_grid, tau_grid = tau_grid,
rho_ar1_grid = rho_ar1_grid, sigma_iid_grid = sigma_iid_grid,
adj = adj_sparse, n_years = n_years, n_obs = n_obs,
scale_factor = sf,
# Joint nested-Laplace defaults to a flat hyperprior over the outer
# grid, while INLA's bym2 precision carries pc.prec(U = 1, alpha =
# 0.01) by default -- prior mean on sigma of 0.22 against 0.65 for
# a flat prior on [0.3, 1.0]. Comparing posterior moments across
# the two engines requires the same prior on both sides, so pass
# INLA's own default here. The 30-seed recovery sweep above keeps
# the flat prior: it scores against a known truth, not against INLA.
prior_sigma = list("pc.prec", c(1, 0.01))
)
tul_mean[i, "sigma"] <- f$sigma_hat
tul_mean[i, "alpha"] <- f$alpha_mean
tul_mean[i, "sigma_iid"] <- f$sigma_iid_mean
tul_sd[i, "sigma"] <- f$sigma_sd
tul_sd[i, "alpha"] <- f$alpha_sd
tul_sd[i, "sigma_iid"] <- f$sigma_iid_sd
# INLA side.
sim <- f$sim
n1 <- length(sim$arm_occ$y); n2 <- length(sim$arm_pos$y)
Y <- matrix(NA, n1 + n2, 2)
Y[1:n1, 1] <- sim$arm_occ$y
Y[(n1 + 1):(n1 + n2), 2] <- sim$arm_pos$y
dat <- list(
Y = Y,
int1 = c(rep(1, n1), rep(NA, n2)),
int2 = c(rep(NA, n1), rep(1, n2)),
x1 = c(sim$arm_occ$X[, 2], rep(NA, n2)),
x2 = c(rep(NA, n1), sim$arm_pos$X[, 2]),
area1 = c(sim$s_idx_1, rep(NA, n2)),
area2 = c(rep(NA, n1), sim$s_idx_2),
year = c(sim$t_idx_1, sim$t_idx_2),
obs = c(sim$o_idx_1, sim$o_idx_2)
)
Ntr <- c(rep(1L, n1), rep(NA, n2))
formula_inla <- Y ~ -1 + int1 + int2 + x1 + x2 +
f(area1, model = "bym2", graph = adj_dense,
scale.model = TRUE, constr = TRUE) +
f(area2, copy = "area1", fixed = FALSE,
hyper = list(beta = list(fixed = FALSE,
prior = "normal",
param = c(0, 1)))) +
f(year, model = "ar1") +
f(obs, model = "iid")
fit_i <- INLA::inla(
formula_inla, data = dat,
family = c("binomial", "beta"), Ntrials = Ntr,
control.predictor = list(compute = FALSE),
control.compute = list(config = FALSE, dic = FALSE, waic = FALSE),
control.inla = list(strategy = "gaussian",
int.strategy = "ccd")
)
hp <- fit_i$summary.hyperpar
prec_area <- hp["Precision for area1", c("mean", "sd")]
beta_area <- hp["Beta for area2", c("mean", "sd")]
prec_obs <- hp["Precision for obs", c("mean", "sd")]
# sigma = 1/sqrt(precision) is a nonlinear transform, so the posterior
# mean of sigma is E[1/sqrt(tau)], not 1/sqrt(E[tau]). Transform the
# precision marginal and integrate it; the plug-in and its delta-method
# SD are biased low on the weakly identified spatial precision (the
# tau posterior is right-skewed at this data size).
sig_area <- .sigma_from_prec_marginal(fit_i, "Precision for area1")
sig_obs <- .sigma_from_prec_marginal(fit_i, "Precision for obs")
m_sig <- sig_area[["mean"]]; s_sig <- sig_area[["sd"]]
m_alp <- beta_area[["mean"]]; s_alp <- beta_area[["sd"]]
m_iid <- sig_obs[["mean"]]; s_iid <- sig_obs[["sd"]]
inla_mean[i, ] <- c(m_sig, m_alp, m_iid)
inla_sd[i, ] <- c(s_sig, s_alp, s_iid)
}
tul_mean_avg <- colMeans(tul_mean)
inla_mean_avg <- colMeans(inla_mean)
tul_sd_avg <- colMeans(tul_sd)
inla_sd_avg <- colMeans(inla_sd)
rel_mean <- abs(tul_mean_avg - inla_mean_avg) / abs(inla_mean_avg)
rel_sd <- abs(tul_sd_avg - inla_sd_avg) / abs(inla_sd_avg)
info_str <- paste0("\n",
sprintf(" %-10s tulpa %.3f +/- %.3f INLA %.3f +/- %.3f rel(mean)=%.2f rel(sd)=%.2f\n",
keys,
tul_mean_avg, tul_sd_avg,
inla_mean_avg, inla_sd_avg,
rel_mean, rel_sd),
collapse = ""
)
# Means: within 20% on each well-identified quantity (alpha at truth = 1
# is less constrained than at truth = 1.5, so loosen from 0.15 to 0.20).
expect_lt(rel_mean[["sigma"]], 0.20, label = info_str)
expect_lt(rel_mean[["alpha"]], 0.25, label = info_str)
expect_lt(rel_mean[["sigma_iid"]], 0.20, label = info_str)
# SDs: discrete tulpa grid produces ~30-90% wider / narrower posterior
# SD than INLA's continuous integration -- expected. Cap to catch
# order-of-magnitude regressions.
expect_lt(rel_sd[["sigma"]], 0.60, label = info_str)
expect_lt(rel_sd[["alpha"]], 0.90, label = info_str)
expect_lt(rel_sd[["sigma_iid"]], 0.95, label = info_str)
})
# --------------------------------------------------------------------------- #
# dev_notes/plans/accuracy.md headline at alpha = 0 -- positioning note. #
# #
# dev_notes/plans/accuracy.md claims a "beat rgeneric" win at alpha = 0: tulpa's (sigma, #
# alpha) reparameterization with PC-style prior puts calibrated mass at #
# alpha = 0 while INLA's `rgeneric` (custom user-coded Q with no boundary #
# atom) overshoots. The 30-seed sweep above pins down the tulpa side of #
# that claim: empirical CI lower bound on alpha touches 0 in >= 85% of #
# seeds at truth alpha = 0. #
# #
# A direct INLA cross-check at alpha = 0 was prototyped against `copy = #
# "area1"` with `prior = "normal", param = c(0, 1)` on Beta -- INLA's #
# canonical built-in copy machinery. That comparison is NOT informative: #
# the zero-centered Gaussian on Beta puts effective mass at 0 just like #
# tulpa's grid + PC atom, and 95% CI contains 0 in 100% of seeds. INLA's #
# `copy =` is not what dev_notes/plans/accuracy.md calls "rgeneric" -- the rgeneric path #
# requires writing a user-defined Q-matrix + prior callback (no PC prior on #
# alpha by default), and *that* is where the boundary atom matters. #
# #
# Implementing the rgeneric BYM2-copy callback to validate the headline #
# "beat rgeneric" claim is tracked as future work; not in Phase 3 scope. #
# --------------------------------------------------------------------------- #
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.