Prediction accuracy and causal validity are fundamentally different objectives.
OLS is unbiased for the causal effect under CIA + linearity
XGBoost, Lasso, Ridge minimise prediction error — they do not minimise bias for a causal parameter
The core conflict: regularisation (Lasso penalty, RF subsampling) introduces deliberate bias toward zero to reduce variance — good for prediction, contaminates the treatment effect estimate
If you run Lasso on the full model, the penalty shrinks \(\hat\theta\) toward zero alongside \(\hat\gamma_j\):
The resulting \(\hat\theta\) has regularisation bias of order \(\lambda\) which does not vanish as \(n \to \infty\) unless \(\lambda \to 0\) (but then Lasso = OLS, which may not converge in high dimensions).
DML is FWL generalised to high-dimensional nonparametric nuisance. Residualising \(y\) and \(D\) on \(\mathbf{x}\) with Lasso/RF/XGBoost gives the same asymptotic \(\hat\theta\) as if we had used the oracle \(g_0\) and \(m_0\) — provided estimation error satisfies the product-rate condition.
Structural: a model of behaviour — parameters have economic meaning (elasticities, returns, preferences); identification comes from theory + exclusion restrictions
Reduced-form: the causal effect of \(D\) on \(y\) — identification comes from a research design (experiment, IV, DiD, RDD)
Causal ML sits in the reduced-form tradition: the design still identifies \(\theta\); ML only estimates the nuisance parts of the model flexibly
Identification is a property of the population, not the estimator:
\[\theta_0 \text{ identified} \iff \theta_0 \text{ is a unique functional of } P(y, D, \mathbf{x})\]
Under conditional independence (CIA) / unconfoundedness:
Modern causal ML strengthens the estimation side with Neyman orthogonality — the moment must also be insensitive to small errors in the nuisance functions (details in a moment). This is the bridge from Hansen/Wooldridge-style GMM thinking to DML.
Misspecification: flexible ML nuisance reduces functional-form bias relative to a linear control specification
Double robustness: AIPW-type scores stay consistent if either the outcome model or the propensity model is correct
Rates: each nuisance may converge slowly, \(o(n^{-1/4})\), yet \(\hat\theta\) stays \(\sqrt{n}\)-consistent
Question: what does union membership add to the log wage? Data: wagepan (Vella & Verbeek 1998), \(n = 4360\) person-years, ~35 controls. Naive Lasso penalises the union coefficient itself — watch it shrink.
Naive Lasso (lambda.1se) union = 0.1060 <- shrunk toward 0
Code
import pandas as pd, numpy as npimport wooldridge as woofrom sklearn.linear_model import LinearRegression, LassoCV, Lassoctrl_u = (["educ","exper","expersq","married","black","hisp","south","rur","poorhlth","agric","bus","construc","ent","fin","manuf","min","per","pro","pub","tra","trad"]+ [f"occ{i}"for i inrange(1, 10)] + [f"d8{i}"for i inrange(1, 8)])df_u = woo.data("wagepan")[["lwage", "union"] + ctrl_u]y_u = df_u["lwage"].valuesX_u = df_u.drop(columns="lwage").values # union is column 0ols_short = LinearRegression().fit(X_u[:, [0]], y_u).coef_[0]ols_long = LinearRegression().fit(X_u, y_u).coef_[0]sd_u = X_u.std(axis=0)Xs_u = (X_u - X_u.mean(axis=0)) / sd_ulas_u = LassoCV(cv=10, max_iter=10000, random_state=14159).fit(Xs_u, y_u)b_min = las_u.coef_[0] / sd_u[0] # back to original scale# 1-SE rule (same idea as glmnet's lambda.1se): largest alpha within 1 SE of min CV-MSEmse = las_u.mse_path_.mean(axis=1)sem = las_u.mse_path_.std(axis=1) / np.sqrt(las_u.mse_path_.shape[1])alpha_1se = las_u.alphas_[np.where(mse <= mse.min() + sem[mse.argmin()])[0][0]]las_1se = Lasso(alpha=alpha_1se, max_iter=10000).fit(Xs_u, y_u)b_1se = las_1se.coef_[0] / sd_u[0]out = (f"n = {len(df_u)}, controls = {X_u.shape[1]-1}\n"f"OLS (no controls) union = {ols_short:7.4f}\n"f"OLS (all controls) union = {ols_long:7.4f}\n"f"Naive Lasso (alpha.min) union = {b_min:7.4f}\n"f"Naive Lasso (alpha.1se) union = {b_1se:7.4f} <- shrunk toward 0")import sys; sys.stdout.write(out +"\n"); sys.stdout.flush()
n = 4360, controls = 37
OLS (no controls) union = 0.1793
OLS (all controls) union = 0.1757
Naive Lasso (alpha.min) union = 0.1738
Naive Lasso (alpha.1se) union = 0.1012 <- shrunk toward 0
198
Code
quietly frause wagepan, clearlocal ctrl educ exper expersq married black hisp south rur poorhlth /// agric bus construc ent fin manuf min per pro pub tra trad occ* d8*quietlyregress lwage uniondisplay"OLS (no controls) union = " %7.4f _b[union]quietlyregress lwage union`ctrl'display"OLS (all controls) union = " %7.4f _b[union]rlasso lwage union`ctrl'
OLS (no controls) union = 0.1793
OLS (all controls) union = 0.1757
Warning: . negative penalty loadings encountered/adjusted.
Variables affected:
---------------------------------------------------
Selected | Lasso Post-est OLS
------------------+--------------------------------
union | 0.0999718 0.1735471
educ | 0.0739572 0.0851466
exper | 0.0342147 0.0451109
married | 0.0869099 0.1053649
black | -0.0509237 -0.1377840
rur | -0.0680086 -0.1305855
agric | -0.1565748 -0.2893141
ent | -0.1965215 -0.4316762
fin | 0.0223336 0.1292208
manuf | 0.0591511 0.0885251
pro | -0.0715197 -0.2191764
tra | 0.0498883 0.1130744
trad | -0.0782035 -0.1278401
occ1 | 0.0327465 0.1474974
occ2 | 0.0158238 0.1203305
occ9 | -0.0666308 -0.0808257
_cons |* 0.5297641 0.3200513
---------------------------------------------------
*Not penalized
ATE \(= \mathbb{E}[\tau(\mathbf{X})]\) — the CATE aggregates up
ATE \(= \Pr(D{=}1)\,\text{ATT} + \Pr(D{=}0)\,\text{ATC}\)
Under an RCT: ATE \(=\) ATT \(=\) ATC (treatment independent of potentials)
With heterogeneous effects + selection, ATT \(\neq\) ATE — say which one you estimate
The fundamental problem of causal inference
We observe \(Y_i = D_i Y_i(1) + (1-D_i) Y_i(0)\) — only one potential outcome per unit, never both. The ITE \(\tau_i\) is therefore unobservable for every \(i\); all causal inference is a missing-data problem solved by averaging over comparable units (Holland 1986, doi:10.1080/01621459.1986.10478354). ML does not change this — even a perfect CATE model predicts \(\tau(\mathbf{x})\), not \(\tau_i\).
This is exactly the HC-robust variance of the residual-on-residual regression — classical econometrics recovered.
Plug-in vs orthogonal — one-line comparison
Plug-in: bias of \(\hat\theta\)\(\sim\)first power of the nuisance error → needs \(o(n^{-1/2})\) nuisance rates, which ML cannot deliver. Orthogonal: bias \(\sim\)product of two nuisance errors → \(o(n^{-1/4})\) each suffices, which Lasso/RF/boosting achieve.
Orthogonality kills first-order nuisance bias — but a second bias remains if \(\hat{g}\) is trained and evaluated on the same observations:
an overfit \(\hat{g}(\mathbf{x}_i)\) absorbs part of \(\theta_0 D_i\) → residuals \(\tilde{y}_i\) too small → \(\hat\theta\) biased toward zero
this own-observation bias does not vanish with \(n\) for flexible learners
\(K\)-fold cross-fitting (Chernozhukov et al. 2018):
Split \(\{1,\dots,n\}\) into \(K\) folds \(I_1, \dots, I_K\)
For each \(k\): train \(\hat{g}^{(-k)}, \hat{m}^{(-k)}\) on all folds except\(I_k\)
For \(i \in I_k\): form out-of-fold residuals \[\tilde{y}_i = y_i - \hat{g}^{(-k)}(\mathbf{x}_i), \qquad \tilde{D}_i = D_i - \hat{m}^{(-k)}(\mathbf{x}_i)\]
Estimate \(\hat\theta\) from all \(n\) residual pairs in one final regression
Standard errors from the influence function \(\hat\psi_i\)
no observation is ever predicted by a model trained on it — own-observation bias removed by construction
unlike simple sample-splitting, all \(n\) observations enter the final estimate — no efficiency loss
combined with orthogonality: \(\sqrt{n}\)-normal inference when each nuisance converges at just \(o(n^{-1/4})\)
Practical choices
\(K = 5\) is the standard default (\(K = 10\) for small \(n\)). Because the fold split is random, repeat the whole procedure a few times with different splits and report the median estimate — DoubleML automates this (n_rep). Tune the learners inside each training fold, never on the full sample.
Application — 401(k) Eligibility: Naive vs ML
Question: effect of 401(k) eligibility (e401) on net financial assets. Data: hdm::pension, \(n = 9915\) (SIPP 1991). Eligibility is plausibly exogenous given income and saver-type controls (Poterba, Venti & Wise 1995, doi:10.1016/0047-2727(94)01462-W).
(Chernozhukov et al. 2018; Hansen 2022 §29.22): DML extends partialling-out (§29.21) by adding cross-fitting. The score \(\psi(W;\theta,\eta) = (y - \theta D - g(\mathbf{x}))(D - m(\mathbf{x}))\) satisfies the Neyman-orthogonality condition \(\partial_\eta\,\mathbb{E}[\psi] = 0\) at the true nuisance \(\eta_0 = (g_0, m_0)\). This means first-order errors in the ML nuisance estimates do not transmit to \(\hat\theta\). Cross-fitting then removes the own-observation overfitting bias, giving the product-rate condition \(\|\hat g - g_0\|\cdot\|\hat m - m_0\| = o(n^{-1/2})\) — each nuisance need only converge at \(o(n^{-1/4})\), well within reach of Lasso, random forests, or boosting. DML is the state-of-the-art synthesis: Robinson partialling-out + Neyman orthogonality + cross-fitting.
Why cross-fitting is mandatory
Without it, \(\hat{g}\) is overfit on the same observations where it is evaluated. The in-sample residuals \(\tilde{y}\) underestimate true residuals, absorbing part of the treatment variation and biasing \(\hat\theta\)toward zero. Cross-fitting prevents this by enforcing that \(\hat{g}\) was never trained on observation \(i\) when computing \(\tilde{y}_i\).
DML — Setup & DGP
DGP:\(n=500\), \(p=50\), partially linear. True treatment effect \(\theta_0=2\). Treatment \(D\) is endogenous (correlated with nuisance controls). Five controls enter \(g_0(\mathbf{x})\) non-trivially; 45 are pure noise.
from sklearn.linear_model import LinearRegressionols_bias = LinearRegression().fit(D_dml.reshape(-1,1), y_dml).coef_[0]print(f"OLS of y on D only: {ols_bias:.4f} (upward-biased, true = {theta_true})")
OLS of y on D only: 2.9642 (upward-biased, true = 2.0)
Code
clearsetobs 500setseed 14159forvalues j = 1/50 { gen x`j' = rnormal() }gen D = 0.5*x1 - 0.4*x2 + 0.3*x3 + rnormal()geny = 2*D + 1.5*x1 - 1.2*x2 + 0.8*x3 - 0.5*x4 + x5 + rnormal()display"DGP: n=500, p=50, theta_0=2.0"quietly correlate D x1display"Cor(D, x1) = " %6.3f r(rho)quietlyregressy Ddisplay"OLS of y on D only: " %6.4f _b[D] " (upward-biased)"splitsample, generate(sample_dml) split(0.7 0.3) rseed(14159)
Number of observations (_N) was 0, now 500.
program error: code follows on the same line as open brace
r(198);
r(198);
Method Union premium SE CI
Naive OLS 0.1663 0.0162 [0.1346, 0.1981]
DML-Lasso 0.3127 0.1591 [0.0008, 0.6246]
Code
import pandas as pd, numpy as npfrom sklearn.linear_model import LassoCVfrom sklearn.model_selection import KFoldfrom sklearn.preprocessing import StandardScalerimport wooldridge as woowagepan_py = woo.data("wagepan")base_py = [c for c in ["exper","expersq","south","smsa","married","educ","union"]if c in wagepan_py.columns]ind_py = [c for c in ["agric","bus","construc","ndurman","trcommpu","trade","services","profserv","profocc","clerocc","servocc"]if c in wagepan_py.columns]interact = []for v in ["exper","south","smsa","married","educ"]:if v in wagepan_py.columns and"union"in wagepan_py.columns: wagepan_py[f"{v}_u"] = wagepan_py[v] * wagepan_py["union"] interact.append(f"{v}_u")feat_cols = [c for c in base_py if c !="lwage"] + ind_py + interactfeat_cols = [c for c in feat_cols if c in wagepan_py.columns]df_wp_py = wagepan_py[["lwage"] + feat_cols].dropna()X_wp_py = df_wp_py.drop(columns="lwage").valuesy_wp_py = df_wp_py["lwage"].valuesD_wp_idx = feat_cols.index("union") # union column index in X_wp_pyprint(f"wagepan: n={len(df_wp_py)}, p={X_wp_py.shape[1]} features")
wagepan: n=4360, p=13 features
Code
import numpy as npfrom sklearn.linear_model import LassoCVfrom sklearn.model_selection import KFold# Extract union (treatment) and wage (outcome) plus controlsD_wp_py = X_wp_py[:, D_wp_idx]# Controls = X without union columnctrl_mask = np.ones(X_wp_py.shape[1], dtype=bool)ctrl_mask[D_wp_idx] =FalseXc_wp_py = X_wp_py[:, ctrl_mask]kf_wp = KFold(5, shuffle=True, random_state=14159)y_res_wp_py = np.zeros(len(y_wp_py))D_res_wp_py = np.zeros(len(y_wp_py))for tr, te in kf_wp.split(Xc_wp_py): lg = LassoCV(cv=5, max_iter=5000).fit(Xc_wp_py[tr], y_wp_py[tr]) lm = LassoCV(cv=5, max_iter=5000).fit(Xc_wp_py[tr], D_wp_py[tr]) y_res_wp_py[te] = y_wp_py[te] - lg.predict(Xc_wp_py[te]) D_res_wp_py[te] = D_wp_py[te] - lm.predict(Xc_wp_py[te])theta_wp_py = np.dot(D_res_wp_py, y_res_wp_py) / np.dot(D_res_wp_py, D_res_wp_py)psi_wp = D_res_wp_py * (y_res_wp_py - theta_wp_py * D_res_wp_py)se_wp_py = ((D_res_wp_py**2).mean()**(-2) * (psi_wp**2).mean() /len(y_wp_py))**0.5lo_wp, hi_wp = theta_wp_py -1.96*se_wp_py, theta_wp_py +1.96*se_wp_pyprint(f"DML union premium: {theta_wp_py:.4f} SE: {se_wp_py:.4f}")
Residualisation needs comparable units: if \(\hat{m}(\mathbf{x}) \approx 0\) or \(\approx 1\) for binary \(D\), some covariate cells contain only treated or only controls.
cat(sprintf("Var(D_tilde) = %.3f overlap: %.1f%% of m_hat in [0.02, 0.98]\n",var(D_dg - m_hat), 100*mean(m_hat >0.02& m_hat <0.98)))
Var(D_tilde) = 0.205 overlap: 98.1% of m_hat in [0.02, 0.98]
Code
data.frame(m_hat = m_hat, group =factor(D_dg, labels=c("Not eligible","Eligible"))) %>%ggplot() +aes(m_hat, fill=group) +geom_histogram(alpha=0.65, bins=40, position="identity") +scale_fill_manual(values=c("Not eligible"="#185FA5", "Eligible"="#D85A30")) +labs(x="Out-of-fold propensity m_hat(x)", y="Count", fill=NULL,title="401(k) eligibility: propensity overlap",subtitle="Both groups span the same propensity range - overlap OK") + theme_lecture
Application — Minimum Wage: Card & Krueger with DML
The design: New Jersey raised its minimum wage from $4.25 to $5.05 in April 1992; eastern Pennsylvania did not. Outcome: change in full-time-equivalent employment at 410 fast-food stores (Card & Krueger 1994). DML residualises the store-level covariates with a random forest instead of assuming linear controls.
Lasso of \(D\) on \(\mathbf{x}\) → residual \(\hat{v}_i = D_i - \mathbf{x}_i'\hat{\boldsymbol\gamma}\)
Lasso of \(y\) on \(\mathbf{x}\) → residual \(\hat{u}_i = y_i - \mathbf{x}_i'\hat{\boldsymbol\eta}\)
OLS of \(\hat{u}\) on \(\hat{v}\) → \(\hat\theta^{PR}\) (this is Robinson 1988 with Lasso nuisance)
The trade-off (Hansen §29.21):
Double-Selection
Partialling-Out
Controls used
Union \(X_1 \cup X_2\)
Separate for \(y\) and \(D\)
Property
More robust (less bias)
More efficient (parsimony)
Asymptotics
Harder to derive
Easier (rate manipulation only)
Stata
dsregress
poregress
R
hdm
hdm
Why partialling-out is robust to selection — the Neyman-orthogonality argument
(Hansen 2022, §29.21) The naive moment condition \(m(\theta,\boldsymbol\beta) = \mathbb{E}[D(y - D\theta - \mathbf{x}'\boldsymbol\beta)]\) has sensitivity to the nuisance:
Because \(v\) is a regression error, it is orthogonal to \(\mathbf{x}\). The moment is insensitive to errors in \(\hat{\boldsymbol\beta}\) — this is Neyman orthogonality, and it is exactly what makes the estimator immune to regularisation bias.
\(\hat\theta^{DML}\)IS the result. The nuisance estimation goes in a footnote. Report \(\hat\theta\), HC3 standard error, and confidence interval in the main table exactly as you would for OLS.
Same partially linear model, three native implementations — the 401(k) eligibility effect once more. R and Python use random-forest nuisance, Stata’s ddml uses rigorous Lasso; all three cross-fit with \(K=5\).
\(\tau_i\) is never observed — one potential outcome is always missing
\(\tau(\mathbf{x})\) is identified under unconfoundedness and overlap
The CATE is the best approximation of the ITE given observables:
\[\text{Var}(\tau_i) = \underbrace{\text{Var}\big(\tau(\mathbf{X}_i)\big)}_{\text{explained by } \mathbf{X}} + \underbrace{\mathbb{E}\big[\text{Var}(\tau_i \mid \mathbf{X}_i)\big]}_{\text{invisible to any estimator}}\]
even a perfect CATE model cannot rank individuals within a covariate cell
richer \(\mathbf{X}\) → CATE closer to the ITE — but the second term never reaches zero in practice
CATE estimation is a regression problem on an unobserved outcome — that is why every estimator (forest, meta-learner) first constructs a proxy for \(\tau_i\)
inference targets: pointwise CIs for \(\tau(\mathbf{x})\), the best linear projection, or group ATEs — never individual \(\tau_i\)
report: the ATE, a heterogeneity test, and group-level CATEs with CIs
grf uses a gradient-based approximation of this criterion for speed
residualised outcomes \(Y - \hat{y}(\mathbf{x})\) and treatments \(W - \hat{w}(\mathbf{x})\) enter the splits — an orthogonalised forest (the DML idea again)
With honesty + subsampling (each tree sees a random fraction, without replacement):
the variance is estimable (infinitesimal jackknife) → pointwise confidence intervals
rates are slower than parametric — CIs are honest but wide in small samples
Why honesty buys valid inference
An adaptive tree chooses splits where the sample effect difference is largest — partly signal, partly noise. Re-using the same observations for the leaf estimate keeps that noise, so leaf effects are biased outward and CIs undercover. Honesty makes the leaf estimate independent of the split choice given the structure — the estimate inside a leaf is then a clean subgroup ATE. The price is efficiency (half the data per task); the reward is asymptotic normality (Wager & Athey 2018, Thm 3.1).
Causal Forests — Implementation
DGP 2 (from the setup): \(n = 500\), \(p = 5\), true CATE \(\tau(\mathbf{x}) = 2 + v_1\), selection into treatment through \(v_1, v_2\).
Leaf size (min.node.size): too small → noisy leaf effects; too large → heterogeneity averaged away
Honesty fraction: default 0.5; with small \(n\), honesty can leave leaves nearly empty — check honesty.prune.leaves
Overlap: the forest’s own \(\hat{W}(\mathbf{x})\) should stay inside \((0.05, 0.95)\)
Variable importance: which covariates drive the splits — sanity-check against economics
Calibration test (test_calibration): is the mean prediction right, and is the differential prediction informative?
Code
cat("Calibration test (DGP 2 forest):\n")
Calibration test (DGP 2 forest):
Code
print(round(unclass(test_calibration(cf)), 3))
Estimate Std. Error t value Pr(>t)
mean.forest.prediction 1.004 0.045 22.412 0
differential.forest.prediction 1.373 0.210 6.543 0
attr(,"method")
[1] "Best linear fit using forest predictions (on held-out data)\nas well as the mean forest prediction as regressors, along\nwith one-sided heteroskedasticity-robust (HC3) SEs"
attr(,"df")
[1] 498
attr(,"nobs")
[1] 500
attr(,"logLik")
'log Lik.' -743.6698 (df=3)
Learner RMSE Cor Mean tau
----------------------------------
T 0.6952 0.782 2.204
S 0.6988 0.779 2.208
X 0.5332 0.867 2.154
CF 0.5897 0.955 2.396
206
Which learner when?
Balanced arms + big samples: T-learner is hard to beat. Very unequal arms (5% treated): X-learner — it borrows strength from the large arm. Effects likely near zero for many units: S-learner’s shrinkage toward zero becomes a feature. Observational data with confounding: R-learner or the causal forest, because both are built on orthogonalised residuals (Nie & Wager 2021, doi:10.1093/biomet/asaa076).
Application — Class Size: STAR with the X-Learner
Data: Tennessee STAR kindergarten cohort (Krueger 1999; AER::STAR), small classes (13–17) vs regular (22–25), randomised within schools. Outcome: reading + math score.
Uplift = CATE used for targeting. Rank units by \(\hat\tau(\mathbf{x})\), treat from the top, and ask: does the observed treatment–control gap actually decline down the ranking?
if yes, the CATE model has targeting value — the marketing-science name is the uplift or Qini curve
a flat profile means the model ranks noise — target nobody, or everybody
identical mathematics to CATE validation; only the business framing differs (Gutierrez & Gérardy 2017, proceedings.mlr.press/v67)
Many instruments: with dozens of candidate instruments, 2SLS overfits the first stage — bias toward OLS
Nonlinear first stages: the optimal instrument is \(\mathbb{E}[D|\mathbf{z}]\) — rarely linear in \(\mathbf{z}\)
Weak instruments: low first-stage signal → size distortions; adding junk instruments makes it worse
Selection: Lasso picks the few instruments that actually predict \(D\) (Belloni et al. 2012)
Flexibility: forests/boosting approximate \(\mathbb{E}[D|\mathbf{z}]\) without a functional form
Orthogonality carries over: the IV moment \(\mathbb{E}[\tilde{z}(\tilde{y} - \theta\tilde{D})] = 0\) with ML-residualised \(\tilde{y}, \tilde{D}, \tilde{z}\) is Neyman-orthogonal — Part II’s machinery applies
ML selection does not fix weak identification — if no instrument is strong, Lasso selects nothing (a feature: it tells you)
the exclusion restriction is still an economic assumption — no algorithm can test it
report the selected instruments and the first-stage fit alongside \(\hat\theta\)
The efficient instrument is \(f(\mathbf{z}, \mathbf{x}) = \mathbb{E}[D|\mathbf{z}, \mathbf{x}]\); approximate it with Lasso on a large dictionary of transformations of \((\mathbf{z}, \mathbf{x})\).
Lasso of \(D\) on the instrument dictionary + controls → selected instruments \(\hat{f}(\mathbf{z}, \mathbf{x})\)
Lasso of \(y\) on controls and of \(D\) on controls → double-selected control set (Part II logic)
2SLS with \(\hat{f}\) as instrument and the selected controls
Conventional heteroskedastic-robust inference on \(\hat\theta\)
\(\sqrt{n}\)-consistent and asymptotically normal under approximate sparsity
attains the semiparametric efficiency bound when the first stage is truly sparse
rigorous (plug-in) penalty level — not cross-validated — is what the theory covers (hdm, pdslasso defaults)
Application — Returns to Education: Card (1995)
Data:Card (1995), \(n = 3010\) men (NLS66), instrument: grew up near a 4-year college (nearc4). OLS is biased by ability; proximity shifts schooling cost.
fixest/reghdfe absorb \(\alpha_i, \lambda_t\) by iterated demeaning — FE are nuisance parameters, never reported
ML enters for \(g(\mathbf{x}_{it})\): apply DML to the within-transformed data
The panel DML recipe:
Within-transform \(y\), \(D\), \(\mathbf{x}\) (or partial out FE with fixest)
Cross-fit ML residualisation of \(\ddot{y}\) and \(\ddot{D}\) on \(\ddot{\mathbf{x}}\) — cluster the folds by unit, never split a unit across folds
Final regression of residuals; cluster-robust SEs at the unit level
serial correlation: observation-level cross-fitting leaks information across time within a unit — fold by unit
dynamics: lagged outcomes in \(\mathbf{x}\) change the estimand (sequential exogeneity) — be explicit
staggered treatments: TWFE with heterogeneous effects is biased — the DiD slides next
Application — Firm Productivity
Production function estimation is the original control-function problem: input choices respond to unobserved productivity \(\omega_{it}\), so OLS factor elasticities are biased. ACF (Ackerberg, Caves & Frazer 2015) recovers them with a nonparametric first stage — nuisance estimation, exactly the causal-ML pattern.
Method Labour (blue) Labour (white) Capital
OLS 0.268 0.222 0.191
ACF (control function) 0.155 0.159 0.137
ACF labour elasticities sit below OLS — OLS attributes part of unobserved productivity to labour (simultaneity bias)
the control function plays the role of \(\hat{g}(\mathbf{x})\) in DML: a flexible nuisance that purges endogeneity before the structural parameters are estimated
modern variants replace the polynomial first stage with random forests or boosting — same identification, better fit
With staggered adoption and heterogeneous effects, the TWFE coefficient is a weighted average with possibly negative weights (Goodman-Bacon 2021, doi:10.1016/j.jeconom.2021.03.014) — it can even flip sign.
Difference-in-difference with Multiple Time Periods
Number of obs = 2,500
Outcome model : least squares
Treatment model: inverse probability
------------------------------------------------------------------------------
| Coefficient Std. err. z P>|z| [95% conf. interval]
-------------+----------------------------------------------------------------
ATT | -.0417518 .0115028 -3.63 0.000 -.0642969 -.0192066
------------------------------------------------------------------------------
Control: Never Treated
See Callaway and Sant'Anna (2021) for details
For aggregate units (states, regions) with a single treated unit, DiD’s parallel trends is often untenable. Synthetic methods build the counterfactual by prediction:
Synthetic control (Abadie): convex combination of donors matching pre-treatment path
Matrix completion (Athey et al. 2021, doi:10.1080/01621459.2021.1891924): treat counterfactuals as missing entries; nuclear-norm-regularised ML imputation
Generalised synthetic control (gsynth): interactive fixed effects \(\lambda_i' f_t\) estimated by factor models
All three are the same template: predict \(Y_{it}(0)\) for treated cells using untreated data, then
\[\hat\tau_{it} = Y_{it} - \hat{Y}_{it}(0)\]
Regularisation (weights simplex, nuclear norm, factor rank) plays exactly the role of the ML nuisance — and the same overfitting cautions apply.
full treatment — California Prop 99, placebo inference, matrix completion, scpi/gsynth/causaltensor — in the companion deck synthetic-control-and-matrix-completion-methods.Qmd
The event-study parameter is the dynamic ATT at event time \(e = t - g\):
\[ATT^{dyn}(e) = \mathbb{E}\big[Y_{g+e}(g) - Y_{g+e}(0) \mid G = g\big] \;\text{aggregated over } g\]
pre-treatment (\(e < 0\)) estimates test parallel trends
ML residualisation of covariates makes conditional parallel trends flexible — same DR machinery per \((g,t)\) cell
never use a single TWFE lead/lag regression under staggered adoption — contaminated by heterogeneity (Sun & Abraham 2021)
Code
agg_d <-aggte(gt, type="dynamic")ggdid(agg_d) +labs(title="Minimum wage: dynamic ATT (Callaway-Sant'Anna, DR with lpop)") + theme_lecture
Code
# Same real data (mpdta), second estimator: Sun-Abraham interaction-weighted# event study - cohort-specific effects, aggregated with sample weightses <-feols(lemp ~sunab(first.treat, year) | countyreal + year,data=mpdta, cluster=~countyreal)iplot(es, main="Minimum wage: Sun-Abraham event study (mpdta)",xlab="Event time (years since state minimum-wage rise)", col=col_main)
two modern staggered-DiD estimators on the same real data agree: Sun–Abraham ATT ≈ Callaway–Sant’Anna ATT ≈ −0.04 — teen employment falls about 4% after a state minimum-wage rise
flat pre-trends (\(e < 0\)) in the event-study plot support the (conditional) parallel-trends assumption
a naive TWFE lead/lag regression on these data would mix cohorts with opposite signs — the Sun–Abraham interaction weights prevent that
Software — IV, DiD & Panels
hdm — rlassoIV(); grf — instrumental_forest()
did — att_gt(), aggte(), ggdid(); DRDID — the DR 2×2 building block
Causal-forest CIs are asymptotic and pointwise. Conformal prediction gives distribution-free, finite-sample guarantees (Lei & Candès 2021):
Split: train nuisance models on one half, calibrate on the other
Conformity score on calibration data: \(s_i = |Y_i - \hat\mu_{W_i}(\mathbf{x}_i)|\), per arm
Band: \(\hat\tau(\mathbf{x}) \pm \big(q_{1-\alpha}^{(1)} + q_{1-\alpha}^{(0)}\big)\) with \(q\) the calibration quantiles
coverage holds for the individual effect\(Y(1)-Y(0)\), not just its conditional mean — hence the bands are honest about the Part III variance decomposition
the price: bands are wide — irreducible outcome noise is inside them
ORF (Oprescu, Syrgkanis & Wu 2019) pushes orthogonalisation inside the forest: every leaf runs its own locally-weighted DML — nuisances \(\hat{g}, \hat{m}\) are re-estimated locally around each target point, with forest kernel weights.
grf orthogonalises globally (one \(\hat{y}(\mathbf{x}), \hat{W}(\mathbf{x})\) for all splits); ORF orthogonalises locally — more robust when confounding strength varies over \(\mathbf{x}\)
cost: two nested estimations per prediction point — markedly slower
Code
import warnings; warnings.filterwarnings("ignore")import pandas as pd, numpy as npfrom econml.orf import DMLOrthoForestdf_orf = pd.read_csv("../data/causal-ml-pension.csv") # real 401(k) datarng_orf = np.random.default_rng(14159)sub = rng_orf.choice(len(df_orf), 2000, replace=False) # ORF is slow: subsampled_orf = df_orf.iloc[sub]X_orf = d_orf[["age","inc","fsize","educ"]].values # heterogeneity driversW_ctl = d_orf[["marr","twoearn","db","pira","hown"]].values # controlsT_orf = d_orf["e401"].valuesY_orf = d_orf["net_tfa"].valuesorf = DMLOrthoForest(n_trees=200, min_leaf_size=50, subsample_ratio=0.5, verbose=0, random_state=14159)orf_fitted = orf.fit(Y_orf, T_orf, X=X_orf, W=W_ctl)inc_q = np.quantile(d_orf["inc"], [0.1, 0.25, 0.5, 0.75, 0.9])X_ev = np.column_stack([np.full(5, np.median(d_orf["age"])), inc_q, np.full(5, np.median(d_orf["fsize"])), np.full(5, np.median(d_orf["educ"]))])te_orf = orf.effect(X_ev)lines = [f"ORF (200 trees, local DML in every leaf), n = 2000 (401(k) subsample)","CATE along the income distribution (other covariates at medians):"]for q, e inzip([10, 25, 50, 75, 90], te_orf): lines.append(f" income p{q:<2d}: ${e:>8,.0f}")import sys; sys.stdout.write("\n".join(lines) +"\n"); sys.stdout.flush()
ORF (200 trees, local DML in every leaf), n = 2000 (401(k) subsample)
CATE along the income distribution (other covariates at medians):
income p10: $ 1,190
income p25: $ 2,074
income p50: $ 3,209
income p75: $ 5,887
income p90: $ 8,424
256
the rising income gradient replicates the Part III causal-forest finding on the same real data — with locally re-estimated nuisances
strong, covariate-dependent confounding: local nuisance fits adapt where global ones average
large samples with mild confounding: grf/CausalForestDML deliver similar accuracy at a fraction of the cost (hence the n = 2000 subsample here)
panel extension — local orthogonalisation per unit-time cell — is the research frontier; the dynamic-effects slide later shows a pragmatic panel forest
Real data: FRED-MD, 1960–2025. \(D_t = \Delta\) federal funds rate, \(y_{t+1}\) = next-month CPI inflation, \(\mathbf{X}_t\) = 120 macro series — the price puzzle in one slide.
Code
set.seed(14159)df_ts <-read.csv("../data/causal-ml-fredmd.csv") # created by causal-ml-data.Ry_ts <- df_ts$y # next-month inflation (ann. %)d_ts <- df_ts$FEDFUNDS # policy-rate change at tX_ts <-as.matrix(df_ts[, !(names(df_ts) %in%c("date","y","FEDFUNDS"))])T_ts <-length(y_ts)naive_ts <-lm(y_ts ~ d_ts)cat(sprintf("Naive OLS (no controls): %.3f (NW SE %.3f) - the 'price puzzle'\n",coef(naive_ts)["d_ts"],sqrt(sandwich::NeweyWest(naive_ts, lag=4)["d_ts","d_ts"])))
Naive OLS (no controls): 1.179 (NW SE 0.622) - the 'price puzzle'
cat(sprintf(" HC SE: %.3f Newey-West SE (lag 4): %.3f\n", se_hc, se_nw))
HC SE: 0.575 Newey-West SE (lag 4): 0.525
Code
import warnings; warnings.filterwarnings("ignore")import pandas as pd, numpy as npimport statsmodels.api as smfrom sklearn.ensemble import RandomForestRegressordf_ts = pd.read_csv("../data/causal-ml-fredmd.csv") # same data as the R taby_ts = df_ts["y"].valuesd_ts = df_ts["FEDFUNDS"].valuesX_ts = df_ts.drop(columns=["date","y","FEDFUNDS"]).valuesT_ts =len(y_ts)naive = sm.OLS(y_ts, sm.add_constant(d_ts)).fit(cov_type="HAC", cov_kwds={"maxlags": 4})blocks = np.minimum(np.arange(T_ts) *5// T_ts, 4) # consecutive blocksyr, dr = np.zeros(T_ts), np.zeros(T_ts)for k inrange(5): tr, te = blocks != k, blocks == k fg = RandomForestRegressor(n_estimators=500, max_features="sqrt", random_state=14159, n_jobs=8).fit(X_ts[tr], y_ts[tr]) fm = RandomForestRegressor(n_estimators=500, max_features="sqrt", random_state=14159, n_jobs=8).fit(X_ts[tr], d_ts[tr]) yr[te] = y_ts[te] - fg.predict(X_ts[te]) dr[te] = d_ts[te] - fm.predict(X_ts[te])dml_hc = sm.OLS(yr, sm.add_constant(dr)).fit(cov_type="HC1")dml_nw = sm.OLS(yr, sm.add_constant(dr)).fit(cov_type="HAC", cov_kwds={"maxlags": 4})out = (f"Naive OLS (no controls): {naive.params[1]:.3f} (NW SE {naive.bse[1]:.3f})"f" - the 'price puzzle'\n"f"DML (block CF): theta = {dml_nw.params[1]:.3f}\n"f" HC SE: {dml_hc.bse[1]:.3f} Newey-West SE (lag 4): {dml_nw.bse[1]:.3f}")import sys; sys.stdout.write(out +"\n"); sys.stdout.flush()
Naive OLS (no controls): 1.179 (NW SE 0.624) - the 'price puzzle'
DML (block CF): theta = 0.082
HC SE: 0.565 Newey-West SE (lag 4): 0.509
144
naive OLS says rate hikes raise next-month inflation — the classic price puzzle (Sims 1992): the Fed raises rates because inflation pressure is high
DML with 120 macro controls (random-forest nuisances, block cross-fit) makes the puzzle vanish — \(\hat\theta \approx 0\), indistinguishable from zero — conditioning on the macro state removes the policy endogeneity, in the spirit of Romer & Romer (2004) with Greenbook controls
HC vs Newey–West: with serially correlated residuals the iid-robust SE understates uncertainty — always report HAC in time-series DML
Causal ML for Forecasting — Macro Application
Forecasting is the one task where prediction error is the criterion — the Part I dichotomy in reverse. The honest horse race, on real data: FRED-MD, forecasting next-month US CPI inflation.
predictors: all 121 series dated \(t\) (production, labour, housing, money, rates, prices), each stationarised with its McCracken–Ng transformation code
split: train 1960–2014, test 2015–2025 — the test window includes the COVID inflation surge
this is the design of Medeiros et al. (2021), whose headline finding is that random forests beat sparse linear methods for US inflation
Code
set.seed(14159)# FRED-MD, transformed: y = next-month CPI inflation (annualised %),# predictors = all series dated t. Created by causal-ml-data.R.df_fm <-read.csv("../data/causal-ml-fredmd.csv")d_fm <-as.Date(df_fm$date)y_fm <- df_fm$yX_fm <-as.matrix(df_fm[, !(names(df_fm) %in%c("date","y"))])Ttr <-sum(d_fm <as.Date("2015-01-01")) # test = 2015-2025 (incl. COVID)Xtr_m <- X_fm[1:Ttr, ]; ytr_m <- y_fm[1:Ttr]Xte_m <- X_fm[-(1:Ttr), ]; yte_m <- y_fm[-(1:Ttr)]lag_i <- X_fm[, "CPIAUCSL"] *1200# last month's inflationrmse_m <-function(y, yh) sqrt(mean((y - yh)^2))df_tr_m <-data.frame(y = ytr_m, Xtr_m)df_te_m <-data.frame(Xte_m)foldid <-cut(seq_len(Ttr), 5, labels =FALSE) # 5 contiguous time blocksm_ar <-lm(ytr_m ~ lag_i[1:Ttr]) # AR(1) benchmarkm_ols <-lm(y ~ ., data = df_tr_m)m_las <-cv.glmnet(Xtr_m, ytr_m, alpha =1, foldid = foldid)m_rf <-ranger(y ~ ., data = df_tr_m, num.trees =500, min.node.size =5,seed =14159)m_xgb <-xgb.train(list(objective ="reg:squarederror", max_depth =3, eta =0.05),xgb.DMatrix(Xtr_m, label = ytr_m), nrounds =300, verbose =0)res_fc <-data.frame(Method =c("AR(1)", "OLS (all p)", "Lasso", "Random Forest", "XGBoost"),RMSE =c(rmse_m(yte_m, coef(m_ar)[1] +coef(m_ar)[2] * lag_i[-(1:Ttr)]),rmse_m(yte_m, predict(m_ols, df_te_m)),rmse_m(yte_m, predict(m_las, Xte_m, s ="lambda.min")),rmse_m(yte_m, predict(m_rf, data = df_te_m)$predictions),rmse_m(yte_m, predict(m_xgb, xgb.DMatrix(Xte_m)))))res_fc <- res_fc[order(res_fc$RMSE), ]ggplot(res_fc) +aes(RMSE, reorder(Method, -RMSE), fill = RMSE ==min(RMSE)) +geom_col(alpha =0.85, show.legend =FALSE) +geom_text(aes(label =sprintf("%.3f", RMSE)), hjust =-0.15, size =3.6) +scale_fill_manual(values =c("TRUE"="#1a6ea8", "FALSE"="#999999")) +scale_x_continuous(expand =expansion(mult =c(0, 0.12))) +labs(x ="Test RMSE (annualised inflation, %)", y =NULL,title =sprintf("FRED-MD: 1-month-ahead US CPI inflation - winner: %s", res_fc$Method[1]),subtitle =sprintf("T = %d months (%s to %s), p = %d, test from 2015-01",length(y_fm), format(min(d_fm), "%Y-%m"),format(max(d_fm), "%Y-%m"), ncol(X_fm))) + theme_lecture
Code
import pandas as pd, numpy as np, matplotlib.pyplot as pltfrom sklearn.linear_model import LassoCV, LinearRegressionfrom sklearn.preprocessing import StandardScalerfrom sklearn.pipeline import make_pipelinefrom sklearn.ensemble import RandomForestRegressorimport xgboost as xgbfrom sklearn.metrics import mean_squared_errordf_mc = pd.read_csv("../data/causal-ml-fredmd.csv") # written by the R tabd_mc = pd.to_datetime(df_mc["date"])y_mc = df_mc["y"].valuesX_mc = df_mc.drop(columns=["date", "y"]).valueslag_i = df_mc["CPIAUCSL"].values *1200# last month's inflationT_tr2 =int((d_mc <"2015-01-01").sum())Xtr2, Xte2 = X_mc[:T_tr2], X_mc[T_tr2:]ytr2, yte2 = y_mc[:T_tr2], y_mc[T_tr2:]rmse =lambda a, b: mean_squared_error(a, b) **0.5ar = LinearRegression().fit(lag_i[:T_tr2].reshape(-1, 1), ytr2)ols = LinearRegression().fit(Xtr2, ytr2)las = make_pipeline(StandardScaler(), LassoCV(cv=5, max_iter=20000)).fit(Xtr2, ytr2)rf_ = RandomForestRegressor(500, max_features="sqrt", min_samples_leaf=5, random_state=14159).fit(Xtr2, ytr2)xm = xgb.XGBRegressor(max_depth=3, learning_rate=0.05, n_estimators=300, random_state=14159, verbosity=0).fit(Xtr2, ytr2)methods = ["AR(1)", "OLS (all p)", "Lasso", "Random Forest", "XGBoost"]preds = [ar.predict(lag_i[T_tr2:].reshape(-1, 1)), ols.predict(Xte2), las.predict(Xte2), rf_.predict(Xte2), xm.predict(Xte2)]rmses = [rmse(yte2, p) for p in preds]order =list(np.argsort(rmses))[::-1] # worst at bottom, winner on topfig, ax = plt.subplots(figsize=(8, 3.4))cols = ["#999999"] *len(order); cols[-1] ="#1a6ea8"bars = ax.barh([methods[i] for i in order], [rmses[i] for i in order], color=cols, alpha=0.85)lbls = ax.bar_label(bars, fmt="%.3f", padding=4, fontsize=9)axopts = ax.set( xlabel="Test RMSE (annualised inflation, %)", xlim=(0, max(rmses) *1.12), title=(f"FRED-MD: 1-month-ahead US CPI inflation - "f"winner: {methods[order[-1]]}"))ax.grid(True, axis="x", color="#e8e8e8")plt.tight_layout(); plt.show()
Random Forest wins in both implementations — replicating Medeiros et al. (2021): inflation has nonlinearities and interactions that sparse linear models miss
OLS with all 121 regressors overfits spectacularly — nearly twice the RMSE of the AR(1) benchmark
R (glmnet/ranger) and Python (sklearn) RMSEs differ in the 2nd decimal — same data, different tie-breaking inside the learners; rankings agree
the top three (RF, XGBoost, Lasso) are close — in practice, test equal predictive accuracy with a Diebold–Mariano test before declaring a winner
The SCM / DAG tradition (Pearl) makes identification graphical: write the causal graph, read off which adjustment sets identify the effect. ML enters twice:
identification stays graph-theoretic — backdoor, frontdoor, instruments are properties of the graph, not the estimator
estimation of the identified functional uses any ML learner — and the DML scores from Part II plug straight in
refutation tests (placebo treatments, random confounders, subset stability) probe the assumptions the graph encodes
Code
import warnings; warnings.filterwarnings("ignore")import numpy as np, pandas as pdimport wooldridge as woofrom dowhy import CausalModel# Real data: NSW job-training experiment (LaLonde 1986); experimental# benchmark ATE = 1.794 (thousand $, 1978 earnings)df_dw = woo.data("jtrain2")[["re78","train","age","educ","black","hisp","married","re74","re75","nodegree"]]ctrl_dw = ["age","educ","black","hisp","married","re74","re75","nodegree"]cm = CausalModel(data=df_dw, treatment="train", outcome="re78", common_causes=ctrl_dw)ie = cm.identify_effect(proceed_when_unidentifiable=True)est = cm.estimate_effect(ie, method_name="backdoor.linear_regression")plac = cm.refute_estimate(ie, est, method_name="placebo_treatment_refuter", placebo_type="permute", num_simulations=10)rand = cm.refute_estimate(ie, est, method_name="random_common_cause", num_simulations=5)out = (f"Identified estimand: backdoor via {{age, educ, race, marriage, "f"pre-earnings}}\n"f"ATE estimate: {est.value:.3f} thousand $ "f"(experimental benchmark: 1.794)\n"f"Placebo-treatment refuter: {plac.new_effect:.4f} (should be ~0)\n"f"Random-common-cause refuter: {rand.new_effect:.3f} "f"(should be ~{est.value:.2f})")import sys; sys.stdout.write(out +"\n"); sys.stdout.flush()
Identified estimand: backdoor via {age, educ, race, marriage, pre-earnings}
ATE estimate: 1.676 thousand $ (experimental benchmark: 1.794)
Placebo-treatment refuter: -0.3329 (should be ~0)
Random-common-cause refuter: 1.697 (should be ~1.68)
245
NSW is randomised, so the backdoor estimate can be checked against the experimental benchmark of \(\$1{,}794\) — the graph-based pipeline lands close
the placebo refuter re-runs the analysis with a fake randomised treatment — an estimate near 0 means the pipeline does not manufacture effects
the random-common-cause refuter adds a synthetic confounder — the estimate should not move
swap backdoor.linear_regression for backdoor.econml.dml.DML to combine graph identification with DML estimation
Neural networks as CATE learners make sense when \(n\) is large and \(\tau(\mathbf{x})\) is a complex function of rich inputs (text, images, long histories):
S/T-learner with nets — the Part III recipes, MLP instead of forest
representation learning (TARNet/CFRNet, Shalit et al. 2017): shared covariate representation, separate heads for \(\mu_1, \mu_0\), balancing penalty
the Part II warning stands: at moderate \(n\), nets miss the rate condition — validate against a forest baseline
Code
import warnings; warnings.filterwarnings("ignore")import pandas as pd, numpy as np, torch# Real data: 401(k) eligibility (hdm::pension) - the Part III application,# now with a deep S-learner instead of a causal forestdf_dl = pd.read_csv("../data/causal-ml-pension.csv")feats = ["age","inc","fsize","educ","marr","twoearn","db","pira","hown"]X_dl = df_dl[feats].values.astype(np.float32)W_dl = df_dl["e401"].values.astype(np.float32)Y_dl = df_dl["net_tfa"].values.astype(np.float32)Xs = (X_dl - X_dl.mean(0)) / X_dl.std(0)ysd = Y_dl.std()Ys = (Y_dl - Y_dl.mean()) / ysdgen = torch.manual_seed(14159)XW = torch.tensor(np.column_stack([Xs, W_dl]).astype(np.float32))Yt = torch.tensor(Ys).unsqueeze(1)net = torch.nn.Sequential(torch.nn.Linear(XW.shape[1], 64), torch.nn.ReLU(), torch.nn.Linear(64, 32), torch.nn.ReLU(), torch.nn.Linear(32, 1))opt = torch.optim.Adam(net.parameters(), lr=1e-3)for epoch inrange(300): opt.zero_grad() loss = torch.nn.functional.mse_loss(net(XW), Yt) loss.backward(); opt.step()X1 = XW.clone(); X1[:, -1] =1.0X0 = XW.clone(); X0[:, -1] =0.0with torch.no_grad(): tau_hat_dl = (net(X1) - net(X0)).squeeze().numpy() * ysdinc_q = pd.qcut(df_dl["inc"], 4, labels=False).valuesout = (f"Deep S-learner (10-64-32-1 MLP), n = {len(Y_dl)}, 401(k) eligibility\n"f"Estimated ATE: ${tau_hat_dl.mean():,.0f} "f"(causal forest / DoubleML range: $8,900-9,400)\n"f"CATE by income quartile: "+" ".join(f"Q{q+1}: ${tau_hat_dl[inc_q==q].mean():,.0f}"for q inrange(4)))import sys; sys.stdout.write(out +"\n"); sys.stdout.flush()
Deep S-learner (10-64-32-1 MLP), n = 9915, 401(k) eligibility
Estimated ATE: $8,829 (causal forest / DoubleML range: $8,900-9,400)
CATE by income quartile: Q1: $3,111 Q2: $5,742 Q3: $9,544 Q4: $16,921
205
on the real 401(k) data the net lands in the causal-forest/DoubleML ATE range and reproduces the rising income gradient from Part III — three estimator families, one answer
under confounding, wrap the net in the DML/DR machinery — a deep learner is a nuisance estimator, not an identification strategy
production tools: econml’s deep IV / DeepIV-style estimators; TARNet/CFRNet reference implementations in torch
The nuisance learner’s tuning parameters are themselves a modelling choice. The right criterion is out-of-fold nuisance fit — never the resulting \(\hat\theta\):
tune inside each training fold (caret, mlr3tuning, optuna) — cross-fitting already provides the honest evaluation loop
choosing hyper-parameters to make \(\hat\theta\) “significant” is specification search, laundered through ML
DoubleML + mlr3tuning and econml’s CV-wrapped learners automate this correctly
Code
set.seed(14159)X_am <-as.matrix(df_hd[, paste0("x",1:50)])y_am <- df_hd$y; D_am <- df_hd$Dgrid_am <-expand.grid(mtry=c(7,15,25), splitrule="variance", min.node.size=c(5,20))tuned <- caret::train(x=X_am, y=y_am, method="ranger", tuneGrid=grid_am,trControl=trainControl(method="cv", number=3),num.trees=300)cat("Selected by 3-fold CV on the OUTCOME nuisance:\n")
Identification needs sequential ignorability — treatment and mediator unconfounded given \(\mathbf{x}\). The efficient scores involve four nuisance functions; causalweight::medDML (Farbmacher et al. 2022) estimates them all by ML with cross-fitting.
R only on this slide: neither Python nor Stata has a mature DML-mediation implementation — medDML is the reference.
Code
# Real data: Job Corps experiment (causalweight::JC, n = 9,240).# D = random assignment to Job Corps, M = share of weeks employed in year 2,# Y = weekly earnings in year 4 ($). Schochet et al. (2008) benchmark: ~$20/week.data("JC", package ="causalweight")set.seed(14159)x_jc <- JC[, 2:28] # baseline covariates (pre-assignment)med <- causalweight::medDML(y = JC$earny4, d = JC$assignment,m = JC$pworky2, x = x_jc, k =3)res_md <- med$resultscat(sprintf("Job Corps -> year-4 weekly earnings, mediator: year-2 employment (n=%d)\n",nrow(JC)))
Job Corps -> year-4 weekly earnings, mediator: year-2 employment (n=9240)
the indirect channel is negative: Job Corps reduces year-2 employment (lock-in — participants are still in training), which transmits negatively to year-4 earnings
the direct (human-capital) effect more than offsets the lock-in — exactly the decomposition a plain ATE hides
the honest summary: Part V topics are where Stata hands over to R/Python
Exercises — Estimation
Nuisance learner robustness: replace the Lasso nuisance in the union-premium application (Part II) with random forest and gradient boosting. Does the point estimate move? Does the standard error? Report all three side by side.
Cross-fitting folds: re-run the 401(k) DML estimate with \(K \in \{2, 5, 10, 20\}\) folds, holding the learner fixed. Plot \(\hat\theta\) and its SE against \(K\). Where does the estimate stabilise, and what does that cost in run time?
Causal Forest BLP: on the 401(k) data, run best_linear_projection() against income, age and education. Is income the dominant driver of heterogeneity, or does it only look that way because it is correlated with eligibility?
LASSO IV weak instruments: reduce the first-stage signal gradually in the Card (1995) application. At what concentration parameter does Lasso stop selecting the instruments altogether, and what happens to the 2SLS point estimate just before it does?
Staggered DML: treat union entry as staggered in wagepan. Compare DML-Lasso with the two-way fixed-effects estimator, then with the Callaway–Sant’Anna ATT(g,t). Which two agree, and why?
Meta-learner comparison: on the STAR data, estimate the CATE with the S-, T-, and X-learner using the same base learner. Where do they diverge most — in the tails of the covariate distribution, or the middle?
Exercises — Testing
DML coverage simulation: with \(\theta_0 = 2\), \(n = 300\), \(p = 40\), run 500 Monte Carlo replications of DML-Lasso (5-fold cross-fitting) against the naive plug-in estimator. Report empirical coverage of the nominal 95% CI for each. Which one is honest?
Orthogonality check: verify numerically that the DML score satisfies the Neyman orthogonality condition — perturb the fitted nuisance functions by a small \(\delta\) and confirm the estimating equation’s derivative vanishes. Repeat for the naive plug-in score and show that it does not.
Omnibus heterogeneity test: run test_calibration() on the 401(k) causal forest. Does the deck’s forest pass the mean-prediction and differential-prediction tests? If it fails one, which, and what does that failure imply for the policy tree built on those scores?
Placebo refutation: use the dowhy placebo-treatment refuter on the union-premium estimate. The refuted effect should be indistinguishable from zero — is it? Now repeat with a random-common-cause refuter and interpret any drift.
Conformal coverage: construct conformalised prediction intervals for the individual treatment effect (Part V) and check empirical coverage on a held-out fold at nominal 90%. Does coverage hold marginally, and does it still hold conditionally within income quartiles?
Propensity trimming sensitivity: re-estimate the 401(k) ATE trimming propensity scores at \(\{0, 0.01, 0.05, 0.10\}\). Plot \(\hat\theta\) against the trimming threshold. How much of the reported effect is driven by units near the overlap boundary?
References and Further Reading
Double/Debiased Machine Learning
Chernozhukov et al. (2018). Double/debiased machine learning for treatment and structural parameters. doi:10.1111/ectj.12097