Repository Public Research preview
1#!/usr/bin/env python32# Author: Simon-Pierre Boucher — contact@spboucher.ai3"""Step 05 — LaTeX tables.45Converts the step-02/03 CSVs into booktabs tables under6``results/tables/``; the paper inputs these files directly.78Usage: python scripts/05_make_tables.py9"""10import sys11from pathlib import Path1213import pandas as pd1415sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))1617from wp11 import config # noqa: E4021819RES = config.REPRODUCED20TAB = config.TABLES212223def _f(x, nd=3):24 return f"{x:,.{nd}f}"252627def _esc(s: str) -> str:28 return (s.replace("–", "--").replace("~", r"$\sim$")29 .replace("<", "$<$").replace(">", "$>$"))303132def t_summary():33 s = pd.read_csv(RES / "summary_stats.csv")34 lines = [r"\begin{tabular}{lrrrrrr}", r"\toprule",35 r"Variable & $n$ & Mean & SD & P10 & Median & P90 \\",36 r"\midrule"]37 for _, r in s.iterrows():38 nd = 0 if r["mean"] > 1000 else 239 label = (str(r["variable"]).replace("($)", "(\\$)")40 .replace("(m2)", "(m$^2$)"))41 lines.append(42 f"{label} & {r['n']:,.0f} & {_f(r['mean'], nd)} & "43 f"{_f(r['sd'], nd)} & {_f(r['p10'], nd)} & {_f(r['p50'], nd)} & "44 f"{_f(r['p90'], nd)} \\\\")45 lines += [r"\bottomrule", r"\end{tabular}"]46 (TAB / "summary_stats.tex").write_text("\n".join(lines))474849def _race_table(fname_csv: str, fname_tex: str):50 r = pd.read_csv(RES / fname_csv)51 lines = [r"\begin{tabular}{lcccc c}", r"\toprule",52 r"Model & MdAPE (\%) & MAPE (\%) & RMSE$_{\ln}$ & "53 r"$R^2_{\ln}$ & Fit (s) \\"]54 for g in ["A. Functional form", "B. Time effects",55 "C. Spatial controls", "D. Estimation method"]:56 sub = r[r["group"] == g]57 if sub.empty:58 continue59 lines.append(r"\midrule")60 lines.append(r"\multicolumn{6}{l}{\itshape " + g + r"}\\")61 best = sub["mdape"].min()62 for _, x in sub.iterrows():63 name = _esc(str(x["name"]))64 md = f"\\textbf{{{_f(x['mdape'], 1)}}}" \65 if x["mdape"] == best else _f(x["mdape"], 1)66 lines.append(67 f"\\quad {name} & {md} & {_f(x['mape'], 1)} & "68 f"{_f(x['rmse_ln'])} & {_f(x['r2_ln'])} & "69 f"{_f(x['seconds'], 0)} \\\\")70 lines += [r"\bottomrule", r"\end{tabular}"]71 (TAB / fname_tex).write_text("\n".join(lines))727374def t_generalization():75 a = pd.read_csv(RES / "horserace_random.csv")76 b = pd.read_csv(RES / "horserace_temporal.csv")77 m = a.merge(b, on=["name", "group"], suffixes=("_r", "_t"))78 m = m[m["name"].str.match(r"(A5|B4|C4|D)")]79 lines = [r"\begin{tabular}{lcc c cc}", r"\toprule",80 r" & \multicolumn{2}{c}{Random holdout} & &"81 r" \multicolumn{2}{c}{Forward-in-time} \\",82 r"\cmidrule{2-3}\cmidrule{5-6}",83 r"Model & MdAPE (\%) & $R^2_{\ln}$ & & MdAPE (\%) & "84 r"$R^2_{\ln}$ \\", r"\midrule"]85 for _, x in m.iterrows():86 lines.append(87 f"{_esc(str(x['name']))} & {_f(x['mdape_r'], 1)} & "88 f"{_f(x['r2_ln_r'])} & & {_f(x['mdape_t'], 1)} & "89 f"{_f(x['r2_ln_t'])} \\\\")90 lines += [r"\bottomrule", r"\end{tabular}"]91 (TAB / "generalization.tex").write_text("\n".join(lines))929394def t_boxcox():95 b = pd.read_csv(RES / "boxcox.csv")96 lines = [r"\begin{tabular}{l" + "c" * len(b) + "}", r"\toprule",97 r"$\lambda$ & " + " & ".join(_f(x, 2) for x in b["lambda"])98 + r" \\", r"\midrule",99 r"Profile log-likelihood$^{\dagger}$ & "100 + " & ".join(f"{(x - b['loglik'].max())/1000:,.1f}"101 for x in b["loglik"]) + r" \\",102 r"\bottomrule", r"\end{tabular}"]103 (TAB / "boxcox.tex").write_text("\n".join(lines))104105106def t_learning():107 l = pd.read_csv(RES / "learning.csv")108 piv = l.pivot_table(index="n_train", columns="model", values="mdape")109 cols = list(piv.columns)110 lines = [r"\begin{tabular}{r" + "c" * len(cols) + "}", r"\toprule",111 "Training sales & " + " & ".join(_esc(c) for c in cols) + r" \\",112 r"\midrule"]113 for n, row in piv.iterrows():114 lines.append(f"{n:,.0f} & "115 + " & ".join(_f(v, 1) for v in row) + r" \\")116 lines += [r"\bottomrule", r"\end{tabular}"]117 (TAB / "learning.tex").write_text("\n".join(lines))118119120def t_segments():121 s = pd.read_csv(RES / "segments.csv")122 piv = s.pivot_table(index="segment", columns="model", values="mdape")123 ns = s.groupby("segment")["n"].first()124 lines = [r"\begin{tabular}{lrcc}", r"\toprule",125 r"Segment & $n$ (test) & OLS quadratic & Gradient boosting \\",126 r"\midrule"]127 for seg, row in piv.iterrows():128 label = _esc(str(seg)).replace("Class: ", "").replace("_", " ")129 lines.append(f"{label.capitalize()} & {ns[seg]:,.0f} & "130 f"{_f(row['OLS quadratic'], 1)} & "131 f"{_f(row['Gradient boosting'], 1)} \\\\")132 lines += [r"\bottomrule", r"\end{tabular}"]133 (TAB / "segments.tex").write_text("\n".join(lines))134135136# ---------------------------------------------------------------- revision 1.1137SHORT = {"A1 Linear (levels)": "A1 Linear", "A2 Semi-log": "A2 Semi-log",138 "A3 Log-log": "A3 Log-log", "A4 Box-Cox": "A4 Box--Cox",139 "A5 Semi-log + quadratics": "A5 Quadratics",140 "A6 Semi-log + splines": "A6 Splines",141 "B1 No time effects": "B1 No time FE", "B2 Year FE": "B2 Year FE",142 "B3 Quarter FE": "B3 Quarter FE", "B4 Month FE": "B4 Month FE",143 "C1 No spatial controls": "C1 No spatial FE",144 "C2 Municipality FE": "C2 Municipality FE",145 "C3 Grid-cell FE (~5.5 km)": "C3 Grid 5.5 km",146 "C4 Grid-cell FE (~1.1 km)": "C4 Grid 1.1 km",147 "D1 OLS (muni + month FE)": "D1 OLS", "D2 Ridge (cross-validated)":148 "D2 Ridge", "D3 Random forest": "D3 Random forest",149 "D4 Gradient boosting": "D4 Boosting",150 "D5 Gradient boosting, no coordinates": "D5 Boosting, no coord.",151 "D6 Spatial k-NN comparables": "D6 $k$-NN comparables"}152A6, D4 = "A6 Semi-log + splines", "D4 Gradient boosting"153154155def _stars(p):156 return "$^{***}$" if p < 0.01 else "$^{**}$" if p < 0.05 else \157 "$^{*}$" if p < 0.10 else ""158159160def t_inference():161 """MdAPE with cluster-bootstrap 95% CI, difference versus the spline162 hedonic (A6) with its CI, and the clustered DM t statistic on the APE163 loss differential, under both holdouts."""164 lines = [r"\begin{tabular}{l ccc c ccc}", r"\toprule",165 r" & \multicolumn{3}{c}{Random holdout} & &"166 r" \multicolumn{3}{c}{Forward-in-time holdout} \\",167 r"\cmidrule{2-4}\cmidrule{6-8}",168 r"Model & MdAPE [95\% CI] & $\Delta$ vs.\ A6 [95\% CI] & "169 r"$t_{\mathrm{DM}}$ & & MdAPE [95\% CI] & "170 r"$\Delta$ vs.\ A6 [95\% CI] & $t_{\mathrm{DM}}$ \\",171 r"\midrule"]172 R = {s: pd.read_csv(RES / f"inference_{s}.csv").set_index("model")173 for s in ("random", "temporal")}174 groups = {"A": "A. Functional form", "B": "B. Time effects",175 "C": "C. Spatial controls", "D": "D. Estimation method"}176 cur = None177 for m in SHORT:178 if m[0] != cur:179 cur = m[0]180 lines.append(r"\multicolumn{8}{l}{\itshape " + groups[cur]181 + r"}\\")182 cells = [r"\quad " + SHORT[m]]183 for s in ("random", "temporal"):184 x = R[s].loc[m]185 cells.append(f"{x['mdape']:.1f} [{x['ci_lo']:.1f}, "186 f"{x['ci_hi']:.1f}]")187 if m == A6:188 cells += ["---", "---"]189 else:190 cells.append(f"{x['diff_vs_ref']:+.1f} [{x['diff_lo']:+.1f}, "191 f"{x['diff_hi']:+.1f}]")192 cells.append(f"{x['dm_ape_vs_a6_t']:.1f}"193 + _stars(x['dm_ape_vs_a6_p']))194 if s == "random":195 cells.append("")196 lines.append(" & ".join(cells) + r" \\")197 lines += [r"\bottomrule", r"\end{tabular}"]198 (TAB / "inference.tex").write_text("\n".join(lines))199200201def t_retransformation():202 """Median-consistent versus Duan-smeared (mean-consistent) predictions."""203 sel = ["A2 Semi-log", "A3 Log-log", "A4 Box-Cox",204 "A5 Semi-log + quadratics", "A6 Semi-log + splines",205 "D2 Ridge (cross-validated)", "D3 Random forest",206 "D4 Gradient boosting"]207 R = {s: pd.read_csv(RES / f"horserace_{s}.csv").set_index("name")208 for s in ("random", "temporal")}209 lines = [r"\begin{tabular}{l c cc cc c cc cc}", r"\toprule",210 r" & & \multicolumn{4}{c}{Random holdout} & &"211 r" \multicolumn{4}{c}{Forward-in-time holdout} \\",212 r"\cmidrule{3-6}\cmidrule{8-11}",213 r" & & \multicolumn{2}{c}{MdAPE (\%)} & "214 r"\multicolumn{2}{c}{$R^2_{\ln}$} & & "215 r"\multicolumn{2}{c}{MdAPE (\%)} & "216 r"\multicolumn{2}{c}{$R^2_{\ln}$} \\",217 r"\cmidrule{3-4}\cmidrule{5-6}\cmidrule{8-9}\cmidrule{10-11}",218 r"Model & $\hat{s}$ & Median & Mean & Median & Mean & & "219 r"Median & Mean & Median & Mean \\", r"\midrule"]220 for m in sel:221 a, b = R["random"].loc[m], R["temporal"].loc[m]222 sm = "---" if pd.isna(a["smear"]) else f"{a['smear']:.3f}"223 lines.append(224 f"{SHORT[m]} & {sm} & {a['mdape']:.1f} & {a['mdape_mean']:.1f} & "225 f"{a['r2_ln']:.3f} & {a['r2_ln_mean']:.3f} & & "226 f"{b['mdape']:.1f} & {b['mdape_mean']:.1f} & "227 f"{b['r2_ln']:.3f} & {b['r2_ln_mean']:.3f} \\\\")228 lines += [r"\bottomrule", r"\end{tabular}"]229 (TAB / "retransformation.tex").write_text("\n".join(lines))230231232def t_same_period():233 sp = pd.read_csv(RES / "same_period.csv").set_index("model")234 rl = pd.read_csv(RES / "relevel.csv")235 rl = rl[rl["scheme"] == "temporal"].set_index("model")236 sel = ["A1 Linear (levels)", "A5 Semi-log + quadratics",237 "A6 Semi-log + splines", "B4 Month FE",238 "C3 Grid-cell FE (~5.5 km)", "C4 Grid-cell FE (~1.1 km)",239 "D2 Ridge (cross-validated)", "D3 Random forest",240 "D4 Gradient boosting", "D5 Gradient boosting, no coordinates",241 "D6 Spatial k-NN comparables"]242 lines = [r"\begin{tabular}{l cc c c c c}", r"\toprule",243 r" & \multicolumn{2}{c}{Random split} & Forward split & "244 r"Staleness & Level error & Re-levelled \\",245 r"\cmidrule{2-3}\cmidrule{4-4}",246 r"Model & All test sales & 2025--26 sales & 2025--26 sales & "247 r"cost & $\hat{b}$ & MdAPE \\", r"\midrule"]248 for m in sel:249 x = sp.loc[m]250 lines.append(f"{SHORT[m]} & {x['mdape_random_all']:.1f} & "251 f"{x['mdape_random_2025_26']:.1f} & "252 f"{x['mdape_forward_2025_26']:.1f} & "253 f"{x['mdape_forward_2025_26'] - x['mdape_random_2025_26']:+.1f} & "254 f"{rl.loc[m, 'median_log_resid']:+.3f} & "255 f"{rl.loc[m, 'mdape_relevelled']:.1f} \\\\")256 n1, n2 = int(sp["n_random_2025_26"].iloc[0]), int(sp["n_forward"].iloc[0])257 lines += [r"\midrule", f"Test sales & 102,929 & {n1:,} & {n2:,} & & & \\\\",258 r"\bottomrule", r"\end{tabular}"]259 (TAB / "same_period.tex").write_text("\n".join(lines))260261262def t_pairs():263 pr = pd.read_csv(RES / "pairs.csv")264 lines = [r"\begin{tabular}{ll c cc c cc}", r"\toprule",265 r" & & & \multicolumn{2}{c}{Random holdout} & &"266 r" \multicolumn{2}{c}{Forward-in-time} \\",267 r"\cmidrule{4-5}\cmidrule{7-8}",268 r"Comparison & & MdAPE & $\Delta$MdAPE [95\% CI] & "269 r"$t_{\mathrm{DM}}$ APE / sq.\ log & & "270 r"$\Delta$MdAPE [95\% CI] & $t_{\mathrm{DM}}$ APE / sq.\ log \\",271 r"\midrule"]272 pairs = list(dict.fromkeys(zip(pr["model_a"], pr["model_b"])))273 for a, b in pairs:274 r = pr[(pr["model_a"] == a) & (pr["model_b"] == b)].set_index("scheme")275 x, y = r.loc["random"], r.loc["temporal"]276 cells = [SHORT[a] + " vs.", SHORT[b],277 f"{x['mdape_a']:.1f} / {x['mdape_b']:.1f}",278 f"{x['diff']:+.2f} [{x['diff_lo']:+.2f}, {x['diff_hi']:+.2f}]",279 f"{x['dm_ape_t']:.1f}{_stars(x['dm_ape_p'])} / "280 f"{x['dm_sqlog_t']:.1f}{_stars(x['dm_sqlog_p'])}", "",281 f"{y['diff']:+.2f} [{y['diff_lo']:+.2f}, {y['diff_hi']:+.2f}]",282 f"{y['dm_ape_t']:.1f}{_stars(y['dm_ape_p'])} / "283 f"{y['dm_sqlog_t']:.1f}{_stars(y['dm_sqlog_p'])}"]284 lines.append(" & ".join(cells) + r" \\")285 lines += [r"\bottomrule", r"\end{tabular}"]286 (TAB / "pairs.tex").write_text("\n".join(lines))287288289def t_group_splits():290 R = {s: pd.read_csv(RES / f"horserace_{s}.csv").set_index("name")291 for s in ("random", "group_parcel", "group_coord")}292 lines = [r"\begin{tabular}{l cc c cc c cc}", r"\toprule",293 r" & \multicolumn{2}{c}{Random 80/20} & &"294 r" \multicolumn{2}{c}{Parcel-grouped} & &"295 r" \multicolumn{2}{c}{Building-grouped} \\",296 r"\cmidrule{2-3}\cmidrule{5-6}\cmidrule{8-9}",297 r"Model & MdAPE & $R^2_{\ln}$ & & MdAPE & $R^2_{\ln}$ & & "298 r"MdAPE & $R^2_{\ln}$ \\", r"\midrule"]299 groups = {"A": "A. Functional form", "B": "B. Time effects",300 "C": "C. Spatial controls", "D": "D. Estimation method"}301 cur = None302 for m in SHORT:303 if m[0] != cur:304 cur = m[0]305 lines.append(r"\multicolumn{9}{l}{\itshape " + groups[cur]306 + r"}\\")307 cells = [r"\quad " + SHORT[m]]308 for i, s in enumerate(("random", "group_parcel", "group_coord")):309 x = R[s].loc[m]310 cells += [f"{x['mdape']:.1f}", f"{x['r2_ln']:.3f}"]311 if i < 2:312 cells.append("")313 lines.append(" & ".join(cells) + r" \\")314 ns = [f"{int(R[s]['n_test'].iloc[0]):,}" for s in315 ("random", "group_parcel", "group_coord")]316 lines += [r"\midrule",317 f"Test sales & {ns[0]} & & & {ns[1]} & & & {ns[2]} & \\\\",318 r"\bottomrule", r"\end{tabular}"]319 (TAB / "group_splits.tex").write_text("\n".join(lines))320321322def t_leakage_decomp():323 L = pd.read_csv(RES / "leakage.csv")324 sel = ["A5 Semi-log + quadratics", "A6 Semi-log + splines",325 "D3 Random forest", "D4 Gradient boosting",326 "D5 Gradient boosting, no coordinates",327 "D6 Spatial k-NN comparables"]328 lines = [r"\begin{tabular}{l cc c cc}", r"\toprule",329 r" & \multicolumn{2}{c}{Same parcel in training} & &"330 r" \multicolumn{2}{c}{Same coordinates in training} \\",331 r"\cmidrule{2-3}\cmidrule{5-6}",332 r"Model & Yes & No & & Yes & No \\", r"\midrule"]333 def cell(key, flag, m, seg="all"):334 x = L[(L["key"] == key) & (L["seen_in_training"] == flag)335 & (L["model"] == m) & (L["segment"] == seg)]336 return f"{x['mdape'].iloc[0]:.1f}" if len(x) else "---"337 def n(key, flag, seg="all"):338 x = L[(L["key"] == key) & (L["seen_in_training"] == flag)339 & (L["segment"] == seg)]340 return f"{int(x['n'].iloc[0]):,}"341 lines.append(r"\multicolumn{6}{l}{\itshape All test sales}\\")342 for m in sel:343 lines.append(f"\\quad {SHORT[m]} & {cell('in_train_parcel', True, m)} & "344 f"{cell('in_train_parcel', False, m)} & & "345 f"{cell('in_train_coord', True, m)} & "346 f"{cell('in_train_coord', False, m)} \\\\")347 lines.append(f"\\quad Test sales & {n('in_train_parcel', True)} & "348 f"{n('in_train_parcel', False)} & & {n('in_train_coord', True)} & "349 f"{n('in_train_coord', False)} \\\\")350 lines.append(r"\midrule")351 lines.append(r"\multicolumn{6}{l}{\itshape Condominiums only}\\")352 for m in ("A5 Semi-log + quadratics", "D4 Gradient boosting"):353 lines.append(f"\\quad {SHORT[m]} & {cell('in_train_parcel', True, m, 'condo')} & "354 f"{cell('in_train_parcel', False, m, 'condo')} & & "355 f"{cell('in_train_coord', True, m, 'condo')} & "356 f"{cell('in_train_coord', False, m, 'condo')} \\\\")357 lines.append(f"\\quad Test sales & {n('in_train_parcel', True, 'condo')} & "358 f"{n('in_train_parcel', False, 'condo')} & & "359 f"{n('in_train_coord', True, 'condo')} & "360 f"{n('in_train_coord', False, 'condo')} \\\\")361 lines += [r"\bottomrule", r"\end{tabular}"]362 (TAB / "leakage.tex").write_text("\n".join(lines))363364365def t_nested_fe():366 N = pd.read_csv(RES / "nested_fe.csv")367 R = {s: pd.read_csv(RES / f"horserace_{s}.csv").set_index("name")368 for s in ("random", "temporal")}369 rows = [("C2 Municipality FE", "C2 Municipality FE (1,115 cells)"),370 ("C3 Grid-cell FE (~5.5 km)", "C3 Grid 5.5 km (5,255 cells)"),371 ("C4 Grid-cell FE (~1.1 km)", "C4 Grid 1.1 km (30,548 cells)"),372 ("C3' Municipality + grid-cell FE (~5.5 km)",373 "C3$'$ Municipality + grid 5.5 km"),374 ("C4' Municipality + grid-cell FE (~1.1 km)",375 "C4$'$ Municipality + grid 1.1 km")]376 lines = [r"\begin{tabular}{l cc c cc}", r"\toprule",377 r" & \multicolumn{2}{c}{Random holdout} & &"378 r" \multicolumn{2}{c}{Forward-in-time} \\",379 r"\cmidrule{2-3}\cmidrule{5-6}",380 r"Spatial controls & MdAPE (\%) & $R^2_{\ln}$ & & MdAPE (\%) & "381 r"$R^2_{\ln}$ \\", r"\midrule"]382 for key, label in rows:383 vals = []384 for s in ("random", "temporal"):385 if key in R[s].index:386 x = R[s].loc[key]387 else:388 x = N[(N["name"] == key) & (N["scheme"] == s)].iloc[0]389 vals += [f"{x['mdape']:.1f}", f"{x['r2_ln']:.3f}"]390 lines.append(f"{label} & {vals[0]} & {vals[1]} & & {vals[2]} & "391 f"{vals[3]} \\\\")392 lines += [r"\bottomrule", r"\end{tabular}"]393 (TAB / "nested_fe.tex").write_text("\n".join(lines))394395396def t_hgb_sensitivity():397 H = pd.read_csv(RES / "hgb_sensitivity.csv")398 names = list(dict.fromkeys(H["name"]))399 lines = [r"\begin{tabular}{l cc c cc}", r"\toprule",400 r" & \multicolumn{2}{c}{Random holdout} & &"401 r" \multicolumn{2}{c}{Forward-in-time} \\",402 r"\cmidrule{2-3}\cmidrule{5-6}",403 r"Gradient-boosting configuration & MdAPE (\%) & $R^2_{\ln}$ & & "404 r"MdAPE (\%) & $R^2_{\ln}$ \\", r"\midrule"]405 for nm in names:406 a = H[(H["name"] == nm) & (H["scheme"] == "random")].iloc[0]407 b = H[(H["name"] == nm) & (H["scheme"] == "temporal")].iloc[0]408 label = _esc(nm.replace("D4 ", "").replace("%", r"\%"))409 label = label[0].upper() + label[1:]410 if not pd.isna(b.get("best_iter", float("nan"))):411 label += f" [{int(a['best_iter'])} / {int(b['best_iter'])} it.]"412 lines.append(f"{label} & {a['mdape']:.1f} & {a['r2_ln']:.3f} & & "413 f"{b['mdape']:.1f} & {b['r2_ln']:.3f} \\\\")414 lines += [r"\bottomrule", r"\end{tabular}"]415 (TAB / "hgb_sensitivity.tex").write_text("\n".join(lines))416417418def t_by_quarter():419 Q = pd.read_csv(RES / "by_quarter.csv")420 piv = Q.pivot_table(index="quarter", columns="model", values="mdape")421 ns = Q.groupby("quarter")["n"].first()422 cols = ["A5 Semi-log + quadratics", "A6 Semi-log + splines",423 "D2 Ridge (cross-validated)", "D3 Random forest",424 "D4 Gradient boosting"]425 lines = [r"\begin{tabular}{lr" + "c" * len(cols) + "}", r"\toprule",426 r"Test quarter & $n$ & " + " & ".join(SHORT[c] for c in cols)427 + r" \\", r"\midrule"]428 for q, row in piv.iterrows():429 lines.append(f"{q} & {ns[q]:,.0f} & "430 + " & ".join(f"{row[c]:.1f}" for c in cols) + r" \\")431 lines += [r"\bottomrule", r"\end{tabular}"]432 (TAB / "by_quarter.tex").write_text("\n".join(lines))433434435def main() -> None:436 config.ensure_dirs()437 t_summary()438 _race_table("horserace_random.csv", "horserace_random.tex")439 _race_table("horserace_temporal.csv", "horserace_temporal.tex")440 t_generalization()441 t_boxcox()442 t_learning()443 t_segments()444 for fn in (t_inference, t_retransformation, t_same_period, t_pairs,445 t_group_splits, t_leakage_decomp, t_nested_fe,446 t_hgb_sensitivity, t_by_quarter):447 try:448 fn()449 except FileNotFoundError as e: # step 06 not run yet450 print(f" skipped {fn.__name__}: {e}")451 made = sorted(p.name for p in TAB.glob("*.tex"))452 print(f"{len(made)} tables written:", ", ".join(made))453454455if __name__ == "__main__":456 main()457