Nothing
# Multi-output (co-regionalization) HSGP block (Stage 1.7).
#
# `hsgp_mo` introduces a K = n_arms-output HSGP where K correlated latent
# fields share a basis Phi and eigenvalues, but their basis coefficients
# are coupled across outputs via a K x K covariance Sigma. For K = 2 the
# parameterization is direct: (sigma_1, sigma_2, rho, ell), all raw.
#
# Coverage:
# (1) Smoke: 2-arm joint dispatch with one hsgp_mo block. log_marginal
# finite on every outer-grid cell, weights normalise.
# (2) Pattern correctness: cpp_test_joint_pattern shows M independent
# K x K precision blocks plus the cross-output (k=1, k=0) entry at
# each basis index, in output-major (k*M + m) coordinates.
# (3) Independence reduction: when rho_grid = 0 and sigma_1 = sigma_2 a
# fitted hsgp_mo block places posterior mass on the rho = 0 axis
# (data are independent across outputs).
# (4) Cross-output coupling: when the simulated K = 2 fields are
# strongly correlated, posterior weight concentrates on rho > 0
# cells rather than rho <= 0.
# (5) Validation errors: missing fields, K != 2.
skip_on_cran()
.hsgp_basis_2d_mo <- function(coords, m_per_dim = 4L, cscl = 1.5) {
x <- coords[, 1]; y <- coords[, 2]
L1 <- max(cscl * (max(x) - min(x)) / 2, 0.1)
L2 <- max(cscl * (max(y) - min(y)) / 2, 0.1)
xs <- x - (max(x) + min(x)) / 2
ys <- y - (max(y) + min(y)) / 2
M <- m_per_dim * m_per_dim
N <- nrow(coords)
phi <- matrix(0, N, M)
lam <- numeric(M)
for (j1 in seq_len(m_per_dim)) {
lam_j1 <- (pi * j1 / (2 * L1))^2
for (j2 in seq_len(m_per_dim)) {
lam_j2 <- (pi * j2 / (2 * L2))^2
idx <- (j1 - 1L) * m_per_dim + j2
lam[idx] <- lam_j1 + lam_j2
phi[, idx] <-
(1 / sqrt(L1)) * sin(pi * j1 * (xs + L1) / (2 * L1)) *
(1 / sqrt(L2)) * sin(pi * j2 * (ys + L2) / (2 * L2))
}
}
list(phi = phi, lam = lam, M = M)
}
.sim_two_arm_mo <- function(seed = 31L, N1 = 90L, N2 = 90L,
m_per_dim = 4L,
sigma_1 = 1.0, sigma_2 = 1.0,
rho = 0.0, ell = 0.4) {
set.seed(seed)
coords1 <- cbind(runif(N1), runif(N1))
coords2 <- cbind(runif(N2), runif(N2))
b1 <- .hsgp_basis_2d_mo(coords1, m_per_dim = m_per_dim)
b2 <- .hsgp_basis_2d_mo(coords2, m_per_dim = m_per_dim)
# Use b1's eigenvalues as the shared spectrum (centred coords differ
# by arm but lambda_m are close enough at this scale).
M <- b1$M
lam <- b1$lam
# Sample beta_{:,m} ~ N(0, Sigma) for each m, where
# Sigma = [[s1^2, rho*s1*s2], [rho*s1*s2, s2^2]].
L <- matrix(0, 2L, 2L)
L[1, 1] <- sigma_1
L[2, 1] <- rho * sigma_2
L[2, 2] <- sigma_2 * sqrt(max(1 - rho^2, 0))
Z <- matrix(rnorm(2L * M), nrow = 2L)
beta <- L %*% Z # 2 x M, rows = outputs
# sqrt_S_norm uses the sigma-free 2-D spectral density (matches the
# factory). Multiply phi by sqrt_S elementwise before contracting.
pref <- (2 * pi) * ell^2
sqrt_S <- sqrt(pref * exp(-0.5 * ell^2 * lam))
eta_lat_1 <- b1$phi %*% (sqrt_S * beta[1, ])
eta_lat_2 <- b2$phi %*% (sqrt_S * beta[2, ])
X1 <- matrix(1.0, N1, 1L)
X2 <- matrix(1.0, N2, 1L)
beta_lin_1 <- 0.1
beta_lin_2 <- -0.2
eta1 <- as.numeric(X1 %*% beta_lin_1 + eta_lat_1)
eta2 <- as.numeric(X2 %*% beta_lin_2 + eta_lat_2)
y1 <- rbinom(N1, 1L, plogis(eta1))
y2 <- rnorm(N2, eta2, 0.3)
list(
N1 = N1, N2 = N2, M = M, lam = lam,
phi1 = b1$phi, phi2 = b2$phi,
responses = list(
occ = list(y = as.numeric(y1), n_trials = rep(1L, N1),
X = X1, family = "binomial", phi = 1.0),
pos = list(y = as.numeric(y2), n_trials = rep(1L, N2),
X = X2, family = "gaussian", phi = 0.09)
)
)
}
.mo_block <- function(sim, sigma_1_grid, sigma_2_grid, rho_grid, ell_grid) {
list(
type = "hsgp_mo",
m_total = sim$M,
phi = list(sim$phi1, sim$phi2),
n_obs_per_arm = c(sim$N1, sim$N2),
eigenvalues = sim$lam,
sigma_1_grid = as.numeric(sigma_1_grid),
sigma_2_grid = as.numeric(sigma_2_grid),
rho_grid = as.numeric(rho_grid),
lengthscale_grid = as.numeric(ell_grid)
)
}
# --------------------------------------------------------------------------- #
# (1) Smoke #
# --------------------------------------------------------------------------- #
test_that("hsgp_mo joint dispatch runs end-to-end on 2 arms (K = n_arms = 2)", {
sim <- .sim_two_arm_mo(seed = 31L, sigma_1 = 0.8, sigma_2 = 0.8,
rho = 0.3, ell = 0.5)
s1 <- c(0.5, 1.0); s2 <- c(0.5, 1.0); rh <- c(0.0, 0.4); ls <- c(0.4)
gr <- expand.grid(s1 = s1, s2 = s2, rh = rh, ls = ls,
KEEP.OUT.ATTRS = FALSE, stringsAsFactors = FALSE)
block <- .mo_block(sim, gr$s1, gr$s2, gr$rh, gr$ls)
fit <- tulpa_nested_laplace_joint(
responses = sim$responses, prior = list(block), copy = NULL,
control = list(max_iter = 40L, tol = 1e-7, n_threads = 1L, verbose = FALSE)
)
expect_s3_class(fit, "tulpa_nested_laplace_joint")
expect_cells_solved(fit)
expect_equal(sum(fit$weights), 1.0, tolerance = 1e-6)
expect_equal(length(fit$log_marginal), nrow(gr))
})
# --------------------------------------------------------------------------- #
# (2) Pattern correctness #
# --------------------------------------------------------------------------- #
test_that("hsgp_mo H pattern: M independent K x K precision blocks (K = 2)", {
# Minimal fixture: 2 arms with 3 obs each, M = 4 basis fns. We need
# phi to be all-ones so cpp_test_joint_pattern routes through every
# latent slot in the basis_eval step (the pattern builder for
# DENSE_BASIS over-fills the full sub-block, so positions are not
# data-dependent; phi values are unused at the pattern stage).
M <- 4L
N <- 3L
phi_1 <- matrix(0.5, N, M)
phi_2 <- matrix(0.5, N, M)
arm_template <- function() list(
y = rep(0.0, N), n_trials = rep(1L, N),
X = matrix(1.0, N, 1L),
re_idx = rep(0L, N), n_re_groups = 0L, sigma_re = 1.0,
spatial_idx = rep(0L, N), temporal_idx = rep(0L, N),
obs_idx = rep(0L, N),
family = "gaussian", phi = 1.0
)
arms_list <- list(arm_template(), arm_template())
bs <- list(
type = "hsgp_mo",
m_total = M,
phi = list(phi_1, phi_2),
n_obs_per_arm = c(N, N),
eigenvalues = rep(0.5, M)
)
# Theta grid must have 4 columns (sigma_1, sigma_2, rho, ell). Pattern
# builder doesn't index theta_grid except via block.prep — and we don't
# call prep in cpp_test_joint_pattern. One dummy row is enough.
theta_grid <- matrix(c(1.0, 1.0, 0.0, 0.5), nrow = 1L)
pat <- cpp_test_joint_pattern(
arms_list = arms_list,
copy_arm = -1L, copy_block = -1L,
blocks_spec = list(bs),
theta_grid = theta_grid,
axis_offsets = as.integer(c(0L, 4L))
)
# n_x = per-arm betas (1 each) + multi-output block size (K * M).
expect_equal(pat$n_x, 1L + 1L + 2L * M)
# The block starts after the two betas: latent offset = 2.
blk_start <- 2L
# Reconstruct lower-triangle entries.
nnz <- pat$nnz
col_ptr <- pat$col_ptr
row_idx <- pat$row_idx
cols <- integer(nnz)
for (j in seq_len(pat$n_x)) {
for (e in seq(col_ptr[j] + 1L, col_ptr[j + 1L])) {
if (e <= nnz) cols[e] <- j - 1L
}
}
S <- unique(cbind(row = row_idx, col = cols))
has <- function(r, c) any(S[, "row"] == r & S[, "col"] == c)
# Diagonal of each latent slot must be present.
for (k in 0:1) for (m in 0:(M - 1L)) {
idx <- blk_start + k * M + m
expect_true(has(idx, idx),
info = sprintf("diagonal (k=%d, m=%d)", k, m))
}
# Cross-output entry at each basis index: (k=1, m) couples to (k=0, m).
for (m in 0:(M - 1L)) {
r <- blk_start + 1L * M + m
c <- blk_start + 0L * M + m
expect_true(has(r, c),
info = sprintf("cross-output coupling at m=%d", m))
}
})
# --------------------------------------------------------------------------- #
# (3) Independence reduction: rho_grid = 0 sanity check #
# --------------------------------------------------------------------------- #
test_that("hsgp_mo at rho = 0 stays finite and matches independent-fields scale", {
sim <- .sim_two_arm_mo(seed = 32L, sigma_1 = 0.7, sigma_2 = 0.7,
rho = 0.0, ell = 0.4)
block <- .mo_block(sim,
sigma_1_grid = c(0.7),
sigma_2_grid = c(0.7),
rho_grid = c(0.0),
ell_grid = c(0.4))
fit <- tulpa_nested_laplace_joint(
responses = sim$responses, prior = list(block), copy = NULL,
control = list(max_iter = 60L, tol = 1e-7, n_threads = 1L, verbose = FALSE)
)
expect_cells_solved(fit)
expect_equal(length(fit$log_marginal), 1L)
})
# --------------------------------------------------------------------------- #
# (4) Cross-output coupling: rho posterior tracks the simulated correlation #
# --------------------------------------------------------------------------- #
test_that("hsgp_mo posterior weight concentrates on rho_true > 0 cells", {
# Simulate at rho_true = +0.7 with reasonably large N; fit on a rho
# grid spanning {-0.6, -0.3, 0, 0.3, 0.6}. The posterior weight should
# put more mass on rho > 0 than rho <= 0.
sim <- .sim_two_arm_mo(seed = 33L, N1 = 200L, N2 = 200L,
sigma_1 = 0.8, sigma_2 = 0.8,
rho = 0.7, ell = 0.5)
rh_grid <- c(-0.6, -0.3, 0.0, 0.3, 0.6)
s1 <- c(0.8); s2 <- c(0.8); ls <- c(0.5)
gr <- expand.grid(s1 = s1, s2 = s2, rh = rh_grid, ls = ls,
KEEP.OUT.ATTRS = FALSE, stringsAsFactors = FALSE)
block <- .mo_block(sim, gr$s1, gr$s2, gr$rh, gr$ls)
fit <- tulpa_nested_laplace_joint(
responses = sim$responses, prior = list(block), copy = NULL,
control = list(max_iter = 80L, tol = 1e-8, n_threads = 1L, verbose = FALSE)
)
expect_cells_solved(fit)
w <- fit$weights
rho_cells <- gr$rh
pos_mass <- sum(w[rho_cells > 0])
neg_mass <- sum(w[rho_cells < 0])
zero_mass <- sum(w[rho_cells == 0])
# The data carry a strong rho > 0 signal: positive-rho cells must
# win the posterior weight against the negative-rho cells.
expect_gt(pos_mass, neg_mass + 0.10)
})
# --------------------------------------------------------------------------- #
# (5) Validation #
# --------------------------------------------------------------------------- #
test_that("hsgp_mo raises clean errors on bad spec", {
sim <- .sim_two_arm_mo(seed = 34L, N1 = 30L, N2 = 30L)
# Missing eigenvalues.
expect_error(
tulpa_nested_laplace_joint(
responses = sim$responses,
prior = list(list(type = "hsgp_mo",
m_total = sim$M,
phi = list(sim$phi1, sim$phi2),
n_obs_per_arm = c(sim$N1, sim$N2),
sigma_1_grid = 0.5, sigma_2_grid = 0.5,
rho_grid = 0.0, lengthscale_grid = 0.4)),
control = list(max_iter = 5L, tol = 1e-4, n_threads = 1L)
),
"eigenvalues"
)
# phi length mismatch with n_arms.
expect_error(
tulpa_nested_laplace_joint(
responses = sim$responses,
prior = list(list(type = "hsgp_mo",
m_total = sim$M,
phi = list(sim$phi1), # only one, n_arms = 2
n_obs_per_arm = c(sim$N1, sim$N2),
eigenvalues = sim$lam,
sigma_1_grid = 0.5, sigma_2_grid = 0.5,
rho_grid = 0.0, lengthscale_grid = 0.4)),
control = list(max_iter = 5L, tol = 1e-4, n_threads = 1L)
),
"length n_arms"
)
})
test_that("hsgp_mo errors when n_arms != 2 (first-ship K = 2 restriction)", {
# Build a synthetic 3-arm fixture by replicating arm 1.
sim <- .sim_two_arm_mo(seed = 35L, N1 = 30L, N2 = 30L)
responses3 <- c(sim$responses,
list(third = sim$responses$pos)) # 3 arms
expect_error(
tulpa_nested_laplace_joint(
responses = responses3,
prior = list(list(type = "hsgp_mo",
m_total = sim$M,
phi = list(sim$phi1, sim$phi2,
sim$phi2),
n_obs_per_arm = c(sim$N1, sim$N2, sim$N2),
eigenvalues = sim$lam,
sigma_1_grid = 0.5, sigma_2_grid = 0.5,
rho_grid = 0.0, lengthscale_grid = 0.4)),
control = list(max_iter = 5L, tol = 1e-4, n_threads = 1L)
),
"n_arms == 2"
)
})
# --------------------------------------------------------------------------- #
# (6) Parallel outer grid == serial (per-cell block state) #
# --------------------------------------------------------------------------- #
test_that("hsgp_mo joint fit is identical under n_threads_outer = 2 and 1", {
# The hsgp_mo block caches sqrt_S / Sigma_inv / log|Sigma| per outer-grid
# cell (prep -> basis_eval / add_prior_sparse / log_prior). With the outer
# grid running across 2 threads, every cell must still solve with its OWN
# hyperparameters -- a shared cache makes concurrent cells silently swap
# (sigma, rho, ell) and this equivalence breaks.
sim <- .sim_two_arm_mo(seed = 41L, sigma_1 = 0.8, sigma_2 = 0.8,
rho = 0.3, ell = 0.5)
s1 <- c(0.5, 0.8, 1.2); s2 <- c(0.5, 1.0); rh <- c(-0.3, 0.0, 0.4)
ls <- c(0.3, 0.5)
gr <- expand.grid(s1 = s1, s2 = s2, rh = rh, ls = ls,
KEEP.OUT.ATTRS = FALSE, stringsAsFactors = FALSE)
block <- .mo_block(sim, gr$s1, gr$s2, gr$rh, gr$ls)
fit_serial <- tulpa_nested_laplace_joint(
responses = sim$responses, prior = list(block), copy = NULL,
control = list(max_iter = 40L, tol = 1e-7, n_threads = 1L,
n_threads_outer = 1L, diagnose_k = FALSE)
)
fit_par <- tulpa_nested_laplace_joint(
responses = sim$responses, prior = list(block), copy = NULL,
control = list(max_iter = 40L, tol = 1e-7, n_threads = 1L,
n_threads_outer = 2L, diagnose_k = FALSE)
)
expect_equal(fit_par$log_marginal, fit_serial$log_marginal,
tolerance = 1e-10)
expect_equal(fit_par$weights, fit_serial$weights, tolerance = 1e-10)
})
test_that("coef on a direct joint fit reports only the arm fixed effects", {
sim <- .sim_two_arm_mo(seed = 51L, N1 = 60L, N2 = 60L)
block <- .mo_block(sim, c(0.5, 1.0), c(0.5, 1.0), 0.0, 0.4)
fit <- tulpa_nested_laplace_joint(
responses = sim$responses, prior = list(block), copy = NULL,
control = list(max_iter = 30L, tol = 1e-6, n_threads = 1L,
diagnose_k = FALSE)
)
# One intercept per arm -- not the full [betas, REs, K*M basis] latent.
cf <- coef(fit)
expect_length(cf, 2L)
expect_named(cf, c("occ.beta1", "pos.beta1"))
})
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.