<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>econml | Carlos Mendez</title><link>https://carlos-mendez.org/tag/econml/</link><atom:link href="https://carlos-mendez.org/tag/econml/index.xml" rel="self" type="application/rss+xml"/><description>econml</description><generator>Wowchemy (https://wowchemy.com)</generator><language>en-us</language><copyright>Carlos Mendez</copyright><lastBuildDate>Fri, 01 May 2026 00:00:00 +0000</lastBuildDate><image><url>https://carlos-mendez.org/media/icon_huedfae549300b4ca5d201a9bd09a3ecd5_79625_512x512_fill_lanczos_center_3.png</url><title>econml</title><link>https://carlos-mendez.org/tag/econml/</link></image><item><title>Causal Machine Learning for Policy Evaluation: From ATE to IATE to a Better Assignment Rule</title><link>https://carlos-mendez.org/post/python_cml/</link><pubDate>Fri, 01 May 2026 00:00:00 +0000</pubDate><guid>https://carlos-mendez.org/post/python_cml/</guid><description>&lt;h2 id="overview">Overview&lt;/h2>
&lt;p>A government runs a job-training programme for unemployed jobseekers and wants to know three things at once. Does the programme actually &lt;em>cause&lt;/em> people to spend more months in employment over the next two and a half years? Does the effect depend on who the jobseeker is — for example, on how well they speak the local language? And if effects differ across people, can we use those differences to send training to the &lt;em>right&lt;/em> jobseekers, rather than to everyone or to no one? These three questions correspond to three causal estimands — the &lt;strong>ATE&lt;/strong>, the &lt;strong>GATE&lt;/strong>, and the &lt;strong>IATE&lt;/strong> — and answering them is the bread-and-butter of &lt;strong>Causal Machine Learning (CML)&lt;/strong>.&lt;/p>
&lt;p>CML combines two ideas. From causal inference, it borrows the careful framing of treatment effects under unconfoundedness and the doubly-robust scoring functions that protect against modelling mistakes. From machine learning, it borrows flexible nuisance estimators — random forests, gradient-boosted trees, neural nets — that learn complicated outcome surfaces without forcing the analyst to specify them by hand. The result is a small toolbox — DoubleML for the average effect, doubly-robust averaging for subgroup effects, causal forests for individual effects — that turns observational data into actionable, &lt;em>personalised&lt;/em> policy recommendations. This tutorial walks through the full toolbox on a synthetic Flemish-ALMP-style cohort of 5,000 jobseekers, modelled on the empirical case study in &lt;a href="https://doi.org/10.1016/j.labeco.2023.102306" target="_blank" rel="noopener">Cockx, Lechner &amp;amp; Bollens (2023)&lt;/a> and the methodological roadmap in &lt;a href="https://doi.org/10.1186/s41937-023-00113-y" target="_blank" rel="noopener">Lechner (2023)&lt;/a>. Because the data are synthetic, the &lt;em>true&lt;/em> treatment effects are known — so every estimator can be benchmarked against the truth.&lt;/p>
&lt;h2 id="the-cml-roadmap">The CML roadmap&lt;/h2>
&lt;p>CML organises a treatment-effect study into a sequence of progressively finer questions. The diagram below shows the four-step roadmap that this tutorial follows: estimate the &lt;em>average&lt;/em> effect, then break it down into &lt;em>group&lt;/em> effects, then go all the way to &lt;em>individual&lt;/em> effects, and finally turn those individual effects into a &lt;em>policy&lt;/em>.&lt;/p>
&lt;pre>&lt;code class="language-mermaid">graph LR
A[&amp;quot;&amp;lt;b&amp;gt;1. ATE&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Population&amp;lt;br/&amp;gt;average effect&amp;quot;] --&amp;gt; B[&amp;quot;&amp;lt;b&amp;gt;2. GATE&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Effect by&amp;lt;br/&amp;gt;subgroup&amp;quot;]
B --&amp;gt; C[&amp;quot;&amp;lt;b&amp;gt;3. IATE&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Effect for&amp;lt;br/&amp;gt;each individual&amp;quot;]
C --&amp;gt; D[&amp;quot;&amp;lt;b&amp;gt;4. Policy&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Welfare-optimal&amp;lt;br/&amp;gt;assignment rule&amp;quot;]
style A fill:#6a9bcc,stroke:#141413,color:#fff
style B fill:#d97757,stroke:#141413,color:#fff
style C fill:#00d4c8,stroke:#141413,color:#fff
style D fill:#999999,stroke:#141413,color:#fff
&lt;/code>&lt;/pre>
&lt;p>The arrows are not just decorative. Each step &lt;em>builds&lt;/em> on the previous one: a credible average effect is the floor on which any subgroup analysis stands, and credible group effects are the floor on which any individual analysis stands. Skipping the first step and jumping straight to a fancy heterogeneity model is the most common mistake in applied CML. We will resist that temptation by starting from the simplest possible baseline and only adding complexity when the data warrant it.&lt;/p>
&lt;p>&lt;strong>Learning objectives:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Distinguish&lt;/strong> the three CML estimands — ATE, GATE, IATE — and write each as a formal expectation.&lt;/li>
&lt;li>&lt;strong>Diagnose&lt;/strong> covariate overlap and explain why selection-on-observables matters in observational data.&lt;/li>
&lt;li>&lt;strong>Estimate&lt;/strong> the population-average effect with &lt;code>DoubleMLIRM&lt;/code>, using random-forest nuisances and 5-fold cross-fitting.&lt;/li>
&lt;li>&lt;strong>Estimate&lt;/strong> group effects via doubly-robust pseudo-outcomes and individual effects via &lt;code>CausalForestDML&lt;/code>.&lt;/li>
&lt;li>&lt;strong>Translate&lt;/strong> the individual-level effect estimates into a welfare-maximising training-assignment rule and benchmark it against treat-all and an oracle.&lt;/li>
&lt;/ul>
&lt;h2 id="setup-and-imports">Setup and imports&lt;/h2>
&lt;p>Before running anything, install the two CML libraries this tutorial depends on. &lt;code>doubleml&lt;/code> provides the cross-fitted, orthogonal-score machinery for averages; &lt;code>econml&lt;/code> provides the causal forest for individual effects.&lt;/p>
&lt;pre>&lt;code class="language-python">pip install doubleml econml # https://docs.doubleml.org https://econml.azurewebsites.net
&lt;/code>&lt;/pre>
&lt;p>The next block imports the stack and fixes the random seed. Setting &lt;code>np.random.seed(RANDOM_SEED)&lt;/code> is &lt;em>not&lt;/em> dead code: DoubleML&amp;rsquo;s internal cross-fit splitter uses the legacy global numpy RNG, so removing this line causes the ATE to drift by O(1e-3) across runs.&lt;/p>
&lt;pre>&lt;code class="language-python">import warnings
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.ensemble import RandomForestRegressor, RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from doubleml import DoubleMLData, DoubleMLIRM
from econml.dml import CausalForestDML
# Silence only the predictable noise from the third-party CML stack;
# real deprecation / convergence warnings still surface.
warnings.filterwarnings(&amp;quot;ignore&amp;quot;, category=FutureWarning)
warnings.filterwarnings(&amp;quot;ignore&amp;quot;, category=UserWarning)
RANDOM_SEED = 42
np.random.seed(RANDOM_SEED)
X_COLS = [&amp;quot;age&amp;quot;, &amp;quot;edu_years&amp;quot;, &amp;quot;prior_emp_months&amp;quot;, &amp;quot;dutch_prof&amp;quot;, &amp;quot;female&amp;quot;, &amp;quot;migrant&amp;quot;]
&lt;/code>&lt;/pre>
&lt;p>The two CSVs read in the next section (&lt;code>cml_data.csv&lt;/code> for the observed columns and &lt;code>cml_truth.csv&lt;/code> for the hidden ground truth) ship with this post&amp;rsquo;s page bundle. If you&amp;rsquo;re following along outside the bundle, you can regenerate them by running &lt;a href="script.py">&lt;code>script.py&lt;/code>&lt;/a> once — it produces both files plus all six figures.&lt;/p>
&lt;h2 id="data-a-synthetic-almp-cohort">Data: a synthetic ALMP cohort&lt;/h2>
&lt;p>The dataset is a synthetic Flemish-ALMP-style cohort of 5,000 jobseekers. Each row records six pre-treatment covariates ($X$) — age, years of education, months employed in the look-back window, Dutch proficiency on a 0–3 scale, sex, and migrant status — a binary treatment indicator $D$ (whether the jobseeker received training), and an outcome $Y$ measuring months employed during a 30-month follow-up window. Because the data are synthetic, a companion file (&lt;code>cml_truth.csv&lt;/code>) stores the &lt;em>true&lt;/em> individual treatment effect $\tau_i$ for every row, which lets us benchmark each estimator. The reader does not need to know how the data were generated; only that the truth is known.&lt;/p>
&lt;pre>&lt;code class="language-python">df = pd.read_csv(&amp;quot;cml_data.csv&amp;quot;)
truth = pd.read_csv(&amp;quot;cml_truth.csv&amp;quot;)
print(f&amp;quot;Sample size : {len(df):,}&amp;quot;)
print(f&amp;quot;Treatment share P(D=1) : {df['D'].mean():.3f}&amp;quot;)
print(f&amp;quot;Mean outcome E[Y] : {df['Y'].mean():.2f} months employed (out of 30)&amp;quot;)
print(df.describe().round(2))
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Sample size : 5,000
Treatment share P(D=1) : 0.528
Mean outcome E[Y] : 22.68 months employed (out of 30)
age edu_years prior_emp_months dutch_prof female migrant D Y
count 5000.00 5000.00 5000.00 5000.00 5000.00 5000.00 5000.00 5000.00
mean 39.82 12.02 16.99 1.33 0.49 0.30 0.53 22.68
std 11.54 2.95 9.59 1.02 0.50 0.46 0.50 4.18
min 20.02 6.00 0.37 0.00 0.00 0.00 0.00 9.81
25% 29.78 10.01 9.49 0.00 0.00 0.00 0.00 19.73
50% 39.68 11.94 15.80 1.00 0.00 0.00 1.00 22.81
75% 49.95 14.01 23.33 2.00 1.00 1.00 1.00 25.79
max 59.99 20.00 54.75 3.00 1.00 1.00 1.00 30.00
&lt;/code>&lt;/pre>
&lt;p>The cohort is 5,000 jobseekers aged 20–60 (mean 39.8) with about 12 years of education and 17 months of prior employment in the look-back window. The treatment share of 52.8% is high relative to a real-world ALMP study, but it is calibrated so that propensity scores stay safely inside [0.21, 0.81] and so that overlap is preserved across all four Dutch-proficiency strata. The outcome — months employed in the 30-month window — has a mean of 22.68 and a standard deviation of 4.18, leaving plenty of room for a realistic 5-to-8-month treatment effect to be visible without bumping into the floor of zero or the ceiling of thirty.&lt;/p>
&lt;p>The script also stores the &lt;em>true&lt;/em> parameters in &lt;code>true_parameters.csv&lt;/code> for later benchmarking. The true ATE is 5.628 months, and the true GATEs decline monotonically with Dutch proficiency: 7.634 (no Dutch), 6.123 (low), 4.612 (intermediate), 3.130 (native). In words, jobseekers who do not speak Dutch benefit roughly 2.4× more from training than those who already do — a pattern that mirrors the policy-relevant punchline of the Cockx, Lechner &amp;amp; Bollens (2023) study.&lt;/p>
&lt;h2 id="estimands-ate-gate-and-iate">Estimands: ATE, GATE, and IATE&lt;/h2>
&lt;p>Before estimating anything, we have to be precise about &lt;em>what&lt;/em> we are estimating. Causal Machine Learning targets three estimands of increasing granularity. Throughout the post, $Y(1)$ denotes the &lt;em>potential outcome&lt;/em> under treatment and $Y(0)$ the potential outcome without it. Only one of these is observed for each person; the other is the counterfactual that estimation tries to recover.&lt;/p>
&lt;p>The &lt;strong>Average Treatment Effect (ATE)&lt;/strong> is the mean effect of training across the entire population:&lt;/p>
&lt;p>$$\text{ATE} = E[Y(1) - Y(0)]$$&lt;/p>
&lt;p>In words, this says: average the per-person treatment effect over everyone in the population. In code, this is the quantity &lt;code>DoubleMLIRM&lt;/code> returns in &lt;code>dml_irm.coef[0]&lt;/code> after a single call to &lt;code>.fit()&lt;/code>.&lt;/p>
&lt;p>The &lt;strong>Group Average Treatment Effect (GATE)&lt;/strong> restricts the average to a subgroup defined by a categorical variable $Z$:&lt;/p>
&lt;p>$$\text{GATE}(z) = E[Y(1) - Y(0) \mid Z = z]$$&lt;/p>
&lt;p>In words, this says: average the per-person effect only over people who share the value $Z = z$. We use $Z$ = &lt;code>dutch_prof&lt;/code>, so $z \in \{0, 1, 2, 3\}$. In code, the GATE is computed by averaging the doubly-robust pseudo-outcome (defined later) within each value of &lt;code>df[&amp;quot;dutch_prof&amp;quot;]&lt;/code>.&lt;/p>
&lt;p>The &lt;strong>Individual Average Treatment Effect (IATE)&lt;/strong> goes one level deeper, conditioning on the full covariate vector $X$:&lt;/p>
&lt;p>$$\text{IATE}(x) = E[Y(1) - Y(0) \mid X = x]$$&lt;/p>
&lt;p>In words, this says: at every covariate profile $x$, predict the effect of training for somebody with that profile. In code, the IATE is the per-row prediction returned by &lt;code>cf.effect(X_arr)&lt;/code> after fitting &lt;code>CausalForestDML&lt;/code>.&lt;/p>
&lt;p>The framing of this post is &lt;strong>observational&lt;/strong> — we assume &lt;em>unconfoundedness&lt;/em>: conditional on $X$, treatment assignment is as good as random. The naive difference-in-means is therefore &lt;em>genuinely biased&lt;/em> on these data, not just imprecise. CML methods earn their keep by addressing that confounding through flexible nuisance estimators and orthogonal scores.&lt;/p>
&lt;h2 id="step-1--overlap-diagnostic">Step 1 — Overlap diagnostic&lt;/h2>
&lt;p>Causal estimation under unconfoundedness only works if every covariate profile has a non-trivial chance of being treated &lt;em>and&lt;/em> a non-trivial chance of being untreated. Otherwise the model is forced to extrapolate, and small modelling mistakes blow up. The standard diagnostic is to fit a propensity score $\hat{\pi}(X) = \widehat{P}(D = 1 \mid X)$ and check that the histograms of $\hat{\pi}$ for treated and untreated jobseekers overlap. We use a logistic regression here purely for visualisation — DoubleML and CausalForestDML will fit their own nuisance models later.&lt;/p>
&lt;pre>&lt;code class="language-python">ps_lr = LogisticRegression(max_iter=1000, random_state=RANDOM_SEED).fit(df[X_COLS], df[&amp;quot;D&amp;quot;])
ps_hat = ps_lr.predict_proba(df[X_COLS])[:, 1]
print(f&amp;quot;Propensity range : [{ps_hat.min():.3f}, {ps_hat.max():.3f}]&amp;quot;)
print(f&amp;quot;P(D=1 | X) mean (treated) : {ps_hat[df['D']==1].mean():.3f}&amp;quot;)
print(f&amp;quot;P(D=1 | X) mean (untreat.): {ps_hat[df['D']==0].mean():.3f}&amp;quot;)
fig, ax = plt.subplots(figsize=(8.5, 5))
bins = np.linspace(0, 1, 31)
ax.hist(ps_hat[df[&amp;quot;D&amp;quot;] == 0], bins=bins, alpha=0.65, color=&amp;quot;#6a9bcc&amp;quot;,
label=&amp;quot;Untreated (D=0)&amp;quot;, edgecolor=&amp;quot;white&amp;quot;)
ax.hist(ps_hat[df[&amp;quot;D&amp;quot;] == 1], bins=bins, alpha=0.65, color=&amp;quot;#d97757&amp;quot;,
label=&amp;quot;Treated (D=1)&amp;quot;, edgecolor=&amp;quot;white&amp;quot;)
ax.set_xlabel(r&amp;quot;Estimated propensity score $\hat{\pi}(X)$&amp;quot;)
ax.set_ylabel(&amp;quot;Number of individuals&amp;quot;)
ax.set_title(&amp;quot;Covariate overlap: propensity-score distribution by treatment status&amp;quot;)
ax.legend()
plt.savefig(&amp;quot;cml_overlap.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;)
plt.show()
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Propensity range : [0.208, 0.810]
P(D=1 | X) mean (treated) : 0.551
P(D=1 | X) mean (untreat.): 0.502
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="cml_overlap.png" alt="Histogram of estimated propensity scores split by treatment status; the two distributions overlap heavily across the [0.2, 0.8] range.">&lt;/p>
&lt;p>Estimated propensities fall safely inside [0.21, 0.81], so neither the strict positivity assumption nor the conventional [0.05, 0.95] trimming bounds bind. The treated mean propensity (0.551) sits only 0.049 above the untreated mean (0.502) — a small but real gap that confirms the data are mildly &lt;em>confounded&lt;/em> rather than randomised. That is exactly the regime where doubly-robust methods are designed to outperform a naive baseline: confounding is real, but not so severe that any sensible adjustment will close the gap. Now that overlap is established, we can move on to the simplest possible estimator and watch it fail.&lt;/p>
&lt;h2 id="step-2--naive-baseline-difference-in-means">Step 2 — Naive baseline: difference-in-means&lt;/h2>
&lt;p>The simplest estimator of an average treatment effect is the difference of two sample means: average $Y$ for the treated, average $Y$ for the untreated, subtract. Under unconfoundedness with random assignment this would be unbiased; under unconfoundedness with &lt;em>observational&lt;/em> data it generally is not. We compute it here precisely so we can see the bias.&lt;/p>
&lt;pre>&lt;code class="language-python">y_treated = df.loc[df[&amp;quot;D&amp;quot;] == 1, &amp;quot;Y&amp;quot;].mean()
y_untreated = df.loc[df[&amp;quot;D&amp;quot;] == 0, &amp;quot;Y&amp;quot;].mean()
naive_ate = y_treated - y_untreated
n1, n0 = int((df[&amp;quot;D&amp;quot;] == 1).sum()), int((df[&amp;quot;D&amp;quot;] == 0).sum())
s1, s0 = df.loc[df[&amp;quot;D&amp;quot;] == 1, &amp;quot;Y&amp;quot;].var(ddof=1), df.loc[df[&amp;quot;D&amp;quot;] == 0, &amp;quot;Y&amp;quot;].var(ddof=1)
naive_se = float(np.sqrt(s1 / n1 + s0 / n0))
print(f&amp;quot;True ATE : 5.628&amp;quot;)
print(f&amp;quot;Naive estimate : {naive_ate:.3f} &amp;quot;
f&amp;quot;[95% CI {naive_ate - 1.96 * naive_se:.3f}, {naive_ate + 1.96 * naive_se:.3f}]&amp;quot;)
print(f&amp;quot;Bias : {naive_ate - 5.628:+.3f} months&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">True ATE : 5.628
Naive estimate : 5.111 [95% CI 4.926, 5.296]
Bias : -0.517 months
&lt;/code>&lt;/pre>
&lt;p>The naive difference-in-means delivers 5.111 months with a Welch-style 95% confidence interval of [4.93, 5.30]. Because we know the truth, we can see that the estimator is biased downward by 0.52 months — about 9.2% of the truth — and that its 95% CI &lt;strong>fails to cover&lt;/strong> the true ATE of 5.628. Why? Because in the synthetic DGP, caseworkers steer low-Dutch-proficiency jobseekers (those with the &lt;em>largest&lt;/em> treatment effects) into training, and those same jobseekers also have shorter prior employment and weaker employability. Their outcomes are pulled down by everything the covariates capture, and a simple comparison cannot disentangle the programme&amp;rsquo;s effect from the selection effect. This is a textbook illustration of confounding: &amp;ldquo;the programme seems to work less well than it really does&amp;rdquo; can be an artefact of who got selected into it.&lt;/p>
&lt;h2 id="step-3--ate-via-double-machine-learning">Step 3 — ATE via Double Machine Learning&lt;/h2>
&lt;p>&lt;a href="https://docs.doubleml.org/stable/api/generated/doubleml.DoubleMLIRM.html" target="_blank" rel="noopener">&lt;code>DoubleMLIRM&lt;/code>&lt;/a> implements the &lt;strong>Interactive Regression Model&lt;/strong> of &lt;a href="https://doi.org/10.1111/ectj.12097" target="_blank" rel="noopener">Chernozhukov et al. (2018)&lt;/a>: a cross-fitted, doubly-robust estimator of the ATE under unconfoundedness. Cross-fitting — splitting the data into folds and predicting each fold using nuisance models trained on the other folds — prevents the random forests from overfitting to their own training sample and contaminating the score. A useful analogy is grading homework: imagine assessing each student using a rubric calibrated on &lt;em>other&lt;/em> students' papers, never their own — that way the rubric cannot have been tailored to inflate any individual grade. The doubly-robust score is &lt;em>orthogonal&lt;/em> to small mistakes in either nuisance, which is what gives the estimator its $\sqrt{n}$ rate even when the nuisances are themselves slow-converging machine-learning fits.&lt;/p>
&lt;p>The Interactive Regression Model uses two nuisance functions: an outcome regression $g(d, X) = E[Y \mid D = d, X]$ and a propensity score $m(X) = P(D = 1 \mid X)$. The doubly-robust ATE score, evaluated at observation $i$, is&lt;/p>
&lt;p>$$\psi_i = g_1(X_i) - g_0(X_i) + \frac{D_i \, \bigl(Y_i - g_1(X_i)\bigr)}{m(X_i)} - \frac{(1 - D_i) \, \bigl(Y_i - g_0(X_i)\bigr)}{1 - m(X_i)}.$$&lt;/p>
&lt;p>In words, this says: start from the pure outcome-regression contrast $g_1 - g_0$, and then add a residual correction that weighs each observation by the inverse of its propensity. The clever bit is that $E[\psi_i] = \text{ATE}$ as long as &lt;em>either&lt;/em> $g$ &lt;em>or&lt;/em> $m$ is correctly specified — that is the &amp;ldquo;double&amp;rdquo; in &lt;em>doubly&lt;/em> robust. In code, $g_0(X_i)$ and $g_1(X_i)$ correspond to &lt;code>dml_irm.predictions[&amp;quot;ml_g0&amp;quot;]&lt;/code> and &lt;code>[&amp;quot;ml_g1&amp;quot;]&lt;/code>, $m(X_i)$ to &lt;code>[&amp;quot;ml_m&amp;quot;]&lt;/code>, $D_i$ to &lt;code>df[&amp;quot;D&amp;quot;]&lt;/code>, and $Y_i$ to &lt;code>df[&amp;quot;Y&amp;quot;]&lt;/code>.&lt;/p>
&lt;p>We fit DoubleML with random-forest nuisances and 5-fold cross-fitting, with &lt;code>trimming_threshold=0.01&lt;/code> to discard the (tiny) extreme tails of the propensity.&lt;/p>
&lt;pre>&lt;code class="language-python">dml_data = DoubleMLData(df, y_col=&amp;quot;Y&amp;quot;, d_cols=&amp;quot;D&amp;quot;, x_cols=X_COLS)
ml_g = RandomForestRegressor(n_estimators=200, max_features=&amp;quot;sqrt&amp;quot;,
min_samples_leaf=5, random_state=RANDOM_SEED, n_jobs=-1)
ml_m = RandomForestClassifier(n_estimators=200, max_features=&amp;quot;sqrt&amp;quot;,
min_samples_leaf=5, random_state=RANDOM_SEED, n_jobs=-1)
dml_irm = DoubleMLIRM(
dml_data, ml_g=ml_g, ml_m=ml_m,
n_folds=5, score=&amp;quot;ATE&amp;quot;, trimming_threshold=0.01,
)
dml_irm.fit(store_predictions=True)
ate_dml = float(dml_irm.coef[0])
se_dml = float(dml_irm.se[0])
ci = dml_irm.confint(level=0.95).iloc[0]
ci_low, ci_high = float(ci.iloc[0]), float(ci.iloc[1])
print(f&amp;quot;True ATE : 5.628&amp;quot;)
print(f&amp;quot;DoubleML ATE : {ate_dml:.3f} [95% CI {ci_low:.3f}, {ci_high:.3f}]&amp;quot;)
print(f&amp;quot;95% CI covers truth : {bool(ci_low &amp;lt;= 5.628 &amp;lt;= ci_high)}&amp;quot;)
print(f&amp;quot;Bias : {ate_dml - 5.628:+.3f} months&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">True ATE : 5.628
DoubleML ATE : 5.520 [95% CI 5.361, 5.680]
95% CI covers truth : True
Bias : -0.108 months
&lt;/code>&lt;/pre>
&lt;p>Once the random-forest nuisances absorb the dependence of both treatment assignment and the outcome on the covariates, the residual bias collapses from 0.517 to &lt;strong>0.108 months&lt;/strong> — about a 79% reduction — and the 95% CI [5.36, 5.68] now covers the true ATE. In substantive terms, the corrected estimate raises the implied programme effect from &amp;ldquo;about 5.1 extra months of employment&amp;rdquo; to &amp;ldquo;about 5.5 extra months&amp;rdquo; out of a 30-month window. The standard error also drops from 0.094 (naive) to 0.081, so DoubleML is not just less biased but also slightly &lt;em>more&lt;/em> precise — the cross-fitted nuisance models soak up outcome variance that the naive estimator leaves in the residual.&lt;/p>
&lt;h2 id="step-4--gate-by-dutch-proficiency">Step 4 — GATE by Dutch proficiency&lt;/h2>
&lt;p>The ATE answers &amp;ldquo;what is the average effect across the population?&amp;rdquo; — but a policymaker thinking about who to train wants the next layer down: &amp;ldquo;does the effect depend on who you are?&amp;rdquo;. The cleanest way to extract subgroup effects from a DoubleML fit is to compute the doubly-robust pseudo-outcome $\psi_i$ for every individual, and then &lt;em>average&lt;/em> it within each subgroup. This is the same $\psi_i$ as in the equation above; the trick is that $E[\psi_i \mid Z_i = z] = \text{GATE}(z)$, so a simple group-mean of the pseudo-outcomes is an unbiased estimator of the GATE.&lt;/p>
&lt;pre>&lt;code class="language-python">preds = dml_irm.predictions
g0 = np.asarray(preds[&amp;quot;ml_g0&amp;quot;]).squeeze()
g1 = np.asarray(preds[&amp;quot;ml_g1&amp;quot;]).squeeze()
m = np.asarray(preds[&amp;quot;ml_m&amp;quot;]).squeeze()
y_arr, d_arr = df[&amp;quot;Y&amp;quot;].values, df[&amp;quot;D&amp;quot;].values
psi = (g1 - g0
+ d_arr * (y_arr - g1) / m
- (1 - d_arr) * (y_arr - g0) / (1 - m))
rows = []
for z in [0, 1, 2, 3]:
mask = (df[&amp;quot;dutch_prof&amp;quot;] == z).values
psi_z = psi[mask]
est = psi_z.mean()
se = psi_z.std(ddof=1) / np.sqrt(mask.sum())
rows.append({&amp;quot;dutch_prof&amp;quot;: z, &amp;quot;n&amp;quot;: int(mask.sum()),
&amp;quot;gate_estimate&amp;quot;: est, &amp;quot;std_error&amp;quot;: se,
&amp;quot;ci_low&amp;quot;: est - 1.96 * se, &amp;quot;ci_high&amp;quot;: est + 1.96 * se})
gate_df = pd.DataFrame(rows)
print(gate_df.to_string(index=False, float_format=lambda v: f&amp;quot;{v:7.3f}&amp;quot;))
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> dutch_prof n gate_estimate std_error ci_low ci_high
0 1302 7.465 0.157 7.157 7.772
1 1469 6.127 0.140 5.852 6.402
2 1504 4.503 0.142 4.225 4.781
3 725 2.910 0.214 2.490 3.329
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="cml_gate_dutch.png" alt="Bar chart comparing the estimated GATE (steel blue) with the true GATE (warm orange) at each level of Dutch proficiency; both decline monotonically and the bars almost coincide.">&lt;/p>
&lt;p>Averaging the cross-fitted doubly-robust pseudo-outcomes within each Dutch-proficiency stratum recovers the monotone decline almost exactly: 7.47 / 6.13 / 4.50 / 2.91 estimated against 7.63 / 6.12 / 4.61 / 3.13 truth. Every estimate is within 0.22 months of its target, all four 95% confidence intervals cover their respective truths, and the ratio of the lowest-proficiency to highest-proficiency effect (≈ 2.6× under the estimates, 2.4× under the truths) lines up with the policy punchline of Cockx, Lechner &amp;amp; Bollens (2023): training delivers the biggest payoff to those who are furthest from the local-language labour market. As expected, standard errors widen for the smallest stratum (n = 725, SE 0.214) and tighten where data are densest (n = 1,504, SE 0.142). With clean group effects in hand, the natural next step is to push down to &lt;em>individual&lt;/em> effects.&lt;/p>
&lt;h2 id="step-5--iate-via-causal-forest-dml">Step 5 — IATE via Causal Forest DML&lt;/h2>
&lt;p>The GATE collapses every jobseeker in a Dutch-proficiency stratum into a single number. But two people with the same &lt;code>dutch_prof&lt;/code> value can still differ in age, education, prior employment, and migrant status, and the training programme might help them very differently. The &lt;strong>Individual Average Treatment Effect&lt;/strong> $\tau(x) = E[Y(1) - Y(0) \mid X = x]$ asks for a separate prediction at every covariate profile, and the &lt;a href="https://econml.azurewebsites.net/_autosummary/econml.dml.CausalForestDML.html" target="_blank" rel="noopener">&lt;code>CausalForestDML&lt;/code>&lt;/a> estimator from EconML — a Python implementation of the &lt;a href="https://doi.org/10.1214/18-AOS1709" target="_blank" rel="noopener">generalized random forest&lt;/a> framework of &lt;a href="https://doi.org/10.1214/18-AOS1709" target="_blank" rel="noopener">Athey, Tibshirani &amp;amp; Wager (2019)&lt;/a> — is one of the canonical ways to produce one. Think of a causal forest as a regular random forest, except the trees split on &lt;em>heterogeneity in the treatment effect&lt;/em> rather than on heterogeneity in the outcome — every leaf becomes a small neighbourhood within which the IATE is locally constant, and the forest averages many such trees together.&lt;/p>
&lt;pre>&lt;code class="language-python">cf = CausalForestDML(
model_y=RandomForestRegressor(n_estimators=200, min_samples_leaf=5,
random_state=RANDOM_SEED, n_jobs=-1),
model_t=RandomForestClassifier(n_estimators=200, min_samples_leaf=5,
random_state=RANDOM_SEED, n_jobs=-1),
discrete_treatment=True,
n_estimators=400, min_samples_leaf=15, max_samples=0.5,
random_state=RANDOM_SEED, n_jobs=-1,
)
X_arr = df[X_COLS].values
cf.fit(df[&amp;quot;Y&amp;quot;].values, df[&amp;quot;D&amp;quot;].values, X=X_arr)
iate_hat = np.asarray(cf.effect(X_arr)).ravel()
iate_low, iate_high = cf.effect_interval(X_arr, alpha=0.05)
mae = float(np.abs(iate_hat - truth[&amp;quot;tau&amp;quot;].values).mean())
corr = float(np.corrcoef(iate_hat, truth[&amp;quot;tau&amp;quot;].values)[0, 1])
print(f&amp;quot;True ATE : 5.628&amp;quot;)
print(f&amp;quot;Mean of estimated IATEs : {iate_hat.mean():.3f}&amp;quot;)
print(f&amp;quot;MAE(IATE, truth) : {mae:.3f}&amp;quot;)
print(f&amp;quot;Corr(IATE, truth) : {corr:.3f}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">True ATE : 5.628
Mean of estimated IATEs : 5.456
MAE(IATE, truth) : 0.397
Corr(IATE, truth) : 0.956
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="cml_iate_scatter.png" alt="Scatter plot of estimated IATE against true individual effect τ for all 5,000 jobseekers, with a 45° reference line; points cluster tightly along the diagonal.">&lt;/p>
&lt;p>The forest produces 5,000 individual-level effect estimates whose Pearson correlation with the &lt;em>true&lt;/em> individual effects is &lt;strong>0.956&lt;/strong> and whose mean absolute error is just &lt;strong>0.40 months&lt;/strong>. The mean of the estimated IATEs (5.456) is within 0.17 months of the true ATE (5.628) — so the forest is not only ranking individuals correctly (the policy-relevant property) but also broadly calibrated in level. The 0.4-month MAE is small relative to the 4.5-month spread of true effects across individuals, which means an assignment rule built on these estimates can hope to identify &lt;em>which&lt;/em> jobseekers benefit most from training, not just whether the average effect is positive.&lt;/p>
&lt;p>To check that the forest also recovers the GATE-style heterogeneity at the individual level, we look at the histogram of estimated IATEs split by Dutch proficiency.&lt;/p>
&lt;p>&lt;img src="cml_iate_distribution.png" alt="Histogram of estimated IATEs by Dutch proficiency (4 colours), with a dashed reference line at the true ATE of 5.63; distributions shift monotonically left as proficiency rises.">&lt;/p>
&lt;p>The four IATE distributions slide leftwards as Dutch proficiency rises — exactly the pattern the GATE bar chart showed at the group level — and their union centres on the true ATE. The forest is internally consistent with the GATE estimates, and the visible spread &lt;em>within&lt;/em> each colour shows that there is meaningful heterogeneity even among jobseekers who share the same &lt;code>dutch_prof&lt;/code> value.&lt;/p>
&lt;h2 id="step-6--method-comparison">Step 6 — Method comparison&lt;/h2>
&lt;p>We now have three estimators of the ATE and one ground truth. A forest plot puts them side by side and lets the reader judge bias and CI coverage at a glance.&lt;/p>
&lt;pre>&lt;code class="language-python">comp = pd.DataFrame({
&amp;quot;method&amp;quot;: [&amp;quot;Naive (DiM)&amp;quot;, &amp;quot;DoubleML (IRM)&amp;quot;,
&amp;quot;CausalForestDML (mean of IATEs)&amp;quot;, &amp;quot;Truth&amp;quot;],
&amp;quot;estimate&amp;quot;: [naive_ate, ate_dml, iate_hat.mean(), 5.628],
&amp;quot;ci_low&amp;quot;: [4.926, 5.361, iate_hat.mean() - 1.96 * iate_hat.std(ddof=1) / np.sqrt(len(iate_hat)), 5.628],
&amp;quot;ci_high&amp;quot;: [5.296, 5.680, iate_hat.mean() + 1.96 * iate_hat.std(ddof=1) / np.sqrt(len(iate_hat)), 5.628],
})
comp[&amp;quot;bias&amp;quot;] = comp[&amp;quot;estimate&amp;quot;] - 5.628
print(comp.to_string(index=False, float_format=lambda v: f&amp;quot;{v:7.3f}&amp;quot;))
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> method estimate ci_low ci_high bias
Naive (DiM) 5.111 4.926 5.296 -0.517
DoubleML (IRM) 5.520 5.361 5.680 -0.108
CausalForestDML (mean of IATEs) 5.456 5.416 5.497 -0.172
Truth 5.628 5.628 5.628 0.000
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="cml_method_comparison.png" alt="Forest plot of point estimates and 95% CIs for the Naive (gray), DoubleML (steel blue) and CausalForestDML mean-of-IATEs (teal) estimators, with the truth (orange star) and a dashed reference line at the true ATE of 5.628.">&lt;/p>
&lt;p>The forest plot tells the story in a single panel. The &lt;strong>naive&lt;/strong> interval [4.93, 5.30] sits entirely below the true ATE — visually obvious confounding bias. &lt;strong>DoubleML&amp;rsquo;s&lt;/strong> [5.36, 5.68] straddles the truth and is the only interval among the three that delivers correct coverage. The &lt;strong>CausalForestDML&lt;/strong> mean-of-IATEs interval [5.42, 5.50] is the &lt;em>tightest&lt;/em> of the three — it pools 5,000 individual estimates so the average is precisely pinned — but it is in fact slightly too narrow, and its upper bound of 5.50 sits 0.13 months below truth. The reason is methodological: this CI captures sampling uncertainty in the &lt;em>average of individual predictions&lt;/em>, not in the population ATE itself, so it does not pick up the small downward calibration bias of the forest as a whole. The practical takeaway is to prefer DoubleML when the question is &amp;ldquo;what is the ATE?&amp;rdquo; and reserve CausalForestDML for ranking and heterogeneity.&lt;/p>
&lt;h2 id="step-7--a-welfare-maximising-assignment-rule">Step 7 — A welfare-maximising assignment rule&lt;/h2>
&lt;p>The whole reason to estimate individual treatment effects, rather than stop at the average, is that they enable &lt;em>personalised&lt;/em> policy. Suppose training has a fixed cost equivalent to four months of employment per jobseeker. The welfare-optimal assignment rule is then trivial in principle: train person $i$ if and only if the &lt;em>true&lt;/em> effect $\tau_i$ exceeds the cost. We don&amp;rsquo;t know the truth in practice, so the obvious surrogate is to plug in the IATE estimate $\hat{\tau}_i$ from the causal forest.&lt;/p>
&lt;p>We benchmark four rules: treat &lt;em>no one&lt;/em>, treat &lt;em>everyone&lt;/em>, treat where $\hat{\tau}_i &amp;gt; 4$ (the IATE rule), and an &lt;em>oracle&lt;/em> that has access to the true $\tau_i$. Welfare under any rule is computed as&lt;/p>
&lt;p>$$W(\text{rule}) = E\bigl[\,\text{rule}(X) \cdot (\tau(X) - c)\,\bigr],$$&lt;/p>
&lt;p>where $c = 4$ months is the cost of training. In words, for every person the rule treats, we add their true treatment effect minus the cost; the welfare of a rule is the average of those net contributions across the cohort.&lt;/p>
&lt;pre>&lt;code class="language-python">COST = 4.0
assign_treat_none = np.zeros(len(df), dtype=int)
assign_treat_all = np.ones(len(df), dtype=int)
assign_iate_rule = (iate_hat &amp;gt; COST).astype(int)
assign_oracle = (truth[&amp;quot;tau&amp;quot;].values &amp;gt; COST).astype(int)
def welfare(rule, tau_true, cost):
return float((rule * (tau_true - cost)).mean())
policy = pd.DataFrame({
&amp;quot;rule&amp;quot;: [&amp;quot;Treat none&amp;quot;, &amp;quot;Treat all&amp;quot;,
&amp;quot;IATE rule (treat where iate_hat &amp;gt; cost)&amp;quot;,
&amp;quot;Oracle (treat where true tau &amp;gt; cost)&amp;quot;],
&amp;quot;share_treated&amp;quot;: [assign_treat_none.mean(), assign_treat_all.mean(),
assign_iate_rule.mean(), assign_oracle.mean()],
&amp;quot;avg_welfare&amp;quot;: [welfare(assign_treat_none, truth[&amp;quot;tau&amp;quot;].values, COST),
welfare(assign_treat_all, truth[&amp;quot;tau&amp;quot;].values, COST),
welfare(assign_iate_rule, truth[&amp;quot;tau&amp;quot;].values, COST),
welfare(assign_oracle, truth[&amp;quot;tau&amp;quot;].values, COST)],
})
print(policy.to_string(index=False, float_format=lambda v: f&amp;quot;{v:7.3f}&amp;quot;))
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> rule share_treated avg_welfare
Treat none 0.000 0.000
Treat all 1.000 1.628
IATE rule (treat where iate_hat &amp;gt; cost) 0.839 1.749
Oracle (treat where true tau &amp;gt; cost) 0.838 1.758
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="cml_policy_welfare.png" alt="Bar chart of average net welfare per individual under four rules: treat-none (0.00), treat-all (1.63), IATE rule (1.75), and oracle (1.76), with each bar annotated by the share of the cohort treated.">&lt;/p>
&lt;p>Once we have credible per-person effect estimates, the welfare comparison is striking. Holding training back from everyone yields zero net welfare. Treating everyone yields 1.63 months of net welfare per person — the ATE of 5.63 minus the cost of 4.0. Switching to a &lt;em>targeted&lt;/em> rule that trains only individuals with estimated IATE above the 4-month cost threshold treats 83.9% of the cohort — almost identical to the 83.8% the oracle would treat — and lifts welfare to &lt;strong>1.749 months per person, recovering 99.5% of the oracle&amp;rsquo;s 1.758-month welfare and beating treat-all by 7.4%&lt;/strong>. The IATE rule&amp;rsquo;s small remaining gap (just 0.009 months per person) reflects the 0.4-month MAE in the individual estimates: the rule occasionally treats a person it shouldn&amp;rsquo;t and skips a person it should, but those errors net out to a tiny welfare loss because the misranked individuals are concentrated near the cost cutoff where the welfare slope is shallow.&lt;/p>
&lt;h2 id="discussion">Discussion&lt;/h2>
&lt;p>We started with three questions. &lt;em>Does training cause more months of employment?&lt;/em> Yes — DoubleML estimates the ATE at 5.520 months [5.36, 5.68], and that 95% CI covers the true 5.628; the simpler naive comparison would have understated the effect by about half a month and produced a CI that misses the truth entirely. &lt;em>Does the effect depend on who the jobseeker is?&lt;/em> Strongly yes — the GATE declines monotonically from 7.47 months for jobseekers with no Dutch to 2.91 months for native speakers, a 2.6× ratio that is a real policy signal, not noise. &lt;em>Can we use those differences to assign training better?&lt;/em> Also yes — feeding the CausalForestDML&amp;rsquo;s IATE estimates into a simple &amp;ldquo;treat where $\hat{\tau}_i &amp;gt; c$&amp;rdquo; rule (with $c$ the per-jobseeker cost of training) captures 99.5% of the welfare an oracle would achieve and improves on treating everyone by 7.4%.&lt;/p>
&lt;p>The methodological discipline behind these answers is what separates CML from a &amp;ldquo;throw a random forest at it&amp;rdquo; approach. DoubleML&amp;rsquo;s cross-fitting and orthogonal scoring give the ATE estimator a $\sqrt{n}$ rate even with slow-converging machine-learning nuisances; the doubly-robust pseudo-outcome lets us reuse those nuisances for an internally consistent GATE without re-fitting; and the causal forest produces individual-level estimates that respect the same identification logic. A practitioner thinking about a real ALMP would now have a defensible answer to the question that matters most: not just &amp;ldquo;should we run this programme?&amp;rdquo; but &amp;ldquo;for whom?&amp;rdquo;.&lt;/p>
&lt;p>The case study also surfaces a subtle but important caveat about &lt;em>which&lt;/em> tool to use for &lt;em>which&lt;/em> question. The CausalForestDML mean-of-IATEs has the tightest 95% CI of any estimator in the comparison, but that interval is for the &lt;em>average of individual predictions&lt;/em>, not for the population ATE. Its upper bound (5.50) does not cover the truth (5.628), and treating it as a competitor to the DoubleML interval would be a methodological mistake. &lt;strong>DoubleML for the ATE; causal forest for ranking and heterogeneity&lt;/strong> — that is the operational division of labour the literature recommends and that this case study demonstrates concretely.&lt;/p>
&lt;h2 id="limitations-and-next-steps">Limitations and next steps&lt;/h2>
&lt;p>The result is encouraging but rests on assumptions that are worth flagging carefully:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Synthetic data with easy overlap.&lt;/strong> Estimated propensities are bounded inside [0.21, 0.81] by construction, so neither the DoubleML &lt;code>trimming_threshold = 0.01&lt;/code> nor the doubly-robust pseudo-outcome&amp;rsquo;s division by $m$ and $1 - m$ is stressed on these data. In a real ALMP cohort, propensities can drift toward 0 or 1, the doubly-robust score becomes sensitive to small denominators, and trimming choices matter much more than they appear to here.&lt;/li>
&lt;li>&lt;strong>Unconfoundedness.&lt;/strong> Every causal claim assumes selection-on-observables: conditional on the six covariates, treatment assignment is as good as random. The synthetic DGP satisfies this by construction; in a real application this is the strong identifying assumption that justifies DoubleML and CausalForestDML over a naive comparison.&lt;/li>
&lt;li>&lt;strong>Treatment share.&lt;/strong> The cohort has 52.8% treated, which is higher than typical real-world ALMP studies. The synthetic DGP is calibrated to keep overlap comfortable in every stratum, so readers should not over-interpret the &lt;em>magnitude&lt;/em> of effects.&lt;/li>
&lt;li>&lt;strong>Forest CI is not a substitute for the DoubleML CI.&lt;/strong> The CausalForestDML mean-of-IATEs interval misses the truth even though the forest is well-calibrated overall. Use it for heterogeneity, not for ATE inference.&lt;/li>
&lt;li>&lt;strong>Cost is fixed and known.&lt;/strong> The welfare comparison takes the four-month cost as given. In practice the cost of an ALMP intervention is itself uncertain and could vary across jobseekers (administrative cost, opportunity cost, displacement effects), and the optimal assignment rule should propagate that uncertainty.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Next steps&lt;/strong> to strengthen and extend the analysis:&lt;/p>
&lt;ul>
&lt;li>Replace the single Dutch-proficiency-based GATE with &lt;strong>policy trees&lt;/strong> (&lt;a href="https://doi.org/10.3982/ECTA15732" target="_blank" rel="noopener">Athey &amp;amp; Wager, 2021&lt;/a>), which learn the assignment rule directly from data rather than relying on a hand-picked stratification variable.&lt;/li>
&lt;li>Compare CausalForestDML against the &lt;strong>Modified Causal Forest (&lt;code>mcf&lt;/code>)&lt;/strong> package used in &lt;a href="https://doi.org/10.1016/j.labeco.2023.102306" target="_blank" rel="noopener">Cockx, Lechner &amp;amp; Bollens (2023)&lt;/a>, which targets exactly this setting.&lt;/li>
&lt;li>Stress-test overlap by drifting the propensity-score distribution toward 0 or 1 and re-running the full pipeline; observe how trimming choices and DR-score variance change.&lt;/li>
&lt;li>Extend to &lt;strong>multi-valued treatments&lt;/strong> (e.g., several training programmes) and use &lt;code>DoubleMLAPO&lt;/code> to estimate the average potential outcome for each arm.&lt;/li>
&lt;li>Run the doubly-robust pipeline on a &lt;strong>real ALMP dataset&lt;/strong> with weaker overlap and check whether the policy-relevant punchline (lower Dutch → larger benefit) survives outside the synthetic DGP.&lt;/li>
&lt;/ul>
&lt;h2 id="takeaways">Takeaways&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Naive difference-in-means is biased on observational data — visibly so.&lt;/strong> It estimates 5.111 months [4.93, 5.30] against a true ATE of 5.628, a 0.52-month downward bias whose 95% CI fails to cover the truth.&lt;/li>
&lt;li>&lt;strong>DoubleML closes 79% of the bias gap&lt;/strong> and delivers correct coverage. The IRM estimate of 5.520 [5.36, 5.68] both covers the true 5.628 and tightens the standard error from 0.094 (naive) to 0.081.&lt;/li>
&lt;li>&lt;strong>Effect heterogeneity by Dutch proficiency is real and policy-relevant.&lt;/strong> Estimated GATEs of 7.47 / 6.13 / 4.50 / 2.91 across levels 0–3 line up against truths 7.63 / 6.12 / 4.61 / 3.13, with all four 95% CIs covering their target.&lt;/li>
&lt;li>&lt;strong>CausalForestDML recovers the individual effect surface with 0.956 correlation and 0.40-month MAE&lt;/strong> — small relative to the 4.5-month spread of true effects across individuals.&lt;/li>
&lt;li>&lt;strong>A simple IATE-based assignment rule recovers 99.5% of oracle welfare&lt;/strong> (1.749 vs 1.758 months per person) and beats treat-all by 7.4% — the central practical reason to estimate individual effects in the first place.&lt;/li>
&lt;li>&lt;strong>CausalForestDML&amp;rsquo;s CI for the &lt;em>average&lt;/em> of IATEs is not a substitute for DoubleML&amp;rsquo;s CI for the ATE.&lt;/strong> The forest interval [5.42, 5.50] misses truth despite the forest being well-calibrated overall — a methodological subtlety worth remembering.&lt;/li>
&lt;li>&lt;strong>Easy overlap in this synthetic DGP is a feature of the case study, not a property of CML.&lt;/strong> Real-world ALMP applications will encounter tighter propensity bounds, and trimming will matter much more than it appears to here.&lt;/li>
&lt;li>&lt;strong>Next step.&lt;/strong> Replace the hand-picked Dutch-proficiency stratification with a learned policy tree to maximise welfare directly; compare CausalForestDML to the &lt;code>mcf&lt;/code> package on a real ALMP cohort.&lt;/li>
&lt;/ul>
&lt;h2 id="exercises">Exercises&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Change the cost.&lt;/strong> Re-run Step 7 with &lt;code>COST = 2.0&lt;/code> and &lt;code>COST = 6.0&lt;/code> months. How does the IATE rule&amp;rsquo;s share-treated change? At what cost does the rule converge to &amp;ldquo;treat all&amp;rdquo; or &amp;ldquo;treat none&amp;rdquo;, and does the welfare gap to the oracle widen or shrink?&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Swap the nuisance learner.&lt;/strong> Re-fit &lt;code>DoubleMLIRM&lt;/code> with &lt;code>LassoCV&lt;/code> for &lt;code>ml_g&lt;/code> and &lt;code>LogisticRegressionCV&lt;/code> for &lt;code>ml_m&lt;/code>. Does the ATE estimate change meaningfully? Does the 95% CI still cover the truth, and is the standard error smaller or larger than with random forests?&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Stress-test heterogeneity.&lt;/strong> Compute the IATE separately for $X$ profiles that differ &lt;em>only&lt;/em> in &lt;code>migrant&lt;/code> (holding the other five covariates at their median values). Does the &lt;code>CausalForestDML&lt;/code> predict a clear &lt;code>migrant&lt;/code> effect, and is it consistent with the GATE pattern by Dutch proficiency?&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="references">References&lt;/h2>
&lt;ol>
&lt;li>&lt;a href="https://doi.org/10.1186/s41937-023-00113-y" target="_blank" rel="noopener">Lechner, M. (2023). Causal Machine Learning and its use for public policy. &lt;em>Swiss Journal of Economics and Statistics&lt;/em>, 159(8).&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.1016/j.labeco.2023.102306" target="_blank" rel="noopener">Cockx, B., Lechner, M. &amp;amp; Bollens, J. (2023). Priority to unemployed immigrants? A causal machine learning evaluation of training in Belgium. &lt;em>Labour Economics&lt;/em>, 80, 102306.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.1111/ectj.12097" target="_blank" rel="noopener">Chernozhukov, V., Chetverikov, D., Demirer, M., Duflo, E., Hansen, C., Newey, W. &amp;amp; Robins, J. (2018). Double/debiased machine learning for treatment and structural parameters. &lt;em>The Econometrics Journal&lt;/em>, 21(1), C1–C68.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.1214/18-AOS1709" target="_blank" rel="noopener">Athey, S., Tibshirani, J. &amp;amp; Wager, S. (2019). Generalized random forests. &lt;em>Annals of Statistics&lt;/em>, 47(2), 1148–1178.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.3982/ECTA15732" target="_blank" rel="noopener">Athey, S. &amp;amp; Wager, S. (2021). Policy Learning with Observational Data. &lt;em>Econometrica&lt;/em>, 89(1), 133–161.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://docs.doubleml.org/" target="_blank" rel="noopener">DoubleML — Python Package for Double Machine Learning.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://econml.azurewebsites.net/" target="_blank" rel="noopener">EconML — Microsoft Research Python Package for Causal ML.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mcfpy.github.io/mcf/" target="_blank" rel="noopener">Modified Causal Forest (&lt;code>mcf&lt;/code>) — Python Package.&lt;/a>&lt;/li>
&lt;/ol>
&lt;h4 id="acknowledgements">Acknowledgements&lt;/h4>
&lt;p>AI tools (Claude Code, Gemini, NotebookLM) were used to make the contents of this post more accessible to students. Nevertheless, the content in this post may still have errors. Caution is needed when applying the contents of this post to true research projects.&lt;/p></description></item></channel></rss>