1 Introduction

This tutorial demonstrates the workflow implemented in swcrt_bart_analysis.R for calibrated Bayesian analysis of stepped-wedge cluster-randomized trials (SW-CRTs) using mixed-effects Bayesian additive regression trees (mxBART) as the working outcome model.

The pipeline supports:

  • two estimators: model-based g-computation (gform) and model-robust standardization (mr);
  • two random-effects structures: simple-exchangeable (cluster RE only) and nested-exchangeable (cluster + cluster-period REs);
  • both overall and subgroup analyses, with subgroups defined by any baseline covariate;
  • user-specified covariate adjustment via an R formula;
  • calibrated 95% intervals via a cluster-level bootstrap procedure that combines posterior parameter uncertainty with sampling variability, providing valid frequentist coverage where naive posterior quantile intervals exhibit substantial undercoverage.

The pipeline is organized into nine functions chained by a top-level wrapper run_swcrt_analysis(). The functions are summarized in Section 4; full argument-by-argument documentation lives in the comment block above each function in swcrt_bart_analysis.R. Two end-to-end application examples follow in Sections 5 (subgroup analysis) and 6 (overall analysis).

2 Setup

The pipeline depends on the mxBART package for the working outcome model. The other R packages (stats, utils) are part of base R.

# install.packages("mxBART")  # if not already installed
library(mxBART)

Source the analysis functions. Adjust the path to point to where swcrt_bart_analysis.R is saved.

source("swcrt_bart_analysis.R")

After sourcing, the following nine functions are loaded into the workspace:

prepare_data(), make_design_matrix(), bootstrap_clusters_idx(), compute_ATEs_gform(), compute_ATEs_mr(), fit_mxbart_ate(), calibrated_variance(), format_results(), run_swcrt_analysis().

3 Simulated dataset

To illustrate the pipeline, we simulate a small SW-CRT with 30 clusters observed over 4 periods. Each cluster crosses over from control to intervention at one of three staggered steps, with 10 clusters crossing per step (a balanced rollout).

3.1 Interpreting the variables

The dataset uses generic variable names (cluster_id, period, intervention, x1, x2, y) to keep the example application-agnostic. In real SW-CRTs these variables map to substantive quantities. For example, in a healthcare quality-improvement trial:

  • cluster_id could index participating hospitals or clinics;
  • period could index calendar quarters or months over the rollout window;
  • intervention could indicate whether a clinical pathway (e.g., a sepsis bundle, a discharge protocol, a palliative-care program) has been rolled out at that cluster in that period;
  • x1 could be a binary patient characteristic such as a comorbidity indicator (presence of dementia, ESRD, COPD), or a binary risk classification;
  • x2 could be a standardized continuous baseline covariate such as a frailty score, a severity index, or age centered at the cohort mean;
  • y could be a continuous patient-level outcome such as log length of stay, a symptom-scale score, or a quality-of-life measure.

Subgroup analyses are typically motivated by clinically meaningful binary characteristics, which is why we use a binary x1 here as the subgroup variable.

3.2 Data simulation code

set.seed(2026)

I <- 30                 # number of clusters
J <- 4                  # number of periods
stopifnot(I %% (J - 1) == 0)

# Rollout schedule: 10 clusters cross over at each step (periods 2, 3, 4)
rollout <- rep(2:J, each = I / (J - 1))

# Period-varying coefficients (secular trends, covariate effects)
beta0j <- 0.25 + (0.02 / (J - 1)) * (0:(J - 1))   # intercepts
beta1j <- (3 * (1:J)) / 2                          # x1 coefficient by period
beta2j <- (1:J) / J                                # x2 coefficient by period

# Variance components
sig2_b  <- 0.05   # cluster random intercept variance
sig2_cp <- 0.05   # cluster x period random intercept variance
sig2_e  <- 0.90   # residual variance

alpha_i <- rnorm(I, mean = 0, sd = sqrt(sig2_b))   # cluster random effects

# Sample sizes per cluster-period (grow slightly across periods)
N_mat <- matrix(NA_integer_, nrow = I, ncol = J)
for (j in 1:J) {
  lo <- 5 + 5 * j
  hi <- 45 + 5 * j
  N_mat[, j] <- sample(lo:hi, size = I, replace = TRUE)
}
global_mean_N <- mean(N_mat)
EN_i1         <- (10 + 50) / 2

# Build long-format dataset
dat_list <- vector("list", I * J)
idx      <- 0

for (i in 1:I) {
  for (j in 1:J) {
    idx <- idx + 1

    z_ij     <- as.numeric(j >= rollout[i])
    delta_ij <- rnorm(1, mean = 0, sd = sqrt(sig2_cp))
    n_ij     <- N_mat[i, j]

    x1 <- rbinom(n_ij, size = 1, prob = 0.5)
    x2 <- rnorm(n_ij, mean = 0, sd = 0.1)

    mu_fixed <- beta0j[j] +
                beta1j[j] * x1 +
                beta2j[j] * (x2^2)

    # Heterogeneous treatment effect (varies by period, x1, x2,
    # and the cluster-period sample size)
    theta_ijk <- 1 +
                j * sin(x1) -
                (j^2) * exp(-x2) +
                sqrt(n_ij) / global_mean_N +
                3 * log(n_ij) * (n_ij^2) / (EN_i1^2)

    eps <- rnorm(n_ij, mean = 0, sd = sqrt(sig2_e))

    Y_0_true <- mu_fixed + alpha_i[i] + delta_ij + eps
    Y_1_true <- Y_0_true + theta_ijk
    y        <- z_ij * Y_1_true + (1 - z_ij) * Y_0_true

    dat_list[[idx]] <- data.frame(
      cluster_id   = i,
      period       = j,
      intervention = z_ij,
      x1           = x1,
      x2           = x2,
      y            = y
    )
  }
}

sim_dat <- do.call(rbind, dat_list)

3.3 Dataset summary

cat("Dataset:", nrow(sim_dat), "rows x", ncol(sim_dat), "cols\n")
#> Dataset: 4590 rows x 6 cols
cat("Subgroup (x1 = 1) prevalence:", round(mean(sim_dat$x1), 3), "\n")
#> Subgroup (x1 = 1) prevalence: 0.496
# Rollout schedule (1 = cluster is treated in that period)
with(sim_dat, tapply(intervention, list(cluster_id, period), max))
#>    1 2 3 4
#> 1  0 1 1 1
#> 2  0 1 1 1
#> 3  0 1 1 1
#> 4  0 1 1 1
#> 5  0 1 1 1
#> 6  0 1 1 1
#> 7  0 1 1 1
#> 8  0 1 1 1
#> 9  0 1 1 1
#> 10 0 1 1 1
#> 11 0 0 1 1
#> 12 0 0 1 1
#> 13 0 0 1 1
#> 14 0 0 1 1
#> 15 0 0 1 1
#> 16 0 0 1 1
#> 17 0 0 1 1
#> 18 0 0 1 1
#> 19 0 0 1 1
#> 20 0 0 1 1
#> 21 0 0 0 1
#> 22 0 0 0 1
#> 23 0 0 0 1
#> 24 0 0 0 1
#> 25 0 0 0 1
#> 26 0 0 0 1
#> 27 0 0 0 1
#> 28 0 0 0 1
#> 29 0 0 0 1
#> 30 0 0 0 1

The rollout table shows the staggered design: clusters 1–10 are treated from period 2 onward, clusters 11–20 from period 3, and clusters 21–30 from period 4.

3.4 Target estimands

The pipeline estimates two weighted average treatment effects:

  • h-iATE (individual-horizontal ATE): assigns equal weight to each individual, so larger cluster-periods contribute proportionally more to the overall effect.
  • h-cATE (cluster-horizontal ATE): assigns equal weight to each cluster, so every cluster contributes the same regardless of its size.

The two estimands coincide when cluster-period sizes are non-informative, but can differ meaningfully when cluster-period sizes are informative. The pipeline returns posterior draws of both quantities from a single mxBART fit; the weighting scheme is applied at the aggregation step.

4 Pipeline functions

Function Purpose
prepare_data() Rename design columns; build helper cols (period_f, cp, N_ij).
make_design_matrix() Build fixed-effect design matrix for mxBART from a covariate formula.
bootstrap_clusters_idx() Resample whole clusters and return row-index mapping for Y0/Y1.
compute_ATEs_gform() Posterior draws of h-iATE and h-cATE under model-based g-computation (overall or subgroup).
compute_ATEs_mr() Posterior draws of h-iATE and h-cATE under model-robust standardization (overall or subgroup).
fit_mxbart_ate() Fit mxBART and return n x B counterfactual prediction matrices Y0, Y1.
calibrated_variance() Cluster-bootstrap-corrected variance of posterior mean ATEs.
format_results() Two-row uncalibrated/calibrated summary table for h-iATE and h-cATE.
run_swcrt_analysis() Top-level wrapper that chains the above.

Full argument-by-argument documentation is in the comment block above each function in swcrt_bart_analysis.R.

4.1 Arguments to run_swcrt_analysis()

The top-level wrapper takes the following arguments:

Argument Description
data Cleaned data.frame, one row per individual, with the outcome on its final analysis scale.
cluster_col String. Column name in data for the cluster ID.
period_col String. Column name for the period (integer 1..J).
treat_col String. Column name for the treatment indicator (0/1).
outcome_col String. Column name for the outcome.
covariate_formula One-sided formula (e.g., ~ age + factor(race)) listing adjustment covariates. Pass ~ 1 or NULL for no covariate adjustment.
estimator "gform" (model-based g-computation) or "mr" (model-robust standardization). Default "gform".
re_structure "simple_exch" (cluster RE only) or "nested_exch" (cluster + cluster-period REs). Default "simple_exch".
subgroup_var Column name to restrict to for subgroup analysis, or NULL for overall analysis.
subgroup_val Value of subgroup_var defining the subgroup. Required when subgroup_var is non-NULL.
mctotal Total MCMC iterations. Default 4000.
mcburnin Number of burn-in iterations to discard. Default 2000.
ntree Number of trees in the BART ensemble. Default 50.
thin_rate MCMC thinning rate (keepevery argument to mxbart). Default 1 (no thinning).
num_bootstrap Number of cluster-bootstrap resamples for calibrated variance. Default 100.
seed Integer seed for reproducibility. Default 123.
verbose If TRUE, print bootstrap progress every 10 iterations.

The function returns a list with elements summary (uncalibrated/calibrated table), posterior_ATEs (posterior draws of h-iATE and h-cATE), Y0, Y1 (n x B counterfactual prediction matrices), fit (raw mxBART fit), and calibration (calibrated variance components).

Arguments for the lower-level functions follow the same vocabulary; see the comment block above each function in swcrt_bart_analysis.R for full details.

5 Application example: subgroup analysis

This example estimates the treatment effect within the subgroup defined by x1 == 1, using the model-robust standardization estimator with the nested-exchangeable random-effects structure. The full mxBART pipeline runs end-to-end via run_swcrt_analysis().

res_subgroup <- run_swcrt_analysis(
  data              = sim_dat,
  cluster_col       = "cluster_id",
  period_col        = "period",
  treat_col         = "intervention",
  outcome_col       = "y",
  covariate_formula = ~ x1 + x2,
  estimator         = "mr",
  re_structure      = "nested_exch",
  subgroup_var      = "x1",
  subgroup_val      = 1,
  mctotal           = 2000,
  mcburnin          = 1000,
  num_bootstrap     = 100,
  ntree             = 50,
  seed              = 123,
  verbose           = TRUE
)
#> *****Calling gbmm: type=1
#> *****Mixed BART Settings
#> *****Number of levels: 3
#> **Level: 2
#> Units: 30
#> Random Effects (per unit): 1
#> prior,df 1 3
#> scale: 1 
#> **Level: 3
#> Units: 120
#> Random Effects (per unit): 1
#> prior,df 1 3
#> scale: 1 
#> *****Data:
#> data:n,p,np: 4590, 8, 9180
#> y1,yn: -11.622991, 23.636970
#> x1,x[n*p]: 1.000000, 61.000000
#> xp1,xp[np*p]: 1.000000, 61.000000
#> *****Number of Trees: 50
#> *****Number of Cut Points: 1 ... 46
#> *****burn,nd,thin: 1000,1000,1
#> *****Prior:beta,alpha,tau,nu,lambda,offset: 2,0.95,2.19911,3,460.167,13.5752
#> *****sigma: 14.860383
#> *****Dirichlet:sparse,theta,omega,a,b,rho,augment: 0,0,1,0.5,1,8,0
#> *****printevery: 100
#> 
#> MCMC
#> done 0 (out of 2000)
#> done 100 (out of 2000)
#> done 200 (out of 2000)
#> done 300 (out of 2000)
#> done 400 (out of 2000)
#> done 500 (out of 2000)
#> done 600 (out of 2000)
#> done 700 (out of 2000)
#> done 800 (out of 2000)
#> done 900 (out of 2000)
#> done 1000 (out of 2000)
#> done 1100 (out of 2000)
#> done 1200 (out of 2000)
#> done 1300 (out of 2000)
#> done 1400 (out of 2000)
#> done 1500 (out of 2000)
#> done 1600 (out of 2000)
#> done 1700 (out of 2000)
#> done 1800 (out of 2000)
#> done 1900 (out of 2000)
#> time: 8s
#> trcnt,tecnt: 1000,1000
#> Bootstrap iteration 10 / 100
#> Bootstrap iteration 20 / 100
#> Bootstrap iteration 30 / 100
#> Bootstrap iteration 40 / 100
#> Bootstrap iteration 50 / 100
#> Bootstrap iteration 60 / 100
#> Bootstrap iteration 70 / 100
#> Bootstrap iteration 80 / 100
#> Bootstrap iteration 90 / 100
#> Bootstrap iteration 100 / 100

5.1 Summary table

The two-row table reports point estimates and 95% credible intervals (both uncalibrated and calibrated) for h-cATE and h-iATE within the subgroup x1 == 1. The point estimate is the mean of the posterior draws of the corresponding estimand.

print(res_subgroup$summary, row.names = FALSE)
#>  Posterior Inference h-cATE Estimate  h-cATE 95% C.I. h-iATE Estimate
#>         Uncalibrated          20.567 (20.339, 20.853)          21.904
#>           Calibrated          20.567 (17.196, 23.938)          21.904
#>   h-iATE 95% C.I.
#>  (21.693, 22.158)
#>  (18.779, 25.030)

The uncalibrated 95% CIs are the 2.5th and 97.5th percentiles of the posterior draws and reflect parameter uncertainty only. The calibrated 95% CIs are constructed as the point estimate ± 1.96 × calibrated SE, where the calibrated SE adds the cluster-bootstrap variance of the posterior mean to the within-data posterior variance. Calibration is especially important for the model-robust estimator, because its formulation depends jointly on the model parameters and on aggregated observed cluster-period outcomes. Posterior parameter uncertainty alone does not capture the sampling variability of the observed outcomes, so naive uncalibrated intervals can substantially under-cover the true estimand. The calibrated variance estimator uses cluster-level bootstrap that captures this additional variability and restores valid frequentist coverage.

5.2 Posterior draws and calibrated standard errors

The returned object also contains the raw posterior draws and the calibrated variance components. In posterior_ATEs, the column i_ATE stores the h-iATE draws and the column c_ATE stores the h-cATE draws.

summary(res_subgroup$posterior_ATEs)
#>      i_ATE           c_ATE      
#>  Min.   :21.59   Min.   :20.25  
#>  1st Qu.:21.79   1st Qu.:20.44  
#>  Median :21.91   Median :20.57  
#>  Mean   :21.90   Mean   :20.57  
#>  3rd Qu.:22.00   3rd Qu.:20.67  
#>  Max.   :22.30   Max.   :20.99
cat("Calibrated SE for h-cATE:", round(res_subgroup$calibration$SE_c, 4), "\n")
#> Calibrated SE for h-cATE: 1.7199
cat("Calibrated SE for h-iATE:", round(res_subgroup$calibration$SE_i, 4), "\n")
#> Calibrated SE for h-iATE: 1.5945

The full mxBART fit object is available as res_subgroup$fit if posterior diagnostics such as traceplots or Geweke statistics are desired, and the n x B posterior prediction matrices are available as res_subgroup$Y0 and res_subgroup$Y1.

6 Application example: overall analysis

This example estimates the overall (population-level) treatment effect using the model-based g-computation estimator with the simple-exchangeable random-effects structure. To run an overall analysis, simply omit the subgroup_var and subgroup_val arguments from run_swcrt_analysis(); the pipeline then aggregates over the full sample rather than restricting to a subgroup.

res_overall <- run_swcrt_analysis(
  data              = sim_dat,
  cluster_col       = "cluster_id",
  period_col        = "period",
  treat_col         = "intervention",
  outcome_col       = "y",
  covariate_formula = ~ x1 + x2,
  estimator         = "gform",
  re_structure      = "simple_exch",
  # subgroup_var and subgroup_val omitted -> overall analysis
  mctotal           = 2000,
  mcburnin          = 1000,
  num_bootstrap     = 100,
  ntree             = 50,
  seed              = 123,
  verbose           = TRUE
)
#> *****Calling gbmm: type=1
#> *****Mixed BART Settings
#> *****Number of levels: 2
#> **Level: 2
#> Units: 30
#> Random Effects (per unit): 1
#> prior,df 1 3
#> scale: 1 
#> *****Data:
#> data:n,p,np: 4590, 8, 9180
#> y1,yn: -11.622991, 23.636970
#> x1,x[n*p]: 1.000000, 61.000000
#> xp1,xp[np*p]: 1.000000, 61.000000
#> *****Number of Trees: 50
#> *****Number of Cut Points: 1 ... 46
#> *****burn,nd,thin: 1000,1000,1
#> *****Prior:beta,alpha,tau,nu,lambda,offset: 2,0.95,2.19911,3,460.167,13.5752
#> *****sigma: 14.860383
#> *****Dirichlet:sparse,theta,omega,a,b,rho,augment: 0,0,1,0.5,1,8,0
#> *****printevery: 100
#> 
#> MCMC
#> done 0 (out of 2000)
#> done 100 (out of 2000)
#> done 200 (out of 2000)
#> done 300 (out of 2000)
#> done 400 (out of 2000)
#> done 500 (out of 2000)
#> done 600 (out of 2000)
#> done 700 (out of 2000)
#> done 800 (out of 2000)
#> done 900 (out of 2000)
#> done 1000 (out of 2000)
#> done 1100 (out of 2000)
#> done 1200 (out of 2000)
#> done 1300 (out of 2000)
#> done 1400 (out of 2000)
#> done 1500 (out of 2000)
#> done 1600 (out of 2000)
#> done 1700 (out of 2000)
#> done 1800 (out of 2000)
#> done 1900 (out of 2000)
#> time: 8s
#> trcnt,tecnt: 1000,1000
#> Bootstrap iteration 10 / 100
#> Bootstrap iteration 20 / 100
#> Bootstrap iteration 30 / 100
#> Bootstrap iteration 40 / 100
#> Bootstrap iteration 50 / 100
#> Bootstrap iteration 60 / 100
#> Bootstrap iteration 70 / 100
#> Bootstrap iteration 80 / 100
#> Bootstrap iteration 90 / 100
#> Bootstrap iteration 100 / 100

6.1 Summary table

print(res_overall$summary, row.names = FALSE)
#>  Posterior Inference h-cATE Estimate  h-cATE 95% C.I. h-iATE Estimate
#>         Uncalibrated          19.032 (18.765, 19.247)          20.623
#>           Calibrated          19.032 (16.259, 21.806)          20.623
#>   h-iATE 95% C.I.
#>  (20.367, 20.828)
#>  (18.262, 22.983)

The table reports the overall h-cATE and h-iATE under model-based g-computation. As in the subgroup example, the point estimate is the posterior mean and the two rows give the uncalibrated and calibrated 95% credible intervals.

6.2 Posterior draws and calibrated standard errors

summary(res_overall$posterior_ATEs)
#>      i_ATE           c_ATE      
#>  Min.   :20.23   Min.   :18.62  
#>  1st Qu.:20.55   1st Qu.:18.95  
#>  Median :20.63   Median :19.04  
#>  Mean   :20.62   Mean   :19.03  
#>  3rd Qu.:20.71   3rd Qu.:19.12  
#>  Max.   :20.95   Max.   :19.36
cat("Calibrated SE for h-cATE:", round(res_overall$calibration$SE_c, 4), "\n")
#> Calibrated SE for h-cATE: 1.4152
cat("Calibrated SE for h-iATE:", round(res_overall$calibration$SE_i, 4), "\n")
#> Calibrated SE for h-iATE: 1.2042

By switching the estimator and re_structure arguments, the same pipeline returns estimates under the other three combinations (mr + simple_exch, gform + nested_exch, mr + nested_exch). Adding subgroup_var and subgroup_val turns any of these into a subgroup analysis.