SPB Git

spb/wp9_uqo

Public

UQO Working Paper No. 9 — A grand hedonic model of the Canadian housing market: decomposing structure and location value.

26.7 KB · 481 lines python
1#!/usr/bin/env python32# Author: Simon-Pierre Boucher — contact@spboucher.ai3"""Step 05 — Generate every LaTeX table used in the paper (13 files).45Tables are built from the results tier selected with ``--results``. Since the6October 2026 revision the default is ``reproduced`` (the fully reproducible7pipeline with FSA-derived provinces); ``reference`` re-creates the six tables8of the May 2026 version from the archived original outputs.910Usage:  python scripts/05_make_tables.py [--results reproduced|reference]11"""12import argparse13import json14import sys15from pathlib import Path1617import numpy as np18import pandas as pd1920sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))2122from wp9 import sample  # noqa: E40223from wp9.config import REFERENCE, REPRODUCED, TABLES, ensure_dirs  # noqa: E4022425SUMMARY_VARS = [("price_cad", "List price (CAD)", "{:,.0f}"),26                ("ppm2", "Price per m$^2$ (CAD)", "{:,.0f}"),27                ("living_m2", "Living area (m$^2$)", "{:,.1f}"),28                ("bedrooms", "Bedrooms", "{:.2f}"),29                ("bathrooms", "Full bathrooms", "{:.2f}"),30                ("half_baths", "Half bathrooms", "{:.2f}"),31                ("parking_n", "Parking spaces", "{:.2f}"),32                ("stories_n", "Storeys", "{:.2f}"),33                ("lot_m2_f", "Lot area (m$^2$)", "{:,.0f}")]3435COEF_ORDER = [("ln_living", "$\\ln$ living area"), ("bathrooms", "Full bathrooms"),36              ("half_baths", "Half bathrooms"), ("bedrooms", "Bedrooms"),37              ("parking_n", "Parking spaces"), ("stories_n", "Storeys"),38              ("has_lot", "Has lot info"), ("ln_lot", "$\\ln(1+$lot m$^2)$")]394041def write(name: str, content: str) -> None:42    (TABLES / name).write_text(content + "\n")43    print(" tab", name)444546def stars(p: float) -> str:47    return "$^{***}$" if p < 0.01 else "$^{**}$" if p < 0.05 else "$^{*}$" if p < 0.1 else ""484950def cell(coefs: pd.DataFrame, key: str) -> str:51    if key not in coefs.index:52        return ""53    b, se, p = coefs.loc[key, ["coef", "se", "p"]]54    return f"\\makecell{{{b:.3f}{stars(p)}\\\\\\scriptsize({se:.3f})}}"555657# --------------------------------------------------------------------------58def table_summary_stats(res_dir: Path) -> None:59    stats_file = res_dir / "summary_stats.csv"60    if stats_file.exists():61        stats = pd.read_csv(stats_file, index_col=0)62        n = int(stats.attrs.get("n", 0)) or int(stats["n"].iloc[0])63    else:  # reproduced tier: compute from the micro sample64        s = sample.load_sample()65        rows = {}66        for var, _, _ in SUMMARY_VARS:67            col = s[var]68            rows[var] = {"mean": col.mean(), "sd": col.std(), "p25": col.quantile(0.25),69                         "median": col.median(), "p75": col.quantile(0.75), "n": len(s)}70        stats = pd.DataFrame(rows).T71        stats.to_csv(res_dir / "summary_stats.csv")72        n = len(s)73    lines = []74    for var, label, fmt in SUMMARY_VARS:75        r = stats.loc[var]76        lines.append(f"{label} & {fmt.format(r['mean'])} & {fmt.format(r['sd'])} & "77                     f"{fmt.format(r['p25'])} & {fmt.format(r['median'])} & "78                     f"{fmt.format(r['p75'])} \\\\")79    write("summary_stats.tex",80          "\\begin{table}[htbp]\\centering\n\\caption{Descriptive statistics}\n"81          "\\label{tab:summary_stats}\n\\begin{threeparttable}\n"82          "\\begin{tabular}{lccccc}\n\\toprule\n"83          "Variable & Mean & SD & P25 & Median & P75 \\\\\n\\midrule\n"84          + "\n".join(lines) +85          "\n\\bottomrule\n\\end{tabular}\n\\begin{tablenotes}\\footnotesize\\item "86          f"Notes: Estimation sample of {n:,} residential dwellings (houses, "87          "condominiums, plexes, townhouses and apartments) from the Canadian MLS, "88          "after parsing and trimming the extreme 1\\% tails of price and living area.\n"89          "\\end{tablenotes}\n\\end{threeparttable}\n\\end{table}")909192# --------------------------------------------------------------------------93def table_regression(res_dir: Path) -> None:94    fit = json.load(open(res_dir / "fit.json"))95    n_fe = int(fit["M5"].get("n_fe", 1153))96    coefs = {m: pd.read_csv(res_dir / f"coef_{m}.csv", index_col=0)97             for m in ("M1", "M2", "M3", "M5")}98    lines = [label + " & " + " & ".join(cell(coefs[m], key) for m in ("M1", "M2", "M3", "M5"))99             + " \\\\" for key, label in COEF_ORDER]100    footer = ["\\midrule",101              "Dwelling-type FE & No & Yes & Yes & Yes \\\\",102              "Ownership FE & No & Yes & Yes & Yes \\\\",103              "Province FE & No & No & Yes & --- \\\\",104              "FSA fixed effects & No & No & No & Yes \\\\",105              f"$R^2$ & {fit['M1']['r2']:.3f} & {fit['M2']['r2']:.3f} & "106              f"{fit['M3']['r2']:.3f} & {fit['M5']['r2']:.3f} \\\\",107              f"Observations & {fit['M1']['n']:,} & {fit['M2']['n']:,} & "108              f"{fit['M3']['n']:,} & {fit['M5']['n']:,} \\\\"]109    write("regression.tex",110          "\\begin{table}[htbp]\\centering\n\\caption{Hedonic regression estimates}\n"111          "\\label{tab:regression}\n\\begin{threeparttable}\n"112          "\\begin{tabular}{lcccc}\n\\toprule\n"113          " & (1) & (2) & (3) & (4) \\\\\n"114          " & Structural & +Type/Own. & +Province & Grand+FSA \\\\\n\\midrule\n"115          + "\n".join(lines + footer) +116          "\n\\bottomrule\n\\end{tabular}\n\\begin{tablenotes}\\footnotesize\\item "117          "Notes: Dependent variable is $\\ln(\\text{list price})$. Columns (1)--(3) use the "118          "house subsample; column (4) is the grand model over all residential dwellings "119          f"with {n_fe:,} absorbed FSA fixed effects (incl. nine province-level residual "120          "categories pooling FSAs with fewer than 25 listings). Columns (2)--(4) also include "121          "dwelling-type, ownership and listing-category dummies (Table~\\ref{tab:type_effects}). "122          "Cluster-robust standard errors (by FSA) in parentheses. "123          "$^{*}p<0.1$, $^{**}p<0.05$, $^{***}p<0.01$.\n"124          "\\end{tablenotes}\n\\end{threeparttable}\n\\end{table}")125126127# --------------------------------------------------------------------------128def table_robustness(res_dir: Path) -> None:129    rob = pd.read_csv(res_dir / "robustness.csv")130    lines = []131    for _, r in rob.iterrows():132        lines.append(133            f"{r['label']} & {r['n']:,.0f} & {r['r2']:.3f} & "134            f"\\makecell{{{r['ln_living']:.3f}\\\\\\scriptsize({r['se_living']:.3f})}} & "135            f"\\makecell{{{r['bathrooms']:.3f}\\\\\\scriptsize({r['se_bath']:.3f})}} & "136            f"\\makecell{{{r['ln_lot']:.3f}\\\\\\scriptsize({r['se_lot']:.3f})}} \\\\")137    write("robustness.tex",138          "\\begin{table}[htbp]\\centering\n"139          "\\caption{Robustness of key implicit prices across samples}\n"140          "\\label{tab:robustness}\n\\begin{threeparttable}\n"141          "\\begin{tabular}{lccccc}\n\\toprule\n"142          "Sample / specification & $N$ & $R^2$ & $\\ln$ area & Full bath & $\\ln$ lot \\\\\n"143          "\\midrule\n" + "\n".join(lines) +144          "\n\\bottomrule\n\\end{tabular}\n\\begin{tablenotes}\\footnotesize\\item "145          "Notes: Each row re-estimates the grand FSA-fixed-effects model on a different "146          "sample. Cluster-robust (FSA) standard errors in parentheses. The last two rows "147          "cap storeys and parking counts at their 99th percentiles and drop the "148          "province-level categories that pool FSAs with fewer than 25 listings.\n"149          "\\end{tablenotes}\n\\end{threeparttable}\n\\end{table}")150151152# --------------------------------------------------------------------------153def table_quantile(res_dir: Path) -> None:154    q = pd.read_csv(res_dir / "quantile.csv")155    taus = q[q["tau"].notna()]156    ols = q[q["tau"].isna()].iloc[0]157    lines = []158    for _, r in taus.iterrows():159        tau = f"{r['tau']:g}"160        lines.append(161            f"$\\tau={tau}$ & "162            f"\\makecell{{{r['ln_living']:.3f}\\\\\\scriptsize({r['se_living']:.3f})}} & "163            f"\\makecell{{{r['bathrooms']:.3f}\\\\\\scriptsize({r['se_bath']:.3f})}} & "164            f"\\makecell{{{r['ln_lot']:.3f}\\\\\\scriptsize({r['se_lot']:.3f})}} \\\\")165    write("quantile.tex",166          "\\begin{table}[htbp]\\centering"167          "\\caption{Quantile hedonic estimates across the price distribution}\n"168          "\\label{tab:quantile}\\begin{threeparttable}\\begin{tabular}{lccc}\n\\toprule\n"169          "Quantile & $\\ln$ area & Full bath & $\\ln$ lot \\\\\n\\midrule\n"170          + "\n".join(lines) + "\n\\midrule\nOLS & "171          f"{ols['ln_living']:.3f} & {ols['bathrooms']:.3f} & {ols['ln_lot']:.3f} \\\\\n"172          "\\bottomrule\\end{tabular}\\begin{tablenotes}\\footnotesize\\item Notes: House "173          "subsample, province fixed effects (province derived from the FSA), structural "174          "controls and ownership dummies. Kernel-based heteroskedasticity-robust standard "175          "errors (Epanechnikov kernel, Hall--Sheather bandwidth) in parentheses; the OLS row "176          "reports HC1 standard errors.\\end{tablenotes}\\end{threeparttable}\\end{table}")177178179# --------------------------------------------------------------------------180def table_lopo(res_dir: Path) -> None:181    lopo = pd.read_csv(res_dir / "lopo.csv")182    lines = [f"{r['prov']} & {r['n']:,.0f} & {r['r2_within']:.3f} \\\\"183             for _, r in lopo.iterrows()]184    write("lopo.tex",185          "\\begin{table}[htbp]\\centering"186          "\\caption{Leave-one-province-out spatial cross-validation}\n"187          "\\label{tab:lopo}\\begin{threeparttable}\\begin{tabular}{lcc}\n\\toprule\n"188          "Held-out province & $N$ & Within-province $R^2$ \\\\\n\\midrule\n"189          + "\n".join(lines) +190          f"\n\\midrule\nMean & --- & {lopo['r2_within'].mean():.3f} \\\\\n"191          "\\bottomrule\\end{tabular}\\begin{tablenotes}\\footnotesize\\item Notes: The "192          "structural hedonic model is estimated on all provinces except one and used to "193          "predict the held-out province; a province-specific intercept is allowed (the "194          "price \\emph{level} is not identified out of sample), so the metric captures "195          "whether the \\emph{structural} implicit prices transfer across space."196          "\\end{tablenotes}\\end{threeparttable}\\end{table}")197198199# --------------------------------------------------------------------------200def table_oos(res_dir: Path) -> None:201    oos = json.load(open(res_dir / "oos.json"))202    write("oos.tex",203          "\\begin{table}[htbp]\\centering\n"204          "\\caption{Out-of-sample valuation performance (80/20 split)}\n"205          "\\label{tab:oos}\n\\begin{threeparttable}\n\\begin{tabular}{lc}\n\\toprule\n"206          "Metric & Value \\\\\n\\midrule\n"207          f"Training listings & {oos['n_train']:,} \\\\\n"208          f"Test listings & {oos['n_test']:,} \\\\\n"209          f"Out-of-sample $R^2$ (log price) & {oos['oos_r2']:.3f} \\\\\n"210          f"RMSE (log points) & {oos['rmse_log']:.3f} \\\\\n"211          f"Median absolute \\% error & {oos['median_ape']:.1f}\\% \\\\\n"212          f"Mean absolute \\% error & {oos['mean_ape']:.1f}\\% \\\\\n"213          f"Share priced within $\\pm$10\\% & {oos['within10']:.1f}\\% \\\\\n"214          f"Share priced within $\\pm$20\\% & {oos['within20']:.1f}\\% \\\\\n"215          "\\bottomrule\n\\end{tabular}\n\\begin{tablenotes}\\footnotesize\\item Notes: "216          "Model trained on a random 80\\% of listings and scored on the held-out 20\\% "217          "(restricted to neighbourhoods observed in training). Prices back-transformed "218          "with Duan's smearing estimator. Table~\\ref{tab:spatial_holdout} reports "219          "repeated splits and spatial (FSA-level) holdouts.\n"220          "\\end{tablenotes}\n\\end{threeparttable}\n\\end{table}")221222223# ==========================================================================224# Tables added in the October 2026 revision (reproduced tier only)225# ==========================================================================226def _pct(b):227    return f"{(np.exp(b) - 1) * 100:+.1f}"228229230def table_shapley(res_dir: Path) -> None:231    sh = pd.read_csv(res_dir / "shapley.csv")232    lines = []233    for sample_label, g in sh.groupby("sample", sort=False):234        lines.append(f"\\multicolumn{{4}}{{l}}{{\\emph{{{sample_label}}} "235                     f"($N={int(g['n'].iloc[0]):,}$, $R^2={g['total_r2'].iloc[0]:.3f}$)}} \\\\")236        for _, r in g.iterrows():237            owen = "---" if pd.isna(r["owen"]) else f"{r['owen'] * 100:.1f}"238            if r["block"] == "Neighbourhood (FSA)":239                owen = f"{r['owen'] * 100:.1f}\\tnote{{a}}"240            lines.append(f"\\quad {r['block']} & {r['sequential'] * 100:.1f} & "241                         f"{r['shapley'] * 100:.1f} & {owen} \\\\")242        lines.append("\\addlinespace")243    write("shapley.tex",244          "\\begin{table}[htbp]\\centering\n"245          "\\caption{Decomposition of the explained variance of log list prices "246          "(percentage points of $R^2$)}\n\\label{tab:shapley}\n\\begin{threeparttable}\n"247          "\\begin{tabular}{lccc}\n\\toprule\n"248          "Block & Sequential & Shapley & Owen (grouped) \\\\\n\\midrule\n"249          + "\n".join(lines) +250          "\\bottomrule\n\\end{tabular}\n\\begin{tablenotes}\\footnotesize\n"251          "\\item Notes: \\emph{Sequential} is the increment in $R^2$ when the block is "252          "added in the order structure $\\to$ type/ownership $\\to$ province $\\to$ FSA. "253          "\\emph{Shapley} averages the increment over all $4!$ orderings of the four blocks "254          "\\citep{israeli2007shapley,gromping2007estimators}. \\emph{Owen} treats province and "255          "FSA as a single location group and averages over the $3!$ orderings of the three "256          "groups \\citep{huettner2012axiomatic}. Province dummies are nested in the FSA effects, "257          "so the province block adds nothing once FSA effects are present.\n"258          "\\item[a] Location group (province $+$ FSA).\n"259          "\\end{tablenotes}\n\\end{threeparttable}\n\\end{table}")260261262def table_se_comparison(res_dir: Path) -> None:263    se = pd.read_csv(res_dir / "se_comparison.csv")264    labels = {"ln_living": "$\\ln$ living area", "bathrooms": "Full bathrooms",265              "half_baths": "Half bathrooms", "bedrooms": "Bedrooms", "ln_lot": "$\\ln(1+$lot m$^2)$"}266    lines = []267    for _, r in se.iterrows():268        lines.append(f"{labels[r['variable']]} & {r['coef']:.3f} & {r['se_hc1']:.4f} & "269                     f"{r['se_fsa']:.4f} & {r['se_prov']:.4f} & {r['se_twoway']:.4f} & "270                     f"{r['p_wild_prov']:.3f} \\\\")271    g_fsa, g_prov = int(se["n_fsa_clusters"].iloc[0]), int(se["n_prov_clusters"].iloc[0])272    write("se_comparison.tex",273          "\\begin{table}[htbp]\\centering\n"274          "\\caption{Grand-model coefficients under alternative variance estimators}\n"275          "\\label{tab:se_comparison}\n\\begin{threeparttable}\\small\n"276          "\\begin{tabular}{lcccccc}\n\\toprule\n"277          " & & \\multicolumn{4}{c}{Standard error} & Wild-cluster \\\\\n"278          "\\cmidrule(lr){3-6}\n"279          f" & Coef. & HC1 & \\makecell{{FSA\\\\($G={g_fsa:,}$)}} & \\makecell{{Province\\\\($G={g_prov}$)}} & Two-way & "280          "$p$-value \\\\\n\\midrule\n"281          + "\n".join(lines) +282          "\n\\bottomrule\n\\end{tabular}\n\\begin{tablenotes}\\footnotesize\\item Notes: "283          "Grand model (M5) estimated on the FSA-demeaned data. HC1 is the "284          "heteroskedasticity-robust estimator; FSA and province cluster by neighbourhood "285          "and by province; two-way clusters by both. The last column is the $p$-value of a "286          "restricted wild-cluster bootstrap-$t$ test at the province level with Rademacher "287          "weights and full enumeration of the $2^{9}=512$ sign vectors "288          "\\citep{cameron2015practitioner,mackinnon2017wild}; its smallest attainable value "289          "is $2/512=0.004$.\n\\end{tablenotes}\n\\end{threeparttable}\n\\end{table}")290291292def table_spline(res_dir: Path) -> None:293    sp = pd.read_csv(res_dir / "spline.csv")294    lines = [f"{int(r['segment'])} & {r['lo_m2']:.0f}--{r['hi_m2']:.0f} & "295             f"\\makecell{{{r['elasticity']:.3f}\\\\\\scriptsize({r['se']:.3f})}} \\\\"296             for _, r in sp.iterrows()]297    r0 = sp.iloc[0]298    write("spline.tex",299          "\\begin{table}[htbp]\\centering\n"300          "\\caption{Living-area elasticity by size segment (piecewise-linear spline, grand model)}\n"301          "\\label{tab:spline}\n\\begin{threeparttable}\n"302          "\\begin{tabular}{lcc}\n\\toprule\n"303          "Segment & Living area (m$^2$) & Elasticity \\\\\n\\midrule\n"304          + "\n".join(lines) +305          f"\n\\midrule\n$R^2$: linear / quadratic / spline & \\multicolumn{{2}}{{c}}{{{r0['r2_linear']:.4f} / "306          f"{r0['r2_quadratic']:.4f} / {r0['r2_spline']:.4f}}} \\\\\n"307          "\\bottomrule\n\\end{tabular}\n\\begin{tablenotes}\\footnotesize\\item Notes: "308          "Linear spline in $\\ln(\\text{area})$ with knots at the quintiles of living area, "309          "all other grand-model controls and FSA fixed effects included. Each entry is the "310          "elasticity of price with respect to area within the segment; FSA-clustered standard "311          "errors in parentheses.\n\\end{tablenotes}\n\\end{threeparttable}\n\\end{table}")312313314def table_spatial_holdout(res_dir: Path) -> None:315    sh = json.load(open(res_dir / "spatial_holdout.json"))316    rm, rs = sh["random_mean"], sh["random_sd"]317    pm, ps = sh["fsa_holdout_mean_prov_mean"], sh["fsa_holdout_sd_prov_mean"]318    nm, ns = sh["fsa_holdout_mean_nn_idw"], sh["fsa_holdout_sd_nn_idw"]319    def c(m, s_, key, fmt):320        return f"{fmt.format(m[key])} \\scriptsize({fmt.format(s_[key])})"321    rows = [("Out-of-sample $R^2$ (log price)", "oos_r2", "{:.3f}"),322            ("RMSE (log points)", "rmse_log", "{:.3f}"),323            ("Median absolute \\% error", "median_ape", "{:.1f}"),324            ("Mean absolute \\% error (top 1\\% trimmed)", "mean_ape_trim", "{:.1f}"),325            ("Share within $\\pm$10\\%", "within10", "{:.1f}"),326            ("Share within $\\pm$20\\%", "within20", "{:.1f}")]327    lines = [f"{lab} & {c(rm, rs, k, f)} & {c(pm, ps, k, f)} & {c(nm, ns, k, f)} \\\\"328             for lab, k, f in rows]329    n_held = sh["fsa_holdout"][0]["n_fsa_held"]330    write("spatial_holdout.tex",331          "\\begin{table}[htbp]\\centering\n"332          "\\caption{Random versus spatial holdouts: valuation accuracy}\n"333          "\\label{tab:spatial_holdout}\n\\begin{threeparttable}\\small\n"334          "\\begin{tabular}{lccc}\n\\toprule\n"335          " & Random 80/20 & \\multicolumn{2}{c}{Held-out FSAs (20\\%)} \\\\\n"336          "\\cmidrule(lr){3-4}\n"337          "Metric & \\makecell{(within\\\\support)} & \\makecell{Province-mean\\\\effect} & \\makecell{IDW of 5\\\\nearest FSAs} \\\\\n\\midrule\n"338          + "\n".join(lines) +339          "\n\\bottomrule\n\\end{tabular}\n\\begin{tablenotes}\\footnotesize\\item Notes: "340          "Means over five replications, standard deviations across replications in "341          "parentheses. Column~1: random 80/20 splits of listings, evaluation restricted to "342          "FSAs observed in training. Columns~2--3: 20\\% of the FSAs (" f"{n_held}" " of the "343          "non-pooled neighbourhoods) are held out entirely; their listings are valued with the "344          "structural coefficients from the training FSAs and a neighbourhood effect imputed "345          "either as the mean effect of the training FSAs in the same province or as the "346          "inverse-distance-weighted mean of the five nearest training FSAs (centroid "347          "distance). Prices back-transformed with Duan's smearing estimator. The trimmed mean "348          "excludes the largest 1\\% of errors in each replication: a few listings with "349          "implausible parking or storey counts (up to 3{,}620 spaces) otherwise dominate "350          "the untrimmed mean.\n"351          "\\end{tablenotes}\n\\end{threeparttable}\n\\end{table}")352353354def table_moran_sensitivity(res_dir: Path) -> None:355    ms = pd.read_csv(res_dir / "moran_sensitivity.csv")356    seeds = sorted(ms["seed"].unique())357    lines = []358    for k in sorted(ms["k"].unique()):359        cells = []360        for seed in seeds:361            for model in ("structure_only", "grand_model"):362                r = ms[(ms["k"] == k) & (ms["seed"] == seed) & (ms["model"] == model)].iloc[0]363                cells.append(f"{r['I']:.3f} \\scriptsize($z={r['z']:.0f}$)")364        lines.append(f"$k={k}$ & " + " & ".join(cells) + " \\\\")365    write("moran_sensitivity.tex",366          "\\begin{table}[htbp]\\centering\n"367          "\\caption{Moran's $I$ of residuals: sensitivity to the weight matrix and subsample}\n"368          "\\label{tab:moran_sensitivity}\n\\begin{threeparttable}\n"369          "\\begin{tabular}{lcccc}\n\\toprule\n"370          " & \\multicolumn{2}{c}{Subsample A} & \\multicolumn{2}{c}{Subsample B} \\\\\n"371          "\\cmidrule(lr){2-3}\\cmidrule(lr){4-5}\n"372          "Neighbours & Structure only & Grand model & Structure only & Grand model \\\\\n\\midrule\n"373          + "\n".join(lines) +374          "\n\\bottomrule\n\\end{tabular}\n\\begin{tablenotes}\\footnotesize\\item Notes: "375          "Moran's $I$ with row-standardised $k$-nearest-neighbour weights (great-circle "376          "distance) on two independent random subsamples of 15{,}000 listings. $z$-scores "377          "from 199 random permutations; every permutation $p$-value equals its minimum "378          "attainable value of $1/200=0.005$. ``Structure only'' is the residual of the "379          "model with structural, type and ownership controls but no location effects.\n"380          "\\end{tablenotes}\n\\end{threeparttable}\n\\end{table}")381382383def table_type_effects(res_dir: Path) -> None:384    coefs = pd.read_csv(res_dir / "coef_M5.csv", index_col=0)385    groups = [("Dwelling type (ref.: apartment)", "bt_",386               {"House": "House", "Row/Townhouse": "Row / townhouse", "Duplex": "Duplex",387                "Triplex": "Triplex", "Fourplex": "Fourplex", "Manufactured": "Manufactured home",388                "Other": "Other"}),389              ("Ownership (ref.: co-op / co-ownership)", "ow_",390               {"Freehold": "Freehold", "Condo/Strata": "Condominium / strata",391                "Leasehold": "Leasehold", "Unknown": "Not reported", "Other": "Other"})]392    lines = []393    for title, pfx, labels in groups:394        lines.append(f"\\multicolumn{{4}}{{l}}{{\\emph{{{title}}}}} \\\\")395        for key, lab in labels.items():396            name = pfx + key397            if name not in coefs.index:398                continue399            b, se, p = coefs.loc[name, ["coef", "se", "p"]]400            kennedy = (np.exp(b - 0.5 * se ** 2) - 1) * 100401            lines.append(f"\\quad {lab} & {b:.3f}{stars(p)} & ({se:.3f}) & {kennedy:+.1f} \\\\")402        lines.append("\\addlinespace")403    write("type_effects.tex",404          "\\begin{table}[htbp]\\centering\n"405          "\\caption{Dwelling-type and ownership effects in the grand model}\n"406          "\\label{tab:type_effects}\n\\begin{threeparttable}\n"407          "\\begin{tabular}{lccc}\n\\toprule\n"408          " & Coefficient & SE & \\% effect \\\\\n\\midrule\n"409          + "\n".join(lines) +410          "\\bottomrule\n\\end{tabular}\n\\begin{tablenotes}\\footnotesize\\item Notes: "411          "Grand model (M5), all residential dwellings, FSA fixed effects absorbed, "412          "FSA-clustered standard errors. The percentage effect is the \\citet{kennedy1981estimation} "413          "estimator $100\\,[\\exp(\\hat\\gamma-\\tfrac{1}{2}\\widehat{V}(\\hat\\gamma))-1]$, "414          "which corrects the \\citet{halvorsen1980interpretation} transformation for "415          "estimation error. Effects are relative to the omitted category and conditional "416          "on living area, the other structural attributes and the neighbourhood. "417          "$^{*}p<0.1$, $^{**}p<0.05$, $^{***}p<0.01$.\n"418          "\\end{tablenotes}\n\\end{threeparttable}\n\\end{table}")419420421def table_gradient(res_dir: Path) -> None:422    g = json.load(open(res_dir / "gradient_metro.json"))423    bins = pd.read_csv(res_dir / "gradient_bins.csv")424    edges = [0, 5, 10, 20, 40, 80, 160, 320]425    blines = []426    for i, r in bins.iterrows():427        lab = f"{edges[i]}--{edges[i + 1]}" if i + 1 < len(edges) else f"$>${edges[-1]}"428        blines.append(f"\\quad {lab} km & {int(r['n']):,} & {r['x']:.0f} & {(np.exp(r['prem']) - 1) * 100:+.1f} \\\\")429    p, m, q = g["pooled"], g["metro_fe"], g["quadratic"]430    write("gradient.tex",431          "\\begin{table}[htbp]\\centering\n"432          "\\caption{The urban price gradient: location premium and distance to the nearest metro}\n"433          "\\label{tab:gradient}\n\\begin{threeparttable}\n"434          "\\begin{tabular}{lccc}\n\\toprule\n"435          "\\multicolumn{4}{l}{\\emph{Panel A: mean location premium by distance band}} \\\\\n"436          "Distance band & Listings & Median km & Premium (\\%) \\\\\n\\midrule\n"437          + "\n".join(blines) +438          "\n\\midrule\n\\multicolumn{4}{l}{\\emph{Panel B: regressions of the location premium on $\\ln(\\text{distance})$}} \\\\\n"439          " & Slope & SE & $R^2$ \\\\\n\\midrule\n"440          f"\\quad Pooled & {p['beta']:.4f} & ({p['se_fsa']:.4f}) & {p['r2']:.3f} \\\\\n"441          f"\\quad Nearest-metro intercepts & {m['beta']:.4f} & ({m['se_fsa']:.4f}) & {m['r2']:.3f} \\\\\n"442          f"\\quad Quadratic: $\\ln d$ & {q['b1']:.4f} & ({q['se1']:.4f}) & \\multirow{{2}}{{*}}{{{q['r2']:.3f}}} \\\\\n"443          f"\\quad \\phantom{{Quadratic:}} $(\\ln d)^2$ & {q['b2']:.4f} & ({q['se2']:.4f}) & \\\\\n"444          "\\bottomrule\n\\end{tabular}\n\\begin{tablenotes}\\footnotesize\\item Notes: "445          "The location premium is the residual of the model with structural, type and "446          "ownership controls but no location effects; the premium in Panel~A is "447          "$100\\,(e^{\\bar{u}}-1)$ for the band mean $\\bar{u}$. Distance is the great-circle "448          "distance to the nearest of nine metropolitan centres (Toronto, Montreal, Vancouver, "449          "Calgary, Ottawa--Gatineau, Edmonton, Winnipeg, Quebec City, Halifax); listings within "450          "0.5~km of a centre are excluded from Panel~B. FSA-clustered standard errors.\n"451          "\\end{tablenotes}\n\\end{threeparttable}\n\\end{table}")452453454def main() -> None:455    parser = argparse.ArgumentParser()456    parser.add_argument("--results", choices=["reference", "reproduced"],457                        default="reproduced",458                        help="results tier (default: reproduced — current pipeline)")459    args = parser.parse_args()460    res_dir = REFERENCE if args.results == "reference" else REPRODUCED461    ensure_dirs()462    table_summary_stats(res_dir)463    table_regression(res_dir)464    table_robustness(res_dir)465    table_quantile(res_dir)466    table_lopo(res_dir)467    table_oos(res_dir)468    if args.results == "reproduced":469        table_shapley(res_dir)470        table_se_comparison(res_dir)471        table_spline(res_dir)472        table_spatial_holdout(res_dir)473        table_moran_sensitivity(res_dir)474        table_type_effects(res_dir)475        table_gradient(res_dir)476    print("all tables written to", TABLES)477478479if __name__ == "__main__":480    main()481
Files · 4912287e42ce48d7439ce93e7277a96f6e5a4367
Menu

Theme