Backends: brms, cmdstanr, rstanarm, and survey
JoonHo Lee
2026-07-04
Source:vignettes/backends.Rmd
backends.RmdOverview
svyder computes Design Effect Ratios from a plain matrix of posterior
draws. This vignette is for practitioners who have already fitted a
hierarchical model with brms,
cmdstanr, or rstanarm, and would
rather hand svyder the fit object than reassemble the draws by hand. Two
S3 generics do the wiring: extract_draws() pulls a
standardized draws matrix out of a fit object or a
posterior::draws_* container, and
extract_design() pulls survey weights, cluster ids, and
strata out of a survey design object. The
der_compute() methods for each backend call these generics
for you.
One theme runs through every backend: svyder can auto-detect a
model’s likelihood and parameters, but it can never
infer the survey design from a Stan fit — a brmsfit does
not know which primary sampling units produced its rows. You therefore
always pass weights and, most importantly,
cluster yourself. And the plain-matrix path always works
with no extra packages installed, so it is the reliable fallback
whenever a backend is fussy.
We cover, in order:
- the matrix path (always available), which is exactly what the bundled demo uses;
- the three Stan front-ends — brms, cmdstanr, rstanarm — and what each does and does not auto-extract;
- the
surveypackage viaextract_design(), shown live on demo pieces; -
posteriordraws objects; - how to map your own model onto svyder’s
X,group, andparam_types.
Key insight. No backend supplies
cluster. The aggregation unit is part of the estimand — the DER changes materially between a design-PSU target and a model-group target — so svyder requires you to declare it explicitly. This is a deliberate design decision, not a limitation of any one backend.
This vignette is about integration mechanics only. For the meaning of the DER, the four-tier classification, and the correction algorithm, see Getting Started and The Compute–Classify–Correct Pipeline.
The matrix path (always available)
Every der_compute() method eventually funnels into
der_compute.matrix(), which takes a raw draws matrix
(S samples \times
d parameters, columns ordered beta[1..p] then
theta[1..J]) plus the data and design pieces. The bundled
nsece_demo object is the matrix path
pre-assembled: it stores the draws alongside y,
X, group, weights,
psu, family, and sigma_theta, so
no model fitting or Stan installation is required.
Here is the canonical matrix call, run live on the demo. Note
cluster = nsece_demo$psu — the design PSU ids — which is
required and has no default:
fit <- der_compute(
nsece_demo$draws, # S x d draws matrix: [beta, theta]
y = nsece_demo$y,
X = nsece_demo$X,
group = nsece_demo$group,
weights = nsece_demo$weights,
cluster = nsece_demo$psu, # REQUIRED aggregation unit (design PSUs)
family = nsece_demo$family,
sigma_theta = nsece_demo$sigma_theta,
param_types = nsece_demo$param_types
)
fit
#> svyder diagnostic (54 parameters)
#> Family: binomial | N = 6785 | J = 51
#> Target: 644 cluster(s) | meat: centered, DF-corrected | weights: unit_mean
#> DER range: [0.235, 5.308]
#> Tier III (DER undefined): sigma_theta
#> (not yet classified -- run der_classify())
#> Compute time: 0.072 secThat is the whole contract for the matrix path: you supply
everything. The backend methods below exist only to fill in as
many of these arguments as can be recovered from a fit object, so you
type less. What they can recover differs by backend, but
weights and cluster are never among them.
Note. If you can produce a draws matrix in
[beta, theta]column order — from any sampler, including one svyder has no method for — you never need a backend at all. Pass the matrix directly, as above.
brms
der_compute() has a method for brmsfit
objects. Because brms stores the full model specification (data,
formula, family, priors) inside the fit, this method auto-extracts
almost everything. Concretely, der_compute.brmsfit():
- reads the response
yand design matrixXfrom the stored model data andbrms::standata(); - infers
familyfromstats::family()(brmsbernoulli/binomial\rightarrow"binomial",gaussian\rightarrow"gaussian"); - detects the
groupfactor from the first random-effect grouping term; - estimates
sigma_thetaas the posterior mean of the firstsd_*parameter (andsigma_efromsigma, for gaussian); - extracts the draws via
extract_draws.brmsfit(), which keeps population-level (b_*) and group-level (r_*) parameters, drops variance and correlation components (sd_*,cor_*,sigma,lp__,lprior), and renames them to svyder’sbeta[k]/theta[j]convention.
What you must still pass: weights
(survey weights) and cluster (the
aggregation unit) — brms fit objects carry no survey design. You may
also pass group to override the auto-detected grouping
factor, or sigma_theta / family to override
the auto-detected values.
The following is illustrative and not evaluated here (it requires a compiled Stan model):
library(brms)
# A hierarchical logistic model fitted to survey data
bfit <- brm(
y ~ poverty_cwc + tiered_reim + (1 | state),
data = provider_df,
family = bernoulli()
)
# svyder auto-extracts y, X, group, family, sigma_theta from bfit;
# you supply the survey weights and the cluster aggregation unit.
der <- der_compute(
bfit,
weights = provider_df$w, # survey weights (NOT in the fit)
cluster = provider_df$psu # REQUIRED: design PSU ids
)
der_diagnose(der) # classify + correct in one stepKey insight. brms auto-detection is a convenience, not magic. It reads the model, never the sample design. If your PSUs are not the same as your model grouping factor, be deliberate:
cluster = provider_df$psugives a design-based target,cluster = provider_df$stategives a model-aligned one.
cmdstanr
The CmdStanMCMC method is the most manual of the three,
because CmdStan keeps no model data in the fit object —
only the draws. extract_draws.CmdStanMCMC() converts
fit$draws() to a matrix and strips the diagnostic columns
lp__ and lp_approx__; that is all cmdstanr can
give svyder automatically.
Consequently der_compute.CmdStanMCMC() requires you to
supply every data argument yourself: y,
X, group, weights,
cluster, and sigma_theta (plus
sigma_e for gaussian, and optional strata,
normalize, param_types). Crucially, the draws
matrix you fitted must already be in svyder column
order,
[beta_1, ..., beta_p, theta_1, ..., theta_J], because
svyder cannot reorder columns it cannot name.
Not evaluated (requires CmdStan):
library(cmdstanr)
mod <- cmdstan_model("hierarchical_logit.stan")
cfit <- mod$sample(data = stan_data, parallel_chains = 4)
# cmdstanr stores no data, so supply ALL pieces explicitly.
der <- der_compute(
cfit,
y = stan_data$y,
X = stan_data$X, # columns match beta[1..p]
group = stan_data$group,
weights = stan_data$w, # survey weights
cluster = stan_data$psu, # REQUIRED aggregation unit
family = "binomial",
sigma_theta = 0.66 # or a posterior summary of your sd param
)Note. With cmdstanr the burden of getting
Xand the draw columns into the same order asbeta[1..p]is on you. If in doubt, extract the draws yourself, reorder to[beta, theta], and use the matrix path — it is identical in result and easier to verify.
rstanarm
The stanreg method sits between brms and cmdstanr in
convenience. rstanarm stores the data, formula, and family, so
der_compute.stanreg() auto-extracts:
-
yfromx$yandXfromstats::model.matrix(x); -
familyfromstats::family()(binomialorgaussian); - the
groupfactor fromx$glmod$reTrms$flist(the first grouping term); -
sigma_thetaassqrt(mean(.))of the firstSigma[...]variance component (rstanarm stores group-level variances, so svyder takes the square root), andsigma_efromsigmafor gaussian; - the draws via
extract_draws.stanreg(), which keeps fixed effects (plain names such as(Intercept),x1) and random effects (b[...]), dropsSigma[...],sigma, andlog-posterior, and renames tobeta[k]/theta[j].
As always, you provide weights and
cluster yourself.
Not evaluated (requires a compiled Stan model):
library(rstanarm)
sfit <- stan_glmer(
y ~ poverty_cwc + tiered_reim + (1 | state),
data = provider_df,
family = binomial()
)
# rstanarm auto-extracts y, X, group, family, sigma_theta (and sigma_e);
# you supply weights and the cluster aggregation unit.
der <- der_compute(
sfit,
weights = provider_df$w,
cluster = provider_df$psu # REQUIRED
)The survey package
The survey package is the natural source of the two
things every Stan backend lacks: survey weights and the
cluster (PSU) aggregation unit.
extract_design() has a method for
survey.design2 objects that returns exactly those pieces —
plus strata — ready to hand to der_compute().
Because the survey package is available here, we can
demonstrate this live. We build a minimal single-stage
design from nsece_demo pieces: a data frame holding the PSU
id and the weight, wrapped by survey::svydesign(). (In a
real analysis this is the design object you already have; we reconstruct
a small one purely so the example runs without external data.)
library(survey)
# A data frame carrying the two design columns svyder needs
df_des <- data.frame(
psu = nsece_demo$psu,
w = nsece_demo$weights
)
des <- svydesign(ids = ~psu, weights = ~w, data = df_des)
class(des)
#> [1] "survey.design2" "survey.design"Now call extract_design() on it and inspect what comes
back:
di <- extract_design(des)
str(di, max.level = 1)
#> List of 3
#> $ weights: Named num [1:6785] 0.327 6.848 0.269 0.272 0.937 ...
#> ..- attr(*, "names")= chr [1:6785] "1" "2" "3" "4" ...
#> $ cluster: num [1:6785] 1 1 1 1 1 1 1 1 1 1 ...
#> $ strata :'data.frame': 6785 obs. of 1 variable:extract_design.survey.design2() returns a three-element
list:
-
weights— the survey weights (stats::weights(des)), one per row; -
cluster— the PSU identifiers from the first stage (des$cluster[[1]]); svyder later coerces these to consecutive integer ids; -
strata— a data frame of stratum variables, or (for this unstratified design) a single column of constant values.
cat(sprintf("weights: %d values, mean %.3f\n",
length(di$weights), mean(di$weights)))
#> weights: 6785 values, mean 1.000
cat(sprintf("cluster: %d unique PSUs\n", length(unique(di$cluster))))
#> cluster: 644 unique PSUs
cat(sprintf("strata : %d column(s)\n", ncol(di$strata)))
#> strata : 1 column(s)That gives 644 unique PSUs — exactly the design-based aggregation
unit for nsece_demo. You could stop here and pass
di$weights and di$cluster into the matrix call
by hand. But der_compute() accepts the design object
directly through its design = argument and does the
extraction internally, which is the tidiest route. This runs
live on the demo draws:
fit_des <- der_compute(
nsece_demo$draws,
y = nsece_demo$y,
X = nsece_demo$X,
group = nsece_demo$group,
family = nsece_demo$family,
sigma_theta = nsece_demo$sigma_theta,
param_types = nsece_demo$param_types,
design = des # supplies weights + cluster + strata
)
# The three fixed-effect DERs, recovered via the survey design object
round(fit_des$der[1:3], 3)
#> beta[1] beta[2] beta[3]
#> 0.263 2.689 0.343These are the design-based DERs for the three fixed effects: the
intercept and tiered-reimbursement coefficients (both between-state) sit
well below 1, while beta[2], the within-state poverty
coefficient, is flagged. Passing design = des reproduces
the same fixed-effect DERs as passing
cluster = nsece_demo$psu explicitly, because the design’s
PSUs are those cluster ids — the design object is simply a
convenient carrier for them.
What the design records is stored in fit_des$target:
fit_des$target[c("cluster_n", "strata_n", "normalize")]
#> $cluster_n
#> [1] 644
#>
#> $strata_n
#> [1] 1
#>
#> $normalize
#> [1] "unit_mean"Note. When a
survey.design2carries genuine strata (more than one stratum),der_compute()picks them up automatically and switches the meat estimator to its stratified form.nsece_demohas no strata, sostrata_nis 1 here and the estimator is unstratified. Weights still follow thenormalizeconvention ("unit_mean"by default), recorded alongside the cluster count.
Posterior draws objects
If you already hold your draws as a posterior package
object, you do not need a model-specific backend at all.
extract_draws() has methods for every
posterior::draws_* format — draws_matrix,
draws_df, draws_array,
draws_list, and draws_rvars — each of which
converts to a plain matrix and drops the .chain,
.iteration, and .draw bookkeeping columns. The
matrix method is the identity: a matrix is already
draws.
library(posterior)
# Suppose you assembled your draws as a posterior draws_matrix,
# with columns already ordered [beta[1..p], theta[1..J]].
dm <- as_draws_matrix(my_draws)
der <- der_compute(
as.matrix(dm), # or feed dm through extract_draws()
y = y, X = X, group = group,
weights = w, cluster = psu, # REQUIRED
family = "binomial", sigma_theta = 0.66
)Because these methods only reshape draws, they carry the same
requirement as the matrix path: the columns must be in svyder order, and
you still supply all the data and design arguments, cluster
included.
Mapping your model to X / group / param_types
Whichever backend you use, svyder ultimately needs three model-structure inputs lined up correctly. Getting them right matters more than which front-end produced the fit.
X — the fixed-effect design matrix. Its
columns must correspond, in order, to the beta[1..p] draws.
brms and rstanarm build X for you from the formula; with
cmdstanr or the matrix path you pass it yourself and are responsible for
the column ordering.
group — the grouping factor. An integer
(or coercible) vector, one value per observation, giving each row’s
group. It defines the theta[1..J] random effects and the
shrinkage structure. brms and rstanarm read it from the first
random-effect term; cmdstanr and the matrix path take it directly.
param_types — how each fixed effect is
identified. A character vector, one entry per column of
X, taking values "fe_within" or
"fe_between":
-
"fe_within"— the covariate varies within groups (e.g. an individual-level poverty measure). Under Theorem 1 its DER equals the full design effect (Tier I-a) and it is the primary correction candidate. -
"fe_between"— the covariate is constant within each group (e.g. a state-level policy indicator). Its DER is \mathrm{DEFF}\,(1 - B), shielded by shrinkage (Tier I-b), and it is usually below the threshold.
If you leave param_types = NULL, svyder infers it from
X — any column that is constant within every group is
labelled fe_between, the rest fe_within — and
emits a message so you can check the inference, for example:
param_types inferred from the design matrix: intercept=fe_between,
poverty_cwc=fe_within, tiered_reim=fe_between. Supply 'param_types' to override.
Passing param_types explicitly suppresses the message.
Inference is reliable for the demo, but a within-group covariate that
happens to be constant within every realized group in your
sample would be mislabelled fe_between; when in doubt,
state param_types yourself. In nsece_demo the
mapping is:
data.frame(
column = colnames(nsece_demo$X),
param_type = nsece_demo$param_types
)
#> column param_type
#> 1 intercept fe_between
#> 2 poverty_cwc fe_within
#> 3 tiered_reim fe_betweenKey insight.
param_typesis about the covariate’s variation structure, not about statistical significance or effect size. A within-group covariate is “survey-exposed” because clustered sampling directly inflates the variance of the contrasts that identify it; a between-group covariate is shielded because hierarchical shrinkage absorbs part of the design effect. This is the crux of the whole diagnostic, and it is set here, at the mapping step.
What’s next?
You can now feed svyder from any of its supported backends, or bypass them with a plain draws matrix. The natural next steps:
| Vignette | What you will learn |
|---|---|
| Getting Started | The full diagnostic in one call, read from
print, tidy, glance, and one
profile plot. |
| The Compute–Classify–Correct Pipeline | Step through der_compute \rightarrow der_classify \rightarrow der_correct, tune
the threshold, and see why selective correction beats blanket
correction. |
For the design-target decision that the cluster =
argument encodes — design PSUs versus model groups, weight conventions,
and strata — see vignette("choosing-the-target"). For the
theory behind param_types and the four tiers, see
vignette("theory-decomposition") and
vignette("theory-sandwich").