Code adapted from book's accompanying repo:
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
np.random.seed(42)
# 8 BC municipalities, 24 months, treatment at month 13
cities = {
"Nanaimo": {"base": 1250, "trend": 3.0, "treated": False},
"Kelowna": {"base": 1580, "trend": 4.5, "treated": False},
"Kamloops": {"base": 1150, "trend": 2.5, "treated": False},
"Victoria": {"base": 1620, "trend": 4.0, "treated": False},
"Prince George": {"base": 980, "trend": 1.5, "treated": False},
"Squamish": {"base": 1720, "trend": 5.0, "treated": True},
"Tofino": {"base": 1600, "trend": 6.0, "treated": True},
"Nelson": {"base": 1450, "trend": 5.5, "treated": True},
}
rows = []
for city, info in cities.items():
for month in range(1, 25):
post = int(month > 12)
treated = int(info["treated"])
effect = 0 if not info["treated"] or not post else np.random.uniform(40, 60)
rent = (info["base"]
+ info["trend"] * month
- treated * post * effect
+ np.random.normal(0, 15))
rows.append({
"municipality": city,
"month": month,
"post": post,
"short_term_rental_ban": treated,
"avg_rent": round(rent),
})
df = pd.DataFrame(rows)
print(df.head(16).to_string(index=False))
print(f"\nShape: {df.shape}")
municipality month post short_term_rental_ban avg_rent
Nanaimo 1 0 0 1260
Nanaimo 2 0 0 1254
Nanaimo 3 0 0 1269
Nanaimo 4 0 0 1285
Nanaimo 5 0 0 1261
Nanaimo 6 0 0 1264
Nanaimo 7 0 0 1295
Nanaimo 8 0 0 1286
Nanaimo 9 0 0 1270
Nanaimo 10 0 0 1288
Nanaimo 11 0 0 1276
Nanaimo 12 0 0 1279
Nanaimo 13 1 0 1293
Nanaimo 14 1 0 1263
Nanaimo 15 1 0 1269
Nanaimo 16 1 0 1290
Shape: (192, 5)
# Aggregate to group-level means by month
treated_avg = df[df.short_term_rental_ban == 1].groupby("month")["avg_rent"].mean()
control_avg = df[df.short_term_rental_ban == 0].groupby("month")["avg_rent"].mean()
# Pre-treatment control trend (slope)
pre_ctrl = control_avg.loc[1:12]
trend = (pre_ctrl.iloc[-1] - pre_ctrl.iloc[0]) / 11
# Counterfactual: treated baseline + control growth from that baseline
treated_baseline = treated_avg.loc[12]
cf_months = range(13, 25)
counterfactual = [treated_baseline + trend * (m - 12) for m in cf_months]
# Post-treatment treated mean
post_tr = treated_avg.loc[13:24].mean()
post_cf = np.mean(counterfactual)
att = post_tr - post_cf
fig, ax = plt.subplots(figsize=(10, 5))
# Plot lines
ax.plot(treated_avg.index, treated_avg.values, 'o-', label="Treated (ban adopted)", color="#2c7fb8", markersize=4)
ax.plot(control_avg.index, control_avg.values, 's-', label="Control (no ban)", color="#bdbdbd", markersize=4)
ax.plot(cf_months, counterfactual, 's--', label="Counterfactual", color="#2c7fb8", alpha=0.5, markersize=4)
# Treatment line
ax.axvline(12.5, color="black", linestyle=":", linewidth=1)
ax.text(12.7, ax.get_ylim()[1] - 130, "Treatment", fontsize=9, color="black")
# ATT bracket
post_x = 18 # mid-post period
ax.annotate("", xy=(post_x, post_tr), xytext=(post_x, post_cf),
arrowprops=dict(arrowstyle="<->", color="#e34a33", lw=1.5))
ax.text(post_x + 0.3, post_cf - 50, f"ATT ≈ -${-1*att:.0f}/mo",
fontsize=10, color="#e34a33", va="center")
ax.set_xlabel("Month")
ax.set_ylabel("Average Rent ($)")
ax.legend(loc="upper left")
ax.set_title("Difference-in-Differences: Short-Term Rental Ban and Average Rent")
plt.tight_layout()
plt.show()
print(f"Control trend (pre): ${trend:.1f}/month")
print(f"Treated baseline (month 12): ${treated_baseline:.0f}")
print(f"Post-treatment treated avg: ${post_tr:.0f}")
print(f"Counterfactual avg: ${post_cf:.0f}")
print(f"Estimated ATT: ${att:.0f}/month")Control trend (pre): $2.7/month
Treated baseline (month 12): $1661
Post-treatment treated avg: $1645
Counterfactual avg: $1678
Estimated ATT: $-34/month
treated_avg.loc[13:24].mean() - control_avg.loc[13:24].mean()np.float64(273.9222222222222)import cvxpy as cp
# --- Pivot the data ---
pivot = df.pivot(index="month", columns="municipality", values="avg_rent")
treated_munis = [c for c, i in cities.items() if i["treated"]]
control_munis = [c for c, i in cities.items() if not i["treated"]]
y_tr = pivot[treated_munis]
y_co = pivot[control_munis]
y_pre_co = y_co.loc[1:12]
y_pre_tr = y_tr.loc[1:12]
y_post_co = y_co.loc[13:24]
y_post_tr = y_tr.loc[13:24]# --- Fit canonical synthetic control ---
y_target = y_pre_tr.mean(axis=1).values
X = y_pre_co.values
w = cp.Variable(X.shape[1])
objective = cp.Minimize(cp.sum_squares(X @ w - y_target))
constraints = [cp.sum(w) == 1, w >= 0]
problem = cp.Problem(objective, constraints)
problem.solve()
weights = w.value
synth_pre = X @ weights
synth_post = y_post_co.values @ weights
print("Weights (non-zero):")
for name, wt in zip(control_munis, weights):
if wt > 0.001:
print(f" {name}: {wt:.3f}")Weights (non-zero):
Kelowna: 0.567
Victoria: 0.433
# --- Plot ---
from matplotlib.lines import Line2D
fig, ax = plt.subplots(figsize=(10, 5))
for muni in control_munis:
wt = weights[control_munis.index(muni)] # get this city's weight
color = "gray" if wt < 0.001 else "#1b9e77" # dark teal for selected
alpha = 0.25
ax.plot(pivot.index, pivot[muni], color=color, alpha=alpha, linewidth=0.8)
# Treated average
treated_avg = y_tr.mean(axis=1)
ax.plot(treated_avg.index, treated_avg.values, 'o-', color="#2c7fb8", linewidth=1.8, markersize=4, label="Treated (average)")
# Synthetic control
synth_full = np.concatenate([synth_pre, synth_post])
ax.plot(range(1, 25), synth_full, '--', color="orange", linewidth=1.8, label="Synthetic control")
# Treatment line
ax.axvline(12.5, color="black", linestyle=":", linewidth=1)
ax.text(12.7, ax.get_ylim()[1] - 200, "Treatment", fontsize=9, color="black")
# ATT annotation
post_tr_avg = y_post_tr.mean(axis=1).mean()
post_sc_avg = synth_post.mean()
att = post_tr_avg - post_sc_avg
post_x = 18
treated_y = y_post_tr.mean(axis=1).iloc[5]
synth_y = synth_post[5]
ax.annotate("", xy=(post_x, synth_y-att/2.0), xytext=(post_x, treated_y+att/2.0),
arrowprops=dict(arrowstyle="<->", color="#e34a33", lw=1.5))
ax.text(post_x + 0.3, post_sc_avg - 80, f"ATT ≈ -${-1*att:.0f}/mo", fontsize=10, color="#e34a33", va="center")
ax.set_xlabel("Month")
ax.set_ylabel("Average Rent ($)")
handles, labels = ax.get_legend_handles_labels()
handles.append(Line2D([0], [0], color="#1b9e77", alpha=0.25, linewidth=0.8))
labels.append("Selected controls")
handles.append(Line2D([0], [0], color="gray", alpha=0.25, linewidth=0.8))
labels.append("Unselected controls")
ax.legend(handles, labels, loc="right")
ax.set_title("Synthetic Control: Short-Term Rental Ban and Average Rent")
plt.tight_layout()
plt.show()
print(f"\nTreated post avg: ${post_tr_avg:.0f}")
print(f"Synthetic control post avg: ${post_sc_avg:.0f}")
print(f"Estimated ATT: ${att:.0f}/month")
Treated post avg: $1645
Synthetic control post avg: $1673
Estimated ATT: $-28/month
list(zip(control_munis, weights))[('Nanaimo', np.float64(-2.0835213318110194e-07)),
('Kelowna', np.float64(0.5670371223859955)),
('Kamloops', np.float64(-3.206932655444971e-07)),
('Victoria', np.float64(0.4329640468759293)),
('Prince George', np.float64(-6.385952993292407e-07))]import statsmodels.formula.api as smf
T_pre = len(y_pre_co)
T_post = len(y_post_co)
T = T_pre + T_postfrom sklearn.base import BaseEstimator, RegressorMixin
from sklearn.utils.validation import check_X_y, check_array, check_is_fitted
class SyntheticControl(BaseEstimator, RegressorMixin):
def __init__(self,):
pass
def fit(self, y_pre_co, y_pre_tr):
y_pre_co, y_pre_tr = check_X_y(y_pre_co, y_pre_tr)
w = cp.Variable(y_pre_co.shape[1])
objective = cp.Minimize(cp.sum_squares(y_pre_co@w - y_pre_tr))
constraints = [cp.sum(w) == 1, w >= 0]
problem = cp.Problem(objective, constraints)
self.loss_ = problem.solve(verbose=False)
self.w_ = w.value
self.is_fitted_ = True
return self
def predict(self, y_co):
check_is_fitted(self)
y_co = check_array(y_co)
return y_co @ self.w_sc_model = SyntheticControl()
sc_model.fit(y_pre_co, y_pre_tr.mean(axis=1))
(y_post_tr.mean(axis=1) - sc_model.predict(y_post_co)).mean()np.float64(-27.888391822964422)unit_w = pd.DataFrame(zip(y_pre_co.columns, sc_model.w_),
columns=["municipality", "unit_weight"])
unit_w.head()| municipality | unit_weight | |
|---|---|---|
| 0 | Nanaimo | -2.083521e-07 |
| 1 | Kelowna | 5.670371e-01 |
| 2 | Kamloops | -3.206933e-07 |
| 3 | Victoria | 4.329640e-01 |
| 4 | Prince George | -6.385953e-07 |
df.head()| municipality | month | post | short_term_rental_ban | avg_rent | |
|---|---|---|---|---|---|
| 0 | Nanaimo | 1 | 0 | 0 | 1260 |
| 1 | Nanaimo | 2 | 0 | 0 | 1254 |
| 2 | Nanaimo | 3 | 0 | 0 | 1269 |
| 3 | Nanaimo | 4 | 0 | 0 | 1285 |
| 4 | Nanaimo | 5 | 0 | 0 | 1261 |
df_with_w = (df
.assign(tr_post = lambda d: d["post"]*d["short_term_rental_ban"])
.merge(unit_w, on=["municipality"], how="left")
.fillna({"unit_weight": df["short_term_rental_ban"].mean()}))
df_with_w.head()| municipality | month | post | short_term_rental_ban | avg_rent | tr_post | unit_weight | |
|---|---|---|---|---|---|---|---|
| 0 | Nanaimo | 1 | 0 | 0 | 1260 | 0 | -2.083521e-07 |
| 1 | Nanaimo | 2 | 0 | 0 | 1254 | 0 | -2.083521e-07 |
| 2 | Nanaimo | 3 | 0 | 0 | 1269 | 0 | -2.083521e-07 |
| 3 | Nanaimo | 4 | 0 | 0 | 1285 | 0 | -2.083521e-07 |
| 4 | Nanaimo | 5 | 0 | 0 | 1261 | 0 | -2.083521e-07 |
mod = smf.wls(
"avg_rent ~ tr_post + C(month)",
data=df_with_w.query("unit_weight>=1e-6"),
weights=df_with_w.query("unit_weight>=1e-6")["unit_weight"]
)
mod.fit().params["tr_post"]np.float64(-27.887735208610962)Need to redefine the class to account for an intercept.
class SyntheticControl(BaseEstimator, RegressorMixin):
def __init__(self, fit_intercept=False):
self.fit_intercept = fit_intercept
def fit(self, y_pre_co, y_pre_tr):
y_pre_co, y_pre_tr = check_X_y(y_pre_co, y_pre_tr)
# add intercept
intercept = np.ones((y_pre_co.shape[0], 1))*self.fit_intercept
X = np.concatenate([intercept, y_pre_co], axis=1)
w = cp.Variable(X.shape[1])
objective = cp.Minimize(cp.sum_squares(X@w - y_pre_tr))
constraints = [cp.sum(w[1:]) == 1, w[1:] >= 0]
problem = cp.Problem(objective, constraints)
self.loss_ = problem.solve(verbose=False)
self.w_ = w.value[1:] # remove intercept
self.is_fitted_ = True
return self
def predict(self, y_co):
check_is_fitted(self)
y_co = check_array(y_co)
return y_co @ self.w_time_sc = SyntheticControl(fit_intercept=True)
time_sc.fit(
y_pre_co.T,
y_post_co.mean(axis=0)
)
time_w = pd.DataFrame(zip(y_pre_co.index, time_sc.w_),
columns=["month", "time_weight"])
time_w.tail()| month | time_weight | |
|---|---|---|
| 7 | 8 | 1.819594e-01 |
| 8 | 9 | 4.370048e-16 |
| 9 | 10 | -4.194775e-15 |
| 10 | 11 | 3.881692e-01 |
| 11 | 12 | -5.564373e-15 |
plt.figure(figsize=(10,4))
plt.bar(time_w["month"], time_w["time_weight"])
plt.ylabel("Time Weights")
plt.xticks(rotation=45);scdid_df = (
df_with_w
.merge(time_w, on=["month"], how="left")
.fillna({"time_weight":df["post"].mean()})
.assign(weight = lambda d: (d["time_weight"]*d["unit_weight"]))
)
scdid_df.head()| municipality | month | post | short_term_rental_ban | avg_rent | tr_post | unit_weight | time_weight | weight | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | Nanaimo | 1 | 0 | 0 | 1260 | 0 | -2.083521e-07 | -1.339580e-14 | 2.791043e-21 |
| 1 | Nanaimo | 2 | 0 | 0 | 1254 | 0 | -2.083521e-07 | -1.639272e-15 | 3.415459e-22 |
| 2 | Nanaimo | 3 | 0 | 0 | 1269 | 0 | -2.083521e-07 | -2.026861e-14 | 4.223008e-21 |
| 3 | Nanaimo | 4 | 0 | 0 | 1285 | 0 | -2.083521e-07 | -1.784876e-15 | 3.718827e-22 |
| 4 | Nanaimo | 5 | 0 | 0 | 1261 | 0 | -2.083521e-07 | 4.298714e-01 | -8.956462e-08 |
did_model = smf.wls(
"avg_rent ~ short_term_rental_ban*post",
data=scdid_df.query("weight>1e-10"),
weights=scdid_df.query("weight>1e-10")["weight"]).fit()
did_model.params["short_term_rental_ban:post"]np.float64(-24.28382292103177)tr_period = 12
avg_pre_period = time_w.iloc[int((time_w["time_weight"] * np.arange(0, len(time_w), 1)).sum().round())]["month"]
avg_post_period = 18
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(15,8), sharex=True, gridspec_kw={'height_ratios': [3, 1]})
ax1.plot(y_co.index, y_tr.mean(axis=1), label="Treated")
ax1.plot(y_co.index, sc_model.predict(y_co), label="Synthetic Control", ls="-.")
ax1.axvline(tr_period, color="black", ls="dotted")
pre_sc = did_model.params["Intercept"]
post_sc = pre_sc + did_model.params["post"]
pre_treat = pre_sc + did_model.params["short_term_rental_ban"]
post_treat = post_sc + did_model.params["short_term_rental_ban"] + did_model.params["short_term_rental_ban:post"]
sc_did_y0 = pre_treat + (post_sc - pre_sc)
for muni_idx in unit_w.index:
wt = weights[muni_idx] # get this city's weight
color = "gray" if wt < 0.001 else "#1b9e77" # dark teal for selected
alpha = 0.25 #if wt < 0.001 else 0.9
ax1.plot(pivot.index, pivot[control_munis[muni_idx]], color=color, alpha=alpha, linewidth=0.8)
handles, labels = ax.get_legend_handles_labels()
handles.append(Line2D([0], [0], color="#1b9e77", alpha=0.25, linewidth=0.8))
labels.append("Selected controls")
handles.append(Line2D([0], [0], color="gray", alpha=0.25, linewidth=0.8))
labels.append("Unselected controls")
ax1.legend(handles, labels, loc="right")
ax1.set_title("Synthetic Difference-in-Differences")
ax1.set_ylabel("Average Rent")
post_x = 18
appx_att = -1*did_model.params["short_term_rental_ban:post"]
ax1.text(post_x + 0.3, post_sc_avg - 80, f"ATT ≈ -${appx_att:.0f}/mo", fontsize=10, color="#e34a33", va="center")
ax1.annotate("", xy=(post_x, y_post_tr.iloc[5].mean()-appx_att/2.0), xytext=(post_x, synth_post[5]+appx_att/2.0),
arrowprops=dict(arrowstyle="<->", color="#e34a33", lw=1.5))
ax2.bar(time_w["month"], time_w["time_weight"])
ax2.axvline(tr_period, color="black", ls="dotted")
ax2.set_ylabel("Time Weights")
ax2.set_xlabel("Months")
plt.xticks(rotation=30);y_post_tr.iloc[5].mean()np.float64(1640.3333333333333)