BART is a popular choice for causal inference because it allows one to fit the nuisance functionals required for effect estimation (the relationships among the outcome, the treatment, and the potential confounders) in a flexible manner (Hill 2011). BART has repeatedly been shown to outperform other effect estimation methods in competitions (Dorie et al. 2019). In this vignette, we demonstrate how to use BART implemented in bartisan to estimate treatment effects under the assumption of no unmeasured confounding. This includes both standard BART as well as Bayesian causal forests (BCF), which is an improvement on traditional BART that works by fitting separate BART models for the outcome under control and for the treatment effect itself as a varying coefficient model.
However, it’s important to remember that BART is not a “causal inference method”; it is just a method of estimating certain quantities, which often are interpretable as associations. The assumptions that turn an association into a causal effect are assumptions about the design, and no model supplies them.
In addition to its use here, BART can be used with instrumental variables analysis (McCulloch et al., n.d.) and regression discontinuity (Alcantara et al. 2024).
The example we use here is a dataset used to answer whether right heart catheterization helps or harms critically ill patients (Connors et al. 1996).
Catheterization was not randomized. Doctors chose it, and they chose
it more often for patients who were already doing badly. Those patients
were also more likely to die. We can use cobalt::bal.tab()
to see how the patients differ between groups:
bal.tab(rhc ~ age + sex + race + edu + aps + meanbp + resp +
hema + pafi + paco2 + crea + surv2m + card,
data = rhc, stats = c("m", "ovl"), disp = "m")
#> Balance Measures
#> Type M.0.Un M.1.Un Diff.Un OVL.Un
#> age Contin. 61.816 60.756 -0.065 0.101
#> sex_male Binary 0.524 0.591 0.067 0.067
#> race_white Binary 0.793 0.768 -0.024 0.024
#> race_black Binary 0.157 0.168 0.011 0.011
#> race_other Binary 0.050 0.064 0.013 0.013
#> edu Contin. 11.568 11.767 0.061 0.048
#> aps Contin. 51.369 62.099 0.530 0.223
#> meanbp Contin. 85.302 66.871 -0.505 0.231
#> resp Contin. 28.841 27.465 -0.097 0.055
#> hema Contin. 32.545 30.237 -0.290 0.159
#> pafi Contin. 238.232 182.995 -0.504 0.208
#> paco2 Contin. 40.045 36.871 -0.262 0.096
#> crea Contin. 1.905 2.506 0.292 0.179
#> surv2m Contin. 0.604 0.559 -0.229 0.105
#> card_yes Binary 0.299 0.405 0.106 0.106
#>
#> Sample sizes
#> Control Treated
#> All 935 565The M.0.Un column indicates the mean for each variable
in the control group and the M.1.Un column indicates the
mean for each variable in the treated group. Diff.Un and
OVL.Un are measures of the distributional difference
between the groups for each covariate; values far from 0 indicate
imbalance due to differential selection into treatment. In particular,
we can see that patients with higher values of aps and
crea and lower values of meanbp,
hema, pafi, paco2, and
surv2m are overrepresented among treated units.
Given that sicker patients are both more likely to die and more likely to receive RHC, it would not be unexpected to see that patients who receive RHC are more likely to die, and we do see that:
However, that doesn’t mean RHC causes death; to disentangle the effects of RHC from the confounding effects of patients’ characteristics, we need to adjust for these characteristics.
Several assumptions are required to interpret an adjusted effect estimate as causal, none of which the fit can check:
No unmeasured confounding. Every common cause of catheterization and death is in the model. Here that is the crux: the covariates include a physiological profile and the study’s own prognostic score, which is a serious attempt, but a doctor’s judgment at the bedside may not be fully captured by these thirteen variables.
Positivity. Every kind of patient could have received the procedure or not. This one is partly checkable and is checked below.
Consistency. “Catheterization” names a single well defined intervention.
No interference. One patient’s treatment does not affect another’s outcome.
The first is the one that usually fails and the one to be explicit about. State it as an assumption in what you write, rather than letting the interval imply it has been handled.
Positivity fails when some combination of covariates makes the treatment nearly certain. To look for that, model the treatment and inspect the fitted probabilities in both groups. BART is a good choice for this model too, for the same reason it is a good choice for the outcome: the functional form is not known.
ps_fit <- bartisan(
rhc ~ age + sex + race + edu + aps + meanbp + resp + hema + pafi +
paco2 + crea + surv2m + card,
data = rhc, family = binomial()
)
prop_score <- fitted(ps_fit)We can use cobalt::bal.plot() to examine the overlap of
the propensity score distribution between the groups:
What matters for positivity is that the two groups overlap over most of their range, and they do. Distributions pushed against zero and one with little overlap indicate a failure of positivity, and the honest response then is to restrict the analysis to the region of overlap rather than let the model extrapolate into a part of the covariate space where one treatment was never observed.
This check belongs before the outcome model, not after. Where one
treatment was never observed, any estimate the outcome model produces
there is an extrapolation, and whether its interval shows that depends
on the model. On the example Li et al. (2023) use to
illustrate the problem, a BART model fit separately to each treatment
group kept the same interval width where the groups did not overlap, and
missed the true effect there. In our replication of that example, the
models used in this vignette widened their intervals four- to ninefold
in those regions, but the wider intervals did not remove the bias:
bcf()’s estimates there were still off by more than 1 on an
effect of 5. So a wide interval is worth noticing, but it does not
replace checking overlap before fitting the outcome model.
To estimate the average treatment effect \(\tau_{\text{ATE}} = E[Y(1)] - E[Y(0)]\), which is a function of the unobserved potential outcomes \(Y(1)\) and \(Y(0)\), we can use the assumptions above to express it as a function of observed quantities:
\[E[Y(1)] - E[Y(0)] = E \left[ E[Y | X, A = 1] \right] - E \left[ E[Y | X, A = 0] \right]\] To estimate \(E \left[ E \left[ Y | X, A = a \right] \right] = \theta_a\), we use g-computation (Snowden et al. 2011):
\[\hat{\theta}_a = \frac{1}{n}\sum_{i=1}^n {\mu(a, x_i)}\]
where \(\mu(a, x_i)\) is the predicted value from a regression of \(Y\) on \(A\) and \(X\) for a unit with covariate profile \(X=x_i\) and treatment \(A\) set to \(a\). This estimator is sometimes also known as the “regression estimator” or the “plug-in estimator”.
Here, we use traditional BART and BCF to model \(\mu(A, X)\). The rest of the analysis comes from the definitions above.
Because the estimator averages the model’s predictions over the covariates of the units in the sample, what it estimates is the average of the conditional effect \(\mu(1, x) - \mu(0, x)\) over those covariate values, computed within each posterior draw. Li et al. (2023) call this the “mixed average treatment effect”, and note, as Ding and Li (2018) did, that most Bayesian causal analyses in fact report it.
It differs from the sample average treatment effect and the
population average treatment effect, the two averages usually defined,
in what it holds fixed. The sample average treatment effect is the
average of each unit’s own \(Y_i(1) -
Y_i(0)\), which conditions on the units’ observed outcomes as
well as their covariates. Inference for it requires imputing each unit’s
missing potential outcome, and that imputation depends on how strongly a
unit’s two potential outcomes are associated, about which the data carry
no information. The population average treatment effect is the average
over the population the sample was drawn from, which treats the
covariates as random as well and is the most uncertain of the three
(Li
et al. 2023). The mixed average needs neither a model for the
covariates nor an assumption about how the potential outcomes are
associated, which is why it is the usual target.
estimate_effect() estimates it for the ATE, the ATT, and
the ATC alike, averaging over the treated or untreated units’ covariates
for the latter two.
We can fit the outcome model as a binary BART regression of the outcome on the treatment and covariates, as in Hill (2011), with the propensity score estimated above added as one more covariate:
rhc$prop_score <- prop_score
fit <- bartisan(death ~ rhc + age + sex + race + edu + aps + meanbp + resp +
hema + pafi + paco2 + crea + surv2m + card + prop_score,
data = rhc, family = binomial())We include the treatment, the covariates, and the propensity score in
the formula’s right-hand side, and set family = binomial()
to model the binary outcome with logistic regression. Including the
propensity score is recommended by Carnegie (2019), though Souto and Louzada (2024) report that
it often makes little difference to the estimate. Linero (2024)
gives the Bayesian reason for including it: when the outcome model is
flexible and its prior is independent of the model for the treatment,
the prior it implies for the amount of confounding bias is concentrated
near zero, strongly enough that the data may not overcome it, and
adjusting for the propensity score is one way to relax that. Because the
score is a function of the covariates, adding it does not change what
the model conditions on, and the g-computation below holds it at each
patient’s own value.
The splitting prior is left at its default, which weights the
predictors equally and cannot drop any of them. Setting
sparsity = TRUE would place a variable-selection prior on
them instead, which can drop the treatment from the forest in some draws
and put a point mass at exactly zero in the posterior of its effect, so
it is not the setting for a model whose purpose is a contrast on one
predictor; ?bartisan_control explains the trade-off and
vignette("effects") covers what the point mass does to an
estimate. The propensity score model is a prediction problem with no
contrast read off it, so the sparsity prior can be turned on there when
there are many covariates.
The fits in this vignette use the default single chain for brevity.
Normally, we would run several chains, possibly change the number of
burn-in and retained draws, and examine convergence diagnostics to make
sure the model was fit correctly; see
vignette("diagnostics") for how to set these fit controls
and check them.
The quantities underlying the effect estimate are the two average
potential outcomes: the proportion who would die if every patient were
catheterized, and the proportion who would die if none were.
estimate_effect() computes them on the way to the effect
and prints them beneath it. On a fit from bartisan(), the
treatment has to be named, since nothing in the formula marks one
predictor as the treatment:
ate <- estimate_effect(fit, treat = "rhc")
ate
#> Average treatment effect (difference)
#>
#> Treatment: `rhc`
#> Averaged over 1500 units
#>
#> contrast estimate lower upper n
#> Y[1] - Y[0] 0.063 0.0145 0.113 1500
#>
#> Average potential outcomes
#>
#> quantity estimate lower upper
#> Y[0] 0.631 0.600 0.659
#> Y[1] 0.694 0.655 0.730
#>
#> ℹ estimate is the posterior mean; lower and upper bound the 95% equal-tailed
#> credible interval.
#> ℹ Y[a] is the average response with `rhc` set to "a".Under the assumptions above this is the average treatment effect: catheterization raises the probability of death by about 6 percentage points, with an interval running from roughly 1.4 to 11.3.
The two rows below the contrast are the estimates of \(E[Y(0)]\) and \(E[Y(1)]\), each averaged over the observed
covariate distribution. Reporting both is often more informative than
reporting their difference alone, because a difference of a few
percentage points means something different against a baseline of 63%
than it would against 5%. Setting
potential_outcomes = FALSE in the print() call
drops them where the difference is all that is wanted.
estimate_effect() performs the g-computation above
literally. Every unit is predicted twice, once with the treatment set to
each of its levels; the two sets of predictions are averaged over the
units the estimand asks for, within each posterior draw; and
only then are the two averages contrasted. What comes back is a
posterior for the estimand, summarized by its mean and a credible
interval.
That order makes a ratio here the marginal ratio rather than the average of the conditional ones, which is a different quantity. For a difference the two orders agree, so it only shows up once a ratio is asked for.
The scale matters, and it is the reason the default is
type = "response". On that scale, the average of the
unit-level differences is the marginal effect. A contrast read
off the link scale is not: on a logistic fit, the average of the
conditional log odds ratios is not the marginal log odds ratio, and the
two can differ by a good deal. The comparison argument asks
for the contrast rather than the scale, so a risk ratio or an odds ratio
is available without leaving the response scale:
estimate_effect(fit, treat = "rhc", comparison = "lnor")
#> Average treatment effect (log odds ratio)
#>
#> Treatment: `rhc`
#> Averaged over 1500 units
#>
#> contrast estimate lower upper n
#> log(O(Y[1]) / O(Y[0])) 0.284 0.0633 0.513 1500
#>
#> Average potential outcomes
#>
#> quantity estimate lower upper
#> Y[0] 0.631 0.600 0.659
#> Y[1] 0.694 0.655 0.730
#>
#> ℹ estimate is the posterior mean; lower and upper bound the 95% equal-tailed
#> credible interval.
#> ℹ Y[a] is the average response with `rhc` set to "a", and O(y) is the odds
#> `y/(1-y)`.A fit also works with marginaleffects, and for the average
effect the two routes compute the same thing from the same draws. The
one thing to set is the posterior summary: marginaleffects
reports the median by default and estimate_effect() reports
the mean, so an unadjusted comparison shows a difference that is not
there.
options(marginaleffects_posterior_center = mean)
avg_comparisons(fit, variables = "rhc")
#>
#> Estimate 2.5 % 97.5 %
#> 0.063 0.0145 0.113
#>
#> Term: rhc
#> Type: response
#> Comparison: 1 - 0The point estimate and the interval match
estimate_effect() above to machine precision. Which to
reach for is a question of what else is wanted:
estimate_effect() covers the estimands a treatment question
asks for and needs no extra package, while marginaleffects
covers a much wider class of quantities.
vignette("effects") is about the second.
Traditional BART shrinks the fitted function toward a constant; that shrinkage applies to everything the forest fits, including the part of the outcome that depends on the exposure. When the exposure is strongly predicted by the covariates, the forest can explain the outcome using the covariates alone, leaving little for the exposure to explain, and shrink the estimated effect toward zero. The interval shrinks with it, so the result is a confident estimate biased toward no effect.
Hahn et al. (2020) identified this mechanism; their
remedy is to give the treatment effect its own forest with its own
prior, so that shrinking the confounding part does not shrink the
effect. This is the Bayesian causal forest (BCF) model, a special case
of the varying coefficients BART model; bcf() fits it.
One specifies the control function in the model formula and
identifies the treatment in the treat argument.
bcf() then fits a varying coefficient BART model, the BCF.
By default, bcf() estimates a propensity score using a
logistic BART model and includes that as a covariate in the control
function, as recommended by Hahn et al. (2020) and for the reason given above for
the plain BART model.
fit_bcf <- bcf(death ~ age + sex + race + edu + aps + meanbp + resp + hema +
pafi + paco2 + crea + surv2m + card,
treat = ~ rhc, data = rhc,
family = binomial())
fit_bcf
#> Generalized BART
#>
#> Call:
#> bcf(formula = death ~ age + sex + race + edu + aps + meanbp +
#> resp + hema + pafi + paco2 + crea + surv2m + card, treat = ~rhc,
#> data = rhc, family = binomial())
#>
#> Family: "binomial" with the "logit" link
#> Observations: 1500
#> Structure: 2 forests of 50 and 25 trees, soft decision rules
#> Draws: 800 kept after 200 warmup
#>
#> Posterior means: b.rhc.0 = -0.13, b.rhc.1 = -0.209
#>
#> Treatment: "rhc"
#> Effect moderators: "age", "sex", "race", "edu", "aps", "meanbp", "resp", "hema", "pafi", "paco2", "crea", "surv2m", and "card"
#> ℹ `estimate_effect()` reports the treatment effect, with the average potential
#> outcomes beside it; `plot()` draws the conditional ones.Using bcf() rather than writing the varying coefficient
BART model by hand sets four settings to improve effect estimation:
The splitting prior stays at its default of off for both forests.
Because the treatment is the coefficient rather than a predictor the
forest splits on, nothing can drop it, so the sparsity prior can be
turned on for the effect forest alone with
sparsity = c(FALSE, TRUE), which asks that forest to select
among the moderators; ?bcf discusses when that is worth
doing.
Because the treatment is named in the call,
estimate_effect() needs nothing else:
estimate_effect(fit_bcf)
#> Average treatment effect (difference)
#>
#> Treatment: `rhc`
#> Averaged over 1500 units
#>
#> contrast estimate lower upper n
#> Y[1] - Y[0] 0.0563 0.00241 0.108 1500
#>
#> Average potential outcomes
#>
#> quantity estimate lower upper
#> Y[0] 0.633 0.605 0.662
#> Y[1] 0.689 0.650 0.728
#>
#> ℹ estimate is the posterior mean; lower and upper bound the 95% equal-tailed
#> credible interval.
#> ℹ Y[a] is the average response with `rhc` set to "a".The two potential outcomes are printed beneath the contrast, since a
difference of a few percentage points means one thing against a baseline
of 63% and another against 5%; setting
potential_outcomes = FALSE in print()
suppresses them. The contrast’s label names the quantity rather than
leaving it to the heading, which matters once a ratio is asked for:
Y[1] - Y[0] is a difference of average responses where
log(O(Y[1]) / O(Y[0])) is a log odds ratio, and the note
beneath the table says what Y[a] is, adding what
O(y) is once an odds ratio is asked for.
The output of summary() on a bcf() fit is
the same summary of the forests it is on any other fit, and says at the
end where the effect is reported. That way the same call means the same
thing whichever way the model was written.
The effect forest gives one coefficient per patient, and
estimate_effect() turns those coefficients into the
conditional effect at each patient’s covariates with
estimand = "CATE", reported on the response scale rather
than on the forest’s own link scale. A conditional effect is the average
effect among patients who share those covariates, not the individual
effect for that patient, which depends on how the patient’s two
potential outcomes are associated and which the data cannot identify
(Li
et al. 2023). Here we ask for them as odds ratios, which for
a conditional effect is a conditional odds ratio:
cate <- estimate_effect(fit_bcf, estimand = "CATE", comparison = "or")
quantile(cate$estimate, probs = c(0, .25, .5, .75, 1))
#> 0% 25% 50% 75% 100%
#> 1.141 1.324 1.420 1.523 1.839plot() draws the conditional effects, as differences
unless the comparison argument says otherwise: patients
ordered by their estimate, with a credible interval each, and the
marginal effect as a single interval past the right edge in its own
color. Setting marginal = FALSE leaves it out.
plot() on the fit is the conditional effects on the
response scale; passing a <bartisan_effect> object to
plot() draws whatever that object holds, so
plot(estimate_effect(fit_bcf, by = ~ sex)) is a subgroup
forest plot instead.
Note that a conditional odds ratio is not the marginal one, and their average is not it either. That is the scale caution from earlier in a second place: an average of conditional contrasts equals the marginal contrast only when the contrast is a difference. For an identity link, which the next example uses, the ATE is the average of the conditional effects.
The catheterization question is about a whole population, so the ATE is the estimand. Many questions are not. When a program is offered to a particular group and the question is whether it helped them, the ATT is the estimand to report, and the outcome is often continuous rather than binary.
lalonde, from cobalt, is the standard example:
a job training program, with earnings in 1978 as the outcome.
Participants earned less than non-participants. Taken at face value, the program looks harmful, but that doesn’t mean it actually is: participants were selected for being out of work, so they would have earned less anyway. This is confounding in the opposite direction to the catheterization example, and it is why the raw comparison is worth showing before the adjusted one.
Below, we fit the BCF model, this time setting
family = tweedie(), which is useful for continuous outcomes
with a point mass at 0, as we have here. See
vignette("families") for other families that could be used
here.
fit_earn_bcf <- bcf(
re78 ~ age + educ + race + married + nodegree + re74 + re75,
treat = ~ treat,
data = lalonde, family = tweedie()
)
estimate_effect(fit_earn_bcf, estimand = "ATT")
#> Average treatment effect on the treated (difference)
#>
#> Treatment: `treat`
#> Averaged over the 185 units in group "1"
#>
#> contrast estimate lower upper n
#> Y[1] - Y[0] 1120 -363 2540 185
#>
#> Average potential outcomes
#>
#> quantity estimate lower upper
#> Y[0] 5230 4240 6330
#> Y[1] 6350 5410 7410
#>
#> ℹ estimate is the posterior mean; lower and upper bound the 95% equal-tailed
#> credible interval.
#> ℹ Y[a] is the average response with `treat` set to "a".Setting estimand = "ATT" averages over the treated
rather than over everyone.1
The potential outcomes earn their place here: a difference of about a thousand dollars means something different against a baseline of about five thousand than it would against five hundred.
Because the link is the identity, the ATT is exactly the average of the conditional effects among the treated, which is worth checking once to see that the two agree:
att <- estimate_effect(fit_earn_bcf, estimand = "ATT")
cate_att <- estimate_effect(fit_earn_bcf, estimand = "CATE",
newdata = subset(lalonde, treat == 1))
c(ATT = att$estimate, mean_CATE = mean(cate_att$estimate))
#> ATT mean_CATE
#> 1125 1125And the conditional effects, drawn:
Most of the conditional effects are positive, as the ATT is, and none of their 185 intervals exclude zero. That is the usual picture: the effect at one unit’s covariates is estimated from far less information than an average over all of them, so its interval is much wider.
The posterior interval is a credible interval for the estimand under the model and under the identification assumptions. It covers uncertainty about the outcome surface at the covariate values in the sample, which comes from having a finite sample and from not knowing the surface’s shape. As described in the section on conditional, sample, and population averages, it treats those covariates as fixed, so it does not include the additional uncertainty an interval for the population average would carry.
For a bcf() fit, the interval also takes the propensity
score as known. The score is the posterior mean of a separate model for
the treatment, estimated once before the outcome model is fit, so
uncertainty in it does not reach the interval. Estimating the two models
in stages like this is the usual practice, and it keeps the outcome from
informing the propensity score, which a joint model of the two would
allow; Li et al. (2023) discuss both
approaches and the alternatives between them.
The interval does not cover uncertainty about whether the assumptions hold.
An unmeasured confounder does not widen the interval. It moves the estimate and leaves the interval where it was. This is why a sensitivity analysis, asking how strong a confounder would have to be to explain the result away, is a more informative addition than any refinement of the model.
?estimate_effect is the reference for the estimands used
here, including the subgroup form and the contrast types.
vignette("effects") covers what marginaleffects
adds beyond them, which is a much wider class of quantities and the
route to take when the question is not a treatment contrast.
vignette("diagnostics") covers checking the fit, which
should happen before any of this is interpreted.
The focal argument is not needed because a
0/1 treatment settles which level is the treated one. When the treatment
has more than two levels, focal is required and naming it
is the whole of the choice, since "ATT" and
"ATC" then mean the same thing: the effect among the units
in the level named. When it has two levels whose labels say nothing
about which is which, the level order is assumed and a message says so,
which focal silences.↩︎