#!/usr/bin/env python3 # Author: Simon-Pierre Boucher — contact@spboucher.ai """Step 05 — LaTeX tables. Converts the step-02/03 CSV outputs (and the step-06 revision outputs, ``rev_*.csv``) into booktabs tables under ``results/tables/``. The paper inputs these files directly, so every number in the manuscript is machine-generated. Usage: python scripts/05_make_tables.py """ import sys from pathlib import Path import numpy as np import pandas as pd sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) from wp10 import config # noqa: E402 RES = config.REPRODUCED TAB = config.TABLES def _stars(coef: float, se: float) -> str: t = abs(coef / se) if se > 0 else 0 return "***" if t > 2.576 else "**" if t > 1.96 else "*" if t > 1.645 else "" def _f(x, nd=3): return f"{x:,.{nd}f}" def t_summary(): s = pd.read_csv(RES / "summary_stats.csv") lines = [r"\begin{tabular}{lrrrrrr}", r"\toprule", r"Variable & $n$ & Mean & SD & P10 & Median & P90 \\", r"\midrule"] for _, r in s.iterrows(): big = r["mean"] > 1000 nd = 0 if big else 3 if r["mean"] < 10 else 1 label = (str(r["variable"]).replace("($)", "(\\$)") .replace("(m2)", "(m$^2$)")) lines.append( f"{label} & {r['n']:,.0f} & {_f(r['mean'], nd)} & " f"{_f(r['sd'], nd)} & {_f(r['p10'], nd)} & {_f(r['p50'], nd)} & " f"{_f(r['p90'], nd)} \\\\") lines += [r"\bottomrule", r"\end{tabular}"] (TAB / "summary_stats.tex").write_text("\n".join(lines)) def t_iaao(): d = pd.read_csv(RES / "iaao_overall.csv") lines = [r"\begin{tabular}{lrcccc}", r"\toprule", r"Sample & $n$ & Median ratio & COD & PRD & PRB \\", r"\midrule"] for _, r in d.iterrows(): lines.append( f"{r['group']} & {r['n']:,.0f} & {_f(r['median_ratio'])} & " f"{_f(r['cod'], 1)} & {_f(r['prd'])} & {_f(r['prb'], 4)} \\\\") lines.append( f" & & \\footnotesize[{_f(r['median_lo'])}, {_f(r['median_hi'])}]" f" & \\footnotesize[{_f(r['cod_lo'], 1)}, {_f(r['cod_hi'], 1)}]" f" & \\footnotesize[{_f(r['prd_lo'])}, {_f(r['prd_hi'])}]" f" & \\footnotesize[{_f(r['prb_lo'], 4)}, {_f(r['prb_hi'], 4)}] \\\\") lines += [r"\bottomrule", r"\end{tabular}"] (TAB / "iaao.tex").write_text("\n".join(lines)) def t_cities(): d = pd.read_csv(RES / "iaao_cities.csv").sort_values("n", ascending=False) lines = [r"\begin{tabular}{lrccccc}", r"\toprule", r"Municipality & $n$ & Median ratio & COD & PRD & PRB & " r"\makecell{Share of years\\PRB $<$ 0} \\", r"\midrule"] for _, r in d.iterrows(): lines.append( f"{r['muni']} & {r['n']:,.0f} & {_f(r['median_ratio'])} & " f"{_f(r['cod'], 1)} & {_f(r['prd'])} & {_f(r['prb'], 4)} & " f"{_f(r['share_years_prb_neg'], 2)} \\\\") lines += [r"\bottomrule", r"\end{tabular}"] (TAB / "iaao_cities.tex").write_text("\n".join(lines)) def t_vertical(): v = pd.read_csv(RES / "vertical.csv") pf = pd.read_csv(RES / "paglin_fogarty.csv", index_col=0).squeeze() pf = {k: (v if k == "estimator" else float(v)) for k, v in pf.items()} q = pd.read_csv(RES / "quantile.csv") def row(label, vals): return label + " & " + " & ".join(vals) + r" \\" cols = list(v["estimator"]) lines = [r"\begin{tabular}{l" + "c" * len(cols) + "}", r"\toprule", row("", [f"({i+1})" for i in range(len(cols))]), row("", [c.replace(" (pooled)", "").replace(" (cell)", "") .replace(" (rank instrument)", "") for c in cols]), r"\midrule", row(r"$\beta$ (ln sale price)", [f"{_f(r['beta'])}{_stars(r['beta'] - 1, r['se'])}" for _, r in v.iterrows()]), row("", [f"({_f(r['se'])})" for _, r in v.iterrows()]), row(r"$\gamma = \beta - 1$", [f"{r['gamma']:+.3f}" for _, r in v.iterrows()]), r"\midrule", row("Cell fixed effects", ["No", "Yes", "Yes"]), row("Error-in-price robust", ["No", "No", "Yes"]), row("Observations", [f"{r['n']:,.0f}" for _, r in v.iterrows()]), row("$R^2$", [f"{_f(r['r2'])}" for _, r in v.iterrows()]), r"\bottomrule", r"\end{tabular}"] (TAB / "vertical.tex").write_text("\n".join(lines)) # stars in this table test H0: beta = 1 (proportionality), noted in caption lines = [r"\begin{tabular}{lcc}", r"\toprule", r" & Coefficient & Std.\ error \\", r"\midrule", f"Intercept ($\\$$) & {pf['intercept']:,.0f} & " f"{pf['intercept_se']:,.0f} \\\\", f"Slope on sale price & {_f(pf['slope'])} & " f"{_f(pf['slope_se'])} \\\\", f"Observations & \\multicolumn{{2}}{{c}}{{{pf['n']:,.0f}}} \\\\", r"\bottomrule", r"\end{tabular}"] (TAB / "paglin_fogarty.tex").write_text("\n".join(lines)) lines = [r"\begin{tabular}{lccccc}", r"\toprule", "Quantile $\\tau$ & " + " & ".join(f"{t:.2f}" for t in q["tau"]) + r" \\", r"\midrule", r"$\beta(\tau)$ & " + " & ".join( f"{_f(r['beta'])}{_stars(r['beta'] - 1, r['se'])}" for _, r in q.iterrows()) + r" \\", " & " + " & ".join(f"({_f(r['se'], 4)})" for _, r in q.iterrows()) + r" \\", r"\bottomrule", r"\end{tabular}"] (TAB / "quantile.tex").write_text("\n".join(lines)) def t_heterogeneity(): h = pd.read_csv(RES / "heterogeneity.csv") panels = { "Property class": ["Single-family", "Condominium", "Plex (2–5 units)", "Cottage"], "Building age": ["Age < 20 y", "Age 20–60 y", "Age > 60 y"], "Assessed land share": ["Land share < 0.2", "Land share 0.2–0.4", "Land share > 0.4"], "Roll lag at sale": ["Roll lag < 24 m", "Roll lag 24–48 m", "Roll lag > 48 m"], "Municipality size": ["Muni < 1k sales", "Muni 1k–10k sales", "Muni > 10k sales"], "Sale year": [f"Sales {y}" for y in range(2021, 2027)], } lines = [r"\begin{tabular}{lccr}", r"\toprule", r"Subsample & $\gamma$ & Std.\ error & $n$ \\"] for panel, keys in panels.items(): lines.append(r"\midrule") lines.append(r"\multicolumn{4}{l}{\itshape " + panel + r"}\\") for k in keys: r = h[h["group"] == k] if r.empty: continue r = r.iloc[0] label = (k.replace("<", "$<$").replace(">", "$>$") .replace("–", "--")) lines.append( f"\\quad {label} & {r['gamma']:+.3f}" f"{_stars(r['gamma'], r['se'])} & ({_f(r['se'])}) & " f"{r['n']:,.0f} \\\\") lines += [r"\bottomrule", r"\end{tabular}"] (TAB / "heterogeneity.tex").write_text("\n".join(lines)) def t_taxshift(): t = pd.read_csv(RES / "taxshift.csv") lines = [r"\begin{tabular}{lcccc}", r"\toprule", r"Price decile & $n$ & \makecell{Mean excess\\burden (\%)} & " r"\makecell{Median excess\\burden (\%)} & Std.\ error \\", r"\midrule"] for _, r in t.iterrows(): lines.append( f"{int(r['decile'])} & {r['n']:,.0f} & " f"{100 * r['mean_rel']:+.1f} & {100 * r['median_rel']:+.1f} & " f"({100 * r['se']:.2f}) \\\\") lines += [r"\bottomrule", r"\end{tabular}"] (TAB / "taxshift.tex").write_text("\n".join(lines)) def t_horizontal(): h = pd.read_csv(RES / "horizontal.csv", index_col=0) meta = pd.read_csv(RES / "horizontal_meta.csv", index_col=0).squeeze() labels = {"age_dec": "Building age (decades)", "land_share": "Assessed land share", "is_condo": "Condominium", "is_plex": "Plex (2--5 units)", "is_cottage": "Cottage"} lines = [r"\begin{tabular}{lcc}", r"\toprule", r" & Coefficient & Std.\ error \\", r"\midrule"] for k, lab in labels.items(): r = h.loc[k] lines.append(f"{lab} & {r['coef']:+.4f}{_stars(r['coef'], r['se'])} & " f"({_f(r['se'], 4)}) \\\\") lines += [r"\midrule", f"Mean of dependent variable & \\multicolumn{{2}}{{c}}" f"{{{float(meta['mean_dep']):.3f}}} \\\\", f"Observations & \\multicolumn{{2}}{{c}}{{{float(meta['n']):,.0f}}} \\\\", r"\bottomrule", r"\end{tabular}"] (TAB / "horizontal.tex").write_text("\n".join(lines)) def t_robustness(): r = pd.read_csv(RES / "robustness.csv") lines = [r"\begin{tabular}{lccccr}", r"\toprule", r"Sample variant & $\gamma_{\text{FE}}$ & Std.\ err. & " r"$\gamma_{\text{IV}}$ & Std.\ err. & $n$ \\", r"\midrule"] for _, x in r.iterrows(): label = (str(x["variant"]).replace("<=", "$\\le$") .replace(">=", "$\\ge$").replace("$100k", "\\$100k")) lines.append( f"{label} & {x['gamma_fe']:+.3f}{_stars(x['gamma_fe'], x['se_fe'])}" f" & ({_f(x['se_fe'])}) & " f"{x['gamma_iv']:+.3f}{_stars(x['gamma_iv'], x['se_iv'])} & " f"({_f(x['se_iv'])}) & {x['n']:,.0f} \\\\") lines += [r"\bottomrule", r"\end{tabular}"] (TAB / "robustness.tex").write_text("\n".join(lines)) # ---------------------------------------------------------------- revision tables (2026-10-17) def _pct(x, nd=1): return f"{100 * x:.{nd}f}" def t_rev_trim_grid(): g = pd.read_csv(RES / "rev_trim_grid.csv") short = {"baseline": "Baseline (1/99 pct by vintage)", "3iqr": r"3 $\times$ IQR within cell", "1.5iqr": r"1.5 $\times$ IQR within cell (IAAO)", "2.5pct": "2.5/97.5 pct within cell", "5pct": "5/95 pct within cell"} lines = [r"\begin{tabular}{lrccccc}", r"\toprule", r"\multicolumn{7}{l}{\itshape Panel A. Province-wide statistics and within-cell elasticities}\\", r"\midrule", r"Outlier rule & \makecell{Dropped\\(\%)} & COD & PRD & PRB & " r"$\gamma_{\text{FE}}$ & $\gamma_{\text{IV}}$ \\", r"\midrule"] for _, r in g.iterrows(): lines.append( f"{short[r['rule']]} & {_pct(r['share_dropped'])} & {_f(r['cod'], 1)} & {_f(r['prd'])} & " f"{r['prb']:+.3f} & {r['gamma_fe']:+.3f}{_stars(r['gamma_fe'], r['se_fe'])} & " f"{r['gamma_iv']:+.3f}{_stars(r['gamma_iv'], r['se_iv'])} \\\\") lines.append(f" & & & & & ({_f(r['se_fe'])}) & ({_f(r['se_iv'])}) \\\\") lines += [r"\midrule", r"\multicolumn{7}{l}{\itshape Panel B. Tax shift and municipal compliance}\\", r"\midrule", r"Outlier rule & \makecell{Decile 1\\(\%)} & \makecell{Decile 10\\(\%)} & " r"\makecell{Median\\muni.\ COD} & \makecell{COD $>$ 15\\(\%)} & " r"\makecell{PRB $<$ 0\\(\%)} & \makecell{PRB $<-0.05$\\(\%)} \\", r"\midrule"] for _, r in g.iterrows(): lines.append( f"{short[r['rule']]} & {100 * r['d1_median']:+.1f} & {100 * r['d10_median']:+.1f} & " f"{_f(r['median_muni_cod'], 1)} & {_pct(r['share_muni_cod_gt_15'], 0)} & " f"{_pct(r['share_muni_prb_neg'], 0)} & {_pct(r['share_muni_prb_lt_m05'], 0)} \\\\") lines += [r"\bottomrule", r"\end{tabular}"] (TAB / "rev_trim_grid.tex").write_text("\n".join(lines)) def t_rev_spline(): s = pd.read_csv(RES / "rev_spline.csv") lines = [r"\begin{tabular}{lcccr}", r"\toprule", r"\makecell[l]{Within-cell price\\quintile} & " r"\makecell{Range of demeaned\\$\ln SP$} & $\beta$ & $\gamma = \beta - 1$ & $n$ \\", r"\midrule"] for _, r in s.iterrows(): lo = f"$< {r['hi']:.2f}$" if r['segment'] == 1 else ( f"$\\ge {r['lo']:.2f}$" if r['segment'] == len(s) else f"$[{r['lo']:.2f},\\ {r['hi']:.2f})$") lines.append(f"{int(r['segment'])} & {lo} & {_f(r['beta'])}{_stars(r['beta'] - 1, r['se'])} " f"({_f(r['se'])}) & {r['gamma']:+.3f} & {r['n']:,.0f} \\\\") lines += [r"\bottomrule", r"\end{tabular}"] (TAB / "rev_spline.tex").write_text("\n".join(lines)) def t_rev_taxshift_alt(): t = pd.read_csv(RES / "rev_taxshift_alt.csv") o = pd.read_csv(RES / "rev_outlier_by_decile.csv").set_index("decile")["share_outlier"] cols = [("sale price", "cell median ratio"), ("sale price", "revenue-neutral (aggregate ratio)"), ("IAAO blended value proxy", "cell median ratio"), ("IAAO blended value proxy", "revenue-neutral (aggregate ratio)"), ("assessed value", "cell median ratio"), ("assessed value", "revenue-neutral (aggregate ratio)")] lines = [r"\begin{tabular}{lcccccc|c}", r"\toprule", r" & \multicolumn{2}{c}{Sorted on sale price} & \multicolumn{2}{c}{Sorted on blended proxy} & " r"\multicolumn{2}{c|}{Sorted on assessed value} & \\", r"\cmidrule(lr){2-3}\cmidrule(lr){4-5}\cmidrule(lr){6-7}", r"Decile & Median & Aggregate & Median & Aggregate & Median & Aggregate & " r"\makecell{IQR outliers\\(\%)} \\", r"\midrule"] for dec in range(1, 11): vals = [] for sort, bench in cols: v = t[(t["sort"] == sort) & (t["benchmark"] == bench) & (t["decile"] == dec)]["median_rel"].iloc[0] vals.append(f"{100 * v:+.1f}") lines.append(f"{dec} & " + " & ".join(vals) + f" & {100 * o.loc[dec]:.1f} \\\\") lines += [r"\bottomrule", r"\end{tabular}"] (TAB / "rev_taxshift_alt.tex").write_text("\n".join(lines)) def t_rev_prb_blocks(): a = pd.read_csv(RES / "rev_prb_blocks.csv") b = pd.read_csv(RES / "rev_prb_blocks_trimmed.csv") pw_a = pd.read_csv(RES / "iaao_overall.csv").set_index("group")["prb"] pw_b = pd.read_csv(RES / "rev_iaao_year_trimmed.csv").set_index("year")["prb"] lines = [r"\begin{tabular}{lrccccc}", r"\toprule", r"Blocks & $n$ blocks & \makecell{Median\\ratio} & \makecell{Median\\COD} & " r"\makecell{Median\\PRB} & \makecell{Share\\PRB $<$ 0} & \makecell{Province-\\wide PRB} \\"] for title, d, pw in (("Panel A. Baseline sample (1/99 trim by roll vintage)", a, pw_a), ("Panel B. IAAO 1.5 $\\times$ IQR trimmed sample", b, pw_b)): lines += [r"\midrule", r"\multicolumn{7}{l}{\itshape " + title + r"}\\", r"\midrule"] for _, r in d.iterrows(): g = str(r["group"]) pwv = pw.get(g, pw.get(int(g)) if g.isdigit() else None) if g.isdigit() else None pw_txt = f"{pwv:+.3f}" if pwv is not None and pd.notna(pwv) else "---" label = g.replace("<", "$<$").replace(">", "$>$").replace("–", "--") lines.append(f"{label} & {r['n_blocks']:,.0f} & {_f(r['median_ratio'])} & {_f(r['median_cod'], 1)} & " f"{r['median_prb']:+.3f} & {_f(r['share_prb_neg'], 2)} & {pw_txt} \\\\") lines += [r"\bottomrule", r"\end{tabular}"] (TAB / "rev_prb_blocks.tex").write_text("\n".join(lines)) def t_rev_cities(): a = pd.read_csv(RES / "rev_cities.csv").rename(columns={"city": "muni"}) b = pd.read_csv(RES / "rev_cities_trimmed.csv") m = a.merge(b, on="muni", suffixes=("", "_tr")).sort_values("n", ascending=False) lines = [r"\begin{tabular}{lrcccccccc}", r"\toprule", r" & & & \multicolumn{2}{c}{Baseline sample} & \multicolumn{5}{c}{IAAO 1.5 $\times$ IQR trimmed} \\", r"\cmidrule(lr){4-5}\cmidrule(lr){6-10}", r"Municipality & $n$ & \makecell{Condo\\share} & $\gamma_{\text{FE}}$ & $\gamma_{\text{IV}}$ & " r"COD & PRD & PRB & $\gamma_{\text{FE}}$ & $\gamma_{\text{IV}}$ \\", r"\midrule"] for _, r in m.iterrows(): lines.append( f"{r['muni']} & {r['n']:,.0f} & {_f(r['share_condo'], 2)} & " f"{r['gamma_fe']:+.3f} & {r['gamma_iv']:+.3f} & {_f(r['cod'], 1)} & {_f(r['prd'])} & " f"{r['prb']:+.3f} & {r['gamma_fe_tr']:+.3f} & {r['gamma_iv_tr']:+.3f} \\\\") lines += [r"\bottomrule", r"\end{tabular}"] (TAB / "rev_cities.tex").write_text("\n".join(lines)) def t_rev_het_trimmed(): h = pd.read_csv(RES / "heterogeneity.csv").set_index("group") t = pd.read_csv(RES / "rev_het_trimmed.csv") cl = pd.read_csv(RES / "rev_clusters.csv").set_index("group")["n_clusters"] lines = [r"\begin{tabular}{lccccr}", r"\toprule", r" & Baseline & \multicolumn{3}{c}{IAAO 1.5 $\times$ IQR trimmed} & \\", r"\cmidrule(lr){2-2}\cmidrule(lr){3-5}", r"Subsample & $\gamma_{\text{FE}}$ & $\gamma_{\text{FE}}$ & Std.\ error & $\gamma_{\text{IV}}$ & " r"\makecell{Clusters\\(munic.)} \\", r"\midrule"] for _, r in t.iterrows(): g = r["group"] base = h["gamma"].get(g, np.nan) if g in h.index else np.nan base_txt = f"{base:+.3f}" if pd.notna(base) else "---" ncl = f"{int(cl[g]):,}" if g in cl.index else "---" label = g.replace("<", "$<$").replace(">", "$>$").replace("–", "--") lines.append(f"{label} & {base_txt} & {r['gamma']:+.3f}{_stars(r['gamma'], r['se'])} & " f"({_f(r['se'])}) & {r['gamma_iv']:+.3f} & {ncl} \\\\") lines += [r"\bottomrule", r"\end{tabular}"] (TAB / "rev_het_trimmed.tex").write_text("\n".join(lines)) def t_rev_bounds(): b = pd.read_csv(RES / "rev_bounds.csv").set_index("quantity")["value"] rows = [ (r"Within-cell variance of $\ln SP$", _f(b["var_lnP_within"])), (r"Within-cell variance of $\ln AV$", _f(b["var_lnAV_within"])), (r"Within-cell covariance", _f(b["cov_within"])), (r"Within-cell correlation", _f(b["corr_within"])), (r"Forward slope $\hat\beta_{F}$ (Cheng FE)", _f(b["beta_forward"])), (r"Inverse reverse slope $1/\hat\beta_{R}$ (Kochin--Parks)", _f(b["beta_reverse_inverse"])), (r"Noise share $\lambda$ rationalising $\beta = 1$", _f(b["lambda_for_beta_1"])), (r"Implied transaction-noise s.d.\ for $\beta = 1$ (log points)", _f(b["noise_sd_for_beta_1"])), (r"Clapp IV $\hat\beta_{IV}$", _f(b["beta_iv"])), (r"Noise share $\lambda$ implied by $\hat\beta_{IV}$", _f(b["lambda_implied_by_iv"])), (r"Implied transaction-noise s.d.\ under $\hat\beta_{IV}$ (log points)", _f(b["noise_sd_implied_by_iv"])), (r"First stage: coefficient on $Z$ (s.e.)", f"{_f(b['first_stage_coef'])} ({_f(b['first_stage_se'])})"), (r"First stage: cluster-robust $F$", f"{b['first_stage_F_cluster']:,.0f}"), (r"First stage: $R^2$", _f(b["first_stage_r2"])), (r"Share of sales with $Z = -1$ / $Z = +1$", f"{_f(b['share_z_minus1'])} / {_f(b['share_z_plus1'])}"), ] lines = [r"\begin{tabular}{lc}", r"\toprule", r"Quantity & Value \\", r"\midrule"] lines += [f"{k} & {v} \\\\" for k, v in rows] lines += [r"\bottomrule", r"\end{tabular}"] (TAB / "rev_bounds.tex").write_text("\n".join(lines)) def t_rev_cod(): c = pd.read_csv(RES / "rev_cod.csv") short = {"Municipality x year blocks (baseline)": r"Muni.\ $\times$ year blocks (baseline)", "Municipality x quarter blocks": r"Muni.\ $\times$ quarter blocks", "Municipality x year, time-adjusted ratios": r"Muni.\ $\times$ year, time-adjusted", "Province-wide, time-adjusted ratios": "Province-wide, time-adjusted"} lines = [r"\begin{tabular}{lrccccc}", r"\toprule", r"Time-control scheme & Munis & \makecell{Median\\COD} & " r"\makecell{COD\\IQR} & \makecell{COD $>$ 15\\(\%)} & \makecell{Median\\PRB} & " r"\makecell{PRB $<-0.05$\\(\%)} \\", r"\midrule"] for _, r in c.iterrows(): lab = short.get(r["scheme"], r["scheme"]) if pd.isna(r["n_munis"]): lines.append(f"{lab} & --- & {_f(r['median_cod'], 1)} & --- & --- & " f"{r['median_prb']:+.3f} & --- \\\\") else: lines.append(f"{lab} & {r['n_munis']:,.0f} & " f"{_f(r['median_cod'], 1)} & [{_f(r['p25_cod'], 1)}, {_f(r['p75_cod'], 1)}] & " f"{_pct(r['share_cod_gt_15'], 0)} & {r['median_prb']:+.3f} & " f"{_pct(r['share_prb_lt_m05'], 0)} \\\\") lines += [r"\bottomrule", r"\end{tabular}"] (TAB / "rev_cod.tex").write_text("\n".join(lines)) def main() -> None: config.ensure_dirs() t_summary() t_iaao() t_cities() t_vertical() t_heterogeneity() t_taxshift() t_horizontal() t_robustness() for fn in (t_rev_trim_grid, t_rev_spline, t_rev_taxshift_alt, t_rev_prb_blocks, t_rev_cities, t_rev_het_trimmed, t_rev_bounds, t_rev_cod): if (RES / 'rev_trim_grid.csv').exists(): fn() made = sorted(p.name for p in TAB.glob("*.tex")) print(f"{len(made)} tables written:", ", ".join(made)) if __name__ == "__main__": main()