Wager, Causal Inference: A Statistical Learning Approach, in StatsPAI¶
Stefan Wager's textbook (Stanford, draft of September 2026) builds causal inference from randomized trials outward. Each chapter relaxes one assumption of the trial and asks which estimator still has a central limit theorem. It has fifteen chapters and no code, so there is nothing to replicate line by line. This guide maps each chapter to the StatsPAI call that computes its estimator, and shows the parts that were added or rebuilt while the book was read against the package.
Chapter by chapter¶
| chapter | the book defines | StatsPAI |
|---|---|---|
| 1 | difference in means; interacted regression adjustment | sp.ttest(unequal=True); sp.lm_lin(superpopulation=True) |
| 2 | stratification on the propensity score; inverse-propensity weighting | sp.match(method='stratify', estimand='ATE'); sp.ipw(se_method='sandwich') |
| 3 | augmented IPW with cross-fitting | sp.aipw; sp.dml(model='irm'); sp.tmle |
| 4 | the R-learner and other CATE methods; what to do after a CATE fit | sp.metalearner(learner='r'); sp.causal_forest; sp.rate, sp.calibration_test, sp.best_linear_projection |
| 5 | policy value by IPW and AIPW; empirical welfare maximization | sp.ips, sp.doubly_robust; sp.policy_tree, sp.policy_value |
| 6 | UCB, Thompson sampling; inference after adaptive collection | sp.bandit_experiment, sp.bandit_allocate; sp.adaptive_inference |
| 7 | covariate-balancing propensity scores; approximate balance with an augmented estimator | sp.cbps(variant='exact'), sp.ebalance, sp.sbw; sp.residual_balance |
| 8 | local linear regression discontinuity; optimized weights and bias-aware intervals | sp.rdrobust; sp.rd_honest, sp.rd_optimized |
| 9 | back-door and front-door formulas, do-calculus; instrumental variables with a learned instrument | sp.dag, sp.identify, sp.frontdoor; sp.ivreg, sp.dml(model='pliv') |
| 10 | the local average treatment effect; marginal treatment effects | sp.ivreg; sp.dml(model='iivm'); sp.iv.mte |
| 11 | exposure mappings; permutation tests for spillovers | sp.interference_test |
| 12 | exposure effects with network-robust variance | sp.network_exposure |
| 13 | difference in differences with staggered adoption; synthetic controls | sp.did_imputation, sp.callaway_santanna; sp.synth, sp.sdid |
| 14 | sequential unconfoundedness; doubly robust evaluation of a dynamic policy | sp.ltmle(regime_treated=callable); sp.msm; sp.gformula_ice_fn |
| 15 | long-run policy value in a Markov decision process; marginal policy effects; switchback experiments | sp.mdp_policy_value; sp.marginal_policy_effect; sp.switchback, sp.switchback_design |
Chapters 1 to 3 were run on designs with a known answer. The estimators
recover the truth and their intervals cover at the nominal rate. Two
defaults are worth knowing. sp.lm_lin reports the design-based standard
error, which targets the average effect in the sample; the book's Theorem
1.3 is the population version, which is superpopulation=True. And
sp.match(method='stratify') estimates the effect on the treated unless
told otherwise.
Chapter 4: the R-learner¶
sp.metalearner(learner="r") fits the R-learner: cross-fitted outcome and
propensity models, then a regression of the outcome residual on the
treatment residual with a flexible model for the effect.
fit = sp.metalearner(df, "y", "w", covariates, learner="r")
fit.estimate, fit.se # average effect, from doubly robust scores
fit.model_info["cate"] # fitted conditional effects
The final regression has a heavy-tailed target, since it divides by the
treatment residual. Its default model is gradient boosting with at least
20 observations per leaf. Without that minimum the trees give single
extreme values their own leaves, and the fitted effects were less
accurate than a constant on our test design. Pass cate_model= to use
anything else. After fitting, check that the heterogeneity is real with
sp.rate or sp.calibration_test on a causal forest, or sp.cate_eval
on any set of predictions.
Chapter 6: adaptive experiments¶
A bandit rule shifts assignment towards the arms that look best so far.
sp.bandit_experiment runs one on a reward function or on a table of
potential outcomes, and records the probability with which each arm was
assigned in each period.
import statspai as sp
means = [0.0, 0.3, 0.3]
exp = sp.bandit_experiment(
lambda k, rng: means[k] + rng.normal(),
n_periods=1000, n_arms=3,
algorithm="thompson", sigma=1.0,
prob_floor=0.02, # keep every arm alive
true_means=means, seed=0,
)
exp.arms # pulls and mean reward by arm
exp.regret # shortfall relative to always playing the best arm
In a live experiment, sp.bandit_allocate(df, "y", "arm", ...) gives the
probabilities for the next subject from the data collected so far.
The data are not independent draws, and the usual interval around a
sample mean is not valid. Under Thompson sampling the sample mean of an
arm is biased downwards, because an arm that looks bad early is sampled
less and never gets the chance to recover. In 600 simulated experiments
with two equal arms the nominal 95% interval covered 87% of the time for
the contrast. sp.adaptive_inference weights each observation by the
inverse square root of its assignment probability, which makes the
variance of every term the same and restores a central limit theorem.
r = sp.adaptive_inference(exp.data, "reward", "arm", "prob")
r.estimates # arm means, standard errors, intervals
r.contrasts # each arm against the first
The price is paid at design time. A rule that drives probabilities to
zero quickly earns more during the experiment and leaves less to learn
from afterwards. With prob_floor=0.02 the weighted interval covered
95.5%; with no floor it covered 93% for two arms and 88% for the worst
arm of three. sp.adaptive_inference warns when an assigned arm had
probability below 1/T and refuses data from a deterministic rule such
as UCB.
Chapter 7: balancing with many covariates¶
With more covariates than a propensity model can carry, exact balance is
out of reach. sp.residual_balance finds weights that make the worst
covariate imbalance small, fits a sparse linear outcome model in each
arm, and applies the weights to its residuals.
r = sp.residual_balance(df, "y", "w", covariates) # ATE
r = sp.residual_balance(df, "y", "w", covariates, estimand="ATT")
r.model_info["imbalance_treated"] # worst remaining imbalance, in sd
r.model_info["effective_sample_size"]
The regression does most of the work and the weights clean up what it
missed, which is why neither has to be exact. Weighting alone
(outcome_model="none") is biased in high dimensions: with 150
covariates and 300 units it was off by a full standard error, while the
augmented estimate was unbiased with a 93% interval. The standard error
treats the covariates as fixed, so the interval is for the average
effect in the sample.
With few covariates, prefer exact balance. One finding from the audit:
the over-identified variant of the covariate-balancing propensity score,
the default of sp.cbps and of R's CBPS, was biased upwards by about
0.09 (0.8 standard errors) on a design with a heterogeneous effect and a
correctly specified logistic propensity, in both packages. The exactly
identified variant (variant='exact'), entropy balancing and AIPW were
unbiased on the same data.
Chapter 8: optimized regression discontinuity¶
A sharp regression discontinuity estimate is a weighted sum of outcomes.
Local linear regression picks the weights with a kernel and a bandwidth.
sp.rd_optimized picks them directly: among all weights, the ones with
the smallest worst-case mean squared error over conditional mean
functions whose second derivative is at most M.
r = sp.rd_optimized(df, "y", "x", c=0, M=0.1)
r.estimate, r.se, r.ci
r.model_info["max_bias"] # exact worst case for these weights
r.model_info["effective_bandwidth"] # farthest unit with weight
r.model_info["local_linear"] # the same numbers for local linear
# fuzzy design: ratio of the jump in y to the jump in treatment received
f = sp.rd_optimized(df, "y", "x", c=0, M=0.1, fuzzy="d")
The interval is the bias-aware one of sp.rd_honest: the estimate plus
or minus a critical value that grows with the ratio of worst-case bias to
standard error. It is valid whether the running variable is continuous
or takes a handful of values, because nothing in it relies on a density
at the cutoff. On a design whose mean function sits at the curvature
bound, the interval covered 96% in 300 samples.
For a continuous running variable the gain over local linear regression
with a triangular kernel is small, as theory says it should be: on the
Lee (2008) election data the half-length is 2.996 against 3.002. The
case for the method is the discrete running variable and the fact that
the weights and the worst-case bias are exact objects that can be
inspected. M is the assumption that carries the result. Report it, and
show the interval at twice its value.
Chapters 11 and 12: spillovers on a network¶
Start by asking whether there are spillovers at all. Under the hypothesis
that a unit's outcome depends only on its own treatment, the outcomes of a
set of focal units do not change when the treatments of the other units
are reshuffled. sp.interference_test uses that to build an exact test.
t = sp.interference_test(Y, Z, adjacency=A) # H0: no spillovers
t.pvalue
sp.interference_test(Y, Z, null="no_effect") # H0: no effect at all
Two further hypotheses from the book's hierarchy are available:
null="anonymous" (only the share of treated neighbours matters, not
which ones) and null="no_higher_order" (only a unit's own and its
neighbours' treatments matter). The hypotheses are nested, so testing
them from the most restrictive down needs no multiplicity correction.
The tests are valid for Bernoulli and completely randomized assignment.
The two richer ones hold most of the assignment fixed and have little
power, so failing to reject them says little.
If spillovers are present, sp.network_exposure estimates average
outcomes by exposure level. The default mapping crosses a unit's own
treatment with whether any neighbour is treated.
r = sp.network_exposure(Y, Z, adjacency=A, p_treat=0.5)
# design="complete" if a fixed number of units was treated
r.contrasts # direct, spillover, composite
r.estimates # mean outcome, se, smallest probability and
# effective sample size at each exposure level
Three things to watch.
- Overlap. A unit with ten neighbours has no treated neighbour with
probability
0.5 ** 10. If such a unit is ever observed in that state it carries a weight of a thousand. The function warns, andmin_prob=0.01restricts the averages to units for which every exposure has at least that probability. This changes the population the estimate describes, which is why it is not done silently. - The estimator. The default divides by the sum of the weights
(Hajek). The Horvitz-Thompson form (
estimator="ht") is unbiased but changes when a constant is added to the outcome, and on a 600-node network its spread was five times larger. - The variance. Two units have dependent exposures when they share a
neighbour.
variance="hac"sums over such pairs. It is close to unbiased when overlap is good and can be too small otherwise, because the dependency graph is not positive semidefinite. The defaultvariance="hac_psd"uses its positive semidefinite part, which is conservative for the randomization variance in every case.
Chapter 10: marginal treatment effects¶
sp.iv.mte fits polynomial marginal treatment response functions and
reports the ATE, the effect on the treated and on the untreated, each
with a standard error.
m = sp.iv.mte("y", "d", ["z"], exog=["x"], data=df, poly_degree=1)
m.ate, m.ate_se
m.att, m.extra["att_se"]
The analytic standard errors take the fitted propensity score and the
covariates as given. bootstrap=200 re-estimates both in each draw.
Observations with a fitted propensity outside (trim, 1 - trim) are
dropped, and the averages describe the units that remain.
Chapter 14: dynamic policies¶
A policy that decides each period from the history so far is a callable
regime in sp.ltmle.
def treat_if_high(k, history):
return (history[f"L{k}"] > 0).astype(int)
r = sp.ltmle(df, "Y", ["A0", "A1"], [["L0"], ["L1"]],
regime_treated=treat_if_high, regime_control=[1, 1])
On a two-period design where the second treatment helps only when the intermediate covariate is high, the value of the dynamic policy and its contrast with always treating were recovered without bias and the interval covered 95%.
Chapter 15: dynamic systems¶
When today's action changes tomorrow's state, comparing treated and untreated periods answers the wrong question. Two tools, for two situations.
The state is observed. sp.mdp_policy_value estimates the long-run
average outcome under a policy from one long trajectory collected under
randomized actions.
r = sp.mdp_policy_value(
df, "y", "w", state=["s"],
policy=lambda st: (st["s"] >= 2).astype(int), # the rule to evaluate
baseline=0, # against never treating
propensity="e", # probability of the action that was taken
)
r.value, r.se, r.ci
It combines an excess-reward function with the ratio of stationary state
distributions, and is consistent if either is right. Its error does not
grow with the length of the trajectory, which is what sequential
inverse-probability weighting cannot offer. With a state that takes a few
values the default basis is one indicator per state, and the estimate
equals the stationary average of the fitted transition model. For a
continuous state pass features=.
Part of the state is hidden. Then the value of a different policy is
hard to learn, but the worth of the current one at the margin is not.
sp.marginal_policy_effect estimates what removing a small share of the
treatments that occur would do.
r = sp.marginal_policy_effect(df, "y", "w", ["x"], horizon=5)
r.estimate # theta: removing a share eps of treatments costs eps * theta per period
r.per_treatment # effect of one treatment on current and future outcomes
horizon is how long a treatment keeps mattering. Too short leaves out
part of the effect, too long adds noise, so show a few values.
For chapter 6 with covariates, sp.contextual_bandit runs linear Thompson
sampling or LinUCB and records exact assignment probabilities. Use
inverse-probability weighting with them afterwards; sp.adaptive_inference
is for experiments without covariates.
Not in StatsPAI¶
- Optimized regression discontinuity with a multivariate running
variable, as in geographic designs. The univariate case, sharp or
fuzzy, is
sp.rd_optimized; for boundaries seesp.geographic_rdandsp.rd2d. - Learning a policy in a Markov decision process (the book evaluates policies; it does not learn them either).