"""
The FWL Theorem: Making Multivariate Regressions Intuitive
Demonstrates the Frisch-Waugh-Lovell theorem using a simulated fast-food
chain: 50 restaurants, one per neighborhood, each hands out 100 coupons in a
single day and records how many come back during the month (the coupon
redemption rate). Redemption raises monthly sales but is confounded by
neighborhood income. An appendix block adds a June round of the same
promotion and applies FWL to the January + June panel.
Usage:
python script.py (figures are saved, never shown: no window opens)
Outputs (all written next to this file):
data/fwl_store_data.csv the seed-42 simulated January data (50 restaurants, 4 columns)
data/fwl_restaurant_panel.csv January + June panel (100 rows, restaurant_id, period)
fwl_results.json canonical numbers quoted in the post, slides, apps
web_app/data/results.json forest-plot rows, OVB identity and the 50 restaurants
(only when a web_app/data/ folder exists, so not
in the Quarto zip bundle)
fwl_*.png the five dark-theme figures used in the post
panel_plots/fwl_panel_*.html interactive Plotly FWL plots for the panel appendix
References:
- Frisch & Waugh (1933). Partial Time Regressions as Compared with
Individual Trends. Econometrica.
- Lovell (1963). Seasonal Adjustment of Economic Time Series and
Multiple Regression Analysis. JASA.
- Courthoud (2022). Understanding the Frisch-Waugh-Lovell Theorem
(originally "The FWL Theorem, Or How To Make All Regressions
Intuitive"). Towards Data Science.
"""
import json
import platform
from pathlib import Path
import numpy as np
import pandas as pd
import matplotlib
import matplotlib.pyplot as plt
import seaborn as sns
import statsmodels
import statsmodels.formula.api as smf
import expdpy as ex # analyze_fwl_plot for the panel appendix (pip install expdpy)
HERE = Path(__file__).resolve().parent
# Reproducibility
RANDOM_SEED = 42
np.random.seed(RANDOM_SEED)
# Site color palette
STEEL_BLUE = "#6a9bcc"
WARM_ORANGE = "#d97757"
NEAR_BLACK = "#141413"
TEAL = "#00d4c8"
# Dark theme palette (consistent with site navbar/dark sections)
DARK_NAVY = "#0f1729"
GRID_LINE = "#1f2b5e"
LIGHT_TEXT = "#c8d0e0"
WHITE_TEXT = "#e8ecf2"
# Plot defaults — minimal, spine-free, dark background
plt.rcParams.update({
"figure.facecolor": DARK_NAVY,
"axes.facecolor": DARK_NAVY,
"axes.edgecolor": DARK_NAVY,
"axes.linewidth": 0,
"axes.labelcolor": LIGHT_TEXT,
"axes.titlecolor": WHITE_TEXT,
"axes.spines.top": False,
"axes.spines.right": False,
"axes.spines.left": False,
"axes.spines.bottom": False,
"axes.grid": True,
"grid.color": GRID_LINE,
"grid.linewidth": 0.6,
"grid.alpha": 0.8,
"xtick.color": LIGHT_TEXT,
"ytick.color": LIGHT_TEXT,
"xtick.major.size": 0,
"ytick.major.size": 0,
"text.color": WHITE_TEXT,
"font.size": 12,
"legend.frameon": False,
"legend.fontsize": 11,
"legend.labelcolor": LIGHT_TEXT,
"figure.edgecolor": DARK_NAVY,
"savefig.facecolor": DARK_NAVY,
"savefig.edgecolor": DARK_NAVY,
})
# ── Data Generating Process ──────────────────────────────────────────
def simulate_store_data(n=50, seed=42):
"""Simulate one month of the restaurant chain, confounded by income.
The name is kept from the first edition so the CSV, the R and Stata
companions and the Colab links keep working.
True DGP:
income ~ N(50, 10)
dayofweek ~ Uniform{1, ..., 7} (generic day-of-week control)
coupons = 60 - 0.5 * income + N(0, 5) (% of 100 coupons redeemed)
sales = 10 + 0.2 * coupons + 0.3 * income + 0.5 * dayofweek + N(0, 3)
(monthly sales, thousands of dollars)
The true causal effect of coupons on sales is +0.2.
"""
rng = np.random.default_rng(seed)
income = rng.normal(50, 10, n)
dayofweek = rng.integers(1, 8, n)
coupons = 60 - 0.5 * income + rng.normal(0, 5, n)
sales = (10 + 0.2 * coupons + 0.3 * income
+ 0.5 * dayofweek + rng.normal(0, 3, n))
return pd.DataFrame({
"sales": np.round(sales, 2),
"coupons": np.round(coupons, 2),
"income": np.round(income, 2),
"dayofweek": dayofweek,
})
# ── Generate data ────────────────────────────────────────────────────
N = 50
df = simulate_store_data(n=N, seed=RANDOM_SEED)
# Export the raw dataset once, before any derived columns are added, so the
# R and Stata companions can reproduce every number in the post exactly.
DATA_DIR = HERE / "data"
DATA_DIR.mkdir(exist_ok=True)
CSV_PATH = DATA_DIR / "fwl_store_data.csv"
df.to_csv(CSV_PATH, index=False, lineterminator="\n")
pd.testing.assert_frame_equal(pd.read_csv(CSV_PATH), df, check_dtype=False)
print("Dataset shape:", df.shape)
print()
print(df.head())
print()
print(df.describe().round(2))
# ── Naive regression ─────────────────────────────────────────────────
naive_model = smf.ols("sales ~ coupons", df).fit()
print("\n=== Naive regression: sales ~ coupons ===")
print(naive_model.summary().tables[1])
# ── Multiple regression (controlling for income) ─────────────────────
full_model = smf.ols("sales ~ coupons + income", df).fit()
print("\n=== Full regression: sales ~ coupons + income ===")
print(full_model.summary().tables[1])
# ── FWL Step 1: residualize coupons only ─────────────────────────────
df["coupons_tilde"] = smf.ols("coupons ~ income", df).fit().resid
fwl_step1 = smf.ols("sales ~ coupons_tilde - 1", df).fit()
print("\n=== FWL Step 1: sales ~ coupons_tilde (no intercept) ===")
print(fwl_step1.summary().tables[1])
# ── FWL Step 2: residualize both ─────────────────────────────────────
df["sales_tilde"] = smf.ols("sales ~ income", df).fit().resid
fwl_step2 = smf.ols("sales_tilde ~ coupons_tilde - 1", df).fit()
print("\n=== FWL Step 2: sales_tilde ~ coupons_tilde (no intercept) ===")
print(fwl_step2.summary().tables[1])
# ── Multiple controls: income + dayofweek ────────────────────────────
full_model_2 = smf.ols("sales ~ coupons + income + dayofweek", df).fit()
print("\n=== Full regression with multiple controls ===")
print(full_model_2.summary().tables[1])
df["coupons_tilde_2"] = smf.ols("coupons ~ income + dayofweek", df).fit().resid
df["sales_tilde_2"] = smf.ols("sales ~ income + dayofweek", df).fit().resid
fwl_multi = smf.ols("sales_tilde_2 ~ coupons_tilde_2 - 1", df).fit()
print("\n=== FWL with multiple controls ===")
print(fwl_multi.summary().tables[1])
# Why adding dayofweek moves the coupon coefficient only from 0.2673 to 0.2706:
# the same OVB identity, one control up. The one-control coefficient equals the
# two-control one plus gamma_dow * delta_dow, where gamma_dow is dayofweek's
# coefficient in the two-control model and delta_dow is the coupons coefficient
# in dayofweek ~ coupons + income — the PARTIAL association of dayofweek with
# coupons once income is held fixed, not their raw correlation.
gamma_dow = full_model_2.params["dayofweek"]
delta_dow = smf.ols("dayofweek ~ coupons + income", df).fit().params["coupons"]
dow_tilde = smf.ols("dayofweek ~ income", df).fit().resid
pcorr_dow_coupons = np.corrcoef(dow_tilde, df["coupons_tilde"])[0, 1]
one_minus_two = full_model.params["coupons"] - full_model_2.params["coupons"]
print("\n=== Why dayofweek barely moves the coupon coefficient ===")
print(f"one control - two controls = {full_model.params['coupons']:.4f} - "
f"{full_model_2.params['coupons']:.4f} = {one_minus_two:.4f}")
print(f"gamma_dow x delta_dow = {gamma_dow:.4f} x {delta_dow:.4f} = "
f"{gamma_dow * delta_dow:.4f}")
print(f"partial corr(dayofweek, coupons | income) = {pcorr_dow_coupons:.3f}"
f" (raw corr {df['dayofweek'].corr(df['coupons']):.3f})")
# ── Scaled residuals ─────────────────────────────────────────────────
df["coupons_tilde_scaled"] = df["coupons_tilde"] + df["coupons"].mean()
df["sales_tilde_scaled"] = df["sales_tilde"] + df["sales"].mean()
scaled_model = smf.ols("sales_tilde_scaled ~ coupons_tilde_scaled", df).fit()
print("\n=== Scaled residuals regression ===")
print(scaled_model.summary().tables[1])
# ── Where the bias comes from: the omitted-variable-bias identity ──────
#
# naive = full + gamma_hat * delta_hat holds EXACTLY in the sample, where
# gamma_hat is income's coefficient in the full regression and delta_hat is
# the slope from regressing the omitted variable (income) ON coupons.
gamma_hat = full_model.params["income"]
ovb_aux = smf.ols("income ~ coupons", df).fit()
delta_hat = ovb_aux.params["coupons"]
wrong_dir = smf.ols("coupons ~ income", df).fit().params["income"]
ovb_hat = gamma_hat * delta_hat
naive_minus_full = naive_model.params["coupons"] - full_model.params["coupons"]
print("\n=== OVB identity ===")
print(f"gamma_hat x delta_hat = {gamma_hat:.4f} x {delta_hat:.4f} = {ovb_hat:.4f}")
print(f"naive - full = {naive_model.params['coupons']:.4f} - "
f"{full_model.params['coupons']:.4f} = {naive_minus_full:.4f}")
# ── Why Step 1's standard error explodes: the SE ladder ───────────────
step1_int = smf.ols("sales ~ coupons_tilde", df).fit()
step1_dm = smf.ols("I(sales - sales.mean()) ~ coupons_tilde - 1", df).fit()
se_ladder = {
"step1_no_intercept": fwl_step1.bse["coupons_tilde"],
"step1_with_intercept": step1_int.bse["coupons_tilde"],
"step1_demeaned_sales": step1_dm.bse["coupons_tilde"],
"step2_residualize_both": fwl_step2.bse["coupons_tilde"],
"full_model": full_model.bse["coupons"],
}
print("\n=== SE ladder (coefficient on coupons is 0.2673 in every row) ===")
for k, v in se_ladder.items():
print(f"{k:<24s} {v:.4f}")
gap = se_ladder["step1_no_intercept"] - se_ladder["full_model"]
share_intercept_levels = (se_ladder["step1_no_intercept"]
- se_ladder["step1_with_intercept"]) / gap
# Heteroskedasticity-robust SEs of the coupon coefficient, full model vs Step 2.
# HC0 matches exactly (same residuals, no df factor); HC1 differs by the same
# sqrt(49/47) as the classical SE; HC2/HC3 differ because the leverages differ.
robust_se = {}
for hc in ["HC0", "HC1", "HC2", "HC3"]:
se_full_hc = full_model.model.fit(cov_type=hc).bse["coupons"]
se_step2_hc = fwl_step2.model.fit(cov_type=hc).bse["coupons_tilde"]
robust_se[hc] = {"full_model": float(se_full_hc),
"step2_residualize_both": float(se_step2_hc),
"ratio_full_over_step2": float(se_full_hc / se_step2_hc)}
print("\n=== Robust SEs of the coupon coefficient: full model vs Step 2 ===")
for hc, v in robust_se.items():
print(f"{hc}: full {v['full_model']:.4f} Step 2 "
f"{v['step2_residualize_both']:.4f} ratio {v['ratio_full_over_step2']:.4f}")
# ── FWL by hand in NumPy ─────────────────────────────────────────────
y = df["sales"].to_numpy()
x1 = df["coupons"].to_numpy()
X2 = np.column_stack([np.ones(N), df["income"].to_numpy()])
c_tilde = x1 - X2 @ np.linalg.lstsq(X2, x1, rcond=None)[0]
s_tilde = y - X2 @ np.linalg.lstsq(X2, y, rcond=None)[0]
cov_sc = np.cov(s_tilde, c_tilde)[0, 1]
var_c = np.var(c_tilde, ddof=1)
beta_cov = cov_sc / var_c
M2 = np.eye(N) - X2 @ np.linalg.inv(X2.T @ X2) @ X2.T
beta_matrix = (x1 @ M2 @ y) / (x1 @ M2 @ x1)
print("\n=== FWL by hand ===")
print(f"Cov / Var = {cov_sc:.4f} / {var_c:.4f} = {beta_cov:.4f}")
print(f"Matrix form = {beta_matrix:.4f}")
# ── Invariants: the post's claims, checked on every run ──────────────
b_full = full_model.params["coupons"]
assert abs(fwl_step1.params["coupons_tilde"] - b_full) < 1e-12
assert abs(fwl_step2.params["coupons_tilde"] - b_full) < 1e-12
assert abs(step1_int.params["coupons_tilde"] - b_full) < 1e-12
assert abs(beta_cov - b_full) < 1e-12 and abs(beta_matrix - b_full) < 1e-12
assert abs(fwl_multi.params["coupons_tilde_2"]
- full_model_2.params["coupons"]) < 1e-12
assert abs(scaled_model.params["coupons_tilde_scaled"] - b_full) < 1e-12
assert abs(naive_minus_full - ovb_hat) < 1e-12
assert np.allclose(fwl_step2.resid.to_numpy(), full_model.resid.to_numpy())
assert np.allclose(M2 @ M2, M2) and np.allclose(M2, M2.T)
assert abs(se_ladder["step2_residualize_both"] / se_ladder["full_model"]
- np.sqrt(47 / 49)) < 1e-12
assert abs(one_minus_two - gamma_dow * delta_dow) < 1e-12 # dayofweek identity
assert abs(robust_se["HC0"]["ratio_full_over_step2"] - 1) < 1e-10
assert abs(robust_se["HC1"]["ratio_full_over_step2"] - np.sqrt(49 / 47)) < 1e-10
# ── Canonical results ────────────────────────────────────────────────
def summarize(model, term, label, n_controls):
ci = model.conf_int().loc[term]
return {
"method": label,
"coef": float(model.params[term]),
"se": float(model.bse[term]),
"t": float(model.tvalues[term]),
"p": float(model.pvalues[term]),
"ci_lo": float(ci[0]),
"ci_hi": float(ci[1]),
"df_resid": int(model.df_resid),
"n_controls": n_controls,
}
MODELS = [
summarize(naive_model, "coupons", "Naive OLS (no controls)", 0),
summarize(full_model, "coupons", "Full OLS (+ income)", 1),
summarize(fwl_step1, "coupons_tilde", "FWL Step 1 (residualize X only)", 1),
summarize(step1_int, "coupons_tilde", "FWL Step 1 + intercept", 1),
summarize(fwl_step2, "coupons_tilde", "FWL Step 2 (residualize both)", 1),
summarize(full_model_2, "coupons", "Full OLS (+ income + day)", 2),
summarize(fwl_multi, "coupons_tilde_2", "FWL (+ income + day)", 2),
summarize(scaled_model, "coupons_tilde_scaled", "Scaled residuals", 1),
]
def r10(obj):
"""Round every float to 10 decimals so the JSON is byte-stable."""
if isinstance(obj, float):
return round(obj, 10)
if isinstance(obj, dict):
return {k: r10(v) for k, v in obj.items()}
if isinstance(obj, list):
return [r10(v) for v in obj]
return obj
RESULTS = r10({
"_comment": "Canonical numbers for python_fwl. Generated by script.py; do not edit by hand.",
"seed": RANDOM_SEED,
"n": N,
"true_effect": 0.2,
"dgp": {"income_mean": 50, "income_sd": 10, "coupons_intercept": 60,
"coupons_on_income": -0.5, "coupons_noise_sd": 5,
"sales_intercept": 10, "sales_on_coupons": 0.2,
"sales_on_income": 0.3, "sales_on_dayofweek": 0.5,
"sales_noise_sd": 3},
"describe": {c: {s: float(v) for s, v in df[c].describe().items()}
for c in ["sales", "coupons", "income", "dayofweek"]},
"models": MODELS,
"full_income_coef": float(gamma_hat),
"full_income_se": float(full_model.bse["income"]),
"full_income_p": float(full_model.pvalues["income"]),
"full2_income_coef": float(full_model_2.params["income"]),
"full2_dayofweek_coef": float(full_model_2.params["dayofweek"]),
"full2_dayofweek_p": float(full_model_2.pvalues["dayofweek"]),
"ovb": {
"gamma_hat": float(gamma_hat),
"delta_hat_income_on_coupons": float(delta_hat),
"product": float(ovb_hat),
"naive_minus_full": float(naive_minus_full),
"wrong_direction_coupons_on_income": float(wrong_dir),
"wrong_direction_product": float(gamma_hat * wrong_dir),
"population_delta": -1.0,
"population_naive_plim": 0.2 + 0.3 * -1.0,
},
"se_ladder": {k: float(v) for k, v in se_ladder.items()},
"se_ladder_share_closed_by_intercept_levels": float(share_intercept_levels),
"se_ratio_step2_over_full": float(se_ladder["step2_residualize_both"]
/ se_ladder["full_model"]),
"sqrt_47_over_49": float(np.sqrt(47 / 49)),
"by_hand": {"cov": float(cov_sc), "var": float(var_c),
"beta_cov_over_var": float(beta_cov),
"beta_matrix": float(beta_matrix)},
"correlations": {
"coupons_tilde_income": float(np.corrcoef(c_tilde, df["income"])[0, 1]),
"dayofweek_coupons": float(np.corrcoef(df["dayofweek"], df["coupons"])[0, 1]),
},
"extra": {
"dayofweek_decomposition": {
"_comment": ("OVB identity one control up: full coef (+ income) = "
"full2 coef (+ income + day) + gamma_dow * delta_dow_partial. "
"delta_dow_partial is the coupons coefficient in "
"dayofweek ~ coupons + income (partial, not raw, association). "
"shift = gamma_dow * delta_dow_partial = one-control minus "
"two-control coefficient, so the coefficient RISES by -shift "
"when dayofweek is added."),
"coef_one_control": float(full_model.params["coupons"]),
"coef_two_controls": float(full_model_2.params["coupons"]),
"gamma_dow": float(gamma_dow),
"delta_dow_partial": float(delta_dow),
"partial_corr_dow_coupons_given_income": float(pcorr_dow_coupons),
"shift": float(gamma_dow * delta_dow),
"one_control_minus_two_controls": float(one_minus_two),
"identity_holds": bool(abs(one_minus_two - gamma_dow * delta_dow) < 1e-12),
},
"robust_se_coupons": robust_se,
},
"versions": {"python": platform.python_version(), "numpy": np.__version__,
"pandas": pd.__version__, "statsmodels": statsmodels.__version__,
"matplotlib": matplotlib.__version__, "seaborn": sns.__version__,
"expdpy": ex.__version__},
})
with open(HERE / "fwl_results.json", "w") as f:
json.dump(RESULTS, f, indent=2)
f.write("\n")
# Web app payload: forest-plot rows (display precision), the OVB identity,
# the SE ladder, and the 50 restaurants so the app's animation and quiz use the
# post's own data. The app binds every number it prints to these fields.
WEB_ROWS = [m for m in MODELS if m["method"] != "Scaled residuals"]
SCALED = next(m for m in MODELS if m["method"] == "Scaled residuals")
WEB = {
"_comment": "Generated by ../../script.py from the seed-42 restaurant data (January); do not edit by hand.",
"true_effect": 0.2,
"n_obs": N,
"estimates": [{
"method": m["method"], "outcome": "Monthly sales",
"estimate": round(m["coef"], 4), "se": round(m["se"], 4),
"ci_lo": round(m["ci_lo"], 3), "ci_hi": round(m["ci_hi"], 3),
"p": round(m["p"], 3), "df_resid": m["df_resid"],
"n_controls": m["n_controls"]} for m in WEB_ROWS],
"scaled_residuals": {"estimate": round(SCALED["coef"], 4),
"se": round(SCALED["se"], 4)},
"ovb": {"gamma_hat": round(float(gamma_hat), 4),
"delta_hat": round(float(delta_hat), 4),
"product": round(float(ovb_hat), 4),
"naive_minus_full": round(float(naive_minus_full), 4),
"wrong_direction_coupons_on_income": round(float(wrong_dir), 4),
"wrong_direction_product": round(float(gamma_hat * wrong_dir), 4),
"population_delta": -1.0,
"population_naive_plim": round(0.2 + 0.3 * -1.0, 4)},
"se_ladder": {k: round(float(v), 4) for k, v in se_ladder.items()},
"se_ratio_step2_over_full": round(float(se_ladder["step2_residualize_both"]
/ se_ladder["full_model"]), 4),
"sample": {c: [float(v) for v in df[c]] for c in
["sales", "coupons", "income", "dayofweek"]},
}
saved = ["data/fwl_store_data.csv", "fwl_results.json"]
if (HERE / "web_app" / "data").is_dir(): # absent in the Quarto zip bundle
with open(HERE / "web_app" / "data" / "results.json", "w") as f:
json.dump(WEB, f, indent=2)
f.write("\n")
saved.append("web_app/data/results.json")
print("\nSaved: " + ", ".join(saved))
# ── Predicted values for residual visualization ──────────────────────
df["coupons_hat"] = smf.ols("coupons ~ income", df).fit().predict()
# ── Auxiliary regressions for annotations ───────────────────────────
coupons_on_income = smf.ols("coupons ~ income", df).fit()
def annotate_eq(ax, model, xname="x", yname="y", loc="lower left",
no_intercept=False):
"""Add equation and R² annotation to an axes."""
if no_intercept:
b = model.params.iloc[0]
sign = "+" if b >= 0 else "−"
eq = f"{yname} = {sign}{abs(b):.2f}{xname}"
else:
b0 = model.params["Intercept"]
b1 = model.params.iloc[1]
sign = "+" if b1 >= 0 else "−"
eq = f"{yname} = {b0:.2f} {sign} {abs(b1):.2f}{xname}"
r2 = model.rsquared
txt = f"{eq}\n$R^2$ = {r2:.3f}"
# Position mapping
coords = {
"lower left": (0.05, 0.05, "left", "bottom"),
"lower right": (0.95, 0.05, "right", "bottom"),
"upper left": (0.05, 0.95, "left", "top"),
"upper right": (0.95, 0.95, "right", "top"),
}
x, y, ha, va = coords[loc]
ax.text(x, y, txt, transform=ax.transAxes, fontsize=11,
color=WARM_ORANGE, ha=ha, va=va, linespacing=1.5)
# ═══════════════════════════════════════════════════════════════════════
# FIGURES
# ═══════════════════════════════════════════════════════════════════════
# ── Figure 1: Naive regression ───────────────────────────────────────
fig, ax = plt.subplots(figsize=(8, 6))
fig.patch.set_linewidth(0)
ax.scatter(df["coupons"], df["sales"], color=STEEL_BLUE, alpha=0.75,
edgecolors=DARK_NAVY, s=80, linewidths=0.8, zorder=3,
label="Restaurants")
sns.regplot(x="coupons", y="sales", data=df, ci=None, scatter=False,
line_kws={"color": WARM_ORANGE, "linewidth": 2.5,
"label": "Linear fit", "zorder": 2},
ax=ax)
ax.set_xlabel("Coupon redemption rate (%)")
ax.set_ylabel("Monthly sales (thousands $)")
ax.set_title("Naive relationship: Sales vs. coupon redemption",
fontsize=14, fontweight="bold", color=WHITE_TEXT, pad=12)
annotate_eq(ax, naive_model, xname="x", loc="lower left")
ax.legend(loc="upper right")
plt.tight_layout()
plt.savefig(HERE / "fwl_naive_regression.png", dpi=300, bbox_inches="tight",
facecolor=DARK_NAVY, edgecolor=DARK_NAVY, pad_inches=0)
plt.close(fig)
print("Saved: fwl_naive_regression.png")
# ── Figure 2: Residuals visualization ────────────────────────────────
fig, ax = plt.subplots(figsize=(8, 6))
fig.patch.set_linewidth(0)
ax.vlines(df["income"],
np.minimum(df["coupons"], df["coupons_hat"]),
np.maximum(df["coupons"], df["coupons_hat"]),
linestyle="--", color=LIGHT_TEXT, alpha=0.4, linewidth=0.9,
label="Residuals", zorder=1)
ax.scatter(df["income"], df["coupons"], color=STEEL_BLUE, alpha=0.75,
edgecolors=DARK_NAVY, s=80, linewidths=0.8, zorder=3,
label="Restaurants")
sns.regplot(x="income", y="coupons", data=df, ci=None, scatter=False,
line_kws={"color": WARM_ORANGE, "linewidth": 2.5,
"label": "Linear fit", "zorder": 2},
ax=ax)
ax.set_xlabel("Neighborhood income (thousands $)")
ax.set_ylabel("Coupon redemption rate (%)")
ax.set_title("Partialling-out: removing income's effect on coupon redemption",
fontsize=14, fontweight="bold", color=WHITE_TEXT, pad=12)
annotate_eq(ax, coupons_on_income, xname="x", loc="lower left")
ax.legend(loc="upper right")
plt.tight_layout()
plt.savefig(HERE / "fwl_residuals_income.png", dpi=300, bbox_inches="tight",
facecolor=DARK_NAVY, edgecolor=DARK_NAVY, pad_inches=0)
plt.close(fig)
print("Saved: fwl_residuals_income.png")
# ── Figure 3: Partialled-out relationship ────────────────────────────
fig, ax = plt.subplots(figsize=(8, 6))
fig.patch.set_linewidth(0)
ax.scatter(df["coupons_tilde"], df["sales_tilde"], color=STEEL_BLUE,
alpha=0.75, edgecolors=DARK_NAVY, s=80, linewidths=0.8, zorder=3,
label="Restaurants (residualized)")
sns.regplot(x="coupons_tilde", y="sales_tilde", data=df, ci=None,
scatter=False,
line_kws={"color": WARM_ORANGE, "linewidth": 2.5,
"label": "Linear fit", "zorder": 2},
ax=ax)
ax.set_xlabel("Residual coupon redemption rate")
ax.set_ylabel("Residual monthly sales")
ax.set_title("Conditional relationship after partialling-out income",
fontsize=14, fontweight="bold", color=WHITE_TEXT, pad=12)
# Use model with intercept for proper R²
_resid_model = smf.ols("sales_tilde ~ coupons_tilde", df).fit()
annotate_eq(ax, _resid_model, xname="x", loc="lower right")
ax.legend(loc="upper left")
plt.tight_layout()
plt.savefig(HERE / "fwl_partialled_out.png", dpi=300, bbox_inches="tight",
facecolor=DARK_NAVY, edgecolor=DARK_NAVY, pad_inches=0)
plt.close(fig)
print("Saved: fwl_partialled_out.png")
# ── Figure 4: Scaled residuals ───────────────────────────────────────
fig, ax = plt.subplots(figsize=(8, 6))
fig.patch.set_linewidth(0)
ax.scatter(df["coupons_tilde_scaled"], df["sales_tilde_scaled"],
color=STEEL_BLUE, alpha=0.75, edgecolors=DARK_NAVY, s=80,
linewidths=0.8, zorder=3,
label="Restaurants (residualized + scaled)")
sns.regplot(x="coupons_tilde_scaled", y="sales_tilde_scaled", data=df,
ci=None, scatter=False,
line_kws={"color": WARM_ORANGE, "linewidth": 2.5,
"label": "Linear fit", "zorder": 2},
ax=ax)
ax.set_xlabel("Coupon redemption rate (%, residualized + mean)")
ax.set_ylabel("Monthly sales (thousands $, residualized + mean)")
ax.set_title("Scaled residuals: interpretable magnitudes",
fontsize=14, fontweight="bold", color=WHITE_TEXT, pad=12)
annotate_eq(ax, scaled_model, xname="x", loc="lower right")
ax.legend(loc="upper left")
plt.tight_layout()
plt.savefig(HERE / "fwl_scaled_residuals.png", dpi=300, bbox_inches="tight",
facecolor=DARK_NAVY, edgecolor=DARK_NAVY, pad_inches=0)
plt.close(fig)
print("Saved: fwl_scaled_residuals.png")
# ── Figure 5: Side-by-side comparison ────────────────────────────────
fig, axes = plt.subplots(1, 2, figsize=(14, 6))
fig.patch.set_linewidth(0)
# Left panel: naive
axes[0].scatter(df["coupons"], df["sales"], color=STEEL_BLUE, alpha=0.75,
edgecolors=DARK_NAVY, s=80, linewidths=0.8, zorder=3)
sns.regplot(x="coupons", y="sales", data=df, ci=None, scatter=False,
line_kws={"color": WARM_ORANGE, "linewidth": 2.5, "zorder": 2},
ax=axes[0])
axes[0].set_xlabel("Coupon redemption rate (%)")
axes[0].set_ylabel("Monthly sales (thousands $)")
axes[0].set_title("Naive (no controls)", fontsize=13, fontweight="bold",
color=WHITE_TEXT, pad=10)
annotate_eq(axes[0], naive_model, xname="x", loc="lower left")
# Right panel: partialled-out (scaled)
axes[1].scatter(df["coupons_tilde_scaled"], df["sales_tilde_scaled"],
color=TEAL, alpha=0.75, edgecolors=DARK_NAVY, s=80,
linewidths=0.8, zorder=3)
sns.regplot(x="coupons_tilde_scaled", y="sales_tilde_scaled", data=df,
ci=None, scatter=False,
line_kws={"color": WARM_ORANGE, "linewidth": 2.5, "zorder": 2},
ax=axes[1])
axes[1].set_xlabel("Coupon redemption rate (%, after partialling-out)")
axes[1].set_ylabel("Monthly sales (thousands $, after partialling-out)")
axes[1].set_title("After partialling-out income (FWL)", fontsize=13,
fontweight="bold", color=WHITE_TEXT, pad=10)
annotate_eq(axes[1], scaled_model, xname="x", loc="lower right")
plt.suptitle("Simpson's paradox resolved: the FWL theorem reveals the conditional relationship",
fontsize=14, fontweight="bold", color=WHITE_TEXT, y=1.02)
plt.tight_layout()
plt.savefig(HERE / "fwl_comparison.png", dpi=300, bbox_inches="tight",
facecolor=DARK_NAVY, edgecolor=DARK_NAVY, pad_inches=0)
plt.close(fig)
print("Saved: fwl_comparison.png")
print("\n=== All figures generated successfully ===")
# ═══════════════════════════════════════════════════════════════════════
# APPENDIX: FWL WITH PANEL DATA (January + June)
# ═══════════════════════════════════════════════════════════════════════
#
# The chain runs the same promotion twice. January is the main-body sample,
# reproduced draw for draw. June continues the SAME random stream, so the
# whole panel is pinned by one seed.
def simulate_restaurant_panel(n=50, seed=42, season=3.0, income_shock_sd=2.0,
trait_to_coupons=1.0):
"""Simulate the 50 restaurants in January (period 1) and June (period 2).
January replays simulate_store_data() exactly: same draws, same order.
Its sales noise alpha ~ N(0, 3) is read as a persistent restaurant trait
(location, foot traffic) that raises sales in BOTH months.
June:
income_J = income_Jan + N(0, income_shock_sd)
dayofweek_J ~ Uniform{1, ..., 7}
coupons_J = 60 - 0.5 * income_J + trait_to_coupons * alpha + N(0, 4)
sales_J = 10 + season + 0.2 * coupons_J + 0.3 * income_J
+ 0.5 * dayofweek_J + alpha + N(0, 1.5)
In June the coupons are handed out at the counter, so busier restaurants
(high alpha) redeem more of them. The trait then confounds coupons -> sales
in the pooled data; restaurant fixed effects remove it. The true coupon
effect is still +0.2 in both months.
"""
rng = np.random.default_rng(seed)
# January: identical draw order to simulate_store_data()
income = rng.normal(50, 10, n)
dayofweek = rng.integers(1, 8, n)
coupons = 60 - 0.5 * income + rng.normal(0, 5, n)
alpha = rng.normal(0, 3, n) # persistent restaurant trait
sales = 10 + 0.2 * coupons + 0.3 * income + 0.5 * dayofweek + alpha
# June: the same stream continues
income_j = income + rng.normal(0, income_shock_sd, n)
dayofweek_j = rng.integers(1, 8, n)
coupons_j = (60 - 0.5 * income_j + trait_to_coupons * alpha
+ rng.normal(0, 4, n))
sales_j = (10 + season + 0.2 * coupons_j + 0.3 * income_j
+ 0.5 * dayofweek_j + alpha + rng.normal(0, 1.5, n))
def month(period, s, c, i, d):
return pd.DataFrame({
"restaurant_id": np.arange(1, n + 1),
"period": period,
"sales": np.round(s, 2),
"coupons": np.round(c, 2),
"income": np.round(i, 2),
"dayofweek": d,
})
return pd.concat([month(1, sales, coupons, income, dayofweek),
month(2, sales_j, coupons_j, income_j, dayofweek_j)],
ignore_index=True)
panel = simulate_restaurant_panel(n=N, seed=RANDOM_SEED)
PANEL_COLS = ["sales", "coupons", "income", "dayofweek"]
# January must be the main-body sample, value for value
january = panel.loc[panel["period"] == 1, PANEL_COLS].reset_index(drop=True)
pd.testing.assert_frame_equal(january, df[PANEL_COLS], check_dtype=False)
PANEL_CSV = DATA_DIR / "fwl_restaurant_panel.csv"
panel.to_csv(PANEL_CSV, index=False, lineterminator="\n")
pd.testing.assert_frame_equal(pd.read_csv(PANEL_CSV), panel, check_dtype=False)
print("\n=== Appendix: January + June panel ===")
print("Panel shape:", panel.shape)
print(panel.groupby("period")[["sales", "coupons", "income"]].mean().round(2))
# ── Pooled OLS: one regression on all 100 rows ───────────────────────
CL = {"cov_type": "cluster", "cov_kwds": {"groups": panel["restaurant_id"]}}
pooled = smf.ols("sales ~ coupons + income + C(period)", panel).fit(**CL)
print("\n=== Pooled OLS: sales ~ coupons + income + C(period) ===")
print(pooled.summary().tables[1])
# ── Restaurant fixed effects: dummies vs demeaning vs analyze_fwl_plot ─
fe_lsdv = smf.ols("sales ~ coupons + income + C(restaurant_id)", panel).fit(**CL)
cols = ["sales", "coupons", "income"]
within = panel[cols] - panel.groupby("restaurant_id")[cols].transform("mean")
fe_demeaned = smf.ols("sales ~ coupons + income - 1", within).fit()
fwl_fe = ex.analyze_fwl_plot(panel, dv="sales", var="coupons",
controls="income", feffects="restaurant_id",
clusters="restaurant_id", n_sample=None)
print("\n=== Restaurant FE: three routes to one coefficient ===")
print(f"Dummies, C(restaurant_id): {fe_lsdv.params['coupons']:.4f}")
print(f"Demeaned within restaurant: {fe_demeaned.params['coupons']:.4f}")
print(f"analyze_fwl_plot: {fwl_fe.slope:.4f} (clustered SE {fwl_fe.se:.4f})")
# ── Two-way fixed effects: restaurant + period ───────────────────────
twfe_lsdv = smf.ols("sales ~ coupons + income + C(restaurant_id) + C(period)",
panel).fit(**CL)
fwl_twfe = ex.analyze_fwl_plot(panel, dv="sales", var="coupons",
controls="income",
feffects=["restaurant_id", "period"],
clusters="restaurant_id", n_sample=None)
print("\n=== Two-way FE: restaurant + period ===")
print(f"Dummies, C(restaurant_id) + C(period): {twfe_lsdv.params['coupons']:.4f}"
f" (statsmodels clustered SE {twfe_lsdv.bse['coupons']:.4f})")
print(f"analyze_fwl_plot: {fwl_twfe.slope:.4f}"
f" (clustered SE {fwl_twfe.se:.4f})")
# ── First differences: with T = 2, June minus January ────────────────
wide = panel.pivot(index="restaurant_id", columns="period", values=cols)
fd = pd.DataFrame({f"d_{c}": wide[(c, 2)] - wide[(c, 1)] for c in cols})
fd_model = smf.ols("d_sales ~ d_coupons + d_income", fd).fit(cov_type="HC1")
print("\n=== First differences: d_sales ~ d_coupons + d_income ===")
print(fd_model.summary().tables[1])
# ── Which standard error? Four versions of the TWFE SE ────────────────
import pyfixest as pf # installed with expdpy
twfe_raw = pf.feols("sales ~ coupons + income | restaurant_id + period",
data=panel, vcov={"CRV1": "restaurant_id"},
ssc=pf.ssc(k_adj=False, G_adj=False)) # no small-sample factors
fd_hc0 = smf.ols("d_sales ~ d_coupons + d_income", fd).fit(cov_type="HC0")
print("\n=== Which standard error? ===")
print(f"Two-way FE, cluster SE with no small-sample factors: {twfe_raw.se()['coupons']:.4f}")
print(f"First differences, HC0 robust SE: {fd_hc0.bse['d_coupons']:.4f}")
print(f"analyze_fwl_plot (pyfixest defaults): {fwl_twfe.se:.4f}")
print(f"statsmodels LSDV, cluster SE counting every dummy: {twfe_lsdv.bse['coupons']:.4f}")
# ── Is the seed-42 panel unusual? 500 re-simulated panels ────────────
def pooled_and_twfe(seed):
"""Pooled OLS slope, two-way FE slope (via first differences), and its SE."""
p = simulate_restaurant_panel(n=N, seed=seed)
b_pooled = smf.ols("sales ~ coupons + income + C(period)", p).fit().params["coupons"]
w = p.pivot(index="restaurant_id", columns="period", values=cols)
d = pd.DataFrame({f"d_{c}": w[(c, 2)] - w[(c, 1)] for c in cols})
m = smf.ols("d_sales ~ d_coupons + d_income", d).fit(cov_type="HC1")
return b_pooled, m.params["d_coupons"], m.bse["d_coupons"]
mc_draws = np.array([pooled_and_twfe(seed) for seed in range(1000, 1500)])
mc = {"n_panels": 500, "seeds": "1000-1499",
"pooled_mean": float(mc_draws[:, 0].mean()),
"pooled_sd": float(mc_draws[:, 0].std(ddof=1)),
"twfe_mean": float(mc_draws[:, 1].mean()),
"twfe_sd": float(mc_draws[:, 1].std(ddof=1)),
"twfe_mean_se_hc1": float(mc_draws[:, 2].mean()),
"share_twfe_at_or_below_seed42": float(
(mc_draws[:, 1] <= round(fwl_twfe.slope, 4)).mean()),
"sd_d_coupons_seed42": float(fd["d_coupons"].std()),
"sd_d_coupons_population": float(np.sqrt(0.25 * 4 + 9 + 16 + 25))}
print("\n=== 500 re-simulated panels ===")
print(f"pooled OLS: mean {mc['pooled_mean']:.3f} SD {mc['pooled_sd']:.3f}")
print(f"two-way FE: mean {mc['twfe_mean']:.3f} SD {mc['twfe_sd']:.3f} "
f"average SE {mc['twfe_mean_se_hc1']:.3f}")
# Invariants for the appendix
assert abs(fe_lsdv.params["coupons"] - fe_demeaned.params["coupons"]) < 1e-10
assert abs(fe_lsdv.params["coupons"] - fwl_fe.slope) < 1e-10
assert abs(twfe_lsdv.params["coupons"] - fwl_twfe.slope) < 1e-10
assert abs(fd_model.params["d_coupons"] - twfe_lsdv.params["coupons"]) < 1e-10
assert fwl_fe.n_obs == fwl_twfe.n_obs == 2 * N
assert abs(twfe_raw.se()["coupons"] - fd_hc0.bse["d_coupons"]) < 1e-10 # CRV0 = HC0
# ── Interactive FWL plots (Plotly, dark theme) ───────────────────────
def style_fwl_fig(result, title):
"""Restyle an analyze_fwl_plot figure to the post's dark palette."""
fig = result.fig
band, line, points = fig.data # CI band, fit line, scatter
band.update(fillcolor="rgba(217,119,87,0.18)", line=dict(width=0),
hoverinfo="skip", name="95% CI")
line.update(line=dict(color=WARM_ORANGE, width=3), name="Linear fit",
hovertemplate="Fit: %{y:.2f}
"
"Residual coupons: %{x:.2f}
"
"Residual sales: %{y:.2f}