Add extended robustness: clustered SEs, size control, log form, ring exposure, leave-one-city-out
- scripts/11_extended_robustness.py -> Table 12 (extended_robustness.tex) and Figure 9 (leave_one_city_out.pdf); existing results untouched - Paper: new §6.5-6.6, clustered-SE result replaces the speculative inference caveat; recompiled clean (43 pages, 0 unresolved refs) - Tighter layout on the three widest tables (overfull hboxes fixed) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 10 changed files with +314 and −4
modified
CHANGES.md
+27 −0
@@ -133,6 +133,33 @@ Sections 1 (introduction), 2 (literature) and most of 7–8 needed only the | ||
| 133 | 133 | consistency fixes above; the prose was already in polished academic English and |
| 134 | 134 | was otherwise preserved. |
| 135 | 135 | |
| 136 | +## 4bis. Extended robustness added on request ("make paper more robust") | |
| 137 | + | |
| 138 | +New analyses (2026-08-05, after the initial restructuring commit) — the existing | |
| 139 | +tables and figures are untouched; everything below is **additive**: | |
| 140 | + | |
| 141 | +- **`scripts/11_extended_robustness.py`** → `results/tables/extended_robustness.tex` | |
| 142 | + (Table 12) and `figures/leave_one_city_out.pdf` (Figure 9): | |
| 143 | + - (R1) baseline (replicates Model 1c exactly: β = 0.0039, HC1); | |
| 144 | + - (R2) **city-clustered standard errors** (153 clusters) — clustering *tightens* | |
| 145 | + the SE on the Airbnb coefficient (0.0001 vs 0.0002), so baseline significance | |
| 146 | + is conservative on this dimension; | |
| 147 | + - (R3) **interior-size control** (per 100 sq ft, N = 3,531) — β = 0.0032***; | |
| 148 | + - (R4) **log(1+count) functional form** — 0.048***: doubling the nearby count | |
| 149 | + ≈ +3.4% rent; mild concavity; | |
| 150 | + - (R5) **ring decomposition** (500m count + 500m–1km annulus jointly) — both | |
| 151 | + positive and significant (0.0013 / 0.0018); reported honestly: the | |
| 152 | + association extends to the kilometre scale rather than being confined to | |
| 153 | + 500m; | |
| 154 | + - **leave-one-city-out** over the 8 largest cities — β ranges 0.0038–0.0040; | |
| 155 | + excluding Montreal (half the sample) leaves it at 0.0038. | |
| 156 | +- Paper: new §6.5 "Additional Specification Checks" and §6.6 "Leave-One-City-Out | |
| 157 | + Sensitivity"; the speculative "Inference Caveats" subsection was replaced by | |
| 158 | + the actual clustered-SE result; §4 (Standard Errors) and §6.7 updated. | |
| 159 | +- Cosmetic: `\small` + tighter `\tabcolsep` on the three widest tables removed | |
| 160 | + the pre-existing overfull-hbox warnings (1 minor one remains, in a text line). | |
| 161 | +- PDF: 43 pages, 0 unresolved references/citations. | |
| 162 | + | |
| 136 | 163 | ## 5. Items requiring your review |
| 137 | 164 | |
| 138 | 165 | - **Raw data missing** (`airbnb.csv`, `rent.json`): absent from the original |
modified
README.md
+5 −0
@@ -46,6 +46,8 @@ python3 scripts/07_spatial_models.py # Model 4 (SAR/SEM via spreg) + bu | ||
| 46 | 46 | python3 scripts/08_quantile_models.py # Model 5 (quantile regressions) |
| 47 | 47 | python3 scripts/09_ml_robustness.py # Model 6 (LASSO/ENet/RF/GBM + SHAP) |
| 48 | 48 | python3 scripts/10_robustness_tables_figures.py # robustness tables + extra figures |
| 49 | +python3 scripts/11_extended_robustness.py # clustered SEs, size control, log form, | |
| 50 | + # ring exposure, leave-one-city-out | |
| 49 | 51 | |
| 50 | 52 | cd paper && latexmk # builds paper/main.pdf |
| 51 | 53 | ``` |
@@ -62,6 +64,9 @@ be run from any working directory. | ||
| 62 | 64 | the upper quantiles of the rent distribution. |
| 63 | 65 | - SAR/SEM spatial models confirm substantial spatial autocorrelation in rents; |
| 64 | 66 | the Airbnb coefficient survives with mild attenuation. |
| 67 | +- The estimate is stable under city-clustered standard errors, an interior-size | |
| 68 | + control, a log(1+count) functional form, ring-exposure decomposition, and | |
| 69 | + leave-one-city-out exclusions (including Montreal). | |
| 65 | 70 | - ML benchmarks (random forest, gradient boosting) confirm the predictive |
| 66 | 71 | relevance of Airbnb exposure; the linear specification remains adequate. |
| 67 | 72 | - Cross-sectional design — associations, not causal effects. |
added
figures/leave_one_city_out.pdf
+0 −0
Binary file not shown.
modified
paper/main.pdf
+0 −0
Binary file not shown.
modified
paper/sections/03_data.tex
+2 −0
@@ -48,6 +48,7 @@ Tables~\ref{tab:summary_airbnb} and~\ref{tab:summary_rent} present summary stati | ||
| 48 | 48 | \centering |
| 49 | 49 | \caption{Summary Statistics --- Airbnb Listings} |
| 50 | 50 | \label{tab:summary_airbnb} |
| 51 | + \small\setlength{\tabcolsep}{4pt} | |
| 51 | 52 | \input{../results/tables/summary_stats_airbnb.tex} |
| 52 | 53 | \end{table} |
| 53 | 54 | |
@@ -55,6 +56,7 @@ Tables~\ref{tab:summary_airbnb} and~\ref{tab:summary_rent} present summary stati | ||
| 55 | 56 | \centering |
| 56 | 57 | \caption{Summary Statistics --- Residential Rental Listings} |
| 57 | 58 | \label{tab:summary_rent} |
| 59 | + \small\setlength{\tabcolsep}{4pt} | |
| 58 | 60 | \input{../results/tables/summary_stats_rent.tex} |
| 59 | 61 | \end{table} |
| 60 | 62 | |
modified
paper/sections/04_methodology.tex
+1 −1
@@ -87,4 +87,4 @@ Our city fixed effects control for all time-invariant, city-level confounders, i | ||
| 87 | 87 | |
| 88 | 88 | \subsection{Standard Errors} |
| 89 | 89 | |
| 90 | −Throughout the analysis, we report heteroskedasticity-robust standard errors (HC1) as our baseline. For the quantile regressions, we report the asymptotic standard errors produced by the kernel-based estimator in \texttt{statsmodels}. For the machine-learning models, we report held-out test-set performance metrics, with regularisation parameters selected by 5-fold cross-validation. | |
| 90 | +Throughout the analysis, we report heteroskedasticity-robust standard errors (HC1) as our baseline; Section~\ref{sec:robustness} re-computes the preferred specification with standard errors clustered at the city level. For the quantile regressions, we report the asymptotic standard errors produced by the kernel-based estimator in \texttt{statsmodels}. For the machine-learning models, we report held-out test-set performance metrics, with regularisation parameters selected by 5-fold cross-validation. | |
modified
paper/sections/05_results.tex
+1 −0
@@ -87,6 +87,7 @@ Table~\ref{tab:quantile} and Figure~\ref{fig:quantile_plot} present the quantile | ||
| 87 | 87 | \centering |
| 88 | 88 | \caption{Quantile Regression Results --- Coefficient on Airbnb Count (500m)} |
| 89 | 89 | \label{tab:quantile} |
| 90 | + \small\setlength{\tabcolsep}{4pt} | |
| 90 | 91 | \input{../results/tables/quantile_regression.tex} |
| 91 | 92 | \end{table} |
| 92 | 93 | |
modified
paper/sections/06_robustness.tex
+32 −3
@@ -76,9 +76,38 @@ To assess the sensitivity of the results to extreme values, we re-estimate the b | ||
| 76 | 76 | |
| 77 | 77 | The results are stable across the outlier-handling approaches. Trimming the rent distribution at the 5th and 95th percentiles reduces the point estimate by roughly a fifth (from 0.0039 to 0.0031)---consistent with the quantile-regression finding that the association is strongest in the tails---while trimming extreme Airbnb counts slightly increases it (0.0042). In every case the coefficient remains positive and significant at the 1\% level, providing confidence that the baseline results are not driven by a small number of influential observations. |
| 78 | 78 | |
| 79 | −\subsection{Inference Caveats} | |
| 79 | +\subsection{Additional Specification Checks} | |
| 80 | 80 | |
| 81 | −Our baseline reports HC1 (heteroskedasticity-robust) standard errors, which are consistent under arbitrary forms of heteroskedasticity but assume independence across observations. In a cross-sectional setting with spatially concentrated observations, standard errors clustered at the city level---or corrected for spatial correlation more generally---would likely be larger than the HC1 estimates we report. The very high $t$-statistics on the Airbnb coefficient in the baseline model suggest that the finding would survive a substantial widening of the confidence intervals, but readers should bear this caveat in mind when interpreting the reported significance levels. | |
| 81 | +Table~\ref{tab:extended_robustness} subjects the preferred specification to four further checks. Column~(R1) reproduces the baseline for reference. | |
| 82 | + | |
| 83 | +\begin{table}[htbp] | |
| 84 | + \centering | |
| 85 | + \caption{Extended Robustness --- Preferred Hedonic Specification} | |
| 86 | + \label{tab:extended_robustness} | |
| 87 | + \small | |
| 88 | + \input{../results/tables/extended_robustness.tex} | |
| 89 | +\end{table} | |
| 90 | + | |
| 91 | +\paragraph{Clustered standard errors.} Column~(R2) re-computes the baseline standard errors clustering by city (153 clusters). HC1 standard errors assume independence across observations, which is questionable when listings are spatially concentrated. In our data, city-level clustering in fact \textit{tightens} the standard error on the Airbnb coefficient (0.0001 versus 0.0002 under HC1), so the baseline significance levels are, if anything, conservative on this dimension. Clustering does widen the confidence interval on some controls (bathrooms), as expected. | |
| 92 | + | |
| 93 | +\paragraph{Interior size.} Column~(R3) adds the interior floor area (per 100 sq ft) as a control on the subsample of 3{,}531 listings that report it. Size enters positively and significantly, and the Airbnb coefficient remains positive and highly significant at 0.0032---about a fifth smaller than the baseline, consistent with size absorbing some quality variation, but leaving the qualitative conclusion intact. | |
| 94 | + | |
| 95 | +\paragraph{Functional form.} Column~(R4) replaces the linear count with $\ln(1 + \texttt{airbnb\_count\_500m})$. The elasticity-style coefficient (0.048) is positive and highly significant: doubling the nearby Airbnb count is associated with roughly 3.4\% higher rent. The fit improves marginally over the linear form, indicating mild concavity in the exposure--rent relationship. | |
| 96 | + | |
| 97 | +\paragraph{Ring exposure.} Column~(R5) includes the 500\,m count jointly with the count in the 500\,m--1\,km annulus. Both enter positively and significantly. Splitting the exposure this way attenuates the inner-buffer coefficient (0.0013), and the annulus coefficient (0.0018) is of comparable magnitude per listing: the association is therefore not confined to the immediate 500\,m but extends to the kilometre scale, consistent with the buffer-radius results above, where the total (not per-listing) association accumulates over wider areas. Because the two counts are strongly correlated, the individual coefficients in this specification should be read as a decomposition of a common neighbourhood signal rather than as separate causal gradients. | |
| 98 | + | |
| 99 | +\subsection{Leave-One-City-Out Sensitivity} | |
| 100 | + | |
| 101 | +Figure~\ref{fig:leave_one_out} re-estimates the preferred specification excluding, in turn, each of the eight cities with the most rental listings. | |
| 102 | + | |
| 103 | +\begin{figure}[htbp] | |
| 104 | + \centering | |
| 105 | + \includegraphics[width=0.80\textwidth]{leave_one_city_out.pdf} | |
| 106 | + \caption{Leave-One-City-Out Sensitivity of the Airbnb Coefficient} | |
| 107 | + \label{fig:leave_one_out} | |
| 108 | +\end{figure} | |
| 109 | + | |
| 110 | +The coefficient is remarkably stable, ranging from 0.0038 to 0.0040 across exclusions. Notably, excluding Montreal---which accounts for more than half of the estimation sample---leaves the point estimate essentially unchanged (0.0038), albeit with a wider confidence interval; the association is thus not an artefact of any single market. | |
| 82 | 111 | |
| 83 | 112 | \subsection{Summary of Robustness} |
| 84 | 113 | |
@@ -91,4 +120,4 @@ Figure~\ref{fig:robustness_summary} presents a coefficient plot summarising the | ||
| 91 | 120 | \label{fig:robustness_summary} |
| 92 | 121 | \end{figure} |
| 93 | 122 | |
| 94 | −Across all specifications---varying the buffer radius, the exposure measure, the sample, and the outlier treatment---the estimated association between Airbnb presence and residential rents is consistently positive. While the magnitude varies across specifications, the qualitative conclusion is robust: higher Airbnb density in the immediate vicinity of a rental listing is associated with higher monthly rents, conditional on observable dwelling characteristics and city-level heterogeneity. | |
| 123 | +Across all specifications---varying the buffer radius, the exposure measure, the functional form, the control set, the sample, the outlier treatment, and the standard-error computation---the estimated association between Airbnb presence and residential rents is consistently positive. While the magnitude varies across specifications, the qualitative conclusion is robust: higher Airbnb density in the immediate vicinity of a rental listing is associated with higher monthly rents, conditional on observable dwelling characteristics and city-level heterogeneity. | |
added
results/tables/extended_robustness.tex
+25 −0
@@ -0,0 +1,25 @@ | ||
| 1 | +\begin{tabular}{lccccc} | |
| 2 | +\toprule | |
| 3 | + & \textbf{(R1)} & \textbf{(R2)} & \textbf{(R3)} & \textbf{(R4)} & \textbf{(R5)} \\ | |
| 4 | +Dep.\ var: & \textit{log\_rent} & \textit{log\_rent} & \textit{log\_rent} & \textit{log\_rent} & \textit{log\_rent} \\ | |
| 5 | +\midrule | |
| 6 | +Airbnb count (500m) & 0.0039*** & 0.0039*** & 0.0032*** & & 0.0013*** \\ | |
| 7 | + & (0.0002) & (0.0001) & (0.0003) & & (0.0003) \\[4pt] | |
| 8 | +log(1 + Airbnb count 500m) & & & & 0.0484*** & \\ | |
| 9 | + & & & & (0.0025) & \\[4pt] | |
| 10 | +Airbnb count (500m--1km ring) & & & & & 0.0018*** \\ | |
| 11 | + & & & & & (0.0002) \\[4pt] | |
| 12 | +Bedrooms & 0.1276*** & 0.1276*** & 0.1271*** & 0.1296*** & 0.1322*** \\ | |
| 13 | + & (0.0046) & (0.0043) & (0.0076) & (0.0046) & (0.0047) \\[4pt] | |
| 14 | +Bathrooms & 0.2287*** & 0.2287*** & 0.2103*** & 0.2274*** & 0.2246*** \\ | |
| 15 | + & (0.0126) & (0.0291) & (0.0202) & (0.0125) & (0.0125) \\[4pt] | |
| 16 | +Interior size (100 sq ft) & & & 0.0044*** & & \\ | |
| 17 | + & & & (0.0017) & & \\[4pt] | |
| 18 | +\midrule | |
| 19 | +Standard errors & HC1 & City cluster & HC1 & HC1 & HC1 \\ | |
| 20 | +Building type \& city FE & Yes & Yes & Yes & Yes & Yes \\ | |
| 21 | +Observations & 7,925 & 7,925 & 3,531 & 7,925 & 7,925 \\ | |
| 22 | +R$^2$ & 0.5697 & 0.5697 & 0.6044 & 0.5726 & 0.5786 \\ | |
| 23 | +\bottomrule | |
| 24 | +\end{tabular} | |
| 25 | +\parbox{\textwidth}{\footnotesize Standard errors in parentheses: HC1 except column (R2), which clusters by city. All columns control for bedrooms, bathrooms, building type, and city fixed effects; column (R3) adds interior size (per 100 sq ft) on the subsample reporting it. Column (R5) includes the 500m count and the count in the 500m--1km annulus jointly. $^{***}p<0.01$; $^{**}p<0.05$; $^{*}p<0.10$.} | |
added
scripts/11_extended_robustness.py
+221 −0
@@ -0,0 +1,221 @@ | ||
| 1 | +# Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +""" | |
| 3 | +11_extended_robustness.py | |
| 4 | +------------------------- | |
| 5 | +Extended robustness checks for the preferred hedonic specification | |
| 6 | +(log_rent ~ airbnb_count_500m + bedrooms + bathrooms + building type + city FE): | |
| 7 | + | |
| 8 | + (R1) Baseline with HC1 standard errors (reference column) | |
| 9 | + (R2) Same coefficients with standard errors clustered by city | |
| 10 | + (R3) Adding interior size (in 100 sq ft) on the subsample reporting it | |
| 11 | + (R4) log(1 + count) functional form for the exposure variable | |
| 12 | + (R5) Ring exposure: count in the 500m-1km annulus alongside the 500m count | |
| 13 | + | |
| 14 | +plus a leave-one-city-out sensitivity figure (dropping each of the largest | |
| 15 | +cities in turn). | |
| 16 | + | |
| 17 | +Outputs: | |
| 18 | + results/tables/extended_robustness.tex | |
| 19 | + figures/leave_one_city_out.pdf | |
| 20 | +""" | |
| 21 | + | |
| 22 | +import sys | |
| 23 | +from pathlib import Path | |
| 24 | + | |
| 25 | +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) | |
| 26 | + | |
| 27 | +import matplotlib | |
| 28 | +matplotlib.use("Agg") | |
| 29 | +import matplotlib.pyplot as plt # noqa: E402 | |
| 30 | +import numpy as np # noqa: E402 | |
| 31 | +import pandas as pd # noqa: E402 | |
| 32 | +import statsmodels.api as sm # noqa: E402 | |
| 33 | + | |
| 34 | +from src.config import MERGED_ANALYSIS, TABLE_DIR, FIG_DIR, require # noqa: E402 | |
| 35 | +from src.latex_tables import significance_star as _star # noqa: E402 | |
| 36 | + | |
| 37 | +PROCESSED_HINT = "Run scripts/04_merge_data.py first." | |
| 38 | + | |
| 39 | +CONTROL_LABELS = { | |
| 40 | + "airbnb_count_500m": r"Airbnb count (500m)", | |
| 41 | + "log1p_airbnb_500m": r"log(1 + Airbnb count 500m)", | |
| 42 | + "airbnb_ring_500m_1km": r"Airbnb count (500m--1km ring)", | |
| 43 | + "bedrooms": "Bedrooms", | |
| 44 | + "bathrooms": "Bathrooms", | |
| 45 | + "size_100sqft": r"Interior size (100 sq ft)", | |
| 46 | +} | |
| 47 | + | |
| 48 | + | |
| 49 | +def prepare(df: pd.DataFrame) -> tuple[pd.DataFrame, list[str]]: | |
| 50 | + """Add derived regressors and building-type dummies; return (frame, bt_cols).""" | |
| 51 | + d = df.copy() | |
| 52 | + d["log1p_airbnb_500m"] = np.log1p(d["airbnb_count_500m"]) | |
| 53 | + d["airbnb_ring_500m_1km"] = d["airbnb_count_1000m"] - d["airbnb_count_500m"] | |
| 54 | + d["size_100sqft"] = d["size_interior_sqft"] / 100.0 | |
| 55 | + | |
| 56 | + bt = pd.get_dummies(d["building_type"], prefix="bt", drop_first=True, dtype=float) | |
| 57 | + d = pd.concat([d, bt], axis=1) | |
| 58 | + return d, list(bt.columns) | |
| 59 | + | |
| 60 | + | |
| 61 | +def fit(d: pd.DataFrame, x_main: list[str], bt_cols: list[str], | |
| 62 | + label: str, cluster: bool = False, extra_controls: list[str] | None = None): | |
| 63 | + """ | |
| 64 | + OLS of log_rent on x_main + bedrooms/bathrooms (+extras) + building type | |
| 65 | + + city FE, with HC1 or city-clustered standard errors. | |
| 66 | + """ | |
| 67 | + controls = ["bedrooms", "bathrooms"] + (extra_controls or []) | |
| 68 | + needed = ["log_rent", "city"] + x_main + controls | |
| 69 | + sub = d[needed + bt_cols].dropna().reset_index(drop=True) | |
| 70 | + | |
| 71 | + city_fe = pd.get_dummies(sub["city"], prefix="city", drop_first=True, dtype=float) | |
| 72 | + X = sm.add_constant(pd.concat( | |
| 73 | + [sub[x_main + controls + bt_cols].astype(float), city_fe], axis=1 | |
| 74 | + ).astype(float)) | |
| 75 | + y = sub["log_rent"].astype(float) | |
| 76 | + | |
| 77 | + if cluster: | |
| 78 | + res = sm.OLS(y, X).fit(cov_type="cluster", | |
| 79 | + cov_kwds={"groups": sub["city"]}) | |
| 80 | + else: | |
| 81 | + res = sm.OLS(y, X).fit(cov_type="HC1") | |
| 82 | + | |
| 83 | + print(f" [{label}] N={int(res.nobs):,} R2={res.rsquared:.4f} " | |
| 84 | + f"beta({x_main[0]})={res.params[x_main[0]]:.6f} " | |
| 85 | + f"SE={res.bse[x_main[0]]:.6f}") | |
| 86 | + return res | |
| 87 | + | |
| 88 | + | |
| 89 | +def build_table(results: list, col_headers: list[str], se_row: list[str], | |
| 90 | + display_vars: list[str]) -> None: | |
| 91 | + """Write the extended-robustness table fragment.""" | |
| 92 | + n = len(results) | |
| 93 | + lines: list[str] = [] | |
| 94 | + lines.append(r"\begin{tabular}{l" + "c" * n + "}") | |
| 95 | + lines.append(r"\toprule") | |
| 96 | + lines.append(" & ".join([""] + [f"\\textbf{{{h}}}" for h in col_headers]) + r" \\") | |
| 97 | + lines.append( | |
| 98 | + " & ".join(["Dep.\\ var:"] + [r"\textit{log\_rent}"] * n) + r" \\" | |
| 99 | + ) | |
| 100 | + lines.append(r"\midrule") | |
| 101 | + | |
| 102 | + for var in display_vars: | |
| 103 | + cells_c, cells_s = [], [] | |
| 104 | + for res in results: | |
| 105 | + if var in res.params.index: | |
| 106 | + b, se, p = res.params[var], res.bse[var], res.pvalues[var] | |
| 107 | + cells_c.append(f"{b:.4f}{_star(p)}") | |
| 108 | + cells_s.append(f"({se:.4f})") | |
| 109 | + else: | |
| 110 | + cells_c.append("") | |
| 111 | + cells_s.append("") | |
| 112 | + label = CONTROL_LABELS.get(var, var.replace("_", r"\_")) | |
| 113 | + lines.append(f"{label} & " + " & ".join(cells_c) + r" \\") | |
| 114 | + lines.append(" & " + " & ".join(cells_s) + r" \\[4pt]") | |
| 115 | + | |
| 116 | + lines.append(r"\midrule") | |
| 117 | + lines.append("Standard errors & " + " & ".join(se_row) + r" \\") | |
| 118 | + lines.append("Building type \\& city FE & " + " & ".join(["Yes"] * n) + r" \\") | |
| 119 | + lines.append("Observations & " | |
| 120 | + + " & ".join(f"{int(r.nobs):,}" for r in results) + r" \\") | |
| 121 | + lines.append("R$^2$ & " | |
| 122 | + + " & ".join(f"{r.rsquared:.4f}" for r in results) + r" \\") | |
| 123 | + lines.append(r"\bottomrule") | |
| 124 | + lines.append(r"\end{tabular}") | |
| 125 | + lines.append( | |
| 126 | + r"\parbox{\textwidth}{\footnotesize Standard errors in parentheses: " | |
| 127 | + r"HC1 except column (R2), which clusters by city. All columns control " | |
| 128 | + r"for bedrooms, bathrooms, building type, and city fixed effects; " | |
| 129 | + r"column (R3) adds interior size (per 100 sq ft) on the subsample " | |
| 130 | + r"reporting it. Column (R5) includes the 500m count and the count in " | |
| 131 | + r"the 500m--1km annulus jointly. " | |
| 132 | + r"$^{***}p<0.01$; $^{**}p<0.05$; $^{*}p<0.10$.}" | |
| 133 | + ) | |
| 134 | + | |
| 135 | + out = TABLE_DIR / "extended_robustness.tex" | |
| 136 | + out.write_text("\n".join(lines) + "\n", encoding="utf-8") | |
| 137 | + print(f" -> saved {out}") | |
| 138 | + | |
| 139 | + | |
| 140 | +def leave_one_city_out(d: pd.DataFrame, bt_cols: list[str], n_cities: int = 8) -> None: | |
| 141 | + """Coefficient on the 500m count when each large city is excluded in turn.""" | |
| 142 | + print("\n--- Leave-one-city-out ---") | |
| 143 | + top = d["city"].value_counts().head(n_cities).index.tolist() | |
| 144 | + | |
| 145 | + res_full = fit(d, ["airbnb_count_500m"], bt_cols, "Full sample") | |
| 146 | + beta_full = res_full.params["airbnb_count_500m"] | |
| 147 | + se_full = res_full.bse["airbnb_count_500m"] | |
| 148 | + | |
| 149 | + labels, betas, ses = [], [], [] | |
| 150 | + for city in top: | |
| 151 | + res = fit(d[d["city"] != city], ["airbnb_count_500m"], bt_cols, | |
| 152 | + f"excl. {city}") | |
| 153 | + labels.append(f"excl. {city}") | |
| 154 | + betas.append(res.params["airbnb_count_500m"]) | |
| 155 | + ses.append(res.bse["airbnb_count_500m"]) | |
| 156 | + | |
| 157 | + betas = np.array(betas) | |
| 158 | + ses = np.array(ses) | |
| 159 | + | |
| 160 | + fig, ax = plt.subplots(figsize=(7, 0.45 * len(labels) + 2)) | |
| 161 | + y_pos = np.arange(len(labels)) | |
| 162 | + ax.errorbar(betas, y_pos, xerr=1.96 * ses, | |
| 163 | + fmt="o", color="#2166ac", ecolor="#92c5de", capsize=4, | |
| 164 | + markersize=6, elinewidth=1.5) | |
| 165 | + ax.axvline(beta_full, color="firebrick", linestyle="--", linewidth=1.2, | |
| 166 | + label=f"Full sample ({beta_full:.4f})") | |
| 167 | + ax.axvspan(beta_full - 1.96 * se_full, beta_full + 1.96 * se_full, | |
| 168 | + color="firebrick", alpha=0.08) | |
| 169 | + ax.axvline(0, color="grey", linestyle=":", linewidth=0.8) | |
| 170 | + ax.set_yticks(y_pos) | |
| 171 | + ax.set_yticklabels(labels) | |
| 172 | + ax.set_xlabel(r"Coefficient on Airbnb count within 500m ($\beta$)") | |
| 173 | + ax.set_title("Leave-One-City-Out Sensitivity") | |
| 174 | + ax.legend(fontsize=8, loc="best") | |
| 175 | + ax.invert_yaxis() | |
| 176 | + fig.tight_layout() | |
| 177 | + | |
| 178 | + out = FIG_DIR / "leave_one_city_out.pdf" | |
| 179 | + fig.savefig(out, dpi=300) | |
| 180 | + plt.close(fig) | |
| 181 | + print(f" -> saved {out}") | |
| 182 | + | |
| 183 | + | |
| 184 | +def main() -> None: | |
| 185 | + print("=" * 72) | |
| 186 | + print("11 EXTENDED ROBUSTNESS") | |
| 187 | + print("=" * 72) | |
| 188 | + | |
| 189 | + df = pd.read_parquet(require(MERGED_ANALYSIS, PROCESSED_HINT)) | |
| 190 | + d, bt_cols = prepare(df) | |
| 191 | + print(f"\nSample: {len(d):,} rows | " | |
| 192 | + f"interior size non-missing: {d['size_100sqft'].notna().sum():,}") | |
| 193 | + | |
| 194 | + print("\n--- Specifications R1-R5 ---") | |
| 195 | + res_r1 = fit(d, ["airbnb_count_500m"], bt_cols, "R1 baseline HC1") | |
| 196 | + res_r2 = fit(d, ["airbnb_count_500m"], bt_cols, "R2 clustered", cluster=True) | |
| 197 | + res_r3 = fit(d, ["airbnb_count_500m"], bt_cols, "R3 + size", | |
| 198 | + extra_controls=["size_100sqft"]) | |
| 199 | + res_r4 = fit(d, ["log1p_airbnb_500m"], bt_cols, "R4 log(1+count)") | |
| 200 | + res_r5 = fit(d, ["airbnb_count_500m", "airbnb_ring_500m_1km"], bt_cols, | |
| 201 | + "R5 ring") | |
| 202 | + | |
| 203 | + build_table( | |
| 204 | + [res_r1, res_r2, res_r3, res_r4, res_r5], | |
| 205 | + ["(R1)", "(R2)", "(R3)", "(R4)", "(R5)"], | |
| 206 | + ["HC1", "City cluster", "HC1", "HC1", "HC1"], | |
| 207 | + display_vars=[ | |
| 208 | + "airbnb_count_500m", "log1p_airbnb_500m", "airbnb_ring_500m_1km", | |
| 209 | + "bedrooms", "bathrooms", "size_100sqft", | |
| 210 | + ], | |
| 211 | + ) | |
| 212 | + | |
| 213 | + leave_one_city_out(d, bt_cols) | |
| 214 | + | |
| 215 | + print("\n" + "=" * 72) | |
| 216 | + print("11 DONE") | |
| 217 | + print("=" * 72) | |
| 218 | + | |
| 219 | + | |
| 220 | +if __name__ == "__main__": | |
| 221 | + main() | |
| 222 | ||