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.
A modest example: \(N = 1200\) observations, \(K = 1\) varying covariate, \(q = 4\) independent random effects. Then we’ll split it into 3 nodes.
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)
#> 1 2 3
#> 405 401 394Build 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)
#> [1] 1200 15Each node produces a summary:
summaries <- lapply(splits, function(idx) {
node_summary(y[idx], X_d[idx, , drop = FALSE], Z[idx, , drop = FALSE])
})
summaries[[1]]
#> <vcmm_ss> one-batch sufficient statistics
#> n_obs : 405
#> p : 15 (fixed-effects columns)
#> q : 4 (random-effects columns)
#> a : 2461The size of each summary is independent of \(n_k\):
object.size(summaries[[1]])
#> 4776 bytes
object.size(y[splits[[1]]]) + object.size(X_d[splits[[1]], ])
#> 52104 bytesNow 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
#> <vcmm_fit> Varying Coefficient Mixed-Effects Model fit
#> method : SS
#> n_obs : 1200
#> p (fixed) : 15
#> q (random) : 4
#> RE cov : diag
#> iterations : 5 (converged)
#> sigma_eps : 0.4845
#> sigma_alpha : 0.7251
#> elapsed : <0.001 secfit_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))
#> [1] 1.107447e-14
# alpha estimates
max(abs(fit_pooled$alpha - fit_dist$alpha))
#> [1] 2.220446e-16
# variance components
all.equal(fit_pooled$sigma_eps, fit_dist$sigma_eps)
#> [1] TRUE
all.equal(fit_pooled$sigma_alpha, fit_dist$sigma_alpha)
#> [1] TRUEThe 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
#> <vcmm_accumulator> accumulated sufficient statistics
#> n_obs : 1200
#> p : 15 (fixed-effects columns)
#> q : 4 (random-effects columns)
#> a : 7587Fit from the accumulator — same call signature:
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.