Causal Forests (R) — grf::causal_forest¶

The reference implementation, from the authors of the method¶

grf (Generalized Random Forests — Athey, Tibshirani & Wager, 2019) is the reference implementation of causal forests, written by the researchers who introduced honest causal trees and forests. It is the package cross-check for the from-scratch honest causal tree and econml's CausalForestDML in the Python notebook. We reproduce the same simulated return-to-work policy — a heterogeneous treatment effect with selection-into-treatment confounding, and a known ground-truth $\tau(x)$ to grade against — and fit causal_forest, which estimates

$$\tau(x) = E\big[\,Y(1) - Y(0)\mid X=x\,\big]$$

with honest trees, built-in orthogonalization (local centering by out-of-bag $\hat Y(x)$ and $\hat W(x)$ — grf's version of the double-ML step) and confidence intervals. Everything rests on unconfoundedness: given $X$, enrollment is as-good-as-random. R packages lead here (grf); the from-scratch mechanics live in the Python notebook.

In [1]:
options(repr.plot.width=13, repr.plot.height=4.5, warn=-1)
.libPaths(c("C:/Users/user/R/win-library/4.6", .libPaths()))
suppressMessages(library(grf))
set.seed(0); n <- 6000
age    <- runif(n,22,60); educ <- pmin(pmax(round(rnorm(n,13,2)),8),18)
prior  <- pmax(rnorm(n,20,10),0); health <- runif(n,0,10); female <- rbinom(n,1,.5)
X <- cbind(age=age,educ=educ,prior=prior,health=health,female=female)
prop <- plogis(-0.5 + 0.04*(prior-20) + 0.15*(health-5) - 0.02*(age-40))   # selection (confounding)
W <- rbinom(n,1,prop)
tau  <- 6 - 0.08*(age-22) - 0.12*prior                                     # heterogeneous TRUE effect
base <- 8 + 0.7*prior + 0.5*educ + 0.4*health - 3*female
Y <- base + W*tau + rnorm(n,0,3)
cat(sprintf("grf %s | n=%d  P(enroll)=%.2f  true ATE=%.3f  tau in [%.2f, %.2f]\n",
    as.character(packageVersion("grf")), n, mean(W), mean(tau), min(tau), max(tau)))
grf 2.6.1 | n=6000  P(enroll)=0.39  true ATE=2.056  tau in [-2.75, 5.98]

1. The data and the classical benchmarks¶

The design mirrors the Python notebook exactly: 6,000 working-age adults (age, educ, prior earnings, health, female); enrollment $W$ is confounded (higher prior earnings / better health / younger enroll more), and the program helps the young and low-prior-earnings most, $\tau(x)=6-0.08(\text{age}-22)-0.12\,\text{prior}$. As in the Python notebook we first try the estimates an econometrician reaches for before any ML — a difference in means, an OLS with a treatment dummy, and an OLS with hand-coded interactions (the parametric CATE).

In [2]:
naive <- mean(Y[W==1]) - mean(Y[W==0])
ate_ols <- coef(lm(Y ~ W + age+educ+prior+health+female))["W"]
oi <- lm(Y ~ W + age+educ+prior+health+female + W:age + W:prior)
tau_ols <- coef(oi)["W"] + coef(oi)["W:age"]*age + coef(oi)["W:prior"]*prior
cat(sprintf("1. difference in means         %6.3f   (true ATE %.3f) -> bias %+.3f  CONFOUNDED\n", naive, mean(tau), naive-mean(tau)))
cat(sprintf("2. OLS, treatment dummy         %6.3f   adjusts for X -> the average effect, one number only\n", ate_ols))
cat(sprintf("3. OLS w/ age,prior interaction   corr(implied tau, true) = %.3f  (great HERE: true tau is linear in age & prior)\n", cor(tau_ols, tau)))
cat("   The causal forest must discover that shape with no guess about which variables interact.\n")

par(mfrow=c(1,3), mar=c(4,4,3,1))
hist(tau, breaks=40, col="#3182ce", border="white", main="True effect tau(x)", xlab="earnings gain ($1000s)"); abline(v=mean(tau), col="#c53030", lwd=2)
plot(age, prior, col=colorRampPalette(c("#2b6cb0","grey90","#c53030"))(100)[cut(tau,100)], pch=19, cex=.3, main="tau highest: young & low-prior", xlab="age", ylab="prior earnings")
plot(prior, prop, pch=19, cex=.3, col="#805ad5", main="Selection: who enrolls (confounding)", xlab="prior earnings", ylab="P(enroll)")
par(mfrow=c(1,1))
1. difference in means          4.913   (true ATE 2.056) -> bias +2.857  CONFOUNDED
2. OLS, treatment dummy          1.960   adjusts for X -> the average effect, one number only
3. OLS w/ age,prior interaction   corr(implied tau, true) = 1.000  (great HERE: true tau is linear in age & prior)
   The causal forest must discover that shape with no guess about which variables interact.
No description has been provided for this image

2. Fit causal_forest and validate against the truth¶

causal_forest(X, Y, W) first fits regression forests for $\hat Y(x)$ and $\hat W(x)$ and centers both (the orthogonalization that removes confounding bias — grf's analogue of the DML step), then grows honest trees whose splits target treatment-effect heterogeneity. We fit on 70% and predict $\hat\tau(x)$ with variance estimates on a 30% test set, then check the estimate against the known $\tau(x)$: the scatter should sit on the 45° line, and the 90% intervals should cover the truth about 90% of the time.

In [3]:
set.seed(1); idx <- sample(n, 0.7*n)
cf <- causal_forest(X[idx,], Y[idx], W[idx], num.trees=2000, seed=1)
pr <- predict(cf, X[-idx,], estimate.variance=TRUE)
th <- pr$predictions; se <- sqrt(pr$variance.estimates); tt <- tau[-idx]
cover <- mean(tt >= th-1.645*se & tt <= th+1.645*se)
ate <- average_treatment_effect(cf, target.sample="all")
cat(sprintf("grf ATE estimate  %.3f  (SE %.3f)   true ATE %.3f\n", ate[1], ate[2], mean(tau)))
cat(sprintf("test-set CATE:  corr(est, true) = %.3f   RMSE = %.3f   90%% CI coverage of true tau = %.3f\n",
    cor(th,tt), sqrt(mean((th-tt)^2)), cover))

options(repr.plot.width=6.5, repr.plot.height=5.2)
plot(tt, th, pch=19, cex=.3, col=rgb(.17,.42,.75,.35), xlab="true tau(x)", ylab="estimated tau_hat(x)",
     main="grf causal forest: estimated vs true CATE"); abline(0,1,lty=2,lwd=1.5)
legend("topleft", sprintf("corr %.3f\nRMSE %.2f\nATEhat %.2f", cor(th,tt), sqrt(mean((th-tt)^2)), mean(th)), bty="n")
grf ATE estimate  1.994  (SE 0.110)   true ATE 2.056
test-set CATE:  corr(est, true) = 0.939   RMSE = 0.539   90% CI coverage of true tau = 0.764
No description has been provided for this image

3. test_calibration — the built-in heterogeneity diagnostic¶

grf ships a calibration test tailored to causal forests. It regresses the outcome on the forest's mean prediction and its differential (heterogeneity) prediction; a mean.forest.prediction coefficient near 1 says the average effect is right, and a differential.forest.prediction coefficient near 1 says the forest's ranking of who benefits more is real, not noise (with a one-sided p-value for genuine heterogeneity). It is the causal counterpart to the "proportions vs predictions" reliability check on the predictive side.

In [4]:
print(test_calibration(cf))
cat("\nA differential.forest.prediction coefficient near 1 with a small p-value is grf's evidence that the estimated\n")
cat("heterogeneity is real -- the forest genuinely separates high-benefit from low-benefit workers.\n")
Best linear fit using forest predictions (on held-out data)
as well as the mean forest prediction as regressors, along
with one-sided heteroskedasticity-robust (HC3) SEs:

                               Estimate Std. Error t value    Pr(>t)    
mean.forest.prediction         1.005051   0.053443  18.806 < 2.2e-16 ***
differential.forest.prediction 1.092871   0.090126  12.126 < 2.2e-16 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

A differential.forest.prediction coefficient near 1 with a small p-value is grf's evidence that the estimated
heterogeneity is real -- the forest genuinely separates high-benefit from low-benefit workers.

4. What drives the heterogeneity¶

variable_importance counts how often each covariate is split on (depth-weighted); it should isolate age and prior. The partial-effect curves sweep one covariate across its range with the others held at their medians and overlay the true $\tau$ — the forest recovers the built-in slopes without being told the functional form.

In [5]:
vi <- variable_importance(cf); names(vi) <- colnames(X)
med <- apply(X, 2, median)
pdp <- function(j){ g <- seq(quantile(X[,j],.02), quantile(X[,j],.98), length=60)
    G <- matrix(rep(med, each=60), nrow=60); colnames(G) <- colnames(X); G[,j] <- g
    p <- predict(cf, G, estimate.variance=TRUE)
    list(g=g, est=p$predictions, se=sqrt(p$variance.estimates), true=6-0.08*(G[,"age"]-22)-0.12*G[,"prior"]) }
options(repr.plot.width=13, repr.plot.height=4.2); par(mfrow=c(1,3), mar=c(4,4,3,1))
barplot(sort(vi), horiz=TRUE, las=1, col="#2b6cb0", main="grf variable importance (for the effect)", xlab="importance")
for(j in c("age","prior")){ d <- pdp(j)
    plot(d$g, d$est, type="l", col="#2b6cb0", lwd=2, ylim=range(c(d$est-1.645*d$se, d$est+1.645*d$se, d$true)),
         xlab=j, ylab="tau_hat", main=paste("Effect vs", j))
    polygon(c(d$g,rev(d$g)), c(d$est-1.645*d$se, rev(d$est+1.645*d$se)), col=rgb(.17,.42,.75,.15), border=NA)
    lines(d$g, d$true, col="#c53030", lwd=2, lty=2)
    legend("topright", c("causal forest","90% CI","true tau"), col=c("#2b6cb0",rgb(.17,.42,.75,.4),"#c53030"),
           lty=c(1,1,2), lwd=c(2,6,2), bty="n", cex=.8) }
par(mfrow=c(1,1))
cat(sprintf("Importance: prior=%.2f, age=%.2f dominate; educ/health/female near zero.\n", vi["prior"], vi["age"]))
Importance: prior=0.53, age=0.35 dominate; educ/health/female near zero.
No description has been provided for this image

5. Turning $\hat\tau(x)$ into policy, and summary¶

Ranking the test workers by $\hat\tau(x)$ and enrolling from the top down gives a targeting curve: the average realized effect among the enrolled as the program expands, against perfect targeting (rank by the true $\tau$) and treat-everyone (the flat ATE line). The gap is the value of knowing who benefits.

In [6]:
options(repr.plot.width=7, repr.plot.height=4.6)
o <- order(-th); ot <- order(-tt); k <- seq_along(o)
cum_hat <- cumsum(tt[o])/k; cum_true <- cumsum(tt[ot])/k; frac <- 100*k/length(k)
plot(frac, cum_hat, type="l", col="#2b6cb0", lwd=2.2, ylim=range(c(cum_hat,cum_true,mean(tt))),
     xlab="% of workers enrolled (highest tau_hat first)", ylab="avg realized effect among enrolled ($1000s)",
     main="Targeting curve -- value of knowing who benefits")
lines(frac, cum_true, col="#2f855a", lwd=1.8, lty=2); abline(h=mean(tt), col="#c53030", lwd=1.5, lty=3)
sp <- 100*mean(th>0); abline(v=sp, col="grey40", lwd=1, lty=4)
legend("topright", c("target by grf tau_hat","perfect targeting","treat everyone (ATE)", sprintf("tau_hat>0 for %.0f%%",sp)),
       col=c("#2b6cb0","#2f855a","#c53030","grey40"), lty=c(1,2,3,4), lwd=2, bty="n", cex=.8)
top <- o[1:round(.2*length(o))]
cat(sprintf("Treat top 20%% by tau_hat: realized effect %.2f vs treat-all %.2f -> %.1fx per-enrollee gain.\n",
    mean(tt[top]), mean(tt), mean(tt[top])/mean(tt)))
Treat top 20% by tau_hat: realized effect 4.03 vs treat-all 2.03 -> 2.0x per-enrollee gain.
No description has been provided for this image

Summary¶

grf::causal_forest reproduces the Python notebook's result with the method's reference implementation: it removes the selection-confounding bias by orthogonalization, recovers the heterogeneous effect $\tau(x)$ (correlation with the truth ≈ 0.95) with valid confidence intervals, isolates age and prior as the effect modifiers, passes its own test_calibration for genuine heterogeneity, and yields a targeting rule worth multiples of the per-enrollee gain of treating everyone.

Three implementations now agree — from-scratch honest tree/forest, econml CausalForestDML, and grf — the same triangulation used throughout this portfolio. Causal forests are the ML-powered members of the causal-inference family, built on the random forest with an effect-heterogeneity split and honesty. This arc continues into identification (potential outcomes, matching/propensity, IV, difference-in-differences) — which is what licenses the unconfoundedness assumption — and double/debiased ML for a single well-identified effect.