SPB Git

spb/wp2_uqo

Public

UQO Working Paper No. 2 — Decoding Real Estate Descriptions: text-based hedonic analysis of housing listings.

14.3 KB · 384 lines python
1#!/usr/bin/env python32# Author: Simon-Pierre Boucher — contact@spboucher.ai3#4"""Step 6 — Generate the paper's LaTeX tables from the pipeline results.56Every number printed in these tables comes from results/*.csv (steps 3-4),7so the paper cannot drift from the analysis outputs.89Inputs : results/*.csv, data/processed/hedonic_maison_results.csv10Outputs: paper/tables/tab_descriptive.tex11         paper/tables/tab_similarity_stats.tex12         paper/tables/tab_model_comparison.tex13         paper/tables/tab_full_results.tex14         paper/tables/tab_quantile.tex15         paper/tables/tab_parsimonious.tex      (appendix)16         paper/tables/tab_reference_texts.tex   (appendix)17"""1819import sys20from pathlib import Path2122import numpy as np23import pandas as pd2425sys.path.insert(0, str(Path(__file__).resolve().parents[1]))2627from src import config28from src.references import ENGLISH_LABELS, REFERENCES2930TABLES_DIR = config.ROOT / "paper" / "tables"3132HEADER = "% Author: Simon-Pierre Boucher — contact@spboucher.ai\n% Generated by scripts/06_tables.py — do not edit by hand.\n"333435def tex_num(x, dec=0, signed=False):36    """Format a number with LaTeX minus signs and {,} thousand separators."""37    if signed:38        s = f"{x:+,.{dec}f}"39    else:40        s = f"{x:,.{dec}f}"41    s = s.replace(",", "{,}").replace("-", r"$-$").replace("+", "+")42    return s434445def stars(p):46    return "***" if p < 0.001 else "**" if p < 0.01 else "*" if p < 0.05 else ""474849def pval_str(p):50    return "$< 0.001$" if p < 0.001 else f"{p:.3f}"515253def write(name, content):54    path = TABLES_DIR / name55    path.write_text(HEADER + content)56    print(f"  -> {path}")575859def tab_descriptive():60    d = pd.read_csv(config.RESULTS_DIR / "descriptive_stats.csv", index_col=0)61    rows_spec = [62        ("price", "Price (\\$)", 0),63        ("log_price", "log(Price)", 2),64        ("bedrooms", "Bedrooms", 1),65        ("bathrooms", "Bathrooms", 1),66        ("half_baths", "Half-bathrooms", 1),67        ("parking", "Parking spaces", 1),68        ("stories", "Stories", 1),69        ("land_size", "Lot size (raw field, mixed units)", 0),70        ("remarks_length", "Description length (char.)", 0),71    ]72    lines = []73    for var, label, dec in rows_spec:74        r = d.loc[var]75        cells = [tex_num(r["mean"], dec), tex_num(r["std"], dec),76                 tex_num(r["min"], 0 if var != "log_price" else 2),77                 tex_num(r["25%"], 0 if var != "log_price" else 2),78                 tex_num(r["50%"], 0 if var != "log_price" else 2),79                 tex_num(r["75%"], 0 if var != "log_price" else 2),80                 tex_num(r["max"], 0 if var != "log_price" else 2)]81        lines.append(f"{label} & " + " & ".join(cells) + " \\\\")82    body = "\n".join(lines)83    content = rf"""\begin{{table}}[!htbp]84\centering85\caption{{Descriptive statistics for structural and textual variables ($n = 17{{,}}087$).}}86\label{{tab:descriptive}}87\small88\begin{{adjustbox}}{{max width=\textwidth}}89\begin{{tabular}}{{lR{{1.6cm}}R{{1.7cm}}R{{1.2cm}}R{{1.4cm}}R{{1.4cm}}R{{1.4cm}}R{{1.9cm}}}}90\toprule91\textbf{{Variable}} & \textbf{{Mean}} & \textbf{{Std.\ Dev.}} & \textbf{{Min}} & \textbf{{P25}} & \textbf{{Median}} & \textbf{{P75}} & \textbf{{Max}} \\92\midrule93{body}94\bottomrule95\end{{tabular}}96\end{{adjustbox}}97\begin{{minipage}}{{\textwidth}}98\vspace{{4pt}}\footnotesize99\textit{{Notes:}} The sample is restricted to single-family houses with a positive listing price and a description of at least 20 characters. Descriptions are truncated at approximately 700 characters in the data export, which bounds the description-length variable. The raw lot-size field mixes units at the source (61\\% of values are reported in m$^2$, 32\\% in sq.\\ ft., 2\\% in acres or hectares, 4\\% missing) and is used as reported in the baseline models for comparability with the first version of the paper; a unit-corrected lot area is used in Section~\\ref{{sec:extended}}.100\end{{minipage}}101\end{{table}}102"""103    write("tab_descriptive.tex", content)104105106def tab_similarity_stats():107    s = pd.read_csv(config.RESULTS_DIR / "similarity_stats.csv", index_col=0)108    lines = []109    for label, r in s.iterrows():110        label_tex = label.replace("&", "\\&")111        cells = [f"{r['mean']:.3f}", f"{r['std']:.3f}",112                 tex_num(r["min"], 3), f"{r['50%']:.3f}", f"{r['max']:.3f}",113                 tex_num(r["corr_log_price"], 3)]114        lines.append(f"{label_tex} & " + " & ".join(cells) + " \\\\")115    body = "\n".join(lines)116    content = rf"""\begin{{table}}[!htbp]117\centering118\caption{{Descriptive statistics for cosine similarity features and bivariate correlations with log(price).}}119\label{{tab:sim_stats}}120\begin{{threeparttable}}121\small122\begin{{adjustbox}}{{max width=\textwidth}}123\begin{{tabular}}{{lR{{1.2cm}}R{{1.2cm}}R{{1.2cm}}R{{1.2cm}}R{{1.2cm}}R{{1.4cm}}}}124\toprule125\textbf{{Reference}} & \textbf{{Mean}} & \textbf{{Std.~Dev.}} & \textbf{{Min}} & \textbf{{Median}} & \textbf{{Max}} & \textbf{{Corr.\ $\ln P$}} \\126\midrule127{body}128\bottomrule129\end{{tabular}}130\end{{adjustbox}}131\begin{{tablenotes}}132\footnotesize133\item \textit{{Notes:}} Cosine similarity is computed between each listing embedding and the corresponding reference description embedding using the all-MiniLM-L6-v2 sentence transformer. Corr.\ $\ln P$ denotes the Pearson correlation with log listing price.134\end{{tablenotes}}135\end{{threeparttable}}136\end{{table}}137"""138    write("tab_similarity_stats.tex", content)139140141def tab_model_comparison():142    m = pd.read_csv(config.RESULTS_DIR / "model_comparison.csv")143    spec_labels = {144        "A": "Structural only",145        "B": "+ Description length",146        "C": "+ Semantic similarities (20)",147        "D": "Full model (B + C)",148        "E": "Parsimonious (sig.\\ only)",149    }150    best_r2 = m["r2"].idxmax()151    best_adj = m["adj_r2"].idxmax()152    best_aic = m["aic"].idxmin()153    best_bic = m["bic"].idxmin()154    lines = []155    for i, r in m.iterrows():156        def fmt(val, dec, best):157            s = tex_num(val, dec)158            return rf"\textbf{{{s}}}" if i == best else s159        delta = "---" if r["model"] == "A" else tex_num(r["delta_r2_vs_A"], 3, signed=True)160        lines.append(161            f"{r['model']} & {spec_labels[r['model']]} & "162            f"{fmt(r['r2'], 4, best_r2)} & {fmt(r['adj_r2'], 4, best_adj)} & "163            f"{fmt(r['aic'], 0, best_aic)} & {fmt(r['bic'], 0, best_bic)} & "164            f"{int(r['k'])} & {delta} \\\\"165        )166    body = "\n".join(lines)167    content = rf"""\begin{{table}}[!htbp]168\centering169\caption{{Hedonic model comparison ($n = 17{{,}}087$).}}170\label{{tab:model_comparison}}171\begin{{threeparttable}}172\small173\begin{{adjustbox}}{{max width=\textwidth}}174\begin{{tabular}}{{clR{{1.3cm}}R{{1.3cm}}R{{1.6cm}}R{{1.6cm}}R{{0.8cm}}R{{1.8cm}}}}175\toprule176& \textbf{{Specification}} & $\boldsymbol{{R^2}}$ & \textbf{{Adj.}}~$\boldsymbol{{R^2}}$ & \textbf{{AIC}} & \textbf{{BIC}} & $\boldsymbol{{k}}$ & $\boldsymbol{{\Delta R^2}}$ \textbf{{vs.\ A}} \\177\midrule178{body}179\bottomrule180\end{{tabular}}181\end{{adjustbox}}182\begin{{tablenotes}}183\footnotesize184\item \textit{{Notes:}} $k$ denotes the number of regressors excluding the intercept. Bold values indicate the best fit for each criterion. Models are estimated by OLS with HC3 robust standard errors.185\end{{tablenotes}}186\end{{threeparttable}}187\end{{table}}188"""189    write("tab_model_comparison.tex", content)190191192def _coef_rows(d, variables):193    rows = []194    for _, r in d[d.variable.isin(variables)].iterrows():195        rows.append((r["label"] if r["variable"].startswith("sim_") else None,196                     r["variable"], r["coefficient"], r["std_error"],197                     r["t_value"], r["p_value"], r["impact_pct"]))198    return rows199200201STRUCT_LABELS = {202    "bedrooms": "Bedrooms", "bathrooms": "Bathrooms",203    "half_baths": "Half-bathrooms", "parking": "Parking",204    "stories": "Stories", "land_size": "Lot size",205    "remarks_length": "Description length",206}207208209def tab_full_results():210    d = pd.read_csv(config.RESULTS_DIR / "coefficients_model_D.csv")211    struct = d[~d.variable.str.startswith("sim_")].copy()212    struct["label"] = struct.variable.map(STRUCT_LABELS)213    sims = d[d.variable.str.startswith("sim_")].copy()214    pos = sims[sims.coefficient >= 0].sort_values("coefficient", ascending=False)215    neg = sims[sims.coefficient < 0].sort_values("coefficient")216    struct = struct.sort_values("coefficient", ascending=False)217218    def rows(block):219        out = []220        for _, r in block.iterrows():221            label = str(r["label"]).replace("&", "\\&")222            out.append(223                f"\\quad {label} & {tex_num(r['coefficient'], 4)} & "224                f"{tex_num(r['std_error'], 4)} & {tex_num(r['t_value'], 2)} & "225                f"{pval_str(r['p_value'])} & {tex_num(r['impact_pct'], 1, signed=True)} & "226                f"{stars(r['p_value'])} \\\\"227            )228        return "\n".join(out)229230    content = rf"""\begin{{table}}[!htbp]231\centering232\caption{{Full model (D) coefficient estimates. HC3 robust standard errors. All variables standardised.}}233\label{{tab:full_results}}234\small235\begin{{adjustbox}}{{max width=\textwidth}}236\begin{{tabular}}{{lR{{1.2cm}}R{{1.2cm}}R{{1.1cm}}R{{1.6cm}}R{{1.3cm}}c}}237\toprule238\textbf{{Variable}} & \textbf{{Coeff.}} & \textbf{{Std.\ Err.}} & \textbf{{$t$-stat}} & \textbf{{$p$-value}} & \textbf{{Impact (\%)}} & \\239\midrule240\multicolumn{{7}}{{l}}{{\textit{{Panel A: Structural variables}}}} \\241{rows(struct)}242\midrule243\multicolumn{{7}}{{l}}{{\textit{{Panel B: Semantic similarities --- positive price effects}}}} \\244{rows(pos)}245\midrule246\multicolumn{{7}}{{l}}{{\textit{{Panel C: Semantic similarities --- negative price effects}}}} \\247{rows(neg)}248\bottomrule249\multicolumn{{7}}{{l}}{{\footnotesize{{\signote\ Impact $= (e^{{\hat{{\beta}}}} - 1) \times 100$\%. $n = 17{{,}}087$; Adj.\ $R^2 = 0.511$.}}}}250\end{{tabular}}251\end{{adjustbox}}252\end{{table}}253"""254    write("tab_full_results.tex", content)255256257def tab_quantile():258    r = pd.read_csv(config.RESULTS_DIR / "robustness_results.csv")259    d = pd.read_csv(config.RESULTS_DIR / "coefficients_model_D.csv").set_index("variable")260    dims = ["sim_moderne_contemporain", "sim_luxe", "sim_terrain_nature",261            "sim_familial", "sim_urgence_motivation", "sim_a_renover"]262    patterns = {263        "sim_moderne_contemporain": "High at both tails",264        "sim_luxe": "Increasing",265        "sim_terrain_nature": "Stable",266        "sim_familial": "Mildly attenuating",267        "sim_urgence_motivation": "Attenuating",268        "sim_a_renover": "Attenuating",269    }270    lines = []271    for v in dims:272        label = ENGLISH_LABELS[v[4:]].replace("&", "\\&")273        cells = []274        for tau in ("0.25", "0.5", "0.75"):275            q = r[(r.test == f"QuantReg_tau{tau}") & (r.variable == v)].iloc[0]276            cells.append(f"{tex_num(q['value'], 3)}{stars(q['p_value'])}")277        ols = d.loc[v]278        cells.append(f"{tex_num(ols['coefficient'], 3)}{stars(ols['p_value'])}")279        lines.append(f"{label} & " + " & ".join(cells) + f" & {patterns[v]} \\\\")280    body = "\n".join(lines)281    content = rf"""\begin{{table}}[!htbp]282\centering283\caption{{Quantile regression coefficients for selected semantic dimensions.}}284\label{{tab:quantile}}285\small286\begin{{adjustbox}}{{max width=\textwidth}}287\begin{{tabular}}{{lR{{1.5cm}}R{{1.5cm}}R{{1.5cm}}R{{1.5cm}}l}}288\toprule289\textbf{{Dimension}} & $\boldsymbol{{\tau = 0.25}}$ & $\boldsymbol{{\tau = 0.50}}$ & $\boldsymbol{{\tau = 0.75}}$ & \textbf{{OLS}} & \textbf{{Pattern}} \\290\midrule291{body}292\bottomrule293\multicolumn{{6}}{{l}}{{\footnotesize{{\signote\ Quantile regressions of Model~D; standard errors follow the kernel-based estimator of \citet{{koenker1978regression}}.}}}}294\end{{tabular}}295\end{{adjustbox}}296\end{{table}}297"""298    write("tab_quantile.tex", content)299300301def tab_parsimonious():302    d = pd.read_csv(config.RESULTS_DIR / "coefficients_model_E.csv")303    struct = d[~d.variable.str.startswith("sim_")].copy()304    struct["label"] = struct.variable.map(STRUCT_LABELS)305    sims = d[d.variable.str.startswith("sim_")].sort_values("coefficient", ascending=False)306307    def rows(block):308        out = []309        for _, r in block.iterrows():310            label = str(r["label"]).replace("&", "\\&")311            out.append(312                f"\\quad {label} & {tex_num(r['coefficient'], 4)} & "313                f"{tex_num(r['std_error'], 4)} & {pval_str(r['p_value'])} & "314                f"{tex_num(r['impact_pct'], 1, signed=True)} & {stars(r['p_value'])} \\\\"315            )316        return "\n".join(out)317318    content = rf"""\begin{{table}}[!htbp]319\centering320\caption{{Parsimonious model (E) coefficient estimates. HC3 robust standard errors. All variables standardised.}}321\label{{tab:parsimonious}}322\small323\begin{{adjustbox}}{{max width=\textwidth}}324\begin{{tabular}}{{lR{{1.3cm}}R{{1.3cm}}R{{1.6cm}}R{{1.4cm}}c}}325\toprule326\textbf{{Variable}} & \textbf{{Coeff.}} & \textbf{{Std.\ Err.}} & \textbf{{$p$-value}} & \textbf{{Impact (\%)}} & \\327\midrule328\multicolumn{{6}}{{l}}{{\textit{{Panel A: Structural variables}}}} \\329{rows(struct.sort_values('coefficient', ascending=False))}330\midrule331\multicolumn{{6}}{{l}}{{\textit{{Panel B: Semantic similarities (significant at 5\% in Model D)}}}} \\332{rows(sims)}333\bottomrule334\multicolumn{{6}}{{l}}{{\footnotesize{{\signote\ Impact $= (e^{{\hat{{\beta}}}} - 1) \times 100$\%. $n = 17{{,}}087$; Adj.\ $R^2 = 0.511$.}}}}335\end{{tabular}}336\end{{adjustbox}}337\end{{table}}338"""339    write("tab_parsimonious.tex", content)340341342def tab_reference_texts():343    lines = []344    for slug, text in REFERENCES.items():345        label = ENGLISH_LABELS[slug].replace("&", "\\&")346        text_tex = text.replace("&", "\\&").replace("%", "\\%")347        lines.append(f"{label} & \\textit{{{text_tex}}} \\\\[2pt]")348    body = "\n".join(lines)349    content = rf"""\begin{{footnotesize}}350\begin{{longtable}}{{p{{3.2cm}}p{{11.5cm}}}}351\caption{{The 20 reference descriptions (verbatim French text used for embedding).}}352\label{{tab:reference_texts}} \\353\toprule354\textbf{{Dimension}} & \textbf{{Reference description}} \\355\midrule356\endfirsthead357\toprule358\textbf{{Dimension}} & \textbf{{Reference description}} \\359\midrule360\endhead361\bottomrule362\endfoot363{body}364\end{{longtable}}365\end{{footnotesize}}366"""367    write("tab_reference_texts.tex", content)368369370def main():371    TABLES_DIR.mkdir(parents=True, exist_ok=True)372    tab_descriptive()373    tab_similarity_stats()374    tab_model_comparison()375    tab_full_results()376    tab_quantile()377    tab_parsimonious()378    tab_reference_texts()379    print("All tables generated.")380381382if __name__ == "__main__":383    main()384
Files · d5a5099f2f004278a7a1dba0ebaf2515519ee167
Menu

Theme