| Time | Block |
|---|---|
| 0:00 – 0:40 | 54.1 The interactive regression model |
| 0:40 – 1:30 | 54.2 ATE and ATT with IRM |
| 1:30 – 1:45 | Break |
| 1:45 – 2:40 | 54.3 A confounder that was never stored, and a random holdout |
| 2:40 – 3:20 | 54.4 When there's nothing hidden |
| 3:20 – 3:35 | Break |
| 3:35 – 4:40 | 54.5 Day 54 mini-project: what do features buy an IV? |
| 4:40 – 5:00 | 54.6 Save your work with Git |
54.1 The interactive regression model (40 min)
Concept. The interactive regression model (IRM) drops PLR's constant-effect assumption: spend can depend on the coupon and the features in any way. Its nuisance functions are:
- g(0, X) and g(1, X): expected spend without and with a coupon, for customers with features X;
- m(X): the propensity score.
These are combined in the AIPW score of Day 36, now with ML models and cross-fitting. Everything you learned there still holds: the estimate is doubly robust; it targets the ATE (score="ATE") or the ATT (score="ATTE") directly; and propensities close to 0 or 1 make it noisy, so trimming_threshold clips them (Day 35).
54.2 ATE and ATT with IRM (50 min)
Create day54/irm.py:
# day54/irm.py (python -m day54.irm)
"""
The interactive regression model (IRM): no 'theta x coupon' restriction. Fit spend separately for
couponed and non-couponed customers, plus a propensity model, and combine them in the doubly robust
(AIPW) score of Day 36, now with machine-learning nuisances and cross-fitting.
Targets the ATE (score="ATE") or the ATT (score="ATTE") directly.
"""
import doubleml as dml
import numpy as np
from causalkit.coupons import X_COLS, coupons, public, truth
from causalkit.learners import clf, reg
np.random.seed(54) # DoubleML draws its cross-fitting folds from numpy's global RNG
full = coupons()
df = public(full).to_pandas()
t = truth(full)
data = dml.DoubleMLData(df[X_COLS + ["coupon", "spend"]], "spend", "coupon", X_COLS)
for score, target in [("ATE", t["ATE"]), ("ATTE", t["ATT"])]:
irm = dml.DoubleMLIRM(data, ml_g=reg(), ml_m=clf(), score=score, n_folds=5, n_rep=3,
trimming_threshold=0.02)
irm.fit()
lo, hi = irm.confint().iloc[0]
print(f"IRM {score:<4} {irm.coef[0]:6.2f} (SE {irm.se[0]:.2f}) 95% CI [{lo:.1f}, {hi:.1f}] truth {target:.2f}")
python -m day54.irm
IRM ATE 16.14 (SE 0.60) 95% CI [15.0, 17.3] truth 16.51
IRM ATTE 19.03 (SE 0.71) 95% CI [17.6, 20.4] truth 20.26
The ATE interval contains the truth. The ATT estimate is $1.2 low and its interval just misses the truth ($20.4 vs $20.26): the customers who got coupons include many with propensities near 1, where the non-couponed comparisons are scarce. With a well-targeted campaign, the ATT is always the harder estimand (Day 35's overlap lesson).
The answer to "what did the coupons we sent achieve?" is about $19–20 each, against an ATE of $16.5: the targeting model already sends coupons to customers who respond more than average.
54.3 A confounder that was never stored, and a random holdout (55 min)
The targeting model also used a real-time browsing signal (customers who had just browsed a lot were more likely to get a coupon) that was never written to the data warehouse. Browsers also spend more. That's an unobserved confounder, so IRM, like everything in Days 31–36, will be biased.
But last month, the marketing team held out a random half of customers: they were ineligible for coupons. Random eligibility is an instrument (Day 37). Ineligible customers can't get a coupon at all (one-sided non-compliance), so the compliers are exactly the customers the targeting model picks when eligible: the LATE is the effect for targeted customers.
DoubleML's interactive IV model (IIVM) is the IV version of IRM, with ML nuisance models for spend, for the coupon, and for eligibility. Create day54/hidden_and_iv.py:
# day54/hidden_and_iv.py (python -m day54.hidden_and_iv)
"""
The targeting model also used a real-time browsing signal that was never stored: an unobserved confounder.
But last month NorthMart held out a random half of customers (ineligible for coupons). Random eligibility
is an instrument (Day 37). DoubleML's interactive IV model (IIVM) estimates the LATE with ML nuisances.
With one-sided non-compliance (ineligible customers can't get a coupon), the compliers are exactly the
customers the targeting model picks, so the LATE is the effect for targeted customers.
"""
import doubleml as dml
import numpy as np
from causalkit.coupons import X_COLS, coupons, public, truth
from causalkit.learners import clf, reg
np.random.seed(54) # DoubleML draws its cross-fitting folds from numpy's global RNG
full = coupons(holdout=True, hidden=1.0)
df = public(full).to_pandas()
t = truth(full)
print(f"{df.eligible.mean():.0%} eligible; coupon rate {df.coupon[df.eligible == 1].mean():.0%} among eligible, "
f"{df.coupon[df.eligible == 0].mean():.0%} among ineligible")
irm = dml.DoubleMLIRM(dml.DoubleMLData(df[X_COLS + ["coupon", "spend"]], "spend", "coupon", X_COLS),
ml_g=reg(), ml_m=clf(), score="ATTE", n_folds=5, trimming_threshold=0.02).fit()
print(f"IRM ATT (assumes no hidden confounders): {irm.coef[0]:6.2f} (SE {irm.se[0]:.2f}) truth ATT {t['ATT']:.2f}")
wald = ((df.spend[df.eligible == 1].mean() - df.spend[df.eligible == 0].mean())
/ (df.coupon[df.eligible == 1].mean() - df.coupon[df.eligible == 0].mean()))
print(f"Wald estimate (no features): {wald:6.2f}")
iv_data = dml.DoubleMLData(df[X_COLS + ["eligible", "coupon", "spend"]], "spend", "coupon", X_COLS, z_cols="eligible")
iivm = dml.DoubleMLIIVM(iv_data, ml_g=reg(), ml_m=clf(), ml_r=clf(), n_folds=5, n_rep=3,
subgroups={"always_takers": False, "never_takers": True})
iivm.fit()
lo, hi = iivm.confint().iloc[0]
print(f"IIVM LATE: {iivm.coef[0]:6.2f} (SE {iivm.se[0]:.2f}) 95% CI [{lo:.1f}, {hi:.1f}]"
f" truth LATE {t['LATE']:.2f}")
python -m day54.hidden_and_iv
50% eligible; coupon rate 50% among eligible, 0% among ineligible
IRM ATT (assumes no hidden confounders): 35.62 (SE 0.79) truth ATT 19.70
Wald estimate (no features): 18.34
IIVM LATE: 19.60 (SE 1.35) 95% CI [17.0, 22.2] truth LATE 19.76
IRM reports $35.62 per coupon, 80% too high, with a tight interval. The browsing signal makes coupon recipients look like big responders. IIVM, using the random holdout, gets $19.60, within $0.20 of the truth. The subgroups argument tells IIVM there are no always-takers (no ineligible customer can get a coupon), which makes it more precise.
The holdout cost the marketing team half a month of coupons for half its customers. It bought an answer that doesn't depend on what was stored.
54.4 When there's nothing hidden (40 min)
Try it. Rerun with coupons(holdout=True, hidden=0.0): the same holdout, but no browsing signal. Compare IRM's ATT with IIVM's LATE. Create day54/no_hidden.py.
Solution
# day54/no_hidden.py (python -m day54.no_hidden)
import doubleml as dml
import numpy as np
from causalkit.coupons import X_COLS, coupons, public, truth
from causalkit.learners import clf, reg
full = coupons(holdout=True, hidden=0.0)
df = public(full).to_pandas()
t = truth(full)
np.random.seed(54)
irm = dml.DoubleMLIRM(dml.DoubleMLData(df[X_COLS + ["coupon", "spend"]], "spend", "coupon", X_COLS),
ml_g=reg(), ml_m=clf(), score="ATTE", n_folds=5, trimming_threshold=0.02).fit()
iivm = dml.DoubleMLIIVM(dml.DoubleMLData(df[X_COLS + ["eligible", "coupon", "spend"]], "spend", "coupon", X_COLS,
z_cols="eligible"), reg(), clf(), clf(), n_folds=5,
subgroups={"always_takers": False, "never_takers": True}).fit()
print(f"IRM ATT {irm.coef[0]:.2f} (SE {irm.se[0]:.2f}) truth {t['ATT']:.2f}")
print(f"IIVM LATE {iivm.coef[0]:.2f} (SE {iivm.se[0]:.2f}) truth {t['LATE']:.2f}")
IRM ATT 20.47 (SE 0.65) truth 20.20
IIVM LATE 21.44 (SE 1.04) truth 20.26
Both are right (IIVM is 1.2 SE high, within noise), and IRM is more precise. When nothing is hidden, the holdout is an expensive way to get a noisier answer, but you can only know nothing is hidden with a holdout to compare against. Comparing the two estimates is a check of the no-hidden-confounders assumption: they agree here and disagree by $16 in the previous section.
54.5 Day 54 mini-project: what do features buy an IV? (65 min)
The random holdout identifies the effect without any features (the Wald estimator). Features add precision, as CUPED did on Day 25. How much, and do you need ML for it? Create day54/iv_precision.py (about a minute):
# day54/iv_precision.py (python -m day54.iv_precision)
"""
Mini-project: the random holdout identifies the effect on its own (Wald / 2SLS without features).
What do the 24 features add? Same estimand, smaller standard error, like CUPED on Day 25.
Compare 2SLS without features, 2SLS with linear features, and IIVM with LightGBM, on 10 datasets.
"""
import doubleml as dml
import numpy as np
import polars as pl
from linearmodels.iv import IV2SLS
from causalkit.coupons import X_COLS, coupons, public, truth
from causalkit.learners import clf, reg
np.random.seed(54) # DoubleML draws its cross-fitting folds from numpy's global RNG
rows = {"2SLS, no features": [], "2SLS, linear features": [], "IIVM, LightGBM": []}
ses = {k: [] for k in rows}
for s in range(10):
full = coupons(seed=1500 + s, holdout=True, hidden=1.0)
df = public(full).to_pandas().assign(const=1.0)
late = truth(full)["LATE"]
for k, exog in [("2SLS, no features", ["const"]), ("2SLS, linear features", ["const"] + X_COLS)]:
fit = IV2SLS(df.spend, df[exog], df.coupon, df.eligible).fit(cov_type="robust")
rows[k].append(fit.params["coupon"] - late)
ses[k].append(fit.std_errors["coupon"])
data = dml.DoubleMLData(df[X_COLS + ["eligible", "coupon", "spend"]], "spend", "coupon", X_COLS, z_cols="eligible")
iivm = dml.DoubleMLIIVM(data, reg(), clf(), clf(), n_folds=5,
subgroups={"always_takers": False, "never_takers": True}).fit()
rows["IIVM, LightGBM"].append(iivm.coef[0] - late)
ses["IIVM, LightGBM"].append(iivm.se[0])
print(pl.DataFrame([{"estimator": k, "average error": round(np.mean(v), 2), "SD of error": round(np.std(v), 2),
"average SE": round(np.mean(ses[k]), 2)} for k, v in rows.items()]))
python -m day54.iv_precision
│ estimator ┆ average error ┆ SD of error ┆ average SE │
│ 2SLS, no features ┆ -0.7 ┆ 2.43 ┆ 1.94 │
│ 2SLS, linear features ┆ -0.18 ┆ 1.31 ┆ 1.34 │
│ IIVM, LightGBM ┆ -0.18 ┆ 1.27 ┆ 1.33 │
Features cut the standard error by about 30% (from $1.94 to $1.33), worth twice as many customers. Here linear features do as well as LightGBM: for precision in a randomised design, a linear adjustment captures most of the gain. ML earns its keep when it's needed to remove bias (Days 51–53), not just noise.
54.6 Save your work with Git (20 min)
git add .
git commit -m "Day 54: DoubleML IRM (ATE, ATT), hidden confounding, IIVM with a random holdout"
git push
Coming next (Days 55–57). One effect per question is not enough to decide who gets a coupon. Days 55–57 estimate an effect per customer, and show how to check whether those estimates mean anything.
Day 54 checklist
- I can fit DoubleMLIRM for the ATE and the ATT, with trimming
- I know that the ATT is harder to estimate when targeting is sharp
- I can use a random holdout as an instrument with DoubleMLIIVM
- I can compare IRM and IIVM to check for hidden confounding
- I know when features buy precision and when ML is needed