Accelerated Foundation in Applied Causal InferenceFor Economists · 15 modules · 45 lessons

8.3 Policy Learning: Who Should Be Treated?

about 2 hoursSocial services: optimal targeting of retraining stipends for the unemployed
Lesson 8.3 · Module 8
Policy Learning: Who Should Be Treated?
Video coming soon. The written lesson and the Colab notebook are ready below.
Colab notebook (.ipynb)
By the end of this lesson you can
  • ✓Turn CATE estimates into a treatment rule under a budget constraint
  • ✓Evaluate the value of a policy honestly with doubly robust scores on held-out data
  • ✓Learn a transparent, shallow policy tree that a ministry can apply
  • ✓Compare targeted, universal and random allocation in dollars
Lesson plan (120 minutes)
Written lesson

From effects to decisions

A policy π(x) says whom to treat. Its value is the average outcome if everyone followed it. With a budget that covers only 30% of applicants and a cost per stipend, the question becomes: which 30%?

The guiding rule is simple: treat whoever has the largest expected net benefit, τ(x) − cost, until the budget runs out. The hard part is evaluating a rule honestly, on data not used to build it.

Python · Colab cell
!pip install -q econml lightgbm scikit-learn matplotlib seaborn
import numpy as np, pandas as pd
from econml.dml import CausalForestDML
from econml.policy import DRPolicyTree
from lightgbm import LGBMRegressor, LGBMClassifier
from sklearn.model_selection import KFold
import matplotlib.pyplot as plt
rng = np.random.default_rng(12)
pd.set_option("display.width", 140)

Data, nuisance models and doubly robust scores

Python · Colab cell
def make_data(n):
    X = pd.DataFrame({
        "months_unemployed": rng.gamma(2, 4, n),
        "years_education": rng.integers(8, 21, n).astype(float),
        "age": rng.uniform(20, 60, n),
        "prior_earnings": rng.lognormal(10.3, 0.4, n) / 1_000,
    })
    tau = 500 + 250 * np.minimum(X.months_unemployed, 24) - 150 * (X.years_education - 12)
    p = 1 / (1 + np.exp(-(-0.5 + 0.05 * X.months_unemployed - 0.03 * (X.age - 40))))
    d = rng.binomial(1, p)
    y = 15_000 + 400 * X.prior_earnings + 300 * (X.years_education - 12) - 120 * X.months_unemployed \
        + d * tau + rng.normal(0, 3_000, n)
    return X, d, y.to_numpy(), tau.to_numpy()

X_train, d_train, y_train, _ = make_data(8_000)       # used to learn the policy
X_eval, d_eval, y_eval, tau_eval = make_data(4_000)   # used only to evaluate it
COST = 2_000                                          # cost of one stipend

To evaluate any policy on the evaluation sample, we need a credible estimate of each person's effect that does not depend on the policy model: the doubly robust (AIPW) score from Module 7.

Python · Colab cell
def dr_scores(X, d, y, k=5):
    mu1, mu0, e = np.zeros(len(y)), np.zeros(len(y)), np.zeros(len(y))
    for tr, te in KFold(k, shuffle=True, random_state=0).split(X):
        Xtr, dtr, ytr = X.iloc[tr], d[tr], y[tr]
        mu1[te] = LGBMRegressor(n_estimators=300, verbose=-1).fit(Xtr[dtr == 1], ytr[dtr == 1]).predict(X.iloc[te])
        mu0[te] = LGBMRegressor(n_estimators=300, verbose=-1).fit(Xtr[dtr == 0], ytr[dtr == 0]).predict(X.iloc[te])
        e[te] = LGBMClassifier(n_estimators=200, verbose=-1).fit(Xtr, dtr).predict_proba(X.iloc[te])[:, 1]
    e = e.clip(0.02, 0.98)
    return mu1 - mu0 + d * (y - mu1) / e - (1 - d) * (y - mu0) / (1 - e)

gamma = dr_scores(X_eval, d_eval, y_eval)
print(f"DR estimate of ATE on the evaluation sample: {gamma.mean():,.0f} (true {tau_eval.mean():,.0f})")

The value (net gain per applicant, relative to treating nobody) of any rule π is estimated by mean(π(x) · (γ − cost)).

Budget-constrained targeting

Python · Colab cell
cf = CausalForestDML(model_y=LGBMRegressor(n_estimators=300, verbose=-1),
                     model_t=LGBMClassifier(n_estimators=300, verbose=-1),
                     discrete_treatment=True, n_estimators=800, min_samples_leaf=20, random_state=0)
cf.fit(y_train, d_train, X=X_train)
cate_eval = cf.effect(X_eval)

def value(policy):                       # net dollars per applicant vs treating nobody
    return np.mean(policy * (gamma - COST))

budget = 0.30
rules = {
    "Treat nobody":            np.zeros(len(X_eval)),
    "Treat everyone":          np.ones(len(X_eval)),
    "Random 30%":              rng.binomial(1, budget, len(X_eval)),
    "Longest unemployed 30%":  (X_eval.months_unemployed >= X_eval.months_unemployed.quantile(1 - budget)).astype(int).to_numpy(),
    "Causal forest top 30%":   (cate_eval >= np.quantile(cate_eval, 1 - budget)).astype(int),
    "Oracle top 30%":          (tau_eval >= np.quantile(tau_eval, 1 - budget)).astype(int),
}
report = pd.DataFrame({
    "share treated": {k: v.mean() for k, v in rules.items()},
    "DR value ($/applicant)": {k: value(v) for k, v in rules.items()},
    "true value ($/applicant)": {k: np.mean(v * (tau_eval - COST)) for k, v in rules.items()},
}).round(2)
print(report)

"Treat everyone" may even lose money if many people's effect is below the cost. Targeting with the causal forest captures most of what a perfectly informed (oracle) planner would.

An interpretable policy tree

Ministries rarely deploy a black-box forest. A policy tree searches directly for a shallow decision tree that maximises the doubly robust value: "Offer the stipend if unemployed more than m months and education below e years." EconML's DRPolicyTree does this.

Python · Colab cell
tree = DRPolicyTree(
    max_depth=2, min_samples_leaf=200, random_state=0,
    model_regression=LGBMRegressor(n_estimators=300, verbose=-1),
    model_propensity=LGBMClassifier(n_estimators=200, verbose=-1),
)
# Encode the cost: treatment 'pays off' only if the effect exceeds COST
tree.fit(y_train - COST * d_train, d_train, X=X_train)
tree_policy = tree.predict(X_eval)
print(f"Policy tree treats {tree_policy.mean():.0%} | DR value {value(tree_policy):,.0f} $/applicant "
      f"| true value {np.mean(tree_policy * (tau_eval - COST)):,.0f}")
tree.plot(feature_names=list(X_train.columns), treatment_names=["No stipend", "Stipend"])
plt.show()
Key takeaway

Targeting is where causal inference turns into decision intelligence: the deliverable is not a coefficient but a rule, its expected dollar value and the evidence that it beats simple alternatives. In the capstone you will serve such a rule through an API (Module 15).

Exercise

Exercise

Plot the DR value of "causal forest top q%" for budgets q from 5% to 100%. Where does the value peak, and how does that relate to the share of people whose effect exceeds the cost?

Show a solution
Python · Colab cell
qs = np.linspace(0.05, 1.0, 20)
vals = [value((cate_eval >= np.quantile(cate_eval, 1 - q)).astype(int)) for q in qs]
plt.plot(qs, vals, marker="o"); plt.xlabel("Share treated"); plt.ylabel("DR value ($/applicant)")
plt.title("Value of targeting by budget"); plt.show()
print(f"Share with true effect above cost: {np.mean(tau_eval > COST):.2f}")

Checklist and recap

  • I can turn CATEs into a budget-constrained rule.
  • I evaluate rules with doubly robust scores on held-out data.
  • I can fit and explain a shallow policy tree.