knitr::opts_chunk$set( collapse = TRUE, comment = "#>", fig.width = 6, fig.height = 4, fig.align = "center" ) set.seed(23)
Three scenarios call for the distributed API:
All three are solved with the same primitive: each chunk produces a
vcmm_ss summary; summaries are additive; the fit is computed from the
aggregate.
Theorem 1 of Jalili and Lin (2025): partition the data into $K$ chunks, compute a sufficient-statistics summary on each chunk, sum the summaries, and fit. The result is bit-equivalent (up to BLAS summation noise) to fitting on the pooled data. The summaries lose no information — they are the sufficient statistics for the VCMM likelihood.
The summary on chunk $k$ stores $$ \bigl(a_k,\; b_k,\; C_k,\; (Z^\top Z)_k,\; (Z^\top y)_k,\; (X^\top Z)_k,\; n_k\bigr) $$ where $a = y^\top y$, $b = X^\top y$, $C = X^\top X$. Adding summaries across chunks is element-wise addition; the aggregate is the same as the single summary you would have computed on the pooled data.
library(cevcmm)
A modest example: $N = 1200$ observations, $K = 1$ varying covariate, $q = 4$ independent random effects. Then we'll split it into 3 nodes.
set.seed(23) N <- 1200L q <- 4L t <- runif(N) x <- runif(N) Z <- matrix(rnorm(N * q), N, q) beta_1_fun <- function(u) sin(2 * pi * u) alpha_true <- rnorm(q, sd = 0.5) y <- 2 + beta_1_fun(t) * x + as.vector(Z %*% alpha_true) + rnorm(N, sd = 0.5)
This is the "data on $K$ machines" case. Each node:
node_summary() on its slice,vcmm_ss object (small — a few KB regardless of $n_k$)
to the central node.The central node sums the summaries and calls fit_from_summaries().
# Assign each observation to one of 3 nodes node_id <- sample.int(3L, N, replace = TRUE) splits <- split(seq_len(N), node_id) lengths(splits)
Build the spline design once. (In a real deployment, each node would
construct its piece of the design locally using the same n_basis,
degree, and knot placement — the package handles this automatically as
long as the inputs agree.)
design <- build_vcmm_design(X = x, t = t) X_d <- design$X_design # N rows x p columns; p = intercept + spline-basis coefficients, # with n_basis auto-picked from the sample size. dim(X_d)
Each node produces a summary:
summaries <- lapply(splits, function(idx) { node_summary(y[idx], X_d[idx, , drop = FALSE], Z[idx, , drop = FALSE]) }) summaries[[1]]
The size of each summary is independent of $n_k$:
object.size(summaries[[1]]) object.size(y[splits[[1]]]) + object.size(X_d[splits[[1]], ])
Now fit from the summaries — pass the list directly:
ctrl <- vcmm_control(sigma_eps = 0.5, sigma_alpha = 0.5, update_variance = TRUE) fit_dist <- fit_from_summaries(summaries, penalty = design$penalty, control = ctrl, method = "ss", re_cov = "diag") fit_dist
fit_pooled <- vcmm(y, X = x, Z = Z, t = t, method = "ss", re_cov = "diag", control = ctrl) # beta estimates max(abs(fit_pooled$beta - fit_dist$beta)) # alpha estimates max(abs(fit_pooled$alpha - fit_dist$alpha)) # variance components all.equal(fit_pooled$sigma_eps, fit_dist$sigma_eps) all.equal(fit_pooled$sigma_alpha, fit_dist$sigma_alpha)
The maximum coefficient difference sits at the BLAS summation-noise floor — typically near machine epsilon ($\sim 10^{-15}$). The distributed fit is the pooled fit; only the path that produced it differs.
When you have many small chunks and don't want to keep them all in memory, use the accumulator instead of a list. The accumulator stores a running sum of the sufficient statistics; chunks can be incorporated and discarded as they arrive.
p <- ncol(X_d) acc <- init_accumulator(p = p, q = q) # Pretend chunks arrive one at a time for (k in seq_along(splits)) { idx <- splits[[k]] ss_k <- compute_sufficient_stats(y[idx], X_d[idx, , drop = FALSE], Z[idx, , drop = FALSE]) acc <- accumulate_stats(acc, ss_k) } acc
Fit from the accumulator — same call signature:
fit_acc <- fit_from_summaries(acc, penalty = design$penalty, control = ctrl, method = "ss", re_cov = "diag") # Same answer as Pattern 1 max(abs(fit_acc$beta - fit_dist$beta))
When rowSums(Z) is constant — for example, a row of $Z$ has exactly one
"origin" indicator and one "destination" indicator, so each row sums to
$s = 2$ — the model has a one-dimensional identifiability redundancy.
For any scalar $c$, replacing
$$
\alpha \;\mapsto\; \alpha + c\mathbf{1}, \qquad
\beta_0 \;\mapsto\; \beta_0 - c\,s
$$
leaves $Z\alpha + \beta_0\mathbf{1}$ — and hence the likelihood —
unchanged. vcmm() detects this automatically by inspecting the raw $Z$
matrix and applies a centering shift so that
$\sum_j \hat\alpha_j = 0$.
fit_from_summaries() doesn't auto-detect the row-sum from the
sufficient statistics, so you supply it explicitly via rowsum_constant:
# Skeleton — see vignette("od-migration") for a complete runnable example. # G and Sigma_spatial come from the OD setup (number of regions and an # initial spatial covariance matrix). fit_dist_od <- fit_from_summaries( summaries, penalty = design$penalty, control = ctrl, method = "ss", re_cov = "kronecker", n_groups = G, # number of OD regions Sigma_spatial_init = Sigma_spatial, # G x G initial spatial covariance rowsum_constant = 2 # matches what vcmm() applies internally )
Without rowsum_constant, the distributed fit's $\hat\beta_0$ and
$\hat\alpha$ differ from the pooled fit's by exactly the centering shift.
The fits are the same up to the redundancy; only the choice of
representative within the equivalence class differs.
See vignette("od-migration", package = "cevcmm") for a full OD example.
vignette("od-migration", package = "cevcmm").vignette("getting-started", package = "cevcmm").Jalili, L. and Lin, L.-H. (2025). Scalable and Communication-Efficient Varying Coefficient Mixed Effect Models: Methodology, Theory, and Applications. arXiv:2511.12732; under review at Journal of the American Statistical Association.
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.