tests/testthat/test-model-helper.R

.make_grid_adj <- function(nx, ny) {
  stopifnot(nx >= 1, ny >= 1)
  if (nx * ny == 1) return(Matrix::Matrix(0, 1, 1, sparse = TRUE))
  
  Tx <- Matrix::bandSparse(
    nx,
    k = c(-1, 1),
    diag = list(
      rep.int(1, nx - 1),
      rep.int(1, nx - 1)
    )
  )
  Ty <- Matrix::bandSparse(
    ny,
    k = c(-1, 1),
    diag = list(
      rep.int(1, ny - 1),
      rep.int(1, ny - 1)
    )
  )
  
  W  <- Matrix::Diagonal(ny) %x% Tx + Ty %x% Matrix::Diagonal(nx)
  W
}

.make_disconnected_lattices <- function(
    components = list(c(20, 20)),
    n_isolates = 0
) {
  stopifnot(
    all(vapply(components, length, 1L) == 2L),
    n_isolates >= 0
  )
  
  # Build block matrix from adjacency matrices
  blocks <- lapply(components, function(x) .make_grid_adj(x[1], x[2]))
  W <- Matrix::bdiag(blocks)
  iso_block <- Matrix::Matrix(0, n_isolates, n_isolates, sparse = TRUE)
  W <- Matrix::bdiag(W, iso_block)
  
  # Ensure symmetric numeric sparse (dsCMatrix) and consistent dimnames
  W <- Matrix::forceSymmetric(W, uplo = "U")
  N <- nrow(W)
  lab <- sprintf("Z%04d", seq_len(N))
  dimnames(W) <- list(lab, lab)
  
  # Strict upper-triangle edge list (sparse-safe; no dense conversion)
  Up <- Matrix::triu(W, k = 1)
  E  <- Matrix::summary(Up)
  
  list(
    W      = W,
    labels = lab,
    node1  = as.integer(E$i),
    node2  = as.integer(E$j)
  )
}

# Split a global basis R into per-component blocks R_c
.split_basis_by_component <- function(basis) {
  R    <- basis$R
  comp <- basis$comp
  N    <- nrow(R)
  
  stopifnot(length(comp$comp_id) == N)
  
  N_comps <- comp$N_comps
  
  # Number of nodes in each component
  n_c <- tabulate(comp$comp_id, nbins = N_comps)
  # Number of basis columns per component (0 for singletons)
  p_c <- pmax(n_c - 1L, 0L)
  
  if (ncol(R) != sum(p_c)) {
    stop(
      "ncol(R) = ", ncol(R),
      " but sum(n_c - 1) = ", sum(p_c),
      "; basis dimensions don't match component sizes."
    )
  }
  
  # Column index ranges for each component
  # Example for p_c = c(399,399,0,0,0,0,0):
  # col_start = c(1, 400, 799, 799, 799, 799, 799)
  col_start <- cumsum(c(1L, head(p_c, -1L)))
  col_end   <- col_start + p_c - 1L
  
  out <- vector("list", N_comps)
  for (c in seq_len(N_comps)) {
    if (p_c[c] == 0L) {
      # singleton or degenerate component: no basis columns
      out[[c]] <- NULL
    } else {
      row_idx <- which(comp$comp_id == c)
      col_idx <- col_start[c]:col_end[c]
      out[[c]] <- R[row_idx, col_idx, drop = FALSE]
    }
  }
  out
}


# Check that each component block R_c has columns summing to (approx) zero
.check_basis_sum_to_zero <- function(basis, tol = 1e-6) {
  Rc_list <- .split_basis_by_component(basis)
  
  res <- data.frame(
    component       = integer(0),
    max_abs_colsum  = numeric(0)
  )
  
  # NOTE: use seq_along(Rc_list) to avoid any mismatch with comp$N_comps
  for (c in seq_along(Rc_list)) {
    Rc <- Rc_list[[c]]
    if (is.null(Rc)) next    # singleton: no columns, nothing to check
    
    col_sums <- colSums(Rc)
    max_abs  <- max(abs(col_sums))
    res <- rbind(res, data.frame(
      component      = c,
      max_abs_colsum = max_abs
    ))
  }
  
  attr(res, "ok") <- if (nrow(res) == 0L) TRUE else all(res$max_abs_colsum < tol)
  res
}


# Check that columns of R_c are (approximately) orthogonal
.check_basis_orthogonality <- function(basis, tol_offdiag = 1e-6) {
  Rc_list <- .split_basis_by_component(basis)
  
  res <- data.frame(
    component       = integer(0),
    max_abs_offdiag = numeric(0)
  )
  
  for (c in seq_along(Rc_list)) {
    Rc <- Rc_list[[c]]
    if (is.null(Rc)) next  # singleton or no basis columns
    
    G <- crossprod(Rc)     # p_c x p_c
    diag(G) <- 0           # ignore diagonal
    max_off <- if (length(G)) max(abs(G)) else 0
    
    res <- rbind(res, data.frame(
      component       = c,
      max_abs_offdiag = max_off
    ))
  }
  
  attr(res, "ok") <- if (nrow(res) == 0L) TRUE else all(res$max_abs_offdiag < tol_offdiag)
  res
}

# Check BYM2 scaling: GM(diag(R_c R_c^T)) ≈ 1 for each component
check_basis_scaling <- function(basis, tol_rel = 0.05) {
  Rc_list <- .split_basis_by_component(basis)
  
  res <- data.frame(
    component   = integer(0),
    gm_diag_RRt = numeric(0)
  )
  
  for (c in seq_along(Rc_list)) {
    Rc <- Rc_list[[c]]
    if (is.null(Rc)) next  # singleton: no basis, no scaling to check
    
    RRt_diag <- rowSums(Rc^2)
    gm       <- exp(mean(log(RRt_diag)))
    
    res <- rbind(res, data.frame(
      component   = c,
      gm_diag_RRt = gm
    ))
  }
  
  attr(res, "ok") <- if (nrow(res) == 0L) TRUE else all(abs(res$gm_diag_RRt - 1) <= tol_rel)
  res
}

# Check that R is consistent with the Laplacian Q:
# t(R_c) %*% Q_c %*% R_c should be approximately diagonal.
.check_basis_against_laplacian <- function(basis, node1, node2, N,
                                          tol_offdiag = 1e-6) {
  if (!requireNamespace("Matrix", quietly = TRUE)) {
    stop("Please install the 'Matrix' package.")
  }
  
  R    <- basis$R
  comp <- basis$comp
  stopifnot(nrow(R) == N, length(comp$comp_id) == N)
  
  # Build Laplacian Q
  Wg <- Matrix::sparseMatrix(
    i    = c(node1, node2),
    j    = c(node2, node1),
    x    = 1,
    dims = c(N, N)
  )
  W  <- Matrix::forceSymmetric(Wg, uplo = "U")
  d  <- as.numeric(Matrix::rowSums(W))
  Q  <- Matrix::Diagonal(x = d) - W
  
  Rc_list <- .split_basis_by_component(basis)
  
  res <- data.frame(
    component       = integer(0),
    max_abs_offdiag = numeric(0)
  )
  
  for (c in seq_along(Rc_list)) {
    Rc <- Rc_list[[c]]
    if (is.null(Rc)) next  # singleton or no basis
    
    idx <- which(comp$comp_id == c)
    Qc  <- as.matrix(Q[idx, idx, drop = FALSE])
    
    M <- crossprod(Rc, Qc %*% Rc)  # p_c x p_c
    diag(M) <- 0
    max_off <- if (length(M)) max(abs(M)) else 0
    
    res <- rbind(res, data.frame(
      component       = c,
      max_abs_offdiag = max_off
    ))
  }
  
  attr(res, "ok") <- if (nrow(res) == 0L) TRUE else all(res$max_abs_offdiag < tol_offdiag)
  res
}

test_that("R construction works", {
  # connected graph
  adj <- .make_disconnected_lattices(
    components = list(c(3, 3)),
    n_isolates = 0
  )
  basis <- .compute_icar_basis(
    node1 = adj$node1,
    node2 = adj$node2,
    n_nodes = nrow(adj$W)
  )

  expect_true(attr(.check_basis_sum_to_zero(basis), "ok"))
  expect_true(attr(.check_basis_orthogonality(basis), "ok"))
  expect_true(attr(check_basis_scaling(basis), "ok"))
  expect_true(attr(.check_basis_against_laplacian(
    basis,
    node1 = adj$node1,
    node2 = adj$node2,
    N     = nrow(adj$W)
  ), "ok"))

  # disconnected graph
  adj <- .make_disconnected_lattices(
    components = list(c(3, 3), c(2, 2)),
    n_isolates = 0
  )
  basis <- .compute_icar_basis(
    node1 = adj$node1,
    node2 = adj$node2,
    n_nodes = nrow(adj$W)
  )

  expect_true(attr(.check_basis_sum_to_zero(basis), "ok"))
  expect_true(attr(.check_basis_orthogonality(basis), "ok"))
  expect_true(attr(check_basis_scaling(basis), "ok"))
  expect_true(attr(.check_basis_against_laplacian(
    basis,
    node1 = adj$node1,
    node2 = adj$node2,
    N     = nrow(adj$W)
  ), "ok"))

  # disconnected graph with isolates
  adj <- .make_disconnected_lattices(
    components = list(c(3, 3)),
    n_isolates = 2
  )
  basis <- .compute_icar_basis(
    node1 = adj$node1,
    node2 = adj$node2,
    n_nodes = nrow(adj$W)
  )

  expect_true(attr(.check_basis_sum_to_zero(basis), "ok"))
  expect_true(attr(.check_basis_orthogonality(basis), "ok"))
  expect_true(attr(check_basis_scaling(basis), "ok"))
  expect_true(attr(.check_basis_against_laplacian(
    basis,
    node1 = adj$node1,
    node2 = adj$node2,
    N     = nrow(adj$W)
  ), "ok"))
})

Try the shinymrp package in your browser

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

shinymrp documentation built on Dec. 4, 2025, 5:07 p.m.