Skip to content
Matthew Reda
Go back

Double Machine Learning: Residualize Twice, Then Regress

Edit page

The pitch for using machine learning as a control strategy sounds like a free upgrade. I have a treatment, an outcome, and a pile of confounders whose effects are curved and interact. A linear adjustment is obviously misspecified, so I hand the confounders to a penalized regression or a boosted model, let it “control for” them, and read the treatment effect off what’s left.

It isn’t free. A model tuned to predict well is biased on purpose, and that bias lands on the one coefficient I care about. Double/debiased machine learning (Chernozhukov et al., 2018) is the repair, and it comes down to two moves: residualize both the outcome and the treatment on the confounders, and compute those residuals out of fold. Each move removes a distinct bias. Neither one touches confounders that aren’t in the data. I built an interactive explainer that runs every step below as a live simulation in the browser; this post is the written version of the argument.

The setup

The workhorse is the partially linear model:

Y=θD+g(X)+ε,D=m(X)+vY = \theta D + g(X) + \varepsilon, \qquad D = m(X) + v

θ\theta is the effect of the treatment DD on the outcome YY. The functions gg and mm are nuisance: I have to learn them well, but I don’t care about them for their own sake. In the explainer, DD is weekly paid social spend, YY is revenue, and XX is demand. The media team spends more in weeks it expects to be busy, and those weeks bring in more revenue regardless. The simulation sets θ=0.50\theta = 0.50.

D Y v ε X D Y D Y Paid social spend Revenue θ = 0.50 the effect to estimate Demand m(X) busy weeks get more budget g(X) busy weeks earn more anyway budget surprises noise
The explainer's setup as a graph. Demand moves both spend and revenue, so the raw slope of revenue on spend mixes that path with the effect θ. Dashed nodes are unobserved.

Regressing revenue on spend alone gives a slope near 1.4, almost three times the truth, because

plim θ^OLS=θ+Cov(m(X), g(X))Var(D)\text{plim}\,\hat\theta_{\text{OLS}} = \theta + \frac{\text{Cov}(m(X),\, g(X))}{\text{Var}(D)}

That second term is ordinary confounding. Everyone agrees on the fix: adjust for XX. The trouble is in how.

Bias one: regularization

The naive ML approach fits one flexible model of YY on DD and XX and reads θ\theta off the treatment coefficient. The penalty that makes the model predict well also shrinks g^\hat g toward something flatter than gg. The revenue that g^\hat g fails to explain has to go somewhere, and since the peaks of gg sit in the same weeks where spend is high, it goes to the spend coefficient.

The error of this plug-in estimator splits into two pieces:

n (θ^−θ)≈1n∑iDiεiE[D2]⏟noise, well behaved+1n∑im(Xi) (g−g^)(Xi)E[D2]⏟regularization bias\sqrt{n}\,(\hat\theta - \theta) \approx \underbrace{\frac{\frac{1}{\sqrt n}\sum_i D_i \varepsilon_i}{E[D^2]}}_{\text{noise, well behaved}} + \underbrace{\frac{\frac{1}{\sqrt n}\sum_i m(X_i)\,(g - \hat g)(X_i)}{E[D^2]}}_{\text{regularization bias}}

The first piece is a normal sampling term. The second is the problem. Flexible learners converge more slowly than 1/n1/\sqrt{n}, so after scaling by n\sqrt{n} that sum does not settle down. It grows. In the explainer’s simulation (500 weeks per dataset, 300 datasets, ridge regression on a basis of Gaussian bumps), the plug-in estimates cluster around 0.64 and the 95% intervals contain the truth 4% of the time. This is the same pattern I wrote about in More Data, More Confident, Still Wrong: the interval tightens around the wrong number.

The repair: residualize both sides

The fix is to stop asking the learner for θ\theta at all. Give it two prediction problems, which is what it is good at:

ℓ(X)=E[Y∣X],m(X)=E[D∣X]\ell(X) = E[Y \mid X], \qquad m(X) = E[D \mid X]

Subtract each prediction, then regress residual on residual:

Y~=Y−ℓ^(X),D~=D−m^(X),θ^=∑iD~iY~i∑iD~i2\tilde Y = Y - \hat\ell(X), \qquad \tilde D = D - \hat m(X), \qquad \hat\theta = \frac{\sum_i \tilde D_i \tilde Y_i}{\sum_i \tilde D_i^2}

This is the Frisch-Waugh-Lovell theorem with flexible first stages, which is the estimator Robinson (1988) analyzed. The “twice” matters. If I residualize only the outcome and regress Y~\tilde Y on raw DD, then even with the true ℓ\ell the slope converges to θ⋅Var(v)/Var(D)\theta \cdot \text{Var}(v) / \text{Var}(D), which is too small: spend still carries all the variation that demand put there, but revenue no longer moves with it. In the explainer that halfway step drops the slope to 0.17. Residualizing spend as well leaves only the budget surprises vv, and the slope returns to the truth.

Both ℓ^\hat\ell and m^\hat m are still shrunk and still wrong. What changed is how their errors enter. The leading bias term is now

1n∑i(m−m^)(Xi) (ℓ−ℓ^)(Xi)\frac{1}{\sqrt n}\sum_i (m - \hat m)(X_i)\,(\ell - \hat\ell)(X_i)

a product of two errors instead of a single one. If each learner converges faster than n−1/4n^{-1/4}, the product shrinks faster than n−1/2n^{-1/2} and vanishes even after the n\sqrt{n} scaling. That property has a name, Neyman orthogonality: the moment condition that defines θ\theta has zero derivative with respect to the nuisance functions at their true values, so small mistakes in ℓ^\hat\ell and m^\hat m do not move θ^\hat\theta to first order. With the same ridge model and the same penalty as before, the explainer’s estimates center on 0.50 and the intervals cover 94% of the time.

Power of n in the √n-scaled bias 0 1/4 1/2 +1/2 0 −1/2 a: the nuisance error shrinks like n to the power −a above zero: the bias grows with n below zero: the bias vanishes Plug-in: one error n to the power 1/2 − a Orthogonal: error × error n to the power 1/2 − 2a zero at a = 1/4
An illustration of rates read off the two bias terms, not a simulation. If the nuisance errors shrink like n to the power −a, the plug-in bias scales like n to the power 1/2 − a and the orthogonal bias like n to the power 1/2 − 2a, which turns negative once a passes 1/4.

Bias two: overfitting

Orthogonality handles a learner that is too stiff. It does not handle one that is too flexible. Every row’s outcome decomposes as

Yi=ℓ(Xi)+θvi+εiY_i = \ell(X_i) + \theta v_i + \varepsilon_i

A model that trained on row ii has already seen YiY_i, so its prediction absorbs some of θvi+εi\theta v_i + \varepsilon_i. Subtracting that prediction removes part of the effect I am trying to measure. Formally, the term 1n∑ivi (ℓ−ℓ^)(Xi)\frac{1}{\sqrt n}\sum_i v_i\,(\ell - \hat\ell)(X_i) has mean zero only when ℓ^\hat\ell never saw row ii.

The explainer makes this concrete with a two-nearest-neighbor average, where each training row counts as one of its own two neighbors. In-sample R2R^2 rises from 0.84 to 0.92, and the estimate falls to 0.25, half the truth, with 0% coverage. A better fit produced a worse answer. Deep forests and long boosting runs do a milder version of the same thing.

Cross-fitting is the fix: split the data into folds, train both nuisance models on the other folds, and compute residuals only for the held-out rows. No data is wasted, since every row trains models and every row gets scored.

The sample, shuffled into 5 folds Fold 1 Fold 2 Fold 3 Fold 4 Fold 5 Each fold takes one turn as the held-out set Fold 1 held out Fold 2 train Fold 3 train Fold 4 train Fold 5 train Fit the outcome model and the treatment model on folds 2, 3, 4, 5 Neither model sees fold 1 predict fold 1, subtract 1 of 5 folds scored out of fold Fold 1 train Fold 2 held out Fold 3 train Fold 4 train Fold 5 train Fit the outcome model and the treatment model on folds 1, 3, 4, 5 Neither model sees fold 2 predict fold 2, subtract 2 of 5 folds scored out of fold Fold 1 train Fold 2 train Fold 3 held out Fold 4 train Fold 5 train Fit the outcome model and the treatment model on folds 1, 2, 4, 5 Neither model sees fold 3 predict fold 3, subtract 3 of 5 folds scored out of fold Fold 1 train Fold 2 train Fold 3 train Fold 4 held out Fold 5 train Fit the outcome model and the treatment model on folds 1, 2, 3, 5 Neither model sees fold 4 predict fold 4, subtract 4 of 5 folds scored out of fold Fold 1 train Fold 2 train Fold 3 train Fold 4 train Fold 5 held out Fit the outcome model and the treatment model on folds 1, 2, 3, 4 Neither model sees fold 5 predict fold 5, subtract 5 of 5 folds scored out of fold Fold 1 residuals Fold 2 residuals Fold 3 residuals Fold 4 residuals Fold 5 residuals Every row trained models and every row was scored by models that never saw it Regress outcome residuals on treatment residuals: one slope from all rows
Regress residual on residual
A schematic of the loop in the code below, with five folds. Each fold is scored by an outcome model and a treatment model that never trained on it, and the final regression uses every row's out-of-fold residuals.
import numpy as np
from sklearn.ensemble import HistGradientBoostingRegressor as HGBR
from sklearn.model_selection import KFold

# X: (n, p) pre-treatment confounders   d: treatment   y: outcome
n = len(y)
y_res, d_res = np.zeros(n), np.zeros(n)

for train, test in KFold(5, shuffle=True, random_state=0).split(X):
    y_res[test] = y[test] - HGBR().fit(X[train], y[train]).predict(X[test])
    d_res[test] = d[test] - HGBR().fit(X[train], d[train]).predict(X[test])

theta = np.sum(d_res * y_res) / np.sum(d_res**2)

psi = d_res * (y_res - theta * d_res)  # orthogonal score
se = np.sqrt(np.mean(psi**2) / n) / np.mean(d_res**2)
print(f"theta = {theta:.3f} +/- {1.96 * se:.3f}")

With the same memorizing learner, cross-fitted, the explainer’s estimates go back to a mean of 0.50 and 94% coverage. One detail I appreciate from that simulation: with the stiff ridge model, partialling out worked fine without cross-fitting. Cross-fitting is what buys the freedom to use any learner on a wide, messy XX, which was the reason to reach for machine learning in the first place.

true effect 0.50 95% interval coverage 0.0 0.5 1.0 1.5 estimated effect of spend on revenue Revenue on spend alone no adjustment ≈ 1.4 – Plug-in ML fit ridge, treatment coefficient 0.64 4% Residualize outcome only regressed on raw spend 0.17 – Residualize both ridge, in sample 0.50 94% Residualize both 2-nearest-neighbor, in sample 0.25 0% Residualize both, cross-fitted 2-nearest-neighbor, out of fold 0.50 94%
The values this post quotes from the explainer's simulation, against the true effect of 0.50. Only the reported centers are drawn, with no spread, and a dash marks a step for which the post gives no coverage figure.

What it does not fix

Everything above is about estimation. The model’s assumption E[ε∣D,X]=0E[\varepsilon \mid D, X] = 0 is about identification, and DML takes it as given.

Unobserved confounding. If something outside XX moves both treatment and outcome, the residual-on-residual slope converges, with honest-looking intervals, to a number that is not the causal effect. Two worlds can generate identical (X,D,Y)(X, D, Y) and have different effects, so no diagnostic on the observed data can tell them apart. A better learner does not help.

Bad controls. The learner cannot tell a confounder from a mediator or a collider. Predictive power says nothing about where a column sits in the causal graph, and the columns a treatment causes are often the best predictors of the outcome. Whether a variable belongs in XX is decided before any model is fit.

A single θ\theta. The partially linear model lets gg and mm bend freely, but the effect stays one number. If the effect varies, θ^\hat\theta is a weighted average that leans toward the observations where treatment varies most after conditioning on XX.

No residual variation. If XX predicts DD almost perfectly, D~\tilde D is nearly zero and there is nothing left to regress on.

What to do in practice

  1. Choose XX from the causal graph, not from feature importance. Pre-treatment variables only.
  2. Fit two nuisance models and cross-fit both. Check the out-of-fold predictive performance of each. Poor out-of-fold fit in both means the adjustment is doing little, either because the learner is weak or because XX carries little of the confounding.
  3. Look at the residualized treatment. Its variance is the information the estimate runs on.
  4. Use a maintained implementation for real work. The DoubleML package provides DoubleMLPLR for this model, with cross-fitting and repeated sample splitting built in. For a binary treatment, the doubly robust score is the same idea: its bias is the propensity error times the outcome-model error.
  5. Report how wrong the identifying assumption would have to be. Pair the estimate with a sensitivity analysis, and where possible with a randomized holdout, which replaces the untestable assumption with a design.

DML is a precise answer to a narrow question: given the right adjustment set, how do I estimate the effect without my nuisance models contaminating it? It answers that question well. Whether the adjustment set is right is a separate question that the method cannot ask.


Grounded in Chernozhukov, Chetverikov, Demirer, Duflo, Hansen, Newey & Robins (2018), “Double/debiased machine learning for treatment and structural parameters,” The Econometrics Journal, 21(1). All simulation numbers come from my double ML explainer, which also covers doubly robust ad-lift estimation, sensitivity analysis, and experiments. Related: The Table 2 Fallacy, The Assumptions Are the Model.


Edit page
Share this post:

Previous Post
Markov Equivalence: What Your Data Cannot Tell You About Direction
Next Post
The Hundred-Year Flood Is an Estimate, and Its Uncertainty Is the Result