SPB Git

spb/wp5_uqo Public

UQO Working Paper No. 5 — Airbnb, residential rents and housing market pressure.

TeX 53.4% Python 46.5%

Initial commit: restructured UQO WP5 (Airbnb & residential rents)

Clean repository rebuilt from immo-wp5-spb-20260519 (original left untouched):
- data/processed: verified bit-identical parquet datasets
- src/ + scripts/01-10: refactored pipeline, reproduces published tables
  (14/16 byte-identical, residual diffs documented in AUDIT.md §8)
- figures/: all 15 figures regenerated, including 5 missing from original
- paper/: editorial rewrite, compiles clean (41 pages, 0 unresolved refs)
- AUDIT.md / CHANGES.md: full audit and change log

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 5 days ago (Aug 5, 2026)

Showing 76 changed files with +5,963 and −0

added .gitignore +21 −0
@@ -0,0 +1,21 @@
1 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +__pycache__/
3 +*.pyc
4 +.DS_Store
5 +
6 +# Raw data is not distributed
7 +data/raw/*.csv
8 +data/raw/*.json
9 +
10 +# LaTeX build artifacts
11 +paper/*.aux
12 +paper/*.bbl
13 +paper/*.blg
14 +paper/*.log
15 +paper/*.out
16 +paper/*.toc
17 +paper/*.lof
18 +paper/*.lot
19 +paper/*.fls
20 +paper/*.fdb_latexmk
21 +paper/*.synctex.gz
added AUDIT.md +158 −0
@@ -0,0 +1,158 @@
1 +<!-- Author: Simon-Pierre Boucher — contact@spboucher.ai -->
2 +
3 +# AUDIT — Original project `immo-wp5-spb-20260519`
4 +
5 +Audit of `~/Desktop/UQO/UQO_WP/immo-wp5-spb-20260519` performed on 2026-08-05, **before** any restructuring.
6 +The original folder is left **untouched**; the clean repository is rebuilt at `~/Desktop/wp5_uqo`.
7 +
8 +## 1. Project summary
9 +
10 +Hedonic and spatial econometric analysis of the relationship between Airbnb activity and
11 +residential rents in Quebec, Canada (UQO Working Paper No. 5, Simon-Pierre Boucher, May 2026).
12 +~5,000 Airbnb listings + 8,356 Realtor.ca rental listings, spatial buffer merge (Haversine,
13 +250 m–2 km), OLS/hedonic models, spatial models (SAR/SEM via spreg), quantile regressions,
14 +ML robustness (LASSO, Elastic Net, RF, GBM, SHAP).
15 +
16 +## 2. Scripts (`scripts/`, 10 files, ~2,930 lines)
17 +
18 +| Script | Role | Inputs | Outputs |
19 +|---|---|---|---|
20 +| `01_load_inspect_data.py` | Inspect raw data, write data dictionary log | `airbnb.csv`, `rent.json` (**missing**, see §5.1) | `outputs/logs/data_inspection.txt` |
21 +| `02_clean_airbnb.py` | Standardize cities, winsorize price p1/p99, `log_price`, impute rating (median) & reviews (0), `is_entire_home` | `airbnb.csv` (**missing**) | `data_clean/airbnb_clean.parquet` |
22 +| `03_clean_rent.py` | Filter Single Family + Monthly, parse rent/city/borough/lat/lon/beds/baths/size, winsorize p1/p99, `log_rent` | `rent.json` (**missing**) | `data_clean/rent_clean.parquet` |
23 +| `04_merge_data.py` | Spatial buffer merge (Haversine 250 m/500 m/1 km/2 km, chunked), city/borough aggregation, combined analysis file | the 2 clean parquets | `merged_spatial.parquet`, `merged_neighborhood.parquet`, `merged_analysis.parquet` |
24 +| `05_descriptive_analysis.py` | Summary-stat tables, correlation matrix, 7 descriptive figures | clean + merged parquets | 3 tables, 7 figures |
25 +| `06_hedonic_models.py` | Models 1a–1e (rent), 2a–2c (Airbnb pricing), 3 fwd/rev (city level), OLS HC1 | `merged_analysis`, `airbnb_clean` | 3 `.tex` tables |
26 +| `07_spatial_models.py` | Model 4: SAR (GM_Lag) + SEM (GM_Error) via spreg, KNN(5) weights; buffer robustness 250 m–2 km | `merged_analysis` | 2 tables + `coefficient_buffer_comparison.pdf` |
27 +| `08_quantile_models.py` | Model 5: quantile regressions τ∈{.10,.25,.50,.75,.90} + fine grid plot | `merged_analysis` | 1 table + `quantile_coefficients.pdf` |
28 +| `09_ml_robustness.py` | Model 6: OLS/LASSO/ENet/RF/GBM (seed 42, 80/20 split), SHAP | `merged_analysis` | `ml_comparison.{tex,csv}` + 3 figures |
29 +| `10_generate_tables_figures.py` | Outlier/winsorization + subsample robustness tables, hexbin heatmap, coefficient-robustness plot, quintile bar chart | `merged_analysis` | 2 tables + 3 figures |
30 +
31 +All paths are already relative to the project root via `Path(__file__).resolve().parent.parent` — good.
32 +Scripts are standalone monoliths with heavy duplication (see §5.4).
33 +
34 +## 3. Data
35 +
36 +- `data_clean/` (5 parquet files, all present and readable):
37 + `airbnb_clean.parquet` (4,950 rows expected), `rent_clean.parquet` (8,303 rows),
38 + `merged_spatial.parquet`, `merged_neighborhood.parquet`, `merged_analysis.parquet` (8,258 rows × 42 cols).
39 +- **Raw data (`airbnb.csv`, `rent.json`) are absent** from the folder and from the whole disk
40 + (searched `~/Desktop/UQO` and Spotlight). See §5.1.
41 +
42 +## 4. Figure inventory
43 +
44 +Figures **generated by the code** (15) vs figures **present** in `outputs/figures/` (10):
45 +
46 +| Figure | Generated by | In `outputs/figures/` | Referenced by paper |
47 +|---|---|---|---|
48 +| dist_airbnb_price.pdf | 05 | yes | yes |
49 +| dist_rent.pdf | 05 | yes | yes |
50 +| airbnb_by_city.pdf | 05 | yes | no |
51 +| rent_by_city.pdf | 05 | **no** | no |
52 +| scatter_airbnb_rent.pdf | 05 | yes | no |
53 +| map_airbnb.pdf | 05 | **no** | **yes → broken** |
54 +| map_rent.pdf | 05 | **no** | **yes → broken** |
55 +| coefficient_buffer_comparison.pdf | 07 | yes | yes (×2) |
56 +| quantile_coefficients.pdf | 08 | **no** | **yes → broken** |
57 +| ml_predicted_vs_actual.pdf | 09 | yes | no |
58 +| feature_importance.pdf | 09 | yes | yes |
59 +| shap_summary.pdf | 09 | yes | yes |
60 +| rent_airbnb_heatmap.pdf | 10 | yes | no |
61 +| coefficient_robustness.pdf | 10 | yes | yes |
62 +| rent_by_airbnb_bins.pdf | 10 | **no** | no |
63 +
64 +The 5 missing PDFs were evidently deleted (or the run predates those functions); the code that
65 +produces them is intact. **Consequence:** `wp5/main.tex` fails to compile (fatal error, no PDF);
66 +only `wp5/main_web.tex` compiles because it replaces missing figures with placeholder boxes.
67 +Re-running the pipeline regenerates all 15 figures and fixes the paper build.
68 +
69 +`wp5/figures/` contains a stale copy of the same 10 PDFs (duplicate of `outputs/figures/`).
70 +
71 +## 5. Problems found (nothing was changed; fixes happen in the new repo)
72 +
73 +### 5.1 Raw data missing (blocking for steps 01–03 only)
74 +`airbnb.csv` and `rent.json` are referenced by scripts 01–03 and by the original README but do
75 +not exist anywhere on disk. The pipeline is therefore reproducible **from `data_clean/` onward**
76 +(steps 04–10 + paper), which covers every number and figure in the paper. Steps 01–03 are kept
77 +in the new repo and fail with an explicit message if the raw files are absent.
78 +**→ Requires user review: locate/restore the raw files if full from-scratch reproduction is needed.**
79 +
80 +### 5.2 Two divergent copies of the paper
81 +- `paper/` — same sections, figures referenced as `../outputs/figures/…`, no title page, compiled OK.
82 +- `wp5/` — adds UQO title page (`uq_logo.jpg`), `Makefile`, `.latexmkrc`, `\graphicspath{{figures/}}`,
83 + plus `main_web.tex` (variant with a "Figure indisponible" fallback macro).
84 +- Only real content differences: figure paths in `03_data.tex`, `05_results.tex`, `06_robustness.tex`.
85 +- `wp5/` is the canonical/most recent version (June 2026) → used as the base for the rewrite.
86 +
87 +### 5.3 Figure caption vs content mismatch
88 +`03_data.tex` captions the two distribution histograms as "(log scale)" but scripts plot **levels**
89 +(CAD). Caption corrected in the rewrite (editorial fix; figures unchanged).
90 +
91 +### 5.4 Code-quality issues (fixed by refactor, results preserved)
92 +- Significance-star helper duplicated in 4 scripts; LaTeX regression-table builder duplicated in 4 scripts;
93 + matplotlib style blocks duplicated in 3 scripts; city-FE OLS helper duplicated within script 10.
94 +- Scripts 05 requests columns `size_sqft`, `guests_count`, `amenities_count`, `num_images`,
95 + `quality_score` that don't exist in the parquets (`rent_clean` has `size_interior_sqft`); the
96 + helper silently skips them, so the published summary tables lack those rows. **Kept as-is** to
97 + reproduce identical tables; noted for future work.
98 +- Scripts 06–08 are top-level scripts (no `main()`); 02/03 likewise.
99 +- `scripts/__pycache__/` committed; `.DS_Store` files scattered.
100 +
101 +### 5.5 Dead / duplicate / unused files
102 +- `paper/` entire directory superseded by `wp5/` (kept only as reference in the original folder).
103 +- `wp5/main_web.tex` + `main_web.*` build artifacts: web variant with placeholder hack — obsolete
104 + once figures are regenerated.
105 +- LaTeX build artifacts (`.aux`, `.log`, `.out`, `.bbl`, `.blg`, `.fls`, `.fdb_latexmk`, `.toc`, `.synctex.gz`).
106 +- `scripts/__pycache__/`, `.DS_Store` (×6).
107 +- `outputs/figures/` vs `wp5/figures/`: duplicated PDFs.
108 +
109 +### 5.6 Environment
110 +Python 3.14.4 (Homebrew) with pandas 3.0.2, numpy 2.4.4, statsmodels 0.14.6, scikit-learn 1.6.1,
111 +scipy 1.17.1, matplotlib 3.10.9, pyarrow 24.0.0, shap 0.48.0, libpysal 4.14.1, spreg 1.9.0.
112 +TeX Live (`pdflatex`, `latexmk`, `bibtex`) available. Note: with libpysal+spreg installed, script 07
113 +takes the SAR/SEM branch — consistent with the published `spatial_models.tex`.
114 +
115 +## 6. Paper (`wp5/`)
116 +
117 +- `main.tex` (184 lines): clean preamble (newtx, booktabs, natbib/apalike, fancyhdr, hyperref),
118 + metadata macros, inputs 8 sections + title page + 3 appendices; 24 BibTeX entries, all cited.
119 +- Sections: 01 introduction, 02 literature, 03 data, 04 methodology (models 1–6 + identification),
120 + 05 results, 06 robustness, 07 discussion, 08 conclusion; appendices: data, methods, robustness.
121 +- Prose already in good academic English; rewrite pass = flow/consistency polish, caption fix
122 + (§5.3), figure/table path normalization to the new layout, and making every figure resolvable.
123 +- Tables are `\input` from `../outputs/tables/*.tex` → becomes `../results/tables/` in the new repo.
124 +
125 +## 7. Verification plan (Phase 2b)
126 +
127 +1. Copy the 5 parquets to `data/processed/` (bit-identical, checksummed).
128 +2. Re-run refactored steps 04→10; compare regenerated `merged_*.parquet` (DataFrame equality)
129 + and every `.tex`/`.csv` table against the originals (`diff`). Figures: confirm regeneration and
130 + spot-check values; PDF bytes differ (timestamps) so tables are the numeric ground truth.
131 +3. Any discrepancy is recorded below — never silently corrected.
132 +
133 +## 8. Discrepancies found during verification
134 +
135 +Full pipeline (steps 04–10) re-run in the new repo on 2026-08-05 and compared to the originals:
136 +
137 +- **Merged parquets (3/3): identical**`merged_spatial`, `merged_neighborhood`,
138 + `merged_analysis` regenerated from the committed clean parquets are value-identical
139 + to the originals (`pandas.testing.assert_frame_equal`).
140 +- **Tables: 14/16 byte-identical** after aligning the generators with the published
141 + fragments (the original `outputs/tables/*.tex` had been hand-stripped of their
142 + `\begin{table}…\end{table}` wrappers after generation; the new scripts emit those
143 + fragments directly — see `CHANGES.md`). Residual differences, both preserved and
144 + NOT corrected:
145 + 1. `quantile_regression.tex` — in the τ=0.10 column only, three control
146 + coefficients differ in the 4th decimal (bathrooms 0.2410→0.2411,
147 + bt_House 0.0607→0.0608, bt_Row/Townhouse 0.1302→0.1310, SE 0.0469→0.0468).
148 + The Airbnb coefficient, all other quantiles, and the OLS column are identical.
149 + Cause: IRLS convergence jitter of `statsmodels.QuantReg` at the extreme
150 + quantile; no significance level or claim in the paper changes.
151 + 2. `ml_comparison.csv` — two values differ at the 16th significant digit
152 + (~1e-16 relative; float representation). The 4-decimal `ml_comparison.tex`
153 + is byte-identical.
154 +- **Figures: all 15 regenerated**, including the 5 missing from the original
155 + (`map_airbnb`, `map_rent`, `quantile_coefficients`, `rent_by_city`,
156 + `rent_by_airbnb_bins`), which un-breaks the paper build. PDF bytes differ from
157 + the surviving originals (embedded timestamps/IDs); the numeric ground truth is
158 + the table set above, plus the underlying data verified identical.
added CHANGES.md +152 −0
@@ -0,0 +1,152 @@
1 +<!-- Author: Simon-Pierre Boucher — contact@spboucher.ai -->
2 +
3 +# CHANGES — Restructuring of `immo-wp5-spb-20260519``wp5_uqo`
4 +
5 +Date: 2026-08-05. The original project at
6 +`~/Desktop/UQO/UQO_WP/immo-wp5-spb-20260519` was **left completely untouched** and
7 +serves as the backup (in place of an `_old/` copy inside this repo).
8 +
9 +Per request, every file created in this repo carries the header
10 +`Author: Simon-Pierre Boucher — contact@spboucher.ai` (comment syntax adapted per
11 +language: `#` Python/Makefile, `%` LaTeX/BibTeX, `<!-- -->` Markdown; in
12 +`references.bib` the email is written `contact (at) spboucher.ai` because a literal
13 +`@` inside a `.bib` comment breaks BibTeX).
14 +
15 +## 1. Moved / renamed
16 +
17 +| Original | New |
18 +|---|---|
19 +| `airbnb.csv`, `rent.json` (project root) | `data/raw/` (files missing — see §5) |
20 +| `data_clean/*.parquet` (5 files) | `data/processed/` (bit-identical copies, checksummed) |
21 +| `scripts/01_load_inspect_data.py` | `scripts/01_inspect_raw_data.py` |
22 +| `scripts/02…09_*.py` | same names under `scripts/` (refactored) |
23 +| `scripts/10_generate_tables_figures.py` | `scripts/10_robustness_tables_figures.py` |
24 +| `outputs/tables/` | `results/tables/` |
25 +| `outputs/logs/` | `results/logs/` |
26 +| `outputs/figures/` + `wp5/figures/` (duplicates) | `figures/` (single source, regenerated) |
27 +| `wp5/` (canonical LaTeX) | `paper/` |
28 +| `paper/` (old LaTeX copy), `wp5/main_web.tex` + fallback hack | **dropped** (superseded; the fallback is unnecessary now that all figures exist) |
29 +| `scripts/__pycache__/`, `.DS_Store`, LaTeX build artifacts | dropped / gitignored |
30 +
31 +New files: `README.md`, `AUDIT.md`, `CHANGES.md`, `requirements.txt` (pinned),
32 +`data/raw/README.md`, `.gitignore`, `src/` package.
33 +
34 +## 2. Code refactoring (results preserved — see §4)
35 +
36 +- **`src/config.py`** — all paths and constants (buffer radii, chunk size, Earth
37 + radius, random seed) in one place; output dirs auto-created; `require()` gives a
38 + clear error message when an input is missing instead of a traceback.
39 +- **`src/geo.py`** — the vectorised Haversine matrix (was duplicated in script 04).
40 +- **`src/latex_tables.py`** — significance stars (previously copy-pasted in 4
41 + scripts) and the stargazer-style `results_to_latex` builder (was in script 06).
42 +- **`src/plotting.py`** — the two matplotlib style blocks (were duplicated across
43 + scripts 05, 09, 10). Scripts 07/08 intentionally keep matplotlib defaults, as
44 + in the original.
45 +- All scripts now have a `main()` entry point, docstrings, and import shared
46 + helpers; scripts 01–03 fail with an explanatory message when the raw data are
47 + absent. Script 10's two near-identical OLS helpers were merged into one
48 + (`run_ols_city_fe`, parameterised by the Airbnb exposure variable).
49 +- **Table fragments:** the published `outputs/tables/*.tex` had been *hand-edited*
50 + after generation (the `\begin{table}…\end{table}` wrappers were stripped so the
51 + paper could `\input` them inside its own table environments). The refactored
52 + generators emit exactly those fragments, so pipeline output now feeds the paper
53 + directly with no manual post-processing.
54 +- Deliberately **kept as-is** (to reproduce identical outputs): the
55 + `size_sqft`/`guests_count`/etc. column names requested by script 05 that don't
56 + exist in the processed data (the summary-stats helper skips them, matching the
57 + published tables) — noted in `AUDIT.md` §5.4.
58 +
59 +## 3. Pipeline verification (Phase 2b)
60 +
61 +Full re-run of steps 04→10 in this repo, compared against the originals:
62 +
63 +- 3/3 merged parquet files **value-identical**.
64 +- 14/16 tables **byte-identical**. Two residual, numerically negligible
65 + differences (documented, not corrected): the τ=0.10 quantile-regression column
66 + (3 control coefficients at the 4th decimal — IRLS convergence jitter; the
67 + Airbnb coefficient and all conclusions unchanged) and 2 values of
68 + `ml_comparison.csv` at the 16th significant digit. Details: `AUDIT.md` §8.
69 +- All **15 figures regenerated**, including the 5 missing from the original
70 + project (`map_airbnb`, `map_rent`, `quantile_coefficients`, `rent_by_city`,
71 + `rent_by_airbnb_bins`) — this un-breaks the paper build, which previously
72 + failed on the missing maps.
73 +
74 +## 4. Paper rewrite (`paper/`)
75 +
76 +Structure was already `main.tex` + `sections/` + `appendix/` + `references.bib`;
77 +it was kept, with figure paths pointed at `../figures/` and table inputs at
78 +`../results/tables/`. `latexmk` builds `main.pdf` (41 pages) with **zero
79 +unresolved references or citations**. All 24 BibTeX entries are cited; every
80 +figure/table in the paper is referenced in the text and captioned.
81 +
82 +### Editorial corrections that ALIGN THE TEXT WITH THE PAPER'S OWN TABLES
83 +**⚠️ These need your review — the previous prose contradicted the (unchanged) tables:**
84 +
85 +1. **Abstract, §5.2, Conclusion — Airbnb pricing model (Model 2).** The text
86 + claimed listings in higher-rent cities "command significant price premia"
87 + (with a "$100 → 2–4%" magnitude). Table 4 shows `mean_rent_city` is
88 + *insignificant in every column* (negative point estimates in 2a/2b). The text
89 + now reports the null result and its interpretation. Also corrected: the
90 + claimed positive rating/superhost premia (rating is not in the model;
91 + superhost is significantly **negative**).
92 +2. **§5.3 City-level model.** "Strong positive correlation … fewer than 30
93 + cities" → modest but significant forward association (0.0001**), essentially
94 + zero explanatory power in the reverse regression, and the actual 153 cities.
95 +3. **§5.5 Quantile regressions.** "Insignificant at the 10th percentile,
96 + monotonically increasing" → significant at *all* quantiles, roughly flat over
97 + the lower half, rising to its maximum (0.0047) at τ=0.90.
98 +4. **§6 Alternative exposure / §7 Commercialisation.** The text claimed the
99 + entire-home share is positively associated with rents (supporting the
100 + commercialisation hypothesis). Model 1e shows a small *negative,
101 + insignificant* coefficient; the text now reports this and discusses why. The
102 + phantom "mean Airbnb price" alternative measure (never in the table) was
103 + removed from the list.
104 +5. **§5.6 ML results.** "Gradient boosting best, all ML beat OLS; bedrooms and
105 + city indicators most important" → random forest is best (test R²=0.71), the
106 + regularised linear models tie OLS, and the top features are bathrooms,
107 + coordinates, and bedrooms (city indicators are not ML features).
108 +6. **§5.1 Baseline description.** Column description now matches the actual
109 + (1a)–(1e) layout; bedroom effect corrected to 11–13% (was "15–25%"),
110 + bathrooms 23–30%; adjusted R² 0.56 (was "0.40–0.55").
111 +7. **§6 Outlier sensitivity.** Text described winsorisation at 1/99; the code
112 + *trims* at rent 5/95 and Airbnb count 1/99. Text now matches, and the
113 + "negligible effect" claim was corrected (the rent-trimmed estimate is ~20%
114 + smaller, still significant).
115 +
116 +### Methods descriptions aligned with the actual implementation
117 +8. **Model 4 / methodological appendix.** Radius-based leave-one-out spatial lag
118 + estimated by OLS → the implemented KNN(k=5) row-standardised weights with SAR
119 + estimated by `GM_Lag` and SEM by `GM_Error` (spreg).
120 +9. **Quantile SEs.** "Bootstrap, 1,000 replications" → the asymptotic
121 + kernel-based SEs actually produced by `statsmodels.QuantReg`.
122 +10. **Clustered SEs.** Claims that clustered-SE results are "reported" were
123 + removed (none were computed); replaced by an honest inference caveat.
124 +11. **Model 2/6 covariate lists, GBM "early stopping", RF "permutation
125 + importance"** → corrected to the actual controls, fixed 500 iterations, and
126 + SHAP values.
127 +12. **Figure captions.** The two distribution histograms were captioned "(log
128 + scale)" but plot levels in CAD → captions corrected.
129 +13. **Appendix sample-attrition numbers** updated to the actual counts
130 + (Airbnb 5,000 → 3,456; rent 8,356 → 8,303; regression sample 7,925).
131 +
132 +Sections 1 (introduction), 2 (literature) and most of 7–8 needed only the
133 +consistency fixes above; the prose was already in polished academic English and
134 +was otherwise preserved.
135 +
136 +## 5. Items requiring your review
137 +
138 +- **Raw data missing** (`airbnb.csv`, `rent.json`): absent from the original
139 + folder and the whole disk. Steps 01–03 are ready but cannot run until the files
140 + are restored to `data/raw/`. Everything else reproduces from `data/processed/`.
141 +- **The text↔table contradictions in §4 above** (especially item 1, which changes
142 + the abstract's fourth claim, and item 4). The tables were and remain the
143 + original results; if you believe the *tables* are wrong instead, the code paths
144 + to investigate are `scripts/06_hedonic_models.py` (`mean_rent_city`,
145 + `share_entire_home_500m`).
146 +- The two numerically negligible reproduction differences (`AUDIT.md` §8).
147 +- The paper still says "Version 1.0, May 2026" — bump `\WPversion`/`\WPdate` in
148 + `paper/main.tex` if you consider this revision a new version.
149 +- `figures/ml_predicted_vs_actual.pdf`, `airbnb_by_city.pdf`, `rent_by_city.pdf`,
150 + `scatter_airbnb_rent.pdf`, `rent_airbnb_heatmap.pdf`, `rent_by_airbnb_bins.pdf`
151 + are generated but not included in the paper (same as the original); include
152 + them if desired.
added README.md +75 −0
@@ -0,0 +1,75 @@
1 +<!-- Author: Simon-Pierre Boucher — contact@spboucher.ai -->
2 +
3 +# Airbnb, Residential Rents, and Housing Market Pressure (UQO WP5)
4 +
5 +A hedonic and spatial econometric analysis of the relationship between Airbnb
6 +activity and residential rental prices in Quebec, Canada.
7 +UQO Working Paper No. 5 — Simon-Pierre Boucher, Université du Québec en Outaouais.
8 +
9 +## Repository layout
10 +
11 +```
12 +wp5_uqo/
13 +├── README.md # this file
14 +├── AUDIT.md # audit of the original project (pre-restructuring)
15 +├── CHANGES.md # everything that was moved, refactored, or rewritten
16 +├── requirements.txt # pinned Python dependencies
17 +├── data/
18 +│ ├── raw/ # airbnb.csv + rent.json (NOT distributed — see data/raw/README.md)
19 +│ └── processed/ # cleaned & merged parquet files (committed)
20 +├── src/ # shared modules (paths/config, geo, LaTeX tables, plot styles)
21 +├── scripts/ # numbered pipeline entry points (01–10)
22 +├── figures/ # all 15 paper figures (regenerated by the pipeline)
23 +├── results/
24 +│ ├── tables/ # LaTeX table fragments + CSV outputs
25 +│ └── logs/ # data-inspection log
26 +└── paper/ # LaTeX source (main.tex + sections/ + appendix/) → main.pdf
27 +```
28 +
29 +## Reproducing everything
30 +
31 +Requires Python ≥ 3.12 (developed on 3.14.4) and a TeX distribution with `latexmk`.
32 +
33 +```bash
34 +pip install -r requirements.txt
35 +
36 +# Steps 01–03 need the raw files in data/raw/ (see data/raw/README.md).
37 +# The committed parquets in data/processed/ make them optional.
38 +python3 scripts/01_inspect_raw_data.py # optional — raw data inspection log
39 +python3 scripts/02_clean_airbnb.py # optional — rebuilds airbnb_clean.parquet
40 +python3 scripts/03_clean_rent.py # optional — rebuilds rent_clean.parquet
41 +
42 +python3 scripts/04_merge_data.py # spatial buffer merge (Haversine 250m–2km)
43 +python3 scripts/05_descriptive_analysis.py # summary tables + descriptive figures
44 +python3 scripts/06_hedonic_models.py # Models 1–3 (hedonic OLS, HC1)
45 +python3 scripts/07_spatial_models.py # Model 4 (SAR/SEM via spreg) + buffer robustness
46 +python3 scripts/08_quantile_models.py # Model 5 (quantile regressions)
47 +python3 scripts/09_ml_robustness.py # Model 6 (LASSO/ENet/RF/GBM + SHAP)
48 +python3 scripts/10_robustness_tables_figures.py # robustness tables + extra figures
49 +
50 +cd paper && latexmk # builds paper/main.pdf
51 +```
52 +
53 +Every script reads and writes paths defined in `src/config.py`; the pipeline can
54 +be run from any working directory.
55 +
56 +## Key findings
57 +
58 +- Each additional Airbnb listing within 500 m is associated with ≈ 0.4% higher
59 + monthly rent (0.3–0.5% across specifications), controlling for dwelling
60 + characteristics, building type, and city fixed effects.
61 +- The association decays with buffer radius (250 m → 2 km) and is strongest at
62 + the upper quantiles of the rent distribution.
63 +- SAR/SEM spatial models confirm substantial spatial autocorrelation in rents;
64 + the Airbnb coefficient survives with mild attenuation.
65 +- ML benchmarks (random forest, gradient boosting) confirm the predictive
66 + relevance of Airbnb exposure; the linear specification remains adequate.
67 +- Cross-sectional design — associations, not causal effects.
68 +
69 +## Provenance
70 +
71 +Restructured from `~/Desktop/UQO/UQO_WP/immo-wp5-spb-20260519` (left untouched
72 +as backup) on 2026-08-05. See `AUDIT.md` for the audit of the original project
73 +and `CHANGES.md` for the full list of changes; regenerated tables were verified
74 +against the originals (14/16 byte-identical, 2 residual float-level differences
75 +documented in `AUDIT.md` §8).
added data/processed/airbnb_clean.parquet +0 −0

Binary file not shown.

added data/processed/merged_analysis.parquet +0 −0

Binary file not shown.

added data/processed/merged_neighborhood.parquet +0 −0

Binary file not shown.

added data/processed/merged_spatial.parquet +0 −0

Binary file not shown.

added data/processed/rent_clean.parquet +0 −0

Binary file not shown.

added data/raw/README.md +16 −0
@@ -0,0 +1,16 @@
1 +<!-- Author: Simon-Pierre Boucher — contact@spboucher.ai -->
2 +
3 +# Raw data (not included)
4 +
5 +The two raw input files are **not distributed** with this repository and were
6 +not present in the original project folder either (see `AUDIT.md` §5.1):
7 +
8 +- `airbnb.csv` — ~5,000 Airbnb listings scraped from Airbnb (Quebec province)
9 +- `rent.json` — 8,356 residential rental listings from Realtor.ca (Quebec province)
10 +
11 +Place them in this directory to run pipeline steps 01–03
12 +(`scripts/01_inspect_raw_data.py`, `02_clean_airbnb.py`, `03_clean_rent.py`).
13 +
14 +The cleaned datasets derived from them are committed in `data/processed/`,
15 +so the full analysis (steps 04–10) and the paper are reproducible without
16 +the raw files.
added figures/airbnb_by_city.pdf +0 −0

Binary file not shown.

added figures/coefficient_buffer_comparison.pdf +0 −0

Binary file not shown.

added figures/coefficient_robustness.pdf +0 −0

Binary file not shown.

added figures/dist_airbnb_price.pdf +0 −0

Binary file not shown.

added figures/dist_rent.pdf +0 −0

Binary file not shown.

added figures/feature_importance.pdf +0 −0

Binary file not shown.

added figures/map_airbnb.pdf +0 −0

Binary file not shown.

added figures/map_rent.pdf +0 −0

Binary file not shown.

added figures/ml_predicted_vs_actual.pdf +0 −0

Binary file not shown.

added figures/quantile_coefficients.pdf +0 −0

Binary file not shown.

added figures/rent_airbnb_heatmap.pdf +0 −0

Binary file not shown.

added figures/rent_by_airbnb_bins.pdf +0 −0

Binary file not shown.

added figures/rent_by_city.pdf +0 −0

Binary file not shown.

added figures/scatter_airbnb_rent.pdf +0 −0

Binary file not shown.

added figures/shap_summary.pdf +0 −0

Binary file not shown.

added paper/.latexmkrc +6 −0
@@ -0,0 +1,6 @@
1 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +$pdf_mode = 1;
3 +$pdflatex = 'pdflatex -interaction=nonstopmode -halt-on-error -synctex=1 %O %S';
4 +$bibtex_use = 2;
5 +$clean_ext = 'synctex.gz run.xml bbl bcf fdb_latexmk fls log aux out toc lof lot blg';
6 +@default_files = ('main.tex');
added paper/Makefile +12 −0
@@ -0,0 +1,12 @@
1 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +.PHONY: all clean distclean
3 +
4 +all:
5 + latexmk
6 +
7 +clean:
8 + latexmk -c
9 +
10 +distclean:
11 + latexmk -C
12 + rm -f *.synctex.gz *.run.xml *.bbl *.bcf
added paper/appendix/appendix_data.tex +103 −0
@@ -0,0 +1,103 @@
1 +% Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +% =============================================================================
3 +% appendix_data.tex — Additional data details
4 +% =============================================================================
5 +\section{Data Appendix}\label{app:data}
6 +
7 +\subsection{Variable Definitions}
8 +
9 +Table~\ref{tab:var_definitions} provides a comprehensive list of variables used in the analysis, including their definitions, sources, and units.
10 +
11 +\begin{table}[htbp]
12 + \centering
13 + \caption{Variable Definitions}
14 + \label{tab:var_definitions}
15 + \small
16 + \begin{tabularx}{\textwidth}{l l X l}
17 + \toprule
18 + \textbf{Variable} & \textbf{Source} & \textbf{Definition} & \textbf{Unit} \\
19 + \midrule
20 + \multicolumn{4}{l}{\textit{Rental listing variables}} \\
21 + \addlinespace
22 + \texttt{rent} & Realtor.ca & Monthly asking rent & CAD \\
23 + \texttt{log\_rent} & Constructed & Natural log of monthly rent & --- \\
24 + \texttt{bedrooms} & Realtor.ca & Number of bedrooms & Count \\
25 + \texttt{bathrooms} & Realtor.ca & Number of bathrooms (total) & Count \\
26 + \texttt{building\_type} & Realtor.ca & Building type (Apartment, House, Row/Townhouse) & Category \\
27 + \texttt{size\_interior} & Realtor.ca & Interior floor area & sq ft \\
28 + \texttt{lat}, \texttt{lon} & Realtor.ca & Latitude and longitude of the listing & Degrees \\
29 + \texttt{city} & Realtor.ca & City / municipality name & Category \\
30 + \addlinespace
31 + \midrule
32 + \multicolumn{4}{l}{\textit{Airbnb listing variables}} \\
33 + \addlinespace
34 + \texttt{price\_numeric} & Airbnb & Nightly asking price & CAD \\
35 + \texttt{log\_price} & Constructed & Natural log of nightly price & --- \\
36 + \texttt{property\_type} & Airbnb & Property type (Rental unit, House, Cabin/Chalet, Condo, Apartment) & Category \\
37 + \texttt{rating} & Airbnb & Star rating (0--5 scale) & Numeric \\
38 + \texttt{num\_reviews} & Airbnb & Number of guest reviews & Count \\
39 + \texttt{is\_superhost} & Airbnb & Superhost status indicator & Binary \\
40 + \texttt{is\_guest\_favorite} & Airbnb & Guest favourite designation & Binary \\
41 + \texttt{pets\_allowed} & Airbnb & Whether pets are allowed & Binary \\
42 + \texttt{is\_entire\_home} & Constructed & Indicator for entire-home property types & Binary \\
43 + \texttt{lat}, \texttt{lon} & Airbnb & Latitude and longitude of the listing & Degrees \\
44 + \texttt{city} & Airbnb & City name (standardised) & Category \\
45 + \addlinespace
46 + \midrule
47 + \multicolumn{4}{l}{\textit{Airbnb exposure variables (constructed via spatial buffer merge)}} \\
48 + \addlinespace
49 + \texttt{airbnb\_count\_$r$} & Constructed & Number of Airbnb listings within radius $r$ & Count \\
50 + \texttt{airbnb\_density\_$r$} & Constructed & Airbnb count / buffer area ($\pi r^2$) & Per km$^2$ \\
51 + \texttt{mean\_airbnb\_price\_$r$} & Constructed & Mean Airbnb nightly price within radius $r$ & CAD \\
52 + \texttt{share\_entire\_home\_$r$} & Constructed & Share of entire-home Airbnb listings within $r$ & Proportion \\
53 + \texttt{mean\_rating\_$r$} & Constructed & Mean rating of Airbnb listings within $r$ & Numeric \\
54 + \texttt{superhost\_share\_$r$} & Constructed & Share of superhost listings within $r$ & Proportion \\
55 + \addlinespace
56 + \midrule
57 + \multicolumn{4}{l}{\textit{City-level variables}} \\
58 + \addlinespace
59 + \texttt{airbnb\_count\_city} & Constructed & Total Airbnb listings in the city & Count \\
60 + \texttt{mean\_airbnb\_price\_city} & Constructed & City-level mean Airbnb nightly price & CAD \\
61 + \texttt{share\_entire\_home\_city} & Constructed & City-level share of entire-home listings & Proportion \\
62 + \texttt{mean\_rent\_city} & Constructed & City-level mean monthly rent & CAD \\
63 + \bottomrule
64 + \end{tabularx}
65 + \begin{flushleft}
66 + \footnotesize\textit{Notes:} Buffer radii $r \in \{250\text{m}, 500\text{m}, 1\text{km}, 2\text{km}\}$. All monetary values are in Canadian dollars (CAD). ``Constructed'' indicates variables derived from the raw data during the cleaning and merge stages.
67 + \end{flushleft}
68 +\end{table}
69 +
70 +\subsection{City Name Standardisation}
71 +
72 +The Airbnb dataset contains city names with inconsistent formatting, including accented and unaccented variants and abbreviations. Table~\ref{tab:city_mapping} lists the standardisation mapping applied during data cleaning.
73 +
74 +\begin{table}[htbp]
75 + \centering
76 + \caption{City Name Standardisation Mapping}
77 + \label{tab:city_mapping}
78 + \small
79 + \begin{tabular}{ll}
80 + \toprule
81 + \textbf{Original Name(s)} & \textbf{Standardised Name} \\
82 + \midrule
83 + Montr\'{e}al & Montreal \\
84 + Qu\'{e}bec City, Quebec City, Quebec & Qu\'{e}bec \\
85 + Levis & L\'{e}vis \\
86 + Saint Come & Saint-C\^{o}me \\
87 + Sainte Adele, Sainte-Adele, Ste-Ad\`{e}le & Sainte-Ad\`{e}le \\
88 + Saint Sauveur, Saint-Sauveur-des-Monts & Saint-Sauveur \\
89 + Ste-Agathe-des-Monts & Sainte-Agathe-des-Monts \\
90 + \bottomrule
91 + \end{tabular}
92 +\end{table}
93 +
94 +\subsection{Sample Attrition}
95 +
96 +The following summarises the sample attrition at each stage of data cleaning:
97 +
98 +\begin{itemize}
99 + \item \textbf{Airbnb:} Raw dataset $\approx 5{,}000$ listings. After dropping listings with missing or non-positive prices, the cleaned sample retains 3{,}456 listings.
100 + \item \textbf{Rentals:} Raw dataset of 8{,}356 records. After restricting to single-family properties with monthly rental periods and positive parsed rents, the cleaned sample retains 8{,}303 listings, all with valid coordinates.
101 + \item \textbf{Spatial merge:} All rental listings with valid coordinates are retained; the Airbnb count is zero for listings with no Airbnb neighbours within the buffer radius (composition variables such as the mean nearby price are undefined in that case).
102 + \item \textbf{Regressions:} The baseline hedonic sample comprises the 7{,}925 rental listings with non-missing bedrooms and bathrooms.
103 +\end{itemize}
added paper/appendix/appendix_methods.tex +65 −0
@@ -0,0 +1,65 @@
1 +% Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +% =============================================================================
3 +% appendix_methods.tex — Methodological details
4 +% =============================================================================
5 +\section{Methodological Appendix}\label{app:methods}
6 +
7 +\subsection{Haversine Distance Formula}\label{app:haversine}
8 +
9 +The Haversine formula computes the great-circle distance between two points on the surface of a sphere, given their latitudes and longitudes. For two points $(lat_1, lon_1)$ and $(lat_2, lon_2)$ expressed in radians, the Haversine distance is:
10 +
11 +\begin{equation}\label{eq:haversine}
12 + d = 2R \cdot \arcsin\!\left(\sqrt{\sin^2\!\left(\frac{\Delta lat}{2}\right) + \cos(lat_1) \cdot \cos(lat_2) \cdot \sin^2\!\left(\frac{\Delta lon}{2}\right)}\right),
13 +\end{equation}
14 +
15 +\noindent where:
16 +\begin{itemize}
17 + \item $R = 6{,}371$ km is the mean radius of the Earth;
18 + \item $\Delta lat = lat_2 - lat_1$ is the difference in latitudes;
19 + \item $\Delta lon = lon_2 - lon_1$ is the difference in longitudes.
20 +\end{itemize}
21 +
22 +The Haversine formula provides an accurate approximation for short to medium distances on the Earth's surface. For the distances relevant in our application (typically less than 5 km), the approximation error relative to the exact geodesic distance (Vincenty's formula) is negligible.
23 +
24 +\paragraph{Implementation.} In our spatial merge (Section~\ref{sec:spatial_merge}), the Haversine distance is computed in vectorised form using NumPy. For each chunk of rental listings, we compute the full pairwise distance matrix between the chunk and all Airbnb listings, yielding an $(n_{\text{chunk}} \times n_{\text{Airbnb}})$ matrix of distances. Buffer membership is determined by comparing each element of this matrix against the threshold radius $r$.
25 +
26 +\subsection{Spatial Weights Construction}\label{app:spatial_weights}
27 +
28 +The spatial weights matrix $\mathbf{W}$ used in Model~4 (Equation~\ref{eq:spatial_lag}) is a $k$-nearest-neighbour matrix with $k = 5$. For each rental listing $i$, the neighbourhood $\mathcal{N}_i$ is the set of the five rental listings closest to $i$ in Euclidean coordinate space (excluding $i$ itself):
29 +
30 +\begin{equation}
31 + w_{ik} = \begin{cases}
32 + \frac{1}{5} & \text{if } k \in \mathcal{N}_i, \\[4pt]
33 + 0 & \text{otherwise},
34 + \end{cases}
35 +\end{equation}
36 +
37 +\noindent so that the spatial lag $\sum_k w_{ik} \ln(\text{rent}_k)$ is the mean log rent of listing $i$'s five nearest neighbours. Row-standardisation guarantees that every observation has a well-defined, equally weighted neighbourhood, avoiding the empty-neighbourhood problem that a fixed-radius definition would create in sparse rural areas.
38 +
39 +\paragraph{Choice of $k$.} The choice of $k = 5$ balances two considerations. A smaller $k$ would capture only the most proximate listings, yielding a responsive but noisy spatial lag; a larger $k$ would average over a broader area, reducing noise but blurring relevant spatial variation---and, in dense urban cores, five nearest neighbours typically lie within a few hundred metres.
40 +
41 +\paragraph{Estimation.} The inclusion of a spatial lag of the dependent variable as a regressor introduces a well-known simultaneity problem: $\sum_k w_{ik} \ln(\text{rent}_k)$ is correlated with $\eta_i$ whenever the errors are spatially correlated, so OLS estimation of Equation~\ref{eq:spatial_lag} would be inconsistent. We therefore estimate the SAR model by the generalised-moments instrumental-variable estimator of Kelejian and Prucha (\texttt{GM\_Lag} in \texttt{spreg}), which instruments the spatial lag with spatially lagged exogenous regressors, and the SEM by the corresponding \texttt{GM\_Error} estimator \citep{anselin1988spatial, lesage2009introduction}.
42 +
43 +\subsection{Quantile Regression Estimation}
44 +
45 +The quantile regression model at quantile $\tau$ (Equation~\ref{eq:quantile}) is estimated by minimising the asymmetrically weighted sum of absolute residuals:
46 +
47 +\begin{equation}
48 + \hat{\boldsymbol{\beta}}_\tau = \arg\min_{\boldsymbol{\beta}} \sum_{i=1}^{n} \rho_\tau\!\left(\ln(\text{rent}_i) - \mathbf{X}_i' \boldsymbol{\beta}\right),
49 +\end{equation}
50 +
51 +\noindent where $\rho_\tau(u) = u(\tau - \mathbf{1}[u < 0])$ is the check function \citep{koenker1978regression}. We estimate the model with the iteratively reweighted least squares algorithm implemented in \texttt{statsmodels}.
52 +
53 +Standard errors are the asymptotic estimates based on the kernel estimate of the conditional density of the response at the fitted quantile (the default in \texttt{statsmodels}), which are consistent under independent but not necessarily identically distributed errors.
54 +
55 +\subsection{Machine-Learning Model Details}
56 +
57 +\paragraph{LASSO and Elastic Net.} The LASSO model \citep{tibshirani1996regression} estimates:
58 +\begin{equation}
59 + \hat{\boldsymbol{\beta}}_{\text{LASSO}} = \arg\min_{\boldsymbol{\beta}} \left\{ \frac{1}{2n}\|\mathbf{y} - \mathbf{X}\boldsymbol{\beta}\|_2^2 + \lambda \|\boldsymbol{\beta}\|_1 \right\},
60 +\end{equation}
61 +where $\lambda > 0$ is the regularisation parameter selected by 5-fold cross-validation. The elastic net extends this with an $L_2$ penalty, controlled by a mixing parameter $\alpha \in (0,1)$.
62 +
63 +\paragraph{Random Forest.} The random forest model \citep{breiman2001random} constructs an ensemble of $B = 500$ regression trees with a maximum depth of 15, each trained on a bootstrap sample with random feature subsampling.
64 +
65 +\paragraph{Gradient Boosting.} The gradient boosting model \citep{friedman2001greedy} sequentially fits shallow regression trees to the residuals of the current ensemble. We use 500 boosting iterations, a maximum tree depth of 5, and a learning rate of 0.05. SHAP (SHapley Additive exPlanations) values \citep{lundberg2017unified} are computed on the held-out test set from the fitted gradient boosting model to quantify the contribution of each feature to individual predictions.
added paper/appendix/appendix_robustness.tex +27 −0
@@ -0,0 +1,27 @@
1 +% Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +% =============================================================================
3 +% appendix_robustness.tex — Additional robustness tables
4 +% =============================================================================
5 +\section{Additional Robustness Results}\label{app:robustness}
6 +
7 +This appendix presents additional robustness checks that supplement the results reported in Section~\ref{sec:robustness}.
8 +
9 +\subsection{Full Regression Output --- Baseline Hedonic Model}
10 +
11 +The preferred hedonic rent specification (Model~1c in Table~\ref{tab:hedonic_rent}) includes property controls (bedrooms, bathrooms, building type) and city fixed effects. Full regression output including all control variable coefficients and city fixed effects is available upon request.
12 +
13 +\subsection{Correlation Matrix of Airbnb Exposure Measures}
14 +
15 +The pairwise correlation matrix of the Airbnb exposure variables at the 500\,m buffer radius documents the degree of collinearity among alternative exposure measures. The various exposure measures---count, density, share of entire-home listings, mean price---are positively correlated, reflecting the spatial concentration of Airbnb activity.
16 +
17 +\subsection{Quantile Regression --- Full Coefficient Tables}
18 +
19 +The quantile regression results reported in Table~\ref{tab:quantile} focus on the Airbnb exposure coefficient across the rent distribution. Full coefficient tables including control variables at each quantile are available upon request.
20 +
21 +\subsection{Machine-Learning Cross-Validation Results}
22 +
23 +Table~\ref{tab:ml_performance} in the main text reports hold-out sample performance for all machine-learning models. The gradient boosting and random forest models achieve substantially higher $R^2$ on the test set relative to linear models, suggesting meaningful nonlinearities in the rent--amenity relationship.
24 +
25 +\subsection{Additional Subsample Results}
26 +
27 +The subsample analysis in Section~\ref{sec:robustness} examines Montreal versus non-Montreal and apartment versus house subsamples. Additional subsample results (e.g., by Airbnb exposure quartile or by borough-level regulation status) are available upon request.
added paper/main.pdf +0 −0

Binary file not shown.

added paper/main.tex +186 −0
@@ -0,0 +1,186 @@
1 +% Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +% ============================================================================
3 +% UQO Working Paper No. 5
4 +% Airbnb, Residential Rents, and Housing Market Pressure:
5 +% A Hedonic and Spatial Econometric Analysis
6 +% ============================================================================
7 +\documentclass[12pt,letterpaper]{article}
8 +
9 +% --- Encoding & Language ---
10 +\usepackage[T1]{fontenc}
11 +\usepackage[english]{babel}
12 +
13 +% --- Page Layout ---
14 +\usepackage[
15 + letterpaper,
16 + top=1in, bottom=1in, left=1in, right=1in,
17 + headheight=15pt
18 +]{geometry}
19 +\usepackage{setspace}
20 +\onehalfspacing
21 +
22 +% --- Typography ---
23 +\usepackage{newtxtext,newtxmath}
24 +\usepackage{microtype}
25 +
26 +% --- Math ---
27 +\usepackage{amsmath,amsfonts}
28 +\let\Bbbk\relax
29 +\usepackage{amssymb}
30 +
31 +% --- Tables ---
32 +\usepackage{booktabs}
33 +\usepackage{tabularx}
34 +\usepackage{multirow}
35 +\usepackage{threeparttable}
36 +\usepackage{dcolumn}
37 +\newcolumntype{d}[1]{D{.}{.}{#1}}
38 +
39 +% --- Figures ---
40 +\usepackage{graphicx}
41 +\usepackage{float}
42 +\usepackage[
43 + font = small,
44 + labelfont = bf,
45 + labelsep = period,
46 + skip = 6pt,
47 + justification = centering
48 +]{caption}
49 +\usepackage{subcaption}
50 +
51 +% --- Lists ---
52 +\usepackage{enumitem}
53 +\setlist{nosep,leftmargin=*}
54 +
55 +% --- Colors & Links ---
56 +\usepackage[dvipsnames]{xcolor}
57 +\usepackage[bookmarks, bookmarksnumbered]{hyperref}
58 +\hypersetup{
59 + colorlinks = true,
60 + linkcolor = NavyBlue,
61 + citecolor = NavyBlue,
62 + urlcolor = NavyBlue,
63 + pdfauthor = {Simon-Pierre Boucher},
64 + pdftitle = {Airbnb, Residential Rents, and Housing Market Pressure}
65 +}
66 +
67 +% --- Landscape ---
68 +\usepackage{lscape}
69 +
70 +% --- Bibliography ---
71 +\usepackage[round]{natbib}
72 +\setcitestyle{aysep={}}
73 +\bibliographystyle{apalike}
74 +
75 +% --- Headers & Footers ---
76 +\usepackage{fancyhdr}
77 +\pagestyle{fancy}
78 +\fancyhf{}
79 +\fancyhead[L]{\small\itshape Airbnb, Residential Rents, and Housing Market Pressure}
80 +\fancyhead[R]{\small\thepage}
81 +\renewcommand{\headrulewidth}{0.4pt}
82 +\renewcommand{\footrulewidth}{0pt}
83 +\fancypagestyle{plain}{%
84 + \fancyhf{}
85 + \fancyfoot[C]{\small\thepage}
86 + \renewcommand{\headrulewidth}{0pt}
87 +}
88 +
89 +% --- Section Formatting ---
90 +\usepackage{titlesec}
91 +\titleformat{\section}{\large\bfseries}{\thesection.}{0.5em}{}
92 +\titleformat{\subsection}{\normalsize\bfseries}{\thesubsection.}{0.5em}{}
93 +\titleformat{\subsubsection}{\normalsize\itshape}{\thesubsubsection.}{0.5em}{}
94 +\titlespacing*{\section}{0pt}{18pt}{8pt}
95 +\titlespacing*{\subsection}{0pt}{12pt}{4pt}
96 +
97 +% --- Appendix ---
98 +\usepackage{apptools}
99 +\AtAppendix{%
100 + \renewcommand{\thetable}{A\arabic{table}}%
101 + \setcounter{table}{0}%
102 + \renewcommand{\thefigure}{A\arabic{figure}}%
103 + \setcounter{figure}{0}%
104 + \renewcommand{\theequation}{A.\arabic{equation}}%
105 + \setcounter{equation}{0}%
106 +}
107 +
108 +% --- Graphics Path ---
109 +\graphicspath{{../figures/}{.}}
110 +
111 +% ============================================================================
112 +% METADATA
113 +% ============================================================================
114 +\newcommand{\WPnumber}{5}
115 +\newcommand{\WPtitle}{Airbnb, Residential Rents, and Housing Market Pressure: A Hedonic and Spatial Econometric Analysis}
116 +\newcommand{\WPsubtitle}{}
117 +\newcommand{\WPdate}{May 2026}
118 +\newcommand{\WPversion}{1.0}
119 +\newcommand{\WPabstract}{%
120 +This paper investigates the relationship between Airbnb short-term rental
121 +activity and residential rents in Quebec, Canada. Using cross-sectional
122 +microdata comprising approximately 5,000 Airbnb listings and 8,300
123 +residential rental listings, we employ a hedonic pricing framework augmented
124 +with spatial econometric techniques to quantify the conditional association
125 +between nearby Airbnb presence and monthly rents. For each rental listing we
126 +construct Airbnb exposure measures within 250\,m, 500\,m, 1\,km, and 2\,km
127 +buffers using Haversine distances. Our baseline estimates indicate that an
128 +additional Airbnb listing within 500\,m is associated with a statistically
129 +significant increase in monthly rent of approximately 0.3--0.5\%, controlling
130 +for dwelling characteristics, building type, and city fixed effects. Quantile
131 +regressions reveal that this association is stronger at the upper tail of the
132 +rent distribution. A complementary hedonic model of Airbnb nightly prices
133 +indicates that short-term rental pricing is driven primarily by listing
134 +characteristics, with no significant premium attached to city-level rents.
135 +Robustness checks---including alternative buffer radii, subsample
136 +analyses, and machine-learning benchmarks---confirm the stability of the
137 +rent--exposure association. We discuss the policy implications of these results for housing
138 +affordability and short-term rental regulation, while cautioning that
139 +cross-sectional associations should not be interpreted as causal effects.%
140 +}
141 +\newcommand{\WPkeywords}{Airbnb, short-term rentals, housing rents, hedonic pricing, spatial econometrics, housing affordability, Quebec}
142 +\newcommand{\WPjel}{R21, R31, L83, C21}
143 +
144 +% --- Author ---
145 +\newcommand{\WPauthor}{Simon-Pierre Boucher}
146 +\newcommand{\WPaffiliation}{%
147 + D\'epartement des sciences administratives\\
148 + Universit\'e du Qu\'ebec en Outaouais%
149 +}
150 +\newcommand{\WPemail}{simon-pierre.boucher@uqo.ca}
151 +\newcommand{\WPaddress}{%
152 + Gatineau -- Pavillon Alexandre-Tach\'e\\
153 + 283, boulevard Alexandre-Tach\'e\\
154 + Gatineau, Qu\'ebec, Canada J9A 1L8%
155 +}
156 +
157 +% ============================================================================
158 +% DOCUMENT
159 +% ============================================================================
160 +\begin{document}
161 +
162 +% --- Title Page ---
163 +\input{sections/titlepage}
164 +
165 +% --- Main Body ---
166 +\input{sections/01_introduction}
167 +\input{sections/02_literature}
168 +\input{sections/03_data}
169 +\input{sections/04_methodology}
170 +\input{sections/05_results}
171 +\input{sections/06_robustness}
172 +\input{sections/07_discussion}
173 +\input{sections/08_conclusion}
174 +
175 +% --- References ---
176 +\newpage
177 +\bibliography{references}
178 +
179 +% --- Appendix ---
180 +\newpage
181 +\appendix
182 +\input{appendix/appendix_data}
183 +\input{appendix/appendix_methods}
184 +\input{appendix/appendix_robustness}
185 +
186 +\end{document}
added paper/references.bib +270 −0
@@ -0,0 +1,270 @@
1 +% Author: Simon-Pierre Boucher — contact (at) spboucher.ai
2 +% =============================================================================
3 +% references.bib
4 +% IMPORTANT: All entries below are reconstructed from memory and NEED
5 +% VERIFICATION against the actual publications before submission.
6 +% Check titles, journal names, volume/issue numbers, page ranges, and DOIs.
7 +% =============================================================================
8 +
9 +% ── Short-term rentals and housing markets ──────────────────────────────────
10 +
11 +@article{barron2021effect,
12 + title = {The Effect of Home-Sharing on House Prices and Rents: Evidence from {Airbnb}},
13 + author = {Barron, Kyle and Kung, Edward and Proserpio, Davide},
14 + journal = {Marketing Science},
15 + volume = {40},
16 + number = {1},
17 + pages = {23--47},
18 + year = {2021},
19 + publisher = {INFORMS},
20 +}
21 +
22 +@techreport{sheppard2016airbnb,
23 + title = {Do {Airbnb} Properties Affect House Prices?},
24 + author = {Sheppard, Stephen and Udell, Andrew},
25 + institution = {Williams College Department of Economics},
26 + year = {2016},
27 + type = {Working Paper},
28 +}
29 +
30 +@article{garcia2020airbnb,
31 + title = {{Airbnb}, Short-Term Rentals and Housing Prices: Evidence from {Barcelona}},
32 + author = {Garcia-L\'{o}pez, Miquel-\`{A}ngel and Jofre-Monseny, Jordi and Mart\'{i}nez-Mazza, Rodrigo and Segú, Mariona},
33 + journal = {Journal of Urban Economics},
34 + volume = {119},
35 + pages = {103278},
36 + year = {2020},
37 + publisher = {Elsevier},
38 +}
39 +
40 +@article{horn2017airbnb,
41 + title = {Is Home Sharing Driving Up Rents? Evidence from {Airbnb} in {Boston}},
42 + author = {Horn, Keren and Merante, Mark},
43 + journal = {Journal of Housing Economics},
44 + volume = {38},
45 + pages = {14--24},
46 + year = {2017},
47 + publisher = {Elsevier},
48 +}
49 +
50 +@article{wachsmuth2018airbnb,
51 + title = {{Airbnb} and the Rent Gap: Gentrification Through the Sharing Economy},
52 + author = {Wachsmuth, David and Weisler, Alexander},
53 + journal = {Environment and Planning A: Economy and Space},
54 + volume = {50},
55 + number = {6},
56 + pages = {1147--1170},
57 + year = {2018},
58 + publisher = {SAGE Publications},
59 +}
60 +
61 +@article{ke2017sharing,
62 + title = {Short-Term Rentals and the Sharing Economy: The Case of {Airbnb}},
63 + author = {Ke, Qiulin},
64 + journal = {International Journal of Hospitality Management},
65 + volume = {67},
66 + pages = {120--129},
67 + year = {2017},
68 + publisher = {Elsevier},
69 +}
70 +
71 +% ── Hedonic pricing theory ──────────────────────────────────────────────────
72 +
73 +@article{rosen1974hedonic,
74 + title = {Hedonic Prices and Implicit Markets: Product Differentiation in Pure Competition},
75 + author = {Rosen, Sherwin},
76 + journal = {Journal of Political Economy},
77 + volume = {82},
78 + number = {1},
79 + pages = {34--55},
80 + year = {1974},
81 + publisher = {University of Chicago Press},
82 +}
83 +
84 +@incollection{palmquist2005property,
85 + title = {Property Value Models},
86 + author = {Palmquist, Raymond B.},
87 + booktitle = {Handbook of Environmental Economics},
88 + editor = {M\"{a}ler, Karl-G\"{o}ran and Vincent, Jeffrey R.},
89 + volume = {2},
90 + pages = {763--819},
91 + year = {2005},
92 + publisher = {Elsevier},
93 +}
94 +
95 +@article{parmeter2010applied,
96 + title = {Applied Nonparametric Instrumental Variables Estimation},
97 + author = {Parmeter, Christopher F. and Henderson, Daniel J. and Kumbhakar, Subal C.},
98 + journal = {Econometric Reviews},
99 + volume = {29},
100 + number = {4},
101 + pages = {398--418},
102 + year = {2010},
103 + publisher = {Taylor \& Francis},
104 +}
105 +
106 +% ── Spatial econometrics ────────────────────────────────────────────────────
107 +
108 +@book{anselin1988spatial,
109 + title = {Spatial Econometrics: Methods and Models},
110 + author = {Anselin, Luc},
111 + year = {1988},
112 + publisher = {Kluwer Academic Publishers},
113 + address = {Dordrecht},
114 +}
115 +
116 +@book{lesage2009introduction,
117 + title = {Introduction to Spatial Econometrics},
118 + author = {LeSage, James P. and Pace, R. Kelley},
119 + year = {2009},
120 + publisher = {CRC Press},
121 + address = {Boca Raton, FL},
122 +}
123 +
124 +% ── Quantile regression ────────────────────────────────────────────────────
125 +
126 +@article{koenker1978regression,
127 + title = {Regression Quantiles},
128 + author = {Koenker, Roger and Bassett, Gilbert},
129 + journal = {Econometrica},
130 + volume = {46},
131 + number = {1},
132 + pages = {33--50},
133 + year = {1978},
134 + publisher = {Econometric Society},
135 +}
136 +
137 +@article{zietz2008determinants,
138 + title = {Determinants of House Prices: A Quantile Regression Approach},
139 + author = {Zietz, Joachim and Zietz, Emily Norman and Sirmans, G. Stacy},
140 + journal = {Journal of Real Estate Finance and Economics},
141 + volume = {37},
142 + number = {4},
143 + pages = {317--333},
144 + year = {2008},
145 + publisher = {Springer},
146 +}
147 +
148 +% ── Machine learning and econometrics ───────────────────────────────────────
149 +
150 +@article{mullainathan2017machine,
151 + title = {Machine Learning: An Applied Econometric Approach},
152 + author = {Mullainathan, Sendhil and Spiess, Jann},
153 + journal = {Journal of Economic Perspectives},
154 + volume = {31},
155 + number = {2},
156 + pages = {87--106},
157 + year = {2017},
158 + publisher = {American Economic Association},
159 +}
160 +
161 +@article{athey2019machine,
162 + title = {Machine Learning Methods That Economists Should Know About},
163 + author = {Athey, Susan and Imbens, Guido W.},
164 + journal = {Annual Review of Economics},
165 + volume = {11},
166 + pages = {685--725},
167 + year = {2019},
168 + publisher = {Annual Reviews},
169 +}
170 +
171 +@article{tibshirani1996regression,
172 + title = {Regression Shrinkage and Selection via the {Lasso}},
173 + author = {Tibshirani, Robert},
174 + journal = {Journal of the Royal Statistical Society: Series B (Methodological)},
175 + volume = {58},
176 + number = {1},
177 + pages = {267--288},
178 + year = {1996},
179 + publisher = {Wiley},
180 +}
181 +
182 +@article{breiman2001random,
183 + title = {Random Forests},
184 + author = {Breiman, Leo},
185 + journal = {Machine Learning},
186 + volume = {45},
187 + number = {1},
188 + pages = {5--32},
189 + year = {2001},
190 + publisher = {Springer},
191 +}
192 +
193 +@article{friedman2001greedy,
194 + title = {Greedy Function Approximation: A Gradient Boosting Machine},
195 + author = {Friedman, Jerome H.},
196 + journal = {Annals of Statistics},
197 + volume = {29},
198 + number = {5},
199 + pages = {1189--1232},
200 + year = {2001},
201 + publisher = {Institute of Mathematical Statistics},
202 +}
203 +
204 +@inproceedings{lundberg2017unified,
205 + title = {A Unified Approach to Interpreting Model Predictions},
206 + author = {Lundberg, Scott M. and Lee, Su-In},
207 + booktitle = {Advances in Neural Information Processing Systems},
208 + volume = {30},
209 + pages = {4765--4774},
210 + year = {2017},
211 +}
212 +
213 +% ── Regulation and policy ──────────────────────────────────────────────────
214 +
215 +@article{nieuwland2020regulating,
216 + title = {Regulating {Airbnb}: How Cities Deal with Perceived Negative Externalities of Short-Term Rentals},
217 + author = {Nieuwland, Shirley and van Melik, Rianne},
218 + journal = {Current Issues in Tourism},
219 + volume = {23},
220 + number = {7},
221 + pages = {811--825},
222 + year = {2020},
223 + publisher = {Taylor \& Francis},
224 +}
225 +
226 +@article{agyeman2020airbnb,
227 + title = {{Airbnb} and the Undoing of Mid-Twentieth Century Housing Policy},
228 + author = {Agyeman, Julian and McLaren, Duncan and Schaefer-Borrego, Adrianne},
229 + journal = {Urban Studies},
230 + volume = {57},
231 + number = {14},
232 + pages = {2998--3015},
233 + year = {2020},
234 + publisher = {SAGE Publications},
235 +}
236 +
237 +% ── Housing economics ──────────────────────────────────────────────────────
238 +
239 +@article{glaeser2005urban,
240 + title = {Urban Growth and Housing Supply},
241 + author = {Glaeser, Edward L. and Gyourko, Joseph and Saks, Raven E.},
242 + journal = {Journal of Economic Geography},
243 + volume = {6},
244 + number = {1},
245 + pages = {71--89},
246 + year = {2005},
247 + publisher = {Oxford University Press},
248 +}
249 +
250 +@article{saiz2010geographic,
251 + title = {The Geographic Determinants of Housing Supply},
252 + author = {Saiz, Albert},
253 + journal = {Quarterly Journal of Economics},
254 + volume = {125},
255 + number = {3},
256 + pages = {1253--1296},
257 + year = {2010},
258 + publisher = {Oxford University Press},
259 +}
260 +
261 +@article{gyourko2005superstar,
262 + title = {Superstar Cities},
263 + author = {Gyourko, Joseph and Mayer, Christopher and Sinai, Todd},
264 + journal = {American Economic Journal: Economic Policy},
265 + volume = {5},
266 + number = {4},
267 + pages = {167--199},
268 + year = {2013},
269 + publisher = {American Economic Association},
270 +}
added paper/sections/01_introduction.tex +19 −0
@@ -0,0 +1,19 @@
1 +% Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +% =============================================================================
3 +% 01_introduction.tex
4 +% =============================================================================
5 +\section{Introduction}\label{sec:introduction}
6 +
7 +The rapid expansion of short-term rental platforms has fundamentally transformed urban housing markets around the world. Since its founding in 2008, Airbnb has grown from a modest home-sharing service into a global hospitality platform with millions of listings across virtually every major city. By enabling property owners to rent their dwellings---or portions thereof---to short-term visitors at nightly rates that often exceed what a long-term tenant would pay on a monthly basis, the platform has created powerful economic incentives to divert housing units from the residential rental market.
8 +
9 +This diversion mechanism sits at the heart of a growing policy concern: that the proliferation of Airbnb listings may contribute to rising residential rents and reduced housing affordability, particularly in cities with significant tourist appeal. The economic logic is straightforward. When landlords can earn higher returns by listing a unit on Airbnb than by renting it to a long-term tenant, the effective supply of long-term rental housing contracts. On the demand side, Airbnb may attract additional visitors to a neighbourhood, increasing foot traffic, consumption amenities, and---through general equilibrium effects---the desirability of the area for both tourists and residents. The net effect on rents is theoretically ambiguous: supply withdrawal pushes rents upward, while the sign and magnitude of demand-side amenity effects depend on the local context, the composition of Airbnb listings, and the degree of market segmentation between short-term and long-term rentals.
10 +
11 +The province of Quebec, and the city of Montreal in particular, provides a compelling setting in which to study these dynamics. Montreal is Canada's second-largest city and one of North America's foremost tourist destinations, attracting over 11 million visitors annually prior to the COVID-19 pandemic. Its dense, walkable neighbourhoods, vibrant cultural scene, and historic architecture make it especially attractive for short-term rental guests. At the same time, Quebec has experienced significant rental market pressures in recent years. Vacancy rates in the Montreal census metropolitan area have fallen to historically low levels, and median rents have risen substantially. Policymakers at the provincial and municipal levels have responded with a mix of regulatory interventions, including registration requirements for short-term rental operators and zoning restrictions, but the empirical evidence on the housing-market effects of Airbnb in the Quebec context remains limited.
12 +
13 +Estimating the effect of short-term rental platforms on residential rents poses significant empirical challenges. The most fundamental is endogeneity: Airbnb listings are not randomly assigned to locations. Hosts choose to list in neighbourhoods where rents---and tourist demand---are already high, creating a positive correlation between Airbnb density and rent levels that may reflect reverse causality or omitted neighbourhood characteristics rather than a genuine causal effect. In the ideal research design, one would exploit plausible exogenous variation in Airbnb supply---for example, a regulatory shock that differentially affected neighbourhoods---combined with panel data to control for time-invariant unobservables. Our data, however, are cross-sectional: they comprise a single snapshot of Airbnb and rental listings in Quebec, scraped in 2026. We therefore adopt a transparent hedonic pricing framework that quantifies the conditional association between Airbnb presence and rents, controlling for a rich set of dwelling characteristics and location fixed effects, while being explicit about the limitations of causal interpretation.
14 +
15 +Our empirical strategy proceeds in several stages. First, we estimate a hedonic rent model in which the logarithm of monthly rent is regressed on the count of Airbnb listings within a spatial buffer (our preferred radius is 500 metres), controlling for the number of bedrooms, bathrooms, building type, and city fixed effects. This specification yields a semi-elasticity interpretation: the coefficient on Airbnb count measures the percentage change in rent associated with one additional nearby Airbnb listing, holding observable dwelling characteristics constant. Second, we estimate a complementary hedonic model of Airbnb nightly prices, in which city-level mean rents serve as a control, allowing us to examine the bidirectional pricing relationship between the two market segments. Third, we aggregate the data to the city level to examine cross-city variation in Airbnb penetration and mean rents. Fourth, we incorporate spatial lags and vary the buffer radius (250\,m, 500\,m, 1\,km, 2\,km) to assess the spatial decay of the association. Fifth, quantile regressions at the 10th, 25th, 50th, 75th, and 90th percentiles reveal how the Airbnb--rent association varies across the conditional rent distribution. Sixth, we benchmark our parametric estimates against machine-learning methods---LASSO, elastic net, random forest, and gradient boosting---to assess predictive importance and model robustness.
16 +
17 +This paper makes three contributions. First, it provides the first granular, listing-level analysis of the Airbnb--rent nexus in Quebec, drawing on microdata with precise geographic coordinates that allow exact distance-based matching between Airbnb and rental listings. Second, it applies a multi-method approach---combining hedonic regressions, spatial analysis, quantile regressions, and machine learning---to a single dataset, enabling direct comparison of results across methodological frameworks. Third, it contributes to the ongoing policy debate about short-term rental regulation in Canadian cities by providing empirically grounded estimates of the magnitude of the Airbnb--rent association, even as it highlights the limitations inherent in cross-sectional identification.
18 +
19 +The remainder of the paper is organised as follows. Section~\ref{sec:literature} reviews the related literature on short-term rentals and housing markets. Section~\ref{sec:data} describes the data sources, cleaning procedures, and spatial merge strategy. Section~\ref{sec:methodology} presents the econometric framework. Section~\ref{sec:results} reports the main empirical results. Section~\ref{sec:robustness} provides robustness checks and sensitivity analyses. Section~\ref{sec:discussion} discusses policy implications and limitations. Section~\ref{sec:conclusion} concludes.
added paper/sections/02_literature.tex +43 −0
@@ -0,0 +1,43 @@
1 +% Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +% =============================================================================
3 +% 02_literature.tex
4 +% =============================================================================
5 +\section{Literature Review}\label{sec:literature}
6 +
7 +This section reviews the growing body of empirical research on the relationship between short-term rental platforms and housing markets, situating our analysis within the broader literatures on hedonic pricing, spatial econometrics, and the economics of the sharing economy.
8 +
9 +\subsection{Short-Term Rentals and Housing Markets}
10 +
11 +The seminal empirical study of Airbnb's impact on housing costs is \citet{barron2021effect}, who exploit zipcode-level variation in Airbnb penetration across the United States to estimate the platform's effect on both rents and house prices. Using an instrumental-variables strategy based on Google Trends data for Airbnb-related searches, they find that a 1\% increase in Airbnb listings is associated with a 0.018\% increase in rents and a 0.026\% increase in house prices. The authors attribute the effect primarily to the reduction in rental supply, as landlords convert long-term rental units into short-term listings. Our study differs in geographic scope (Quebec rather than the entire US), unit of analysis (individual listings rather than zipcodes), and identification strategy (hedonic controls rather than instrumental variables), but we share the same underlying economic hypothesis.
12 +
13 +\citet{sheppard2016airbnb} study the impact of Airbnb on property values in New York City using a hedonic framework. Exploiting variation in Airbnb listing density across census tracts, they estimate that Airbnb activity increased property values by approximately 6--11\% in high-penetration neighbourhoods. Their analysis highlights the capitalisation of short-term rental income potential into property prices, a channel that is distinct from the rental-supply-reduction mechanism but operates through similar spatial proximity channels.
14 +
15 +\citet{garcia2020airbnb} provide detailed evidence from Barcelona, one of the world's most Airbnb-affected cities. Using transaction-level data on housing prices and spatially disaggregated Airbnb data, they find that Airbnb activity led to a 1.9\% increase in transaction prices and a 4.6\% increase in posted rents in areas of high Airbnb concentration. Their identification strategy relies on the sharp spatial variation in tourist attractiveness within Barcelona, combined with pre-/post-Airbnb comparisons. The Barcelona context shares important features with Montreal: both are major tourist destinations with dense urban cores and significant heritage architecture.
16 +
17 +\citet{horn2017airbnb} focus specifically on the rental market in Boston, estimating the effect of Airbnb listings on asking rents at the census-tract level. They find that a one-standard-deviation increase in Airbnb listings is associated with a 0.4\% increase in asking rents, an effect concentrated in neighbourhoods where a larger share of Airbnb listings are entire-home units rather than shared rooms. This finding motivates our construction of the \texttt{share\_entire\_home} variable and our attention to the composition of Airbnb listings within each spatial buffer.
18 +
19 +\subsection{Hedonic Pricing Theory}
20 +
21 +The hedonic pricing framework, formalised by \citet{rosen1974hedonic}, provides the theoretical foundation for our empirical approach. Rosen's model characterises housing as a differentiated good whose price is determined by an implicit market in which consumers bid for bundles of characteristics---including structural attributes (bedrooms, bathrooms, floor area), locational attributes (neighbourhood quality, accessibility, amenities), and environmental attributes. In this framework, the Airbnb count within a spatial buffer can be interpreted as a locational characteristic that captures the degree of short-term rental activity in the neighbourhood, and its hedonic coefficient reveals the marginal implicit price of exposure to that activity.
22 +
23 +The hedonic approach has been widely applied in the housing economics literature \citep{palmquist2005property, parmeter2010applied}, and it is well suited to our cross-sectional setting. Its principal limitation is the requirement that all relevant quality differences be observed and included as controls; omitted characteristics that are correlated with both Airbnb density and rents will bias the estimated hedonic coefficients. We address this concern by including city fixed effects, which absorb all unobserved city-level heterogeneity, and by reporting results for multiple specifications with progressively richer control sets.
24 +
25 +\subsection{Spatial Econometrics in Housing Research}
26 +
27 +The recognition that housing prices exhibit strong spatial dependence has motivated a large literature on spatial econometric methods for housing markets. \citet{anselin1988spatial} developed the foundational spatial lag and spatial error models that account for spillovers and spatial autocorrelation in cross-sectional regression. \citet{lesage2009introduction} provide a comprehensive treatment of spatial econometric techniques, including the construction of spatial weights matrices and the interpretation of direct and indirect (spillover) effects.
28 +
29 +In the context of Airbnb and housing markets, spatial dependence arises naturally: the rent of a dwelling is influenced not only by its own characteristics but also by the rents and Airbnb activity in nearby locations. Our buffer-based measure of Airbnb exposure is, in effect, a spatially weighted variable that aggregates short-term rental activity within a defined neighbourhood. We complement this approach by including spatially lagged rent variables (the mean rent of nearby listings) to capture peer effects in pricing.
30 +
31 +\subsection{Tourism, Commercialisation, and Regulation}
32 +
33 +\citet{wachsmuth2018airbnb} examine the ``rent gap'' created by Airbnb, arguing that the platform enables a process of ``tourism gentrification'' in which entire neighbourhoods are transformed from residential to quasi-commercial use. Their analysis of New York City demonstrates that professional, multi-listing hosts account for a disproportionate share of Airbnb revenue, suggesting that the platform has moved well beyond its original peer-to-peer home-sharing model. \citet{ke2017sharing} documents the rise of professional hosts and the commercialisation of Airbnb listings, finding that multi-listing operators are associated with higher prices and greater market concentration.
34 +
35 +The commercialisation of short-term rental platforms has prompted regulatory responses across many jurisdictions. \citet{nieuwland2020regulating} provide a comparative analysis of regulatory approaches in major cities, ranging from outright bans on short-term rentals to registration requirements, occupancy limits, and zoning restrictions. In Quebec, provincial legislation requires short-term rental operators to register with the Corporation de l'industrie touristique du Qu\'{e}bec (CITQ), and the City of Montreal has implemented additional restrictions in certain boroughs. The effectiveness of these regulations remains an active area of research \citep{agyeman2020airbnb}.
36 +
37 +\subsection{Quantile Regression and Distributional Effects}
38 +
39 +A growing strand of the housing literature recognises that the relationship between housing characteristics and prices may vary across the conditional price distribution. \citet{koenker1978regression} introduced quantile regression as a method for estimating conditional quantile functions, and \citet{zietz2008determinants} applied the technique to hedonic housing models, finding that the implicit prices of many dwelling characteristics differ significantly between the lower and upper tails of the price distribution. In the Airbnb context, distributional heterogeneity is economically plausible: short-term rental activity may have different effects on low-rent versus high-rent dwellings, reflecting differences in market segmentation, neighbourhood desirability, and the types of units most likely to be converted to short-term rentals.
40 +
41 +\subsection{Machine Learning in Housing Economics}
42 +
43 +Recent advances in machine learning have been increasingly adopted in housing economics for prediction and variable selection. \citet{mullainathan2017machine} discuss the role of machine learning in econometric analysis, distinguishing between prediction tasks (where flexible models excel) and causal inference tasks (where traditional econometric methods remain essential). \citet{athey2019machine} provide a broader survey of machine-learning methods for causal inference. In our robustness analysis, we employ LASSO, elastic net, random forest, and gradient boosting models not to make causal claims but to assess the predictive importance of Airbnb exposure variables relative to other determinants of rent and to verify that our hedonic estimates are not artefacts of linear functional-form assumptions.
added paper/sections/03_data.tex +107 −0
@@ -0,0 +1,107 @@
1 +% Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +% =============================================================================
3 +% 03_data.tex
4 +% =============================================================================
5 +\section{Data}\label{sec:data}
6 +
7 +This section describes the two primary datasets, the cleaning procedures applied to each, the spatial merge strategy used to link them, and the construction of key variables.
8 +
9 +\subsection{Airbnb Listings}
10 +
11 +Our Airbnb data consist of approximately 5{,}000 listings scraped from the Airbnb platform for the province of Quebec, Canada. Each listing record contains the following fields: listing name, city, latitude and longitude coordinates, property type, nightly price (in Canadian dollars), star rating, number of reviews, superhost status, guest-favourite designation, pet-friendliness, and price/rating category indicators.
12 +
13 +The listings span multiple cities across Quebec, with the largest concentrations in Montreal, Quebec City (Qu\'{e}bec), Sherbrooke, and various resort/cottage communities in the Laurentians and Eastern Townships. Property types include \textit{Rental unit}, \textit{Cabin/Chalet}, \textit{House}, \textit{Condo}, and \textit{Apartment}, with Rental unit being the most common category.
14 +
15 +\paragraph{Cleaning.} We standardise city names by merging accented and unaccented variants (e.g., ``Montr\'{e}al'' and ``Montreal'') and consolidating aliases (e.g., ``Quebec City'' and ``Qu\'{e}bec City'' mapped to ``Qu\'{e}bec''). We drop listings with missing or non-positive nightly prices, which yields a cleaned sample of 3{,}456 listings, and winsorise the price distribution at the 1st and 99th percentiles to mitigate the influence of extreme outliers. The log-transformed price, $\ln(\text{price\_numeric})$, is used as the dependent variable in the Airbnb pricing model. Missing ratings are imputed with the sample median, reflecting the assumption that unrated listings represent recently created properties whose quality is, on average, comparable to the centre of the distribution. Missing review counts are set to zero. An indicator variable, \texttt{is\_entire\_home}, is constructed to identify listings of types House, Cabin/Chalet, and Condo, which are typically rented in their entirety.
16 +
17 +\subsection{Residential Rental Listings}
18 +
19 +The rental data consist of approximately 8{,}300 listings scraped from Realtor.ca, Canada's primary real-estate listing platform, for the province of Quebec. Each record contains: address, latitude and longitude, property type, monthly lease rent (in Canadian dollars), building type, number of bedrooms, number of bathrooms, interior size, number of storeys, and postal code.
20 +
21 +Building types in the rental data are dominated by \textit{Apartment} ($n \approx 7{,}515$), followed by \textit{House} ($n \approx 745$) and \textit{Row/Townhouse} ($n \approx 48$). The geographic distribution is heavily concentrated in the Montreal metropolitan area, with additional clusters in Quebec City, Gatineau, Sherbrooke, and Laval.
22 +
23 +\paragraph{Cleaning.} Monthly rent values are extracted from the lease-rent string and converted to numeric format. We restrict the sample to listings with monthly rental periods and positive rent values. Extreme rent values are handled through winsorisation at the 1st and 99th percentiles. The log of monthly rent, $\ln(\text{rent})$, serves as the dependent variable in the hedonic rent model. Bedroom and bathroom counts are cleaned and converted to numeric types. Listings with missing geographic coordinates are dropped prior to spatial merging.
24 +
25 +\subsection{Spatial Merge Strategy}\label{sec:spatial_merge}
26 +
27 +The core challenge in linking the Airbnb and rental datasets is that they share no common listing identifiers. We exploit the fact that both datasets contain precise latitude and longitude coordinates to construct a spatial buffer merge. For each rental listing $i$, we compute the Haversine (great-circle) distance to every Airbnb listing $j$ and count the number of Airbnb listings falling within four concentric buffers: 250\,m, 500\,m, 1\,km, and 2\,km.
28 +
29 +Formally, the Airbnb count for rental listing $i$ within buffer radius $r$ is:
30 +\begin{equation}\label{eq:buffer_count}
31 + \texttt{airbnb\_count}_{i,r} = \sum_{j=1}^{N_{\text{Airbnb}}} \mathbf{1}\left[ d(i, j) \leq r \right],
32 +\end{equation}
33 +where $d(i, j)$ is the Haversine distance between listings $i$ and $j$, and $\mathbf{1}[\cdot]$ is the indicator function.
34 +
35 +In addition to the count, we construct several exposure metrics within each buffer: Airbnb density (count divided by buffer area, $\pi r^2$), mean Airbnb nightly price, share of entire-home listings, mean rating, and superhost share. These variables capture not only the intensity of Airbnb activity but also its composition and quality.
36 +
37 +At the preferred 500\,m radius, the mean Airbnb count per rental listing is approximately 8 listings, with substantial variation across urban and rural areas.
38 +
39 +\subsection{City-Level Aggregation}
40 +
41 +As a complementary linkage strategy, we aggregate Airbnb data to the city level and merge city-level summary statistics---total Airbnb count, mean nightly price, share of entire-home listings---onto the rental data using the city name as the merge key. This approach sacrifices the within-city spatial variation that drives our buffer-based analysis but provides a useful benchmark for examining cross-city relationships.
42 +
43 +\subsection{Summary Statistics}
44 +
45 +Tables~\ref{tab:summary_airbnb} and~\ref{tab:summary_rent} present summary statistics for the cleaned Airbnb and rental datasets, respectively.
46 +
47 +\begin{table}[htbp]
48 + \centering
49 + \caption{Summary Statistics --- Airbnb Listings}
50 + \label{tab:summary_airbnb}
51 + \input{../results/tables/summary_stats_airbnb.tex}
52 +\end{table}
53 +
54 +\begin{table}[htbp]
55 + \centering
56 + \caption{Summary Statistics --- Residential Rental Listings}
57 + \label{tab:summary_rent}
58 + \input{../results/tables/summary_stats_rent.tex}
59 +\end{table}
60 +
61 +Key features of the data are worth noting. The median Airbnb nightly price is approximately \$220 CAD, while the median monthly rent is approximately \$1{,}950 CAD. There is substantial variation in both prices, reflecting the heterogeneity of listings in terms of location, size, and quality. The Airbnb data span a wide range of property types, from budget-oriented rental units to luxury chalets, while the rental data are dominated by apartment units.
62 +
63 +% Table removed: buffer_summary.tex not generated as a separate file.
64 +% Buffer-based exposure statistics are described in Section~\ref{sec:spatial_merge}.
65 +
66 +\subsection{Geographic Distribution}
67 +
68 +Figures~\ref{fig:map_airbnb} and~\ref{fig:map_rent} display the geographic distribution of Airbnb and rental listings, respectively. The spatial overlap between the two datasets is concentrated in the Montreal metropolitan area and, to a lesser extent, in Quebec City and resort regions.
69 +
70 +\begin{figure}[htbp]
71 + \centering
72 + \includegraphics[width=0.85\textwidth]{map_airbnb.pdf}
73 + \caption{Geographic Distribution of Airbnb Listings in Quebec}
74 + \label{fig:map_airbnb}
75 +\end{figure}
76 +
77 +\begin{figure}[htbp]
78 + \centering
79 + \includegraphics[width=0.85\textwidth]{map_rent.pdf}
80 + \caption{Geographic Distribution of Residential Rental Listings in Quebec}
81 + \label{fig:map_rent}
82 +\end{figure}
83 +
84 +Figure~\ref{fig:price_distributions} shows the distributions of Airbnb nightly prices and monthly rents (both in levels, trimmed above the 99th percentile for readability).
85 +
86 +\begin{figure}[htbp]
87 + \centering
88 + \begin{subfigure}[t]{0.48\textwidth}
89 + \centering
90 + \includegraphics[width=\textwidth]{dist_airbnb_price.pdf}
91 + \caption{Airbnb nightly price (CAD)}
92 + \label{fig:dist_airbnb}
93 + \end{subfigure}
94 + \hfill
95 + \begin{subfigure}[t]{0.48\textwidth}
96 + \centering
97 + \includegraphics[width=\textwidth]{dist_rent.pdf}
98 + \caption{Monthly rent (CAD)}
99 + \label{fig:dist_rent}
100 + \end{subfigure}
101 + \caption{Distributions of Airbnb Nightly Prices and Monthly Rents}
102 + \label{fig:price_distributions}
103 +\end{figure}
104 +
105 +\subsection{Data Limitations}
106 +
107 +Several limitations of the data should be acknowledged. First, the data are cross-sectional, representing a single snapshot in time. We cannot observe how Airbnb entry or exit affects rents over time, nor can we control for time-varying confounders. Second, we do not observe host identifiers, which precludes the construction of multi-listing indicators at the host level---a variable that has been important in prior studies for distinguishing professional from casual hosts. Third, the Airbnb data are scraped from the public-facing platform and may not capture all listings, particularly those that are inactive, delisted, or hidden behind search filters. Fourth, the rental data from Realtor.ca represent \textit{asking} rents rather than \textit{transacted} rents; to the extent that there is systematic negotiation between posted and final rents, our dependent variable may be measured with noise. Fifth, there may be spatial selection in the data: the coverage of both platforms is likely denser in urban areas, particularly Montreal, which limits the generalisability of our findings to rural and peripheral markets.
added paper/sections/04_methodology.tex +90 −0
@@ -0,0 +1,90 @@
1 +% Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +% =============================================================================
3 +% 04_methodology.tex
4 +% =============================================================================
5 +\section{Methodology}\label{sec:methodology}
6 +
7 +This section presents the econometric models employed to quantify the association between Airbnb activity and residential rents, discusses identification, and describes the machine-learning robustness framework.
8 +
9 +\subsection{Model 1: Hedonic Rent Model with Airbnb Exposure}\label{sec:model1}
10 +
11 +Our baseline specification is a hedonic rent equation in which the log of monthly rent is regressed on a measure of nearby Airbnb activity and a vector of dwelling-level controls:
12 +
13 +\begin{equation}\label{eq:hedonic_rent}
14 + \ln(\text{rent}_i) = \alpha + \beta \cdot \texttt{airbnb\_count}_{i,r} + \mathbf{X}_i' \boldsymbol{\gamma} + \sum_{c} \delta_c \cdot \mathbf{1}[\text{city}_i = c] + \varepsilon_i,
15 +\end{equation}
16 +
17 +\noindent where $\text{rent}_i$ is the monthly rent of listing $i$; $\texttt{airbnb\_count}_{i,r}$ is the number of Airbnb listings within buffer radius $r$ of listing $i$ (our preferred specification uses $r = 500$\,m); $\mathbf{X}_i$ is a vector of dwelling characteristics including the number of bedrooms, number of bathrooms, building type (Apartment, House, Row/Townhouse), and interior size (where available); $\delta_c$ denotes city fixed effects that absorb all time-invariant, city-level unobservables (including average neighbourhood quality, local labour-market conditions, and municipal regulations); and $\varepsilon_i$ is a mean-zero error term.
18 +
19 +The coefficient of interest, $\beta$, has a semi-elasticity interpretation: it measures the approximate percentage change in monthly rent associated with one additional Airbnb listing within the buffer, conditional on observed dwelling characteristics and city. A positive and statistically significant $\hat{\beta}$ is consistent with the hypothesis that Airbnb activity is associated with higher residential rents, though it does not, by itself, establish causality.
20 +
21 +We estimate Equation~\eqref{eq:hedonic_rent} by ordinary least squares (OLS) with heteroskedasticity-robust standard errors (HC1).
22 +
23 +\subsection{Model 2: Hedonic Airbnb Pricing Model}\label{sec:model2}
24 +
25 +To examine the pricing determinants of Airbnb listings and the relationship between local rents and short-term rental prices, we estimate a hedonic model of Airbnb nightly prices:
26 +
27 +\begin{equation}\label{eq:hedonic_airbnb}
28 + \ln(\text{price}_j) = \alpha' + \theta \cdot \overline{\text{rent}}_c + \mathbf{Z}_j' \boldsymbol{\lambda} + \sum_{c} \delta'_c \cdot \mathbf{1}[\text{city}_j = c] + u_j,
29 +\end{equation}
30 +
31 +\noindent where $\text{price}_j$ is the nightly price of Airbnb listing $j$; $\overline{\text{rent}}_c$ is the mean log monthly rent in city $c$ (obtained from the rental dataset); $\mathbf{Z}_j$ includes listing characteristics---the number of bedrooms and bathrooms, guest capacity, amenities count, superhost status, and the entire-home indicator; and $u_j$ is an error term.
32 +
33 +The coefficient $\theta$ captures the association between the local residential rent level and Airbnb pricing. A positive $\hat{\theta}$ would suggest that Airbnb hosts in higher-rent cities charge higher nightly prices, consistent with the opportunity-cost channel: when the forgone rental income from listing a unit on Airbnb is higher, hosts set higher nightly prices to compensate. Note that when city fixed effects are included, $\overline{\text{rent}}_c$ is collinear with the city dummies; in such specifications, $\theta$ is identified from the city-level variation absorbed by the fixed effects, and we report results both with and without city fixed effects.
34 +
35 +\subsection{Model 3: City-Level Interaction Model}\label{sec:model3}
36 +
37 +To characterise the bidirectional relationship between Airbnb activity and rents at the city level, we estimate a forward and a reverse aggregate regression:
38 +
39 +\begin{align}
40 + \overline{\ln(\text{rent})}_c &= a_1 + b_1 \cdot \text{airbnb\_count}_c + \mathbf{W}_c' \boldsymbol{\phi}_1 + e_{1c}, \label{eq:city_rent} \\[6pt]
41 + \text{airbnb\_count}_c &= a_2 + b_2 \cdot \overline{\ln(\text{rent})}_c + \mathbf{W}_c' \boldsymbol{\phi}_2 + e_{2c}, \label{eq:city_airbnb}
42 +\end{align}
43 +
44 +\noindent where overlines denote city-level means, $\text{airbnb\_count}_c$ is the total number of Airbnb listings in city $c$, and $\mathbf{W}_c$ contains city-level mean bedrooms and bathrooms. The coefficients $b_1$ and $b_2$ describe the cross-city co-movement of rents and Airbnb activity. Because many of the 153 cities in the aggregated sample contribute few underlying listings, these regressions should be interpreted as descriptive rather than inferential.
45 +
46 +\subsection{Model 4: Spatial Analysis}\label{sec:model4}
47 +
48 +To account for spatial dependence in rents, we estimate a spatial autoregressive (SAR) model and a spatial error model (SEM) alongside the OLS baseline. The SAR model augments the hedonic equation with a spatial lag of the dependent variable:
49 +
50 +\begin{equation}\label{eq:spatial_lag}
51 + \ln(\text{rent}_i) = \alpha'' + \rho \cdot \sum_{k} w_{ik} \ln(\text{rent}_k) + \beta' \cdot \texttt{airbnb\_count}_{i,r} + \mathbf{X}_i' \boldsymbol{\gamma}' + \sum_{c} \delta''_c \cdot \mathbf{1}[\text{city}_i = c] + \eta_i,
52 +\end{equation}
53 +
54 +\noindent where $\mathbf{W} = [w_{ik}]$ is a row-standardised $k$-nearest-neighbour spatial weights matrix with $k = 5$, so that $\sum_k w_{ik} \ln(\text{rent}_k)$ is the mean log rent of the five nearest rental listings. The spatial autoregressive parameter $\rho$ captures the degree to which rents co-move within a spatial neighbourhood, after controlling for observed characteristics. The SEM instead places the spatial process in the disturbance, $\eta_i = \lambda \sum_k w_{ik} \eta_k + \nu_i$, capturing spatially correlated unobservables. Both models are estimated by generalised method of moments \citep[GM\_Lag and GM\_Error;][]{anselin1988spatial, lesage2009introduction}, which avoids the simultaneity bias that OLS estimation of Equation~\eqref{eq:spatial_lag} would entail.
55 +
56 +This specification serves two purposes. First, the spatial models absorb variation from spatially correlated unobservables (e.g., neighbourhood amenities that affect both rents and Airbnb desirability). Second, comparing $\hat{\beta}'$ from Equation~\eqref{eq:spatial_lag} with $\hat{\beta}$ from Equation~\eqref{eq:hedonic_rent} provides a diagnostic for the sensitivity of the Airbnb coefficient to spatial confounders.
57 +
58 +In addition to the spatial lag model, we examine the robustness of the Airbnb exposure measure by varying the buffer radius $r$ across 250\,m, 500\,m, 1\,km, and 2\,km. This exercise traces out the spatial decay of the association: if the Airbnb--rent relationship is genuinely local, we expect $\hat{\beta}$ to be largest at small radii and to attenuate as the buffer expands.
59 +
60 +\subsection{Model 5: Quantile Regression}\label{sec:model5}
61 +
62 +To investigate heterogeneity in the Airbnb--rent association across the conditional rent distribution, we estimate quantile regressions \citep{koenker1978regression}:
63 +
64 +\begin{equation}\label{eq:quantile}
65 + Q_{\tau}\!\left[\ln(\text{rent}_i) \mid \mathbf{X}_i, \texttt{airbnb\_count}_{i,r}\right] = \alpha_\tau + \beta_\tau \cdot \texttt{airbnb\_count}_{i,r} + \mathbf{X}_i' \boldsymbol{\gamma}_\tau + \sum_{c} \delta_{c,\tau} \cdot \mathbf{1}[\text{city}_i = c],
66 +\end{equation}
67 +
68 +\noindent for quantile indices $\tau \in \{0.10, 0.25, 0.50, 0.75, 0.90\}$. City fixed effects are restricted to the five largest cities (with the remainder grouped) to keep the quantile estimation well conditioned. The quantile-specific coefficient $\beta_\tau$ measures the marginal association between Airbnb exposure and the $\tau$th quantile of log rent. If the effect of Airbnb is larger at the upper tail of the distribution, this may indicate that short-term rental activity disproportionately affects higher-quality or higher-rent segments of the market. Standard errors are the asymptotic kernel-based estimates of \citet{koenker1978regression} as implemented in \texttt{statsmodels}.
69 +
70 +\subsection{Model 6: Machine-Learning Robustness}\label{sec:model6}
71 +
72 +We complement the parametric analysis with four machine-learning methods: LASSO, elastic net, random forest, and gradient boosting, alongside an OLS benchmark. These models are trained to predict $\ln(\text{rent}_i)$ from the Airbnb exposure measures at the 500\,m radius (count, density, mean price, entire-home share), dwelling characteristics (bedrooms, bathrooms), and geographic coordinates, and are evaluated on a held-out test set (20\% of the sample) using root mean squared error (RMSE), mean absolute error (MAE), and $R^2$.
73 +
74 +The machine-learning models serve three purposes. First, they provide a benchmark for the predictive accuracy of the hedonic model: if the OLS model achieves comparable $R^2$ to the flexible ML models, this suggests that the linear specification is not severely misspecified. Second, LASSO and elastic net coefficients reveal which variables are selected as predictors, providing a data-driven assessment of the importance of Airbnb exposure relative to dwelling characteristics. Third, random forest and gradient boosting models yield variable-importance measures (SHAP values) that quantify the contribution of each feature to predictive accuracy, without imposing functional-form assumptions.
75 +
76 +\subsection{Identification and Endogeneity}\label{sec:identification}
77 +
78 +We are explicit about the identification challenges inherent in our cross-sectional design. The primary concern is that Airbnb listing density is endogenous to local rent levels and neighbourhood desirability. Three channels of endogeneity are relevant:
79 +
80 +\begin{enumerate}[label=(\roman*)]
81 + \item \textbf{Reverse causality:} High rents may attract Airbnb hosts (because the opportunity cost of leaving a unit vacant is high, and short-term rental income can offset high carrying costs), creating a positive bias in the estimated $\hat{\beta}$.
82 + \item \textbf{Omitted variables:} Neighbourhood amenities---such as proximity to cultural attractions, restaurants, and transit---that are unobserved in our data may simultaneously attract Airbnb guests and drive up residential rents, again biasing $\hat{\beta}$ upward.
83 + \item \textbf{Spatial sorting:} Airbnb hosts may select locations based on unobserved characteristics (e.g., architectural charm, noise tolerance norms) that also affect residential rents.
84 +\end{enumerate}
85 +
86 +Our city fixed effects control for all time-invariant, city-level confounders, including differences in local housing-market tightness, tourism infrastructure, and regulatory regimes. Within-city variation in Airbnb density is our identifying variation, and this variation is plausibly driven by both locational amenities (tourist attractiveness) and the supply of suitable housing units---neither of which is fully exogenous. We therefore interpret our estimates as conditional correlations rather than causal effects. The inclusion of spatial lags, robustness checks across buffer radii, and machine-learning diagnostics provides indirect evidence on the plausibility and stability of our estimates, but a definitive causal statement would require either panel data with suitable fixed effects or a credible instrument for Airbnb supply---neither of which is available in our setting.
87 +
88 +\subsection{Standard Errors}
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.
added paper/sections/05_results.tex +137 −0
@@ -0,0 +1,137 @@
1 +% Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +% =============================================================================
3 +% 05_results.tex
4 +% =============================================================================
5 +\section{Results}\label{sec:results}
6 +
7 +This section presents the empirical results for each of the six models described in Section~\ref{sec:methodology}. Throughout, we use cautious language to reflect the conditional nature of our estimates: the terms ``associated with'' and ``conditional correlation'' are used in place of ``effect'' or ``impact,'' consistent with the limitations of our cross-sectional identification strategy.
8 +
9 +\subsection{Baseline Hedonic Rent Model}
10 +
11 +Table~\ref{tab:hedonic_rent} reports the OLS estimates of the hedonic rent model (Equation~\ref{eq:hedonic_rent}). Column~(1a) presents the bivariate specification with only the Airbnb count within 500\,m; column~(1b) adds dwelling controls (bedrooms, bathrooms, building type); and column~(1c)---our preferred specification---adds city fixed effects. Columns~(1d) and~(1e) replace the Airbnb count with two alternative exposure measures, the Airbnb density and the share of entire-home listings within the buffer.
12 +
13 +\begin{table}[htbp]
14 + \centering
15 + \caption{Hedonic Rent Model --- Baseline OLS Results}
16 + \label{tab:hedonic_rent}
17 + \input{../results/tables/hedonic_rent_baseline.tex}
18 +\end{table}
19 +
20 +The coefficient on $\texttt{airbnb\_count\_500m}$ is positive and statistically significant across all specifications. In the preferred specification (column~1c), each additional Airbnb listing within 500\,m is associated with an approximate 0.4\% increase in monthly rent, holding dwelling characteristics and city constant. This semi-elasticity is economically modest but statistically robust: at the median monthly rent of approximately \$1{,}950, one additional nearby Airbnb listing corresponds to a rent differential of roughly \$6--\$10 per month. However, the practical significance compounds when one considers that many urban rental listings have 10 or more Airbnb listings within 500\,m, implying cumulative differentials that are economically meaningful.
21 +
22 +Among the control variables, dwelling size is the dominant predictor of rent: each additional bedroom is associated with approximately 11--13\% higher rent, and each additional bathroom with approximately 23--30\% higher rent. Building type (House and Row/Townhouse relative to Apartment) also enters significantly. City fixed effects raise the explanatory power of the model considerably, confirming substantial cross-city variation in rent levels.
23 +
24 +The adjusted $R^2$ of the preferred specification is approximately 0.56, indicating that observed dwelling characteristics and city fixed effects explain a substantial share of the cross-sectional variation in rents, though a considerable residual remains, consistent with the importance of unobserved unit-specific and micro-locational factors.
25 +
26 +\subsection{Hedonic Airbnb Pricing Model}
27 +
28 +Table~\ref{tab:hedonic_airbnb} reports the estimates of the Airbnb pricing model (Equation~\ref{eq:hedonic_airbnb}).
29 +
30 +\begin{table}[htbp]
31 + \centering
32 + \caption{Hedonic Airbnb Pricing Model --- OLS Results}
33 + \label{tab:hedonic_airbnb}
34 + \input{../results/tables/hedonic_airbnb_pricing.tex}
35 +\end{table}
36 +
37 +Contrary to the opportunity-cost hypothesis, the city-level mean rent does not enter significantly in any specification: the coefficient $\hat{\theta}$ is small and statistically indistinguishable from zero, with a negative point estimate in columns~(2a) and~(2b) and a positive one in column~(2c). Airbnb nightly prices in our sample are therefore not systematically higher in higher-rent cities once listing characteristics are taken into account; the pricing of short-term rentals appears to be driven primarily by the properties of the listing itself rather than by conditions in the local long-term rental market.
38 +
39 +Among the listing-level controls, dwelling size matters most: bedrooms and, especially, bathrooms are associated with significantly higher nightly prices. Superhost status carries a significant \textit{negative} coefficient, which likely reflects composition effects---superhosts in the sample are concentrated in more modest, high-volume urban units rather than in luxury properties---and guest capacity enters negatively once size is controlled for. The entire-home indicator is positive and significant once city fixed effects are included (column~2c), consistent with whole units commanding a premium over rooms in shared dwellings within the same market.
40 +
41 +When city fixed effects are included, the listing-level coefficients retain their signs and broad magnitudes, suggesting that the within-city pricing structure of Airbnb listings is largely independent of the cross-city rent variation.
42 +
43 +\subsection{City-Level Results}
44 +
45 +Table~\ref{tab:city_level} presents the city-level regressions (Equations~\ref{eq:city_rent} and~\ref{eq:city_airbnb}).
46 +
47 +\begin{table}[htbp]
48 + \centering
49 + \caption{City-Level Regressions}
50 + \label{tab:city_level}
51 + \input{../results/tables/city_level_interaction.tex}
52 +\end{table}
53 +
54 +The forward regression shows a positive and statistically significant---though economically small---cross-city association between the total number of Airbnb listings and mean log rents: cities with more Airbnb listings tend to have somewhat higher average rents, conditional on average dwelling size. The reverse regression, by contrast, has essentially no explanatory power ($R^2 = 0.01$), and mean rent does not significantly predict city-level Airbnb counts. Although the aggregation yields 153 city-level observations, most cities contribute only a handful of underlying listings, which limits the statistical power of these regressions and amplifies the influence of individual city outliers. We interpret these results as descriptive patterns that motivate the listing-level analysis rather than as evidence of a causal relationship.
55 +
56 +\subsection{Spatial Model Results}
57 +
58 +Table~\ref{tab:spatial_results} reports the OLS baseline alongside the spatial lag (SAR) and spatial error (SEM) models of Equation~\ref{eq:spatial_lag}.
59 +
60 +\begin{table}[htbp]
61 + \centering
62 + \caption{Spatial Regression Models}
63 + \label{tab:spatial_results}
64 + \input{../results/tables/spatial_models.tex}
65 +\end{table}
66 +
67 +The spatial autoregressive coefficient ($\hat{\rho}$, the coefficient on $W \cdot \ln(\text{rent})$) is positive and strongly significant, and the spatial error parameter $\hat{\lambda}$ in the SEM is large (0.54), confirming substantial spatial dependence in rents: the rents of nearby listings carry systematic information about a given listing's rent, even after controlling for dwelling characteristics and city fixed effects. This finding is consistent with the large literature on spatial autocorrelation in housing markets.
68 +
69 +Importantly, the coefficient on $\texttt{airbnb\_count\_500m}$ ($\hat{\beta}'$) remains positive and statistically significant in both spatial specifications: it is mildly attenuated in the SAR model (0.0034 versus 0.0038 in the OLS baseline) and essentially unchanged in the SEM. The mild attenuation is expected: to the extent that the Airbnb coefficient in the baseline model partly reflected spatially correlated omitted variables (captured by the spatial terms), the spatial models provide more conservative estimates. The persistence of a significant positive association strengthens confidence that the Airbnb--rent correlation is not solely an artefact of spatial confounding.
70 +
71 +Figure~\ref{fig:buffer_decay} illustrates the spatial decay of the Airbnb coefficient across buffer radii.
72 +
73 +\begin{figure}[htbp]
74 + \centering
75 + \includegraphics[width=0.75\textwidth]{coefficient_buffer_comparison.pdf}
76 + \caption{Estimated Airbnb Coefficient by Buffer Radius}
77 + \label{fig:buffer_decay}
78 +\end{figure}
79 +
80 +The per-listing coefficient is largest at the 250\,m radius and declines monotonically as the buffer expands, consistent with a localised association between Airbnb activity and rents. At the 2\,km radius, the coefficient remains positive but is substantially smaller in magnitude, reflecting the dilution of the Airbnb signal over a larger geographic area. This spatial decay pattern is consistent with prior findings in the literature and suggests that the Airbnb--rent association operates at a highly localised scale.
81 +
82 +\subsection{Quantile Regression Results}
83 +
84 +Table~\ref{tab:quantile} and Figure~\ref{fig:quantile_plot} present the quantile regression estimates of $\beta_\tau$ for $\tau \in \{0.10, 0.25, 0.50, 0.75, 0.90\}$.
85 +
86 +\begin{table}[htbp]
87 + \centering
88 + \caption{Quantile Regression Results --- Coefficient on Airbnb Count (500m)}
89 + \label{tab:quantile}
90 + \input{../results/tables/quantile_regression.tex}
91 +\end{table}
92 +
93 +\begin{figure}[htbp]
94 + \centering
95 + \includegraphics[width=0.75\textwidth]{quantile_coefficients.pdf}
96 + \caption{Quantile Regression Coefficients on Airbnb Count (500m) with 95\% Confidence Intervals}
97 + \label{fig:quantile_plot}
98 +\end{figure}
99 +
100 +The quantile regression results reveal meaningful heterogeneity in the Airbnb--rent association across the conditional rent distribution. The coefficient is positive and statistically significant at every quantile considered. It is roughly flat over the lower half of the distribution (0.0037 at $\tau = 0.10$, 0.0034--0.0035 at $\tau = 0.25$ and $\tau = 0.50$) and then rises in the upper tail, reaching 0.0041 at $\tau = 0.75$ and its largest value, 0.0047, at $\tau = 0.90$---roughly 25\% above the OLS estimate.
101 +
102 +This pattern is economically interpretable. High-rent listings---typically located in desirable neighbourhoods with strong tourist appeal---are precisely the locations where Airbnb activity is most intense and where the supply-withdrawal mechanism is most plausible. The quantile results thus suggest that the association between Airbnb and rents, while present throughout the distribution, is strongest in the upper segment of the rental market.
103 +
104 +\subsection{Machine-Learning Robustness}
105 +
106 +Table~\ref{tab:ml_performance} reports the out-of-sample predictive performance of the four machine-learning models alongside the OLS benchmark.
107 +
108 +\begin{table}[htbp]
109 + \centering
110 + \caption{Machine-Learning Model Performance (Test Set)}
111 + \label{tab:ml_performance}
112 + \input{../results/tables/ml_comparison.tex}
113 +\end{table}
114 +
115 +The random forest achieves the highest $R^2$ on the test set (0.71), closely followed by gradient boosting (0.69); the regularised linear models (LASSO and elastic net) perform essentially on par with OLS (test $R^2 \approx 0.51$). The gap between the tree-based and linear models indicates that nonlinear relationships and interactions among covariates---most plausibly involving the geographic coordinates---contribute meaningfully to rent variation beyond what the linear hedonic model captures. The linear specification nevertheless accounts for the bulk of the explainable variation and remains a reasonable approximation for inference on the Airbnb exposure coefficient.
116 +
117 +Figure~\ref{fig:feature_importance} displays the mean absolute SHAP values from the gradient boosting model.
118 +
119 +\begin{figure}[htbp]
120 + \centering
121 + \includegraphics[width=0.80\textwidth]{feature_importance.pdf}
122 + \caption{Feature Importance from Gradient Boosting Model}
123 + \label{fig:feature_importance}
124 +\end{figure}
125 +
126 +Dwelling characteristics (bathrooms, bedrooms) and geographic coordinates are consistently the most important predictors of rent. The Airbnb exposure variables---mean nearby price, density, count, and entire-home share---each rank as moderately important features and jointly account for a non-trivial share of predictive power, confirming that Airbnb exposure carries information beyond what is captured by dwelling size and raw location. The LASSO and elastic net models retain the Airbnb count variable with a positive coefficient, consistent with the OLS results.
127 +
128 +Figure~\ref{fig:shap_summary} presents a SHAP summary plot, providing a more granular view of how each feature contributes to individual predictions.
129 +
130 +\begin{figure}[htbp]
131 + \centering
132 + \includegraphics[width=0.80\textwidth]{shap_summary.pdf}
133 + \caption{SHAP Summary Plot from Gradient Boosting Model}
134 + \label{fig:shap_summary}
135 +\end{figure}
136 +
137 +The SHAP analysis confirms that higher Airbnb counts are associated with positive contributions to predicted rent, consistent with the parametric results. The distribution of SHAP values for the Airbnb count variable shows a rightward shift for listings with high Airbnb exposure, reinforcing the finding that nearby short-term rental activity is associated with higher rents.
added paper/sections/06_robustness.tex +94 −0
@@ -0,0 +1,94 @@
1 +% Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +% =============================================================================
3 +% 06_robustness.tex
4 +% =============================================================================
5 +\section{Robustness Checks}\label{sec:robustness}
6 +
7 +This section presents a battery of robustness tests designed to assess the sensitivity of our baseline estimates to alternative specifications, sample definitions, and exposure measures.
8 +
9 +\subsection{Buffer Radius Robustness}
10 +
11 +Our baseline specification uses a 500\,m buffer to define Airbnb exposure. Table~\ref{tab:buffer_robustness} reports the coefficient on the Airbnb count variable for buffer radii of 250\,m, 500\,m, 1\,km, and 2\,km, estimated using the same hedonic specification (Equation~\ref{eq:hedonic_rent}) with the full set of dwelling controls and city fixed effects.
12 +
13 +\begin{table}[htbp]
14 + \centering
15 + \caption{Buffer Radius Robustness --- Hedonic Rent Model}
16 + \label{tab:buffer_robustness}
17 + \input{../results/tables/buffer_robustness.tex}
18 +\end{table}
19 +
20 +The results confirm the spatial decay pattern illustrated in Figure~\ref{fig:buffer_decay}. The per-listing coefficient is largest at the 250\,m radius, where the spatial signal is most concentrated, and decreases monotonically as the buffer expands---mechanically so, since a listing counted within a wide buffer is a weaker proxy for immediate proximity than one counted within a narrow buffer. At all radii, the coefficient is positive and statistically significant at the 1\% level. The consistency of the positive association across radii provides reassurance that the finding is not an artefact of the particular buffer choice.
21 +
22 +Figure~\ref{fig:buffer_coef_plot} visualises the coefficient estimates and their 95\% confidence intervals across buffer radii.
23 +
24 +\begin{figure}[htbp]
25 + \centering
26 + \includegraphics[width=0.70\textwidth]{coefficient_buffer_comparison.pdf}
27 + \caption{Airbnb Count Coefficient Across Buffer Radii (with 95\% CI)}
28 + \label{fig:buffer_coef_plot}
29 +\end{figure}
30 +
31 +\subsection{Alternative Exposure Measures}
32 +
33 +Our baseline uses the raw count of Airbnb listings as the exposure measure. We consider two alternative measures to assess whether the results are sensitive to the functional form of the exposure variable:
34 +
35 +\begin{enumerate}[label=(\roman*)]
36 + \item \textbf{Airbnb density:} the count divided by the buffer area ($\pi r^2$ in km$^2$), which normalises for the geometric expansion of the buffer.
37 + \item \textbf{Share of entire-home listings:} the fraction of Airbnb listings within the buffer that are classified as entire-home properties (House, Cabin/Chalet, Condo), capturing the composition of short-term rental activity.
38 +\end{enumerate}
39 +
40 +These alternative specifications are reported as Models~(1d) and~(1e) in Table~\ref{tab:hedonic_rent}.
41 +
42 +The Airbnb density measure yields qualitatively identical results to the raw count, with a positive and significant coefficient---as expected, since at a fixed radius the density is a rescaling of the count. The share of entire-home listings, by contrast, is \textit{not} significantly associated with rents (the point estimate is small and negative): conditional on dwelling characteristics and city fixed effects, it is the intensity of nearby Airbnb activity, rather than its compositional tilt toward entire homes, that co-varies with rents in our data. This finding does not support the compositional prediction of the supply-withdrawal hypothesis, under which entire-home listings---the closest substitutes for long-term rental units---should matter most; we return to this point in Section~\ref{sec:discussion}.
43 +
44 +\subsection{Subsample Analysis}
45 +
46 +To assess whether the baseline results are driven by a particular geographic segment or building type, we estimate the hedonic rent model separately for the following subsamples:
47 +
48 +\begin{enumerate}[label=(\roman*)]
49 + \item \textbf{Montreal vs.\ non-Montreal:} Montreal dominates both datasets and is the primary tourist destination. The Airbnb--rent association may be stronger in Montreal, where short-term rental activity is most concentrated, or weaker if the city's larger and more liquid rental market is better able to absorb the supply shock.
50 + \item \textbf{Apartments vs.\ Houses:} Apartments constitute the vast majority of rental listings and are arguably the closest substitute for Airbnb rental units. Houses, by contrast, are a more heterogeneous category and may operate in a partially segmented market.
51 +\end{enumerate}
52 +
53 +Table~\ref{tab:subsample} presents the results.
54 +
55 +\begin{table}[htbp]
56 + \centering
57 + \caption{Subsample Analysis --- Hedonic Rent Model}
58 + \label{tab:subsample}
59 + \input{../results/tables/robustness_subsamples.tex}
60 +\end{table}
61 +
62 +The Montreal subsample yields a positive and significant Airbnb coefficient that is similar in magnitude to the full-sample estimate, confirming that the baseline results are not driven solely by the inclusion of non-Montreal observations. The non-Montreal subsample also produces a positive coefficient, though with lower precision due to the smaller sample size and the greater heterogeneity of non-Montreal markets (which include both urban centres and resort communities).
63 +
64 +The apartment subsample closely mirrors the full-sample results, which is unsurprising given that apartments constitute approximately 90\% of the rental sample. The house subsample yields a coefficient that is larger than the full-sample estimate (0.0067) and statistically significant despite the much smaller sample, though its wider confidence interval reflects the greater heterogeneity of house rentals.
65 +
66 +\subsection{Outlier Sensitivity}
67 +
68 +To assess the sensitivity of the results to extreme values, we re-estimate the baseline model on two trimmed samples: one that retains only listings with monthly rents between the 5th and 95th percentiles, and one that drops the top and bottom 1\% of observations by Airbnb count within 500\,m (i.e., listings in the most Airbnb-saturated locations).
69 +
70 +\begin{table}[htbp]
71 + \centering
72 + \caption{Outlier Sensitivity --- Hedonic Rent Model}
73 + \label{tab:outlier_sensitivity}
74 + \input{../results/tables/robustness_outliers.tex}
75 +\end{table}
76 +
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 +
79 +\subsection{Inference Caveats}
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.
82 +
83 +\subsection{Summary of Robustness}
84 +
85 +Figure~\ref{fig:robustness_summary} presents a coefficient plot summarising the Airbnb count coefficient across all robustness specifications.
86 +
87 +\begin{figure}[htbp]
88 + \centering
89 + \includegraphics[width=0.80\textwidth]{coefficient_robustness.pdf}
90 + \caption{Summary of Airbnb Count Coefficients Across Specifications}
91 + \label{fig:robustness_summary}
92 +\end{figure}
93 +
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.
added paper/sections/07_discussion.tex +53 −0
@@ -0,0 +1,53 @@
1 +% Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +% =============================================================================
3 +% 07_discussion.tex
4 +% =============================================================================
5 +\section{Discussion}\label{sec:discussion}
6 +
7 +This section interprets the empirical findings in the context of housing policy, discusses the mechanisms that may underlie the observed associations, and addresses the limitations of the analysis.
8 +
9 +\subsection{Policy Implications for Housing Affordability}
10 +
11 +Our finding of a positive association between Airbnb presence and residential rents, while not causal in the strict econometric sense, carries implications for housing affordability policy. If the association reflects, even in part, a genuine supply-withdrawal mechanism---whereby landlords convert long-term rental units into short-term Airbnb listings---then the cumulative effect on rents in high-tourism neighbourhoods could be substantial. At our estimated semi-elasticity of 0.3--0.5\% per additional Airbnb listing within 500\,m, a neighbourhood with 20 nearby Airbnb listings would be associated with 6--10\% higher rents relative to an otherwise identical dwelling in an Airbnb-free area, all else equal.
12 +
13 +For policymakers in Quebec and Montreal, these estimates suggest that short-term rental activity is a factor---though certainly not the only or dominant factor---in the rental affordability equation. The magnitude of the association is modest relative to the contribution of dwelling characteristics (bedrooms, bathrooms) and location (city fixed effects), but it is non-trivial in a market where vacancy rates are historically low and modest rent increases impose real burdens on lower-income tenants.
14 +
15 +\subsection{Short-Term Rental Regulation}
16 +
17 +The province of Quebec requires short-term rental operators to register with the Corporation de l'industrie touristique du Qu\'{e}bec (CITQ) and imposes minimum standards on registered operators. The City of Montreal has supplemented these provincial requirements with additional restrictions, including limits on the number of nights per year that a primary residence can be rented on a short-term basis and outright prohibitions on non-owner-occupied short-term rentals in certain boroughs.
18 +
19 +Our results, while descriptive, provide empirical grounding for the policy rationale underlying these regulations. The spatial decay of the Airbnb--rent association (Section~\ref{sec:results}) suggests that the effects are highly localised, which supports geographically targeted interventions (e.g., borough-level restrictions) rather than blanket province-wide regulations. The quantile regression results further suggest that regulatory attention might focus on high-rent neighbourhoods, where the association is strongest and where the risk of supply withdrawal is most acute.
20 +
21 +However, regulation involves tradeoffs. Short-term rentals generate income for hosts, tax revenue for municipalities, and consumer surplus for travellers. Overly restrictive regulation may push short-term rental activity underground, reduce tourism revenues, and impose compliance costs on casual hosts who rent their primary residence occasionally. A well-calibrated regulatory framework would balance these considerations by targeting commercial operators---those who manage multiple entire-home listings---while preserving the ability of residents to engage in occasional home-sharing.
22 +
23 +\subsection{Professional Hosts and Commercialisation}
24 +
25 +The literature has documented the increasing commercialisation of Airbnb, with a growing share of listings operated by professional, multi-listing hosts \citep{ke2017sharing, wachsmuth2018airbnb}. Unfortunately, our data do not contain host identifiers, precluding direct measurement of multi-listing activity. The compositional evidence we can bring to bear is, moreover, not supportive of a simple commercialisation channel: the share of entire-home listings within the buffer---the listings most likely to be operated commercially---is not significantly associated with rents once dwelling characteristics and city fixed effects are controlled for (Model~1e in Table~\ref{tab:hedonic_rent}). In our cross-section, it is the overall intensity of nearby Airbnb activity, rather than its compositional tilt toward entire homes, that co-varies with rents. One interpretation is that our property-type proxy for entire-home status is too coarse to isolate commercial operations; another is that, within Quebec's market, casual and commercial listings are sufficiently co-located that composition adds little signal beyond the count.
26 +
27 +Future research with host-level data could decompose the Airbnb--rent association into contributions from commercial versus casual hosts, providing a sharper evidence base for regulatory targeting.
28 +
29 +\subsection{The Tourism--Housing Tradeoff}
30 +
31 +At a broader level, the Airbnb--rent relationship exemplifies a fundamental tension in urban policy: the tradeoff between tourism-driven economic activity and residential affordability. Cities like Montreal derive substantial economic benefits from tourism---employment in hospitality, retail, and cultural sectors; tax revenues; and global visibility---but these benefits are unevenly distributed, while the costs of tourism-driven housing pressure fall disproportionately on renters. Short-term rental platforms amplify this tension by enabling the conversion of residential housing into tourism infrastructure at the level of individual units, bypassing the traditional regulatory apparatus that governs hotel and commercial accommodation.
32 +
33 +Our findings suggest that this tension is empirically present in the Quebec context, though its magnitude is moderate. The policy challenge is to design regulatory frameworks that capture the benefits of short-term rental activity while mitigating its externalities on the residential rental market---a challenge that requires ongoing empirical monitoring as the platform economy continues to evolve.
34 +
35 +\subsection{Limitations}
36 +
37 +We reiterate and expand upon the key limitations of our analysis:
38 +
39 +\begin{enumerate}[label=(\roman*)]
40 + \item \textbf{Cross-sectional identification:} Our data represent a single point in time. We cannot distinguish the causal effect of Airbnb on rents from reverse causality (high rents attracting Airbnb hosts) or confounding by unobserved neighbourhood characteristics. A credible causal estimate would require panel data---ideally combined with a policy shock that exogenously shifted Airbnb supply in some locations but not others---or a valid instrumental variable for Airbnb penetration.
41 +
42 + \item \textbf{No host-level data:} The absence of host identifiers prevents us from identifying multi-listing operators and from distinguishing professional from casual hosts, a distinction that is central to the commercialisation debate.
43 +
44 + \item \textbf{Asking vs.\ transacted rents:} Our dependent variable measures asking rents on Realtor.ca rather than actual contract rents. If asking rents systematically overstate transacted rents (due to landlord bargaining power or strategic posting), our estimates may be biased, though the direction of this bias is ambiguous.
45 +
46 + \item \textbf{Platform coverage:} Both datasets are scraped from specific platforms (Airbnb and Realtor.ca) and may not capture the universe of short-term or long-term rental listings. Alternative short-term rental platforms (e.g., VRBO, Booking.com) are not included, and the Realtor.ca data may underrepresent informal or unposted rental units.
47 +
48 + \item \textbf{External validity:} Our sample is dominated by Montreal and a handful of other Quebec cities and resort areas. The findings may not generalise to other Canadian provinces or to rural housing markets where short-term rental dynamics differ.
49 +
50 + \item \textbf{Static analysis:} Our cross-sectional framework cannot capture dynamic adjustments in the housing market, such as the construction of new supply in response to rising rents or the exit of Airbnb hosts in response to regulatory pressure.
51 +\end{enumerate}
52 +
53 +These limitations motivate the cautious interpretive stance adopted throughout the paper and underscore the need for future research with richer data structures.
added paper/sections/08_conclusion.tex +17 −0
@@ -0,0 +1,17 @@
1 +% Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +% =============================================================================
3 +% 08_conclusion.tex
4 +% =============================================================================
5 +\section{Conclusion}\label{sec:conclusion}
6 +
7 +This paper has investigated the relationship between Airbnb short-term rental activity and residential rents in Quebec, Canada, using cross-sectional microdata comprising approximately 5{,}000 Airbnb listings and 8{,}300 residential rental listings. Employing a hedonic pricing framework augmented with spatial analysis, quantile regressions, and machine-learning benchmarks, we have documented a consistent positive association between nearby Airbnb presence and monthly rents.
8 +
9 +Our key findings are as follows. First, each additional Airbnb listing within 500 metres of a rental unit is associated with an approximate 0.4\% increase in monthly rent, conditional on dwelling characteristics and city fixed effects---an estimate that is stable across spatial and trimmed-sample specifications (roughly 0.3--0.5\%). Second, this association exhibits spatial decay, with the per-listing coefficient largest at narrow buffer radii and attenuating at wider distances. Third, quantile regressions reveal that the association, while significant throughout the distribution, is strongest at the upper tail, suggesting that high-rent segments of the market are most strongly linked to Airbnb activity. Fourth, a complementary hedonic model of Airbnb nightly prices indicates that short-term rental pricing is driven primarily by listing characteristics; city-level mean rents carry no significant premium, offering no support for a simple opportunity-cost pricing channel in our cross-section. Fifth, machine-learning models confirm the predictive relevance of Airbnb exposure variables and the adequacy of the linear hedonic specification for inference.
10 +
11 +The principal contribution of this paper is to provide the first granular, listing-level analysis of the Airbnb--rent nexus in Quebec, leveraging precise geographic coordinates for exact distance-based spatial matching. By applying multiple econometric and machine-learning methods to a single dataset, we demonstrate the consistency of the finding across methodological frameworks and provide a rich set of robustness checks that characterise the sensitivity of the estimates to alternative specifications.
12 +
13 +We are candid about the limitations of our analysis. The cross-sectional nature of the data precludes causal identification: the positive association between Airbnb density and rents may reflect reverse causality, omitted neighbourhood characteristics, or spatial sorting, rather than---or in addition to---a genuine supply-withdrawal effect. Establishing causality in this domain requires panel data combined with plausibly exogenous variation in Airbnb supply, such as a regulatory discontinuity or a natural experiment. Our results should therefore be interpreted as well-controlled conditional correlations that are consistent with the supply-withdrawal hypothesis but do not definitively confirm it.
14 +
15 +Several directions for future research emerge from this analysis. First, the construction of panel data---tracking the entry and exit of Airbnb listings and the evolution of rents over time at the neighbourhood level---would enable difference-in-differences or event-study designs that can more credibly isolate the causal effect. Second, the exploitation of regulatory shocks---such as the tightening of Montreal's short-term rental regulations or the introduction of provincial registration requirements---would provide natural-experiment variation for causal inference. Third, the integration of host-level data would allow researchers to distinguish the effects of commercial multi-listing operators from those of casual home-sharers, sharpening the policy relevance of the analysis. Fourth, extending the geographic scope to include other Canadian cities would improve external validity and enable cross-city comparisons of regulatory effectiveness.
16 +
17 +In sum, our findings add to a growing body of evidence suggesting that short-term rental platforms are associated with higher residential rents, at least in cross-section and in localities with significant tourist appeal. While the estimated magnitudes are modest at the per-listing level, their cumulative significance in high-tourism neighbourhoods---combined with the documented concentration of effects at the upper end of the rent distribution---underscores the importance of evidence-based regulatory frameworks that balance the economic benefits of the platform economy with the imperative of housing affordability.
added paper/sections/titlepage.tex +78 −0
@@ -0,0 +1,78 @@
1 +% Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +% ============================================================================
3 +% Title Page
4 +% ============================================================================
5 +\thispagestyle{empty}
6 +
7 +\begin{center}
8 +
9 +% --- Logo ---
10 +\includegraphics[width=3.5cm]{uq_logo.jpg}
11 +
12 +\vspace{0.6cm}
13 +
14 +{\footnotesize\textsc{Universit\'e du Qu\'ebec en Outaouais}}\\[0.15cm]
15 +{\footnotesize\textsc{D\'epartement des sciences administratives}}
16 +
17 +\vspace{0.8cm}
18 +
19 +{\footnotesize\textsc{Working Paper No.~\WPnumber}}
20 +
21 +\vspace{1.2cm}
22 +
23 +% --- Title ---
24 +{\LARGE\bfseries \WPtitle\par}
25 +
26 +\ifx\WPsubtitle\empty\else
27 + \vspace{0.3cm}
28 + {\large\itshape \WPsubtitle\par}
29 +\fi
30 +
31 +\vspace{1.2cm}
32 +
33 +% --- Author ---
34 +{\large \WPauthor\footnotemark[1]}\\[0.3cm]
35 +{\normalsize\itshape \WPaffiliation}
36 +
37 +\footnotetext[1]{Professeur, D\'epartement des sciences administratives, Universit\'e du Qu\'ebec en Outaouais (UQO), 283 boulevard Alexandre-Tach\'e, Gatineau, QC J9A 1L8, Canada. Email: \href{mailto:\WPemail}{\WPemail}.}
38 +
39 +\vspace{0.8cm}
40 +
41 +% --- Date & Version ---
42 +{\normalsize This version: \WPdate}\\[0.1cm]
43 +{\small\itshape Version~\WPversion}
44 +
45 +\end{center}
46 +
47 +\vfill
48 +
49 +\newpage
50 +
51 +% ============================================================================
52 +% Abstract Page
53 +% ============================================================================
54 +\thispagestyle{empty}
55 +
56 +\vspace*{1cm}
57 +
58 +\noindent\rule{\textwidth}{0.4pt}
59 +\vspace{0.3cm}
60 +
61 +\noindent\textbf{Abstract}
62 +
63 +\vspace{0.15cm}
64 +
65 +\noindent\WPabstract
66 +
67 +\vspace{0.4cm}
68 +
69 +\noindent\textbf{Keywords:} \WPkeywords
70 +
71 +\vspace{0.15cm}
72 +
73 +\noindent\textbf{JEL Classification:} \WPjel
74 +
75 +\vspace{0.3cm}
76 +\noindent\rule{\textwidth}{0.4pt}
77 +
78 +\newpage
added paper/uq_logo.jpg +0 −0

Binary file not shown.

added requirements.txt +12 −0
@@ -0,0 +1,12 @@
1 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +# Pinned to the versions used to reproduce the paper (Python 3.14.4)
3 +pandas==3.0.2
4 +numpy==2.4.4
5 +matplotlib==3.10.9
6 +scipy==1.17.1
7 +statsmodels==0.14.6
8 +scikit-learn==1.6.1
9 +pyarrow==24.0.0
10 +shap==0.48.0
11 +libpysal==4.14.1
12 +spreg==1.9.0
added results/logs/data_inspection.txt +766 −0
@@ -0,0 +1,766 @@
1 +================================================================================
2 +AIRBNB DATASET — airbnb.csv
3 +================================================================================
4 +
5 +Shape: 5000 rows x 19 columns
6 +
7 +--- Dtypes ---
8 + name str
9 + city str
10 + property_type str
11 + price_numeric float64
12 + price_category str
13 + rating float64
14 + rating_category str
15 + bedrooms int64
16 + bathrooms float64
17 + guests_count int64
18 + amenities_count int64
19 + num_images int64
20 + num_reviews float64
21 + quality_score float64
22 + is_superhost bool
23 + is_guest_favorite bool
24 + pets_allowed bool
25 + lat float64
26 + long float64
27 +
28 +--- Null counts ---
29 + name 0 ( 0.0%)
30 + city 0 ( 0.0%)
31 + property_type 0 ( 0.0%)
32 + price_numeric 1544 ( 30.9%)
33 + price_category 0 ( 0.0%)
34 + rating 810 ( 16.2%)
35 + rating_category 0 ( 0.0%)
36 + bedrooms 0 ( 0.0%)
37 + bathrooms 0 ( 0.0%)
38 + guests_count 0 ( 0.0%)
39 + amenities_count 0 ( 0.0%)
40 + num_images 0 ( 0.0%)
41 + num_reviews 725 ( 14.5%)
42 + quality_score 0 ( 0.0%)
43 + is_superhost 0 ( 0.0%)
44 + is_guest_favorite 0 ( 0.0%)
45 + pets_allowed 0 ( 0.0%)
46 + lat 0 ( 0.0%)
47 + long 0 ( 0.0%)
48 +
49 +--- Numeric summary ---
50 + price_numeric rating bedrooms bathrooms guests_count amenities_count num_images num_reviews quality_score lat long
51 +count 3456.000000 4190.000000 5000.000000 5000.000000 5000.000000 5000.000000 5000.000000 4275.000000 5000.000000 5000.000000 5000.000000
52 +mean 1520.102228 4.799635 2.083600 1.277600 5.309600 44.917600 27.878400 62.748304 93.780360 46.241247 -72.673328
53 +std 5491.179837 0.231128 1.529536 1.047644 3.367553 15.985441 19.211076 85.019557 41.944939 0.962092 3.267367
54 +min 1.043410 2.250000 0.000000 0.000000 1.000000 3.000000 0.000000 0.000000 0.000000 45.012400 -79.487900
55 +25% 125.000000 4.730000 1.000000 1.000000 2.000000 34.000000 14.000000 10.000000 94.500000 45.513773 -73.741125
56 +50% 219.825000 4.860000 2.000000 1.000000 4.000000 45.000000 23.000000 33.000000 107.900000 45.943650 -73.559351
57 +75% 1211.757500 4.960000 3.000000 2.000000 7.000000 56.000000 36.000000 82.000000 121.000000 46.811925 -71.268675
58 +max 99950.000000 5.000000 25.000000 24.000000 16.000000 109.000000 199.000000 986.000000 139.800000 54.797600 -1.627760
59 +
60 +--- Value counts: city ---
61 + Montreal 1141
62 + Québec 299
63 + Montréal 275
64 + Mont-Tremblant 204
65 + Gatineau 119
66 + Longueuil 96
67 + Petite-Rivière-Saint-François 67
68 + Laval 53
69 + Chertsey 51
70 + Saint Come 49
71 + Beaupré 46
72 + Saguenay 45
73 + Lac-Supérieur 45
74 + Québec City 41
75 + La Malbaie 40
76 + Stoneham-et-Tewkesbury 39
77 + Magog 37
78 + Trois-Rivières 35
79 + Shawinigan 33
80 + Rimouski 32
81 + Sherbrooke 31
82 + Orford 30
83 + Baie-Saint-Paul 29
84 + Mont-Blanc 29
85 + L'Anse-Saint-Jean 28
86 + Saint-Ferréol-les-Neiges 28
87 + La Conception 23
88 + Brossard 23
89 + Levis 22
90 + Mille-Isles 22
91 + Saint-Adolphe-d'Howard 22
92 + Sutton 21
93 + Sainte-Lucie-des-Laurentides 21
94 + Amherst 21
95 + Mandeville 20
96 + Val-des-Monts 20
97 + Bromont 20
98 + Matawinie 20
99 + Gaspe 19
100 + Mansonville 18
101 + Eastman 18
102 + Saint-Alexis-des-Monts 18
103 + Nominingue 17
104 + Saint-Irénée 16
105 + Sainte-Agathe-des-Monts 16
106 + Pont-Rouge 15
107 + Val-Morin 15
108 + Village de Labelle 15
109 + Les Éboulements 14
110 + Saint-David-de-Falardeau 14
111 + Quebec 14
112 + Saint-Gabriel-de-Valcartier 14
113 + Saint-Donat-de-Montcalm 14
114 + Wentworth North 13
115 + Entrelacs 13
116 + Sainte-Béatrix 13
117 + Rouyn-Noranda 13
118 + Saint-Philémon 13
119 + Château-Richer 13
120 + Saint-Mathieu-du-Parc 12
121 + Saint-Damien 12
122 + Terrebonne 12
123 + Gracefield 12
124 + Matane 11
125 + Notre-Dame-de-la-Merci 11
126 + Val-David 11
127 + Notre-Dame-des-Bois 11
128 + Boischatel 10
129 + Saint-Michel-des-Saints 10
130 + Lac-Etchemin 10
131 + Val-d'Or 10
132 + Sainte-Adèle 10
133 + Petite-Vallée 10
134 + Dunham 10
135 + Mirabel 10
136 + Lac-Beauport 10
137 + La Tuque 10
138 + La Minerve 9
139 + Shannon 9
140 + Coaticook 9
141 + Piedmont 9
142 + Saint-Sauveur 9
143 + Les Laurentides 9
144 + Sainte-Anne-des-Monts 9
145 + Sainte-Brigitte-de-Laval 9
146 + Wakefield 9
147 + Saint-Georges 8
148 + Saint-Tite-des-Caps 8
149 + Charlevoix 8
150 + Saint-Jean-de-Matha 8
151 + Cap-Chat 8
152 + Sainte-Émélie-de-l'Énergie 8
153 + Saint-Jean-Port-Joli 8
154 + Sainte-Anne-des-Lacs 8
155 + Alma 8
156 + Namur 7
157 + Chelsea 7
158 + Val-des-Lacs 7
159 + Rawdon 7
160 + Saint-Calixte 7
161 + Sacré-Coeur 7
162 + Mansfield-et-Pontefract 7
163 + Notre-Dame-Auxiliatrice-de-Buckland 7
164 + Lac-Mégantic 7
165 + St-Raymond 7
166 + Pointe-Claire 7
167 + Saint-Joachim 7
168 + Messines 7
169 + Saint-Siméon 7
170 + Lac-aux-Sables 7
171 + Brownsburg 6
172 + Saint-Étienne-de-Bolton 6
173 + Saint-André-Avellin 6
174 + Saint-Jean-de-l'Île-d'Orléans 6
175 + Disraeli 6
176 + Austin 6
177 + Chapeau 6
178 + Roberval 6
179 + Cowansville 6
180 + Adstock 6
181 + Saint-Laurent-Ile-d'Orleans 6
182 + Lac-Sainte-Marie 6
183 + Saint-Jean-sur-Richelieu 6
184 + Pabos 6
185 + Tadoussac 6
186 + Val-des-Bois 6
187 + Pontiac 6
188 + Lévis 6
189 + Îles de la Madeleine 6
190 + Weedon 6
191 + Murdochville 6
192 + Saint-Hyacinthe 5
193 + Repentigny 5
194 + Beloeil 5
195 + Salaberry-de-Valleyfield 5
196 + Les Laurentides Regional County Municipality 5
197 + Saint-Zénon 5
198 + Saint-Côme 5
199 + Trois-Rives 5
200 + Rivière-Rouge 5
201 + Bricquebec-en-Cotentin 5
202 + Saint-Anicet 5
203 + Knowlton 5
204 + Sainte-Marguerite-du-Lac-Masson 5
205 + Saint-Denis-de-Brompton 5
206 + Hérouxville 5
207 + Argenteuil 5
208 + Morin-Heights 5
209 + Beaulac-Garthby 5
210 + Saint-Romain 5
211 + Sainte-Anne-de-Beaupré 5
212 + Notre-Dame-de-Pontmain 5
213 + Fulford 5
214 + Sainte-Flavie 5
215 + Grandes-Bergeronnes 4
216 + Saint-Paulin 4
217 + L'Ange-Gardien 4
218 + La Vallée-de-la-Gatineau 4
219 + Carleton-sur-mer 4
220 + Sayabec 4
221 + Bowman 4
222 + Dolbeau-Mistassini 4
223 + Saint-André-d'Argenteuil 4
224 + Frampton 4
225 + Saint-Casimir 4
226 + Lambton 4
227 + Otterburn Park 4
228 + Berthier-sur-Mer 4
229 + Antoine-Labelle 4
230 + Sainte-Thérèse-de-la-Gatineau 4
231 + Chute-Saint-Philippe 4
232 + L'Île-Perrot 4
233 + Shefford 4
234 + Ayer's Cliff 4
235 + Labelle 4
236 + Waterloo 4
237 + Montréal-Est 4
238 + Saint-Donat 4
239 + Bolton-Est 4
240 + Drummondville 4
241 + Percé 4
242 + Saint-Hippolyte 4
243 + Otter Lake 4
244 + Notre-Dame-des-Monts 4
245 + Saint-Urbain 4
246 + North Hatley 4
247 + Saint-Roch-des-Aulnaies 4
248 + Saint-Michel-de-Bellechasse 4
249 + Le Fjord-du-Saguenay 4
250 + Saint-Pierre 4
251 + La Côte-de-Beaupré 4
252 + Saint-Henri-de-Taillon 4
253 + Grandes-Piles 4
254 + Sainte-Luce 4
255 + West Bolton 4
256 + Duhamel 4
257 + Saint-Gabriel-de-Brandon 4
258 + L'Islet 4
259 + Deschambault 4
260 + Cap-aux-Meules 4
261 + Huberdeau 4
262 + La Prairie 4
263 + Saint-Alphonse-Rodriguez 4
264 + Venise-en-Québec 4
265 + Côte Saint-Luc 4
266 + Stratford 3
267 + Prévost 3
268 + Sainte-Ursule 3
269 + Notre-Dame-de-Montauban 3
270 + Paspébiac 3
271 + Saint-Adrien 3
272 + Kamouraska 3
273 + Saint-André 3
274 + Les Îles-de-la-Madeleine 3
275 + L'Ascension 3
276 + L'Ascension-de-Notre-Seigneur 3
277 + Châteauguay 3
278 + Bury 3
279 + Pohenegamook 3
280 + Saint-Joseph-de-la-Rive 3
281 + Stanstead 3
282 + Val-des-Sources 3
283 + Wemotaci 3
284 + Blainville 3
285 + Piopolis 3
286 + Saint-Narcisse-de-Rimouski 3
287 + Rivière-à-Pierre 3
288 + Saint-Simon 3
289 + Saint-Basile 3
290 + Lac-Drolet 3
291 + Saint-René-de-Matane 3
292 + Normandin 3
293 + Cantley 3
294 + Témiscouata-sur-le-Lac 3
295 + Champlain 3
296 + Sainte-Anne-du-Lac 3
297 + Memphrémagog 3
298 + Dollard-Des Ormeaux 3
299 + Lac-Brome 3
300 + L'Isle-aux-Coudres 3
301 + Saint-Herménégilde 3
302 + Bonaventure 3
303 + Longue-Rive 3
304 + Havre-Saint-Pierre 3
305 + Valcourt 3
306 + Brébeuf 3
307 + Sainte-Julie 3
308 + Woburn 3
309 + Mont-Carmel 3
310 + Saint-Barthélemy 3
311 + Harrington 3
312 + Stukely 3
313 + Saint-Raphaël 3
314 + Saint-Colomban 3
315 + Chénéville 3
316 + Mont-Saint-Pierre 3
317 + New Richmond 3
318 + Saint-Rémi-de-Tingwick 3
319 + Saint-Roch-de-Mékinac 3
320 + Notre-Dame-du-Portage 3
321 + Saint-Polycarpe 3
322 + Foster 3
323 + Saint-Eustache 3
324 + Frelighsburg 3
325 + Westmount 3
326 + Lachute 2
327 + Saint-Georges-de-Malbaie 2
328 + Saint-Ignace-de-Loyola 2
329 + Maria 2
330 + Kazabazua 2
331 + Notre-Dame-du-Nord 2
332 + Hatley 2
333 + Fatima 2
334 + Kiamika 2
335 + St-Tite 2
336 + Sainte-Cécile-de-Masham 2
337 + Lac-Saint-Jean-Est Regional County Municipality 2
338 + Dudswell 2
339 + Cap-Saint-Ignace 2
340 + Saint-Gédéon 2
341 + Baie-Johan-Beetz 2
342 + Cap-Chat-Est 2
343 + Montpellier 2
344 + Saint-Marc-sur-Richelieu 2
345 + Beauceville 2
346 + Boucherville 2
347 + Hampstead 2
348 + Scott 2
349 + Nouvelle 2
350 + Péribonka 2
351 + Scotstown 2
352 + Candiac 2
353 + Saint-Pascal 2
354 + Louiseville 2
355 + Lac-des-Îles 2
356 + Rivière-Bonaventure 2
357 + Chibougamau 2
358 + Rivière-Éternité 2
359 + Saint-Georges-de-Clarenceville 2
360 + Témiscamingue 2
361 + Communauté maritime des Îles-de-la-Madeleine 2
362 + La Bostonnais 2
363 + Joliette 2
364 + Saint-Ambroise 2
365 + Farnham 2
366 + Saint-Félicien 2
367 + Papineauville 2
368 + Matapédia 2
369 + Charlevoix-Est 2
370 + Bassin 2
371 + Pincourt 2
372 + Dorval 2
373 + Baie-Sainte-Catherine 2
374 + Saint-Germain 2
375 + Mt Royal 2
376 + Lac-des-Plages 2
377 + Saint-Charles-Borromée 2
378 + St-Bruno-de-Montarville 2
379 + Saint-Léon-de-Standon 2
380 + Saint-Aimé-des-Lacs 2
381 + Saint-Élie-de-Caxton 2
382 + Rivière-Ouelle 2
383 + Pointe-Calumet 2
384 + Saint-Benoît-Labre 2
385 + Brownsburg-Chatham 2
386 + Saint-Augustin-de-Desmaures 2
387 + Saint-Paul-de-Montminy 2
388 + Ormstown 2
389 + Le Domaine-du-Roy 2
390 + Grenville-sur-la-Rouge 2
391 + Portneuf 2
392 + Desbiens 2
393 + Dollard-des-Ormeaux 2
394 + Saint-Fabien-de-Panet 2
395 + Métabetchouan-Lac-à-la-Croix 2
396 + Saint-Cyrille-de-Lessard 2
397 + Saint-Ludger-de-Milot 2
398 + Amqui 2
399 + Les Pays-d'en-Haut 2
400 + Saint-Adelphe 2
401 + Notre-Dame-de-la-Salette 2
402 + Saint-Aubert 2
403 + Lac-des-Seize-Îles 2
404 + Lorraine 2
405 + Chambly 2
406 + Saint-Émile-de-Suffolk 2
407 + Grondines 2
408 + Saint-Ferdinand 2
409 + Sorel-Tracy 2
410 + Lantier 2
411 + Papineau 2
412 + Hemmingford 2
413 + Montreal West 2
414 + Lac-Simon 2
415 + Barnston-Ouest 2
416 + Quebec City 2
417 + Notre-Dame-du-Rosaire 2
418 + Rivière-du-Loup 2
419 + La Jacques-Cartier 2
420 + La Pocatière 2
421 + Saint-Fabien 2
422 + Les Collines-de-l'Outaouais 2
423 + Victoriaville 2
424 + Saint-Didace 2
425 + Saint-Jérôme 2
426 + La Patrie 2
427 + Les Escoumins 2
428 + Métis-sur-Mer 2
429 + Saint-Félix-d'Otis 2
430 + Lac-Sergent 2
431 + Labrecque 2
432 + Amos 2
433 + Sainte-Rose-du-Nord 2
434 + Saint-Mathieu-de-Rioux 2
435 + Granby 2
436 + Saint-Marc-du-Lac-Long 1
437 + Sainte-Julienne 1
438 + Baie-Comeau 1
439 + Donnacona 1
440 + La Jacques-Cartier Regional County Municipality 1
441 + La Martre 1
442 + Rivière-à-Claude 1
443 + Saint-Armand 1
444 + Les Basques 1
445 + Fabre 1
446 + Sainte-Thècle 1
447 + Batiscan 1
448 + Trois-Pistoles 1
449 + Bégin 1
450 + Frontenac 1
451 + Princeville 1
452 + Odanak 1
453 + Sainte-Praxède 1
454 + Dewittville 1
455 + St Étienne des Grès 1
456 + Nicolet 1
457 + Bristol 1
458 + Fassett 1
459 + Saint-Lin-Laurentides 1
460 + Île-aux-Noix 1
461 + Oka 1
462 + Le Haut-Saint-François Regional County Municipality 1
463 + L'Ancienne-Lorette 1
464 + St-Urbain-de-Charlevoix 1
465 + Lourdes-de-Joliette 1
466 + Wotton 1
467 + Wentworth-Nord 1
468 + Stanstead-Est 1
469 + Larouche 1
470 + Vallée-Jonction 1
471 + Cascapédia-Saint-Jules 1
472 + Mont-Saint-Hilaire 1
473 + Godmanchester 1
474 + Grosse-Île 1
475 + Low 1
476 + La Macaza 1
477 + Vaudreuil-Dorion 1
478 + Laniel 1
479 + Saint-Cuthbert 1
480 + La Bostonnais(près de la tuque) 1
481 + Auclair 1
482 + Biencourt 1
483 + Montcalm 1
484 + Saint-Lucien 1
485 + Tourville 1
486 + Rivière-la-Madeleine 1
487 + Saint-Martin 1
488 + Sept-Îles 1
489 + Caplan 1
490 + Le Fjord-du-Saguenay Regional County Municipality 1
491 + Lourdes 1
492 + Lachine 1
493 + Chartierville 1
494 + Sainte-Séraphine 1
495 + Mont-Saint-Grégoire 1
496 + Lamarche 1
497 + Verdun 1
498 + Saint-Isidore-de-Clifton 1
499 + Duclos 1
500 + Sheenboro 1
501 + Barkmere 1
502 + Kinnear's Mills 1
503 + Sainte-Anne-de-la-Pérade 1
504 + Sainte-Thérèse-de-Gaspé 1
505 + Ripon 1
506 + Sainte-Anne-de-Sorel 1
507 + Richmond 1
508 + Dégelis 1
509 + Saint-Constant 1
510 + Saint-Boniface 1
511 + Davidson 1
512 + Saint-Marcellin 1
513 + Saint-Michel-du-Squatec 1
514 + Stukely-Sud 1
515 + Windsor 1
516 + Saint-Pierre-les-Becquets 1
517 + Notre-Dame-du-Mont-Carmel 1
518 + Saint-Fulgence 1
519 + Ange-Gardien 1
520 + Havre-Aubert 1
521 + price 1
522 + Brome 1
523 + Saint-Zotique 1
524 + Newport 1
525 + saint-Hubert 1
526 + Cascapédia-St-Jules 1
527 + Lac-Saguay 1
528 + Hébertville 1
529 + Ham-Nord 1
530 + Saint-Lambert-de-Lauzon 1
531 + D'Autray 1
532 + Shawville 1
533 + Sainte-Marthe-sur-le-Lac 1
534 + Matawinie Regional County Municipality 1
535 + Ville-Marie 1
536 + Maskinongé 1
537 + Martinville 1
538 + Pointe-Lebel 1
539 + Ville St Laurent 1
540 + Saint-Amable 1
541 + Sainte-Thérèse 1
542 + Esprit-Saint 1
543 + Saint-Joachim-de-Shefford 1
544 + East Broughton 1
545 + Sainte-Félicité 1
546 + Yamachiche 1
547 + Deux-Montagnes 1
548 + Saint-Lambert 1
549 + Saint-Marcel 1
550 + Mont-blanc 1
551 + Sagard 1
552 + Saint-Hubert-de-Rivière-du-Loup 1
553 + Packington 1
554 + Sainte-Sophie 1
555 + Arr. Chicoutimi (Saguenay) 1
556 + Sainte-Marcelline-de-Kildare 1
557 + Saint-Jean-de-Dieu 1
558 + Saint-Alphonse-de-Granby 1
559 + Bryson 1
560 + Saint-Samuel 1
561 + Saint-Antoine-de-l'Isle-aux-Grues 1
562 + Saint-Octave-de-Métis 1
563 + trois rives 1
564 + Blue Sea 1
565 + La Haute-Gaspésie 1
566 + Notre-Dame-de-Ham 1
567 + Val-Brillant 1
568 + Lac-Bouchette 1
569 + Thetford Mines 1
570 + Barachois 1
571 + Plaisance 1
572 + L'Anse-Pleureuse 1
573 + Sainte-Angèle-de-Mérici 1
574 + Potton 1
575 + Saint-Bernard-sur-Mer 1
576 + Waterville 1
577 + Bécancour 1
578 + Sainte-Anne-de-la-Rochelle 1
579 + St-Gabriel-de-Valcartier 1
580 + Sainte-Françoise 1
581 + Lorrainville 1
582 + New Carlisle 1
583 + Ville de Québec 1
584 + Lac-Saint-Jean-Est 1
585 + Nouvelle-Ouest 1
586 + Chambord 1
587 + Saint-Basile-le-Grand 1
588 + Mascouche 1
589 + Sainte-Catherine 1
590 + Carignan 1
591 + Thorne 1
592 + Saint-Modeste 1
593 + Saint-Honoré 1
594 + Saint-Roch-de-Richelieu 1
595 + Saint-Antonin 1
596 + Sainte-Irène 1
597 + Saint-Benjamin 1
598 + La Conception-Station 1
599 + Trécesson 1
600 + Memphrémagog Regional County Municipality 1
601 + Ladysmith 1
602 + Sainte-Aurélie 1
603 + Sainte-Christine-d'Auvergne 1
604 + Saint-Athanase 1
605 + Chandler 1
606 + Petit-Saguenay 1
607 + Saint-François-de-l'Île-d'Orléans 1
608 + Cap-d'Espoir 1
609 + Sainte-Madeleine-de-la-Rivière-Madeleine 1
610 + Saint-Damase 1
611 + Saint-Léonard-d'Aston 1
612 + Saint-Valère 1
613 + Temiscaming 1
614 + Roxton Falls 1
615 + Sainte-Agathe-de-Lotbinière 1
616 + Mékinac 1
617 + Barraute 1
618 + Ferland-et-Boilleau 1
619 + La Baleine 1
620 + Saint-Malo 1
621 + Saint-Siméon-de-Bonaventure 1
622 + Saint-Hilarion 1
623 + Sainte-Pétronille 1
624 + Saint-Raymond 1
625 + Notre-Dame-du-Laus 1
626 + Saint-Zacharie 1
627 + Arthabaska 1
628 + Charles-E. 1
629 + Sainte Ursule 1
630 + Luskville 1
631 + Mercier 1
632 + Saint-Pie 1
633 + Saint-Pierre-de-Broughton 1
634 + Ferme-Neuve 1
635 + Havre-aux-Maisons 1
636 + St-irénée 1
637 + Sainte-Apolline-de-Patton 1
638 + Hudson 1
639 + Kirkland 1
640 + Saint-Faustin--Lac-Carré 1
641 + Saint-Anaclet-de-Lessard 1
642 + l'Ascension-de-notre-Seigneur 1
643 + Saint Donat 1
644 + Saint-Joseph-de-Beauce 1
645 + Notre-Dame-de-l'Île-Perrot 1
646 + Le Haut-Saint-François 1
647 + La Matapédia 1
648 + Lac-Saint-Paul 1
649 +
650 +--- Value counts: property_type ---
651 + Rental unit 1777
652 + Cabin/Chalet 977
653 + House 963
654 + Other 819
655 + Condo 332
656 + Apartment 132
657 +
658 +--- Value counts: price_category ---
659 + Unknown 1544
660 + Moderate ($100-200) 1003
661 + Ultra-Luxury (>$1000) 915
662 + Premium ($200-400) 682
663 + Budget (<$100) 579
664 + Luxury ($400-1000) 277
665 +
666 +--- Value counts: rating_category ---
667 + Exceptional (4.9-5.0) 1761
668 + Excellent (4.7-4.9) 1530
669 + No rating 810
670 + Very Good (4.5-4.7) 548
671 + Good (4.0-4.5) 324
672 + Average (<4.0) 27
673 +
674 +--- Value counts: is_superhost ---
675 + True 2616
676 + False 2384
677 +
678 +--- Value counts: is_guest_favorite ---
679 + False 3328
680 + True 1672
681 +
682 +--- Value counts: pets_allowed ---
683 + False 3538
684 + True 1462
685 +
686 +--- First 5 rows ---
687 + name city property_type price_numeric price_category rating rating_category bedrooms bathrooms guests_count amenities_count num_images num_reviews quality_score is_superhost is_guest_favorite pets_allowed lat long
688 +0 Rental unit in Montréal · ★New · 1 bedroom · 1 bed · 1 shared bath Montréal Rental unit NaN Unknown NaN No rating 1 0.0 2 31 29 NaN 0.0 False False False 45.477030 -73.607700
689 +1 Rental unit in Québec · 1 bedroom · 2 beds · 1 bath Québec Rental unit NaN Unknown NaN No rating 1 1.0 4 34 7 NaN 0.0 False False False 46.815630 -71.207260
690 +2 Rental unit in Saint-Georges · ★5.0 · 2 bedrooms · 2 beds · 1 bath Saint-Georges Rental unit 83.0 Budget (<$100) 5.0 Exceptional (4.9-5.0) 2 1.0 4 35 16 NaN 100.0 False False False 46.119212 -70.666145
691 +3 Home in Nominingue · ★New · 3 bedrooms · 4 beds · 1 bath Nominingue House NaN Unknown NaN No rating 3 1.0 8 12 21 NaN 0.0 False False False 46.461320 -74.948370
692 +4 Rental unit in Montréal · ★New · 1 bedroom · 1 bed · 1 bath Montréal Rental unit NaN Unknown NaN No rating 1 1.0 4 5 7 NaN 0.0 False False False 45.558490 -73.585060
693 +
694 +================================================================================
695 +RENT DATASET — rent.json
696 +================================================================================
697 +
698 +Total records: 8356
699 +Shape (flattened): 8356 rows x 14 columns
700 +
701 +--- Dtypes ---
702 + address_text str
703 + latitude str
704 + longitude str
705 + property_type str
706 + lease_rent str
707 + lease_rent_unformatted str
708 + building_type str
709 + bedrooms str
710 + bathrooms_total str
711 + size_interior str
712 + stories_total str
713 + postal_code str
714 + province str
715 + scraped_at str
716 +
717 +--- Empty-string + null counts ---
718 + address_text null= 0 blank= 0 total= 0 ( 0.0%)
719 + latitude null= 0 blank= 0 total= 0 ( 0.0%)
720 + longitude null= 0 blank= 0 total= 0 ( 0.0%)
721 + property_type null= 0 blank= 0 total= 0 ( 0.0%)
722 + lease_rent null= 0 blank= 0 total= 0 ( 0.0%)
723 + lease_rent_unformatted null= 0 blank= 0 total= 0 ( 0.0%)
724 + building_type null= 0 blank= 48 total= 48 ( 0.6%)
725 + bedrooms null= 0 blank= 426 total= 426 ( 5.1%)
726 + bathrooms_total null= 0 blank= 48 total= 48 ( 0.6%)
727 + size_interior null= 0 blank= 4438 total= 4438 ( 53.1%)
728 + stories_total null= 0 blank= 2172 total= 2172 ( 26.0%)
729 + postal_code null= 0 blank= 0 total= 0 ( 0.0%)
730 + province null= 0 blank= 0 total= 0 ( 0.0%)
731 + scraped_at null= 0 blank= 0 total= 0 ( 0.0%)
732 +
733 +--- Value counts: property_type ---
734 + Single Family 8308
735 + Vacant Land 48
736 +
737 +--- Value counts: building_type ---
738 + Apartment 7515
739 + House 745
740 + 48
741 + Row / Townhouse 48
742 +
743 +--- LeaseRent frequency patterns (top 10) ---
744 + $1,800/Monthly 237
745 + $2,500/Monthly 214
746 + $1,600/Monthly 204
747 + $2,000/Monthly 190
748 + $1,700/Monthly 186
749 + $2,200/Monthly 181
750 + $1,500/Monthly 178
751 + $2,100/Monthly 165
752 + $1,900/Monthly 161
753 + $1,850/Monthly 143
754 +
755 +--- LeaseRent period distribution ---
756 + Monthly 8319
757 + Yearly 33
758 + Weekly 4
759 +
760 +--- First 5 rows (flattened) ---
761 + address_text latitude longitude property_type lease_rent lease_rent_unformatted building_type bedrooms bathrooms_total size_interior stories_total postal_code province scraped_at
762 +0 1764 Rue Jérôme-Hamel|Trois-Rivières, Quebec G8V1W3 46.402706 -72.533809 Vacant Land $1/Yearly/sqft 1 G8V1W3 Quebec 2026-01-08T01:58:53.220Z
763 +1 24 Rg St-Charles|Mercier, Quebec J6E2L1 45.310747 -73.768111 Vacant Land $1/Yearly/sqft 1 J6E2L1 Quebec 2026-01-08T01:58:53.220Z
764 +2 Route du Président-Kennedy|Lévis (Desjardins), Quebec G6C1C8 46.75664186 -71.12321116 Vacant Land $1/Yearly/sqft 1 G6C1C8 Quebec 2026-01-08T01:58:53.220Z
765 +3 116 Boul. Fortin|Saint-Bernard-de-Lacolle, Quebec J0J1V0 45.012127 -73.453971 Vacant Land $1.50/Yearly/sqft 1.5 J0J1V0 Quebec 2026-01-08T01:58:53.220Z
766 +4 Rue Valmont|Saint-Jérôme, Quebec J7Y4Y2 45.758094 -74.01657 Vacant Land $1.50/Yearly/sqft 1.5 J7Y4Y2 Quebec 2026-01-08T01:58:53.220Z
added results/tables/buffer_robustness.tex +15 −0
@@ -0,0 +1,15 @@
1 +\begin{tabular}{lcccc}
2 +\toprule
3 + & \textbf{250m} & \textbf{500m} & \textbf{1km} & \textbf{2km} \\
4 +Dep.\ var: & \textit{log\_rent} & \textit{log\_rent} & \textit{log\_rent} & \textit{log\_rent} \\
5 +\midrule
6 +Airbnb count & 0.009272*** & 0.003826*** & 0.001589*** & 0.000612*** \\
7 + & (0.000636) & (0.000221) & (0.000080) & (0.000028) \\[4pt]
8 +Controls & Yes & Yes & Yes & Yes \\
9 +City FE & Yes & Yes & Yes & Yes \\
10 +\midrule
11 +Observations & 7,925 & 7,925 & 7,925 & 7,925 \\
12 +R$^2$ & 0.5026 & 0.5081 & 0.5167 & 0.5205 \\
13 +\bottomrule
14 +\end{tabular}
15 +\parbox{\textwidth}{\footnotesize Robust (HC1) standard errors in parentheses. Controls: bedrooms, bathrooms, building-type dummies. $^{***}p<0.01$; $^{**}p<0.05$; $^{*}p<0.10$.}
added results/tables/city_level_interaction.tex +21 −0
@@ -0,0 +1,21 @@
1 +\begin{tabular}{lcc}
2 +\toprule
3 + & \textbf{(3-fwd)} & \textbf{(3-rev)} \\
4 +Dep.\ var: & \textit{mean\_log\_rent} & \textit{airbnb\_count\_city} \\
5 +\midrule
6 +const & 6.9341*** & -40.9296 \\
7 + & (0.0707) & (58.2750) \\[4pt]
8 +airbnb\_count\_city & 0.0001** & \\
9 + & (0.0000) & \\[4pt]
10 +mean\_bedrooms & 0.0882*** & -14.4332 \\
11 + & (0.0241) & (9.4579) \\[4pt]
12 +mean\_bathrooms & 0.3741*** & 11.6631* \\
13 + & (0.0528) & (6.4498) \\[4pt]
14 +mean\_log\_rent & & 10.2254 \\
15 + & & (10.9040) \\[4pt]
16 +\midrule
17 +Observations & 153 & 153 \\
18 +R$^2$ & 0.4916 & 0.0136 \\
19 +\bottomrule
20 +\end{tabular}
21 +\parbox{\textwidth}{\footnotesize Robust (HC1) standard errors in parentheses. $^{***}p<0.01$; $^{**}p<0.05$; $^{*}p<0.10$.}
added results/tables/correlation_matrix.csv +8 −0
@@ -0,0 +1,8 @@
1 +,log_rent,airbnb_count_500m,airbnb_density_500m,mean_airbnb_price_500m,share_entire_home_500m,bedrooms,bathrooms
2 +log_rent,1.0,0.143,0.143,0.07,0.074,0.534,0.597
3 +airbnb_count_500m,0.143,1.0,1.0,0.067,-0.189,-0.19,0.003
4 +airbnb_density_500m,0.143,1.0,1.0,0.067,-0.189,-0.19,0.003
5 +mean_airbnb_price_500m,0.07,0.067,0.067,1.0,0.143,0.018,0.075
6 +share_entire_home_500m,0.074,-0.189,-0.189,0.143,1.0,0.084,0.081
7 +bedrooms,0.534,-0.19,-0.19,0.018,0.084,1.0,0.485
8 +bathrooms,0.597,0.003,0.003,0.075,0.081,0.485,1.0
added results/tables/correlation_matrix.tex +13 −0
@@ -0,0 +1,13 @@
1 +\begin{tabular}{lrrrrrrr}
2 +\toprule
3 + & log_rent & airbnb_count_500m & airbnb_density_500m & mean_airbnb_price_500m & share_entire_home_500m & bedrooms & bathrooms \\
4 +\midrule
5 +log_rent & 1.00 & 0.14 & 0.14 & 0.07 & 0.07 & 0.53 & 0.60 \\
6 +airbnb_count_500m & 0.14 & 1.00 & 1.00 & 0.07 & -0.19 & -0.19 & 0.00 \\
7 +airbnb_density_500m & 0.14 & 1.00 & 1.00 & 0.07 & -0.19 & -0.19 & 0.00 \\
8 +mean_airbnb_price_500m & 0.07 & 0.07 & 0.07 & 1.00 & 0.14 & 0.02 & 0.07 \\
9 +share_entire_home_500m & 0.07 & -0.19 & -0.19 & 0.14 & 1.00 & 0.08 & 0.08 \\
10 +bedrooms & 0.53 & -0.19 & -0.19 & 0.02 & 0.08 & 1.00 & 0.48 \\
11 +bathrooms & 0.60 & 0.00 & 0.00 & 0.07 & 0.08 & 0.48 & 1.00 \\
12 +\bottomrule
13 +\end{tabular}
added results/tables/hedonic_airbnb_pricing.tex +31 −0
@@ -0,0 +1,31 @@
1 +\small
2 +\begin{tabular}{lccc}
3 +\toprule
4 + & \textbf{(2a)} & \textbf{(2b)} & \textbf{(2c)} \\
5 +Dep.\ var: & \textit{log\_price} & \textit{log\_price} & \textit{log\_price} \\
6 +\midrule
7 +const & 6.1958*** & 6.3698*** & 4.7948*** \\
8 + & (0.8696) & (0.8889) & (0.7004) \\[4pt]
9 +mean\_rent\_city & -0.0296 & -0.0531 & 0.0307 \\
10 + & (0.1129) & (0.1180) & (0.1039) \\[4pt]
11 +bedrooms & & 0.0923** & 0.0852* \\
12 + & & (0.0448) & (0.0457) \\[4pt]
13 +bathrooms & & 0.1555*** & 0.1226*** \\
14 + & & (0.0428) & (0.0458) \\[4pt]
15 +guests\_count & & -0.0409** & -0.0187 \\
16 + & & (0.0182) & (0.0190) \\[4pt]
17 +amenities\_count & & 0.0008 & 0.0019 \\
18 + & & (0.0022) & (0.0024) \\[4pt]
19 +is\_superhost & & -0.3467*** & -0.2766*** \\
20 + & & (0.0621) & (0.0651) \\[4pt]
21 +is\_entire\_home & & -0.0722 & 0.1670** \\
22 + & & (0.0661) & (0.0749) \\[4pt]
23 +\midrule
24 +Observations & 2437 & 2437 & 2437 \\
25 +R$^2$ & 0.0000 & 0.0300 & 0.1404 \\
26 +Adj.\ R$^2$ & -0.0004 & 0.0272 & 0.0804 \\
27 +\bottomrule
28 +\end{tabular}
29 +\vspace{4pt}
30 +\parbox{\textwidth}{\footnotesize Model (2c) includes city fixed effects (not shown).}
31 +\parbox{\textwidth}{\footnotesize Robust (HC1) standard errors in parentheses. $^{***}p<0.01$; $^{**}p<0.05$; $^{*}p<0.10$.}
added results/tables/hedonic_rent_baseline.tex +31 −0
@@ -0,0 +1,31 @@
1 +\small
2 +\begin{tabular}{lccccc}
3 +\toprule
4 + & \textbf{(1a)} & \textbf{(1b)} & \textbf{(1c)} & \textbf{(1d)} & \textbf{(1e)} \\
5 +Dep.\ var: & \textit{log\_rent} & \textit{log\_rent} & \textit{log\_rent} & \textit{log\_rent} & \textit{log\_rent} \\
6 +\midrule
7 +const & 7.5990*** & 7.0390*** & 7.2380*** & 7.2380*** & 7.2766*** \\
8 + & (0.0040) & (0.0139) & (0.0677) & (0.0677) & (0.0332) \\[4pt]
9 +airbnb\_count\_500m & 0.0020*** & 0.0048*** & 0.0039*** & & \\
10 + & (0.0003) & (0.0002) & (0.0002) & & \\[4pt]
11 +airbnb\_density\_500m & & & & 0.0030*** & \\
12 + & & & & (0.0002) & \\[4pt]
13 +share\_entire\_home\_500m & & & & & -0.0041 \\
14 + & & & & & (0.0107) \\[4pt]
15 +bedrooms & & 0.1118*** & 0.1276*** & 0.1276*** & 0.1215*** \\
16 + & & (0.0046) & (0.0046) & (0.0046) & (0.0057) \\[4pt]
17 +bathrooms & & 0.2604*** & 0.2287*** & 0.2287*** & 0.3033*** \\
18 + & & (0.0128) & (0.0126) & (0.0126) & (0.0104) \\[4pt]
19 +bt\_House & & 0.0880*** & 0.0825*** & 0.0825*** & 0.0305 \\
20 + & & (0.0128) & (0.0137) & (0.0137) & (0.0260) \\[4pt]
21 +bt\_Row / Townhouse & & 0.1493*** & 0.0981*** & 0.0981*** & 0.1187* \\
22 + & & (0.0375) & (0.0366) & (0.0366) & (0.0695) \\[4pt]
23 +\midrule
24 +Observations & 8303 & 7925 & 7925 & 7925 & 4370 \\
25 +R$^2$ & 0.0075 & 0.4705 & 0.5697 & 0.5697 & 0.4961 \\
26 +Adj.\ R$^2$ & 0.0074 & 0.4702 & 0.5554 & 0.5554 & 0.4857 \\
27 +\bottomrule
28 +\end{tabular}
29 +\vspace{4pt}
30 +\parbox{\textwidth}{\footnotesize Models (1c)-(1e) include city fixed effects (not shown).}
31 +\parbox{\textwidth}{\footnotesize Robust (HC1) standard errors in parentheses. $^{***}p<0.01$; $^{**}p<0.05$; $^{*}p<0.10$.}
added results/tables/ml_comparison.csv +6 −0
@@ -0,0 +1,6 @@
1 +Model,R2_train,R2_test,RMSE_test,MAE_test
2 +OLS,0.4741177154267531,0.5093654009877304,0.2242232339592205,0.17265875039921771
3 +LASSO,0.47400794165005944,0.5099410720642269,0.22409165261983835,0.17251231878346804
4 +Elastic Net,0.4739978180099471,0.5099126726551279,0.22409814569440273,0.17251299831932632
5 +Random Forest,0.9092409107926911,0.7079038624040863,0.1730073021177198,0.12841586215699632
6 +Gradient Boosting,0.8761672438495761,0.6934733510541368,0.17722935025730183,0.13190366914337534
added results/tables/ml_comparison.tex +14 −0
@@ -0,0 +1,14 @@
1 +\begin{tabular}{lcccc}
2 +\toprule
3 +Model & $R^2$ (Train) & $R^2$ (Test) & RMSE (Test) & MAE (Test) \\
4 +\midrule
5 +OLS & 0.4741 & 0.5094 & 0.2242 & 0.1727 \\
6 +LASSO & 0.4740 & 0.5099 & 0.2241 & 0.1725 \\
7 +Elastic Net & 0.4740 & 0.5099 & 0.2241 & 0.1725 \\
8 +Random Forest & 0.9092 & 0.7079 & 0.1730 & 0.1284 \\
9 +Gradient Boosting & 0.8762 & 0.6935 & 0.1772 & 0.1319 \\
10 +\bottomrule
11 +\end{tabular}
12 +\begin{tablenotes}\small
13 +\item \textit{Notes:} OLS, LASSO, and Elastic Net use standardized features. Tree-based models use raw features. Train/test split is 80/20 with random\_state=42.
14 +\end{tablenotes}
added results/tables/quantile_regression.tex +24 −0
@@ -0,0 +1,24 @@
1 +\begin{tabular}{lcccccc}
2 +\toprule
3 + & \textbf{$\tau=0.10$} & \textbf{$\tau=0.25$} & \textbf{$\tau=0.50$} & \textbf{$\tau=0.75$} & \textbf{$\tau=0.90$} & \textbf{OLS} \\
4 +Dep.\ var: & \textit{log\_rent} & \textit{log\_rent} & \textit{log\_rent} & \textit{log\_rent} & \textit{log\_rent} & \textit{log\_rent} \\
5 +\midrule
6 +const & 6.8249*** & 6.9035*** & 7.0339*** & 7.1237*** & 7.2461*** & 7.0508*** \\
7 + & (0.0258) & (0.0211) & (0.0209) & (0.0221) & (0.0328) & (0.0197) \\[4pt]
8 +airbnb\_count\_500m & 0.0037*** & 0.0034*** & 0.0035*** & 0.0041*** & 0.0047*** & 0.0038*** \\
9 + & (0.0003) & (0.0003) & (0.0002) & (0.0002) & (0.0003) & (0.0002) \\[4pt]
10 +bedrooms & 0.1236*** & 0.1034*** & 0.1071*** & 0.1360*** & 0.1418*** & 0.1152*** \\
11 + & (0.0058) & (0.0043) & (0.0039) & (0.0038) & (0.0055) & (0.0046) \\[4pt]
12 +bathrooms & 0.2411*** & 0.2796*** & 0.2748*** & 0.2669*** & 0.2807*** & 0.2571*** \\
13 + & (0.0103) & (0.0076) & (0.0072) & (0.0076) & (0.0115) & (0.0125) \\[4pt]
14 +bt\_House & 0.0608*** & 0.1079*** & 0.1382*** & 0.1079*** & 0.1094*** & 0.1201*** \\
15 + & (0.0139) & (0.0119) & (0.0120) & (0.0131) & (0.0205) & (0.0131) \\[4pt]
16 +bt\_Row / Townhouse & 0.1310*** & 0.0858** & 0.1602*** & 0.2501*** & 0.2274*** & 0.1753*** \\
17 + & (0.0468) & (0.0383) & (0.0383) & (0.0417) & (0.0625) & (0.0378) \\[4pt]
18 +\midrule
19 +City FE (top 5) & Yes & Yes & Yes & Yes & Yes & Yes \\
20 +Observations & 7925 & 7925 & 7925 & 7925 & 7925 & 7925 \\
21 +(Pseudo-)R$^2$ & 0.2251 & 0.2529 & 0.2976 & 0.3294 & 0.3402 & 0.4849 \\
22 +\bottomrule
23 +\end{tabular}
24 +\parbox{\textwidth}{\footnotesize Standard errors in parentheses. OLS uses HC1 robust SE. $^{***}p<0.01$; $^{**}p<0.05$; $^{*}p<0.10$. City FE limited to the 5 largest cities (others grouped).}
added results/tables/robustness_outliers.tex +15 −0
@@ -0,0 +1,15 @@
1 +\begin{tabular}{lccc}
2 +\toprule
3 + & (1) Full Sample & (2) Rent 5/95 & (3) Airbnb 1/99 \\
4 +\midrule
5 +Airbnb count (500m) & 0.003891*** & 0.003057*** & 0.004222*** \\
6 + & (0.000225) & (0.000186) & (0.000242) \\
7 +\midrule
8 +N & 7,925 & 7,254 & 7,868 \\
9 +$R^2$ & 0.5664 & 0.4966 & 0.5673 \\
10 +City FE & Yes & Yes & Yes \\
11 +\bottomrule
12 +\end{tabular}
13 +\begin{tablenotes}\small
14 +\item \textit{Notes:} Robust standard errors (HC1) in parentheses. * $p<0.10$, ** $p<0.05$, *** $p<0.01$. Column (2) winsorizes monthly rent at the 5th and 95th percentiles. Column (3) trims the top and bottom 1\% of Airbnb listing counts.
15 +\end{tablenotes}
added results/tables/robustness_subsamples.tex +15 −0
@@ -0,0 +1,15 @@
1 +\begin{tabular}{lcccc}
2 +\toprule
3 + & (1) Montreal & (2) Outside Mtl & (3) Apartments & (4) Houses \\
4 +\midrule
5 +Airbnb count (500m) & 0.003865*** & 0.003046* & 0.003860*** & 0.006699*** \\
6 + & (0.000226) & (0.001580) & (0.000226) & (0.001995) \\
7 +\midrule
8 +N & 4,578 & 3,347 & 7,136 & 789 \\
9 +$R^2$ & 0.4587 & 0.6959 & 0.5046 & 0.6403 \\
10 +City FE & Yes & Yes & Yes & Yes \\
11 +\bottomrule
12 +\end{tabular}
13 +\begin{tablenotes}\small
14 +\item \textit{Notes:} Robust standard errors (HC1) in parentheses. * $p<0.10$, ** $p<0.05$, *** $p<0.01$. All specifications include city fixed effects, bedrooms, and bathrooms as controls.
15 +\end{tablenotes}
added results/tables/spatial_models.tex +26 −0
@@ -0,0 +1,26 @@
1 +\begin{tabular}{lccc}
2 +\toprule
3 + & \textbf{OLS} & \textbf{SAR (GM\_Lag)} & \textbf{SEM (GM\_Error)} \\
4 +Dep.\ var: & \multicolumn{3}{c}{\textit{log\_rent}} \\
5 +\midrule
6 +const & 7.1114*** & 6.0767*** & 7.0905*** \\
7 + & (0.0211) & (0.1514) & (0.0328) \\[4pt]
8 +airbnb\_count\_500m & 0.0038*** & 0.0034*** & 0.0038*** \\
9 + & (0.0002) & (0.0002) & (0.0004) \\[4pt]
10 +bedrooms & 0.1194*** & 0.1220*** & 0.1546*** \\
11 + & (0.0045) & (0.0032) & (0.0030) \\[4pt]
12 +bathrooms & 0.2432*** & 0.2285*** & 0.1795*** \\
13 + & (0.0123) & (0.0063) & (0.0057) \\[4pt]
14 +bt\_House & 0.1342*** & 0.1188*** & 0.1299*** \\
15 + & (0.0131) & (0.0101) & (0.0097) \\[4pt]
16 +bt\_Row / Townhouse & 0.1782*** & 0.1604*** & 0.1447*** \\
17 + & (0.0359) & (0.0313) & (0.0296) \\[4pt]
18 +W\_log\_rent & & 0.1367*** & \\
19 + & & (0.0199) & \\[4pt]
20 +$\lambda$ (spatial error) & & & 0.5393 \\
21 +\midrule
22 +Observations & 7925 & 7925 & 7925 \\
23 +R$^2$ / pseudo-R$^2$ & 0.5081 & 0.5503 & 0.4972 \\
24 +\bottomrule
25 +\end{tabular}
26 +\parbox{\textwidth}{\footnotesize Standard errors in parentheses. SAR estimated via GM\_Lag; SEM via GM\_Error. $^{***}p<0.01$; $^{**}p<0.05$; $^{*}p<0.10$. City FE included but not shown.}
added results/tables/summary_stats_airbnb.csv +10 −0
@@ -0,0 +1,10 @@
1 +Variable,N,Mean,SD,Min,P25,Median,P75,Max
2 +Nightly price (CAD),3456,1233.3454716435183,2624.7101296758938,30.959500000000002,125.0,219.825,1211.7575,18205.042499999967
3 +Rating,3456,4.804146412037036,0.21887032810655954,2.25,4.75,4.86,4.94,5.0
4 +Bedrooms,3456,2.1206597222222223,1.557827432192061,0.0,1.0,2.0,3.0,25.0
5 +Bathrooms,3456,1.3017939814814814,1.093888942277106,0.0,1.0,1.0,2.0,24.0
6 +Max guests,3456,5.40162037037037,3.4140490759342326,1.0,2.0,4.0,7.0,16.0
7 +Amenities count,3456,45.78269675925926,15.73953887549991,7.0,35.0,46.0,57.0,109.0
8 +Number of images,3456,28.20341435185185,19.168941954671048,0.0,15.0,24.0,36.0,199.0
9 +Number of reviews,3456,53.404513888888886,74.79929051100895,0.0,6.0,26.0,71.0,986.0
10 +Quality score,3456,96.81504629629629,38.78591572140682,0.0,95.6,108.4,121.1,139.8
added results/tables/summary_stats_airbnb.tex +15 −0
@@ -0,0 +1,15 @@
1 +\begin{tabular}{lrrrrrrrr}
2 +\toprule
3 +Variable & N & Mean & SD & Min & P25 & Median & P75 & Max \\
4 +\midrule
5 +Nightly price (CAD) & 3456 & 1233.35 & 2624.71 & 30.96 & 125.00 & 219.82 & 1211.76 & 18205.04 \\
6 +Rating & 3456 & 4.80 & 0.22 & 2.25 & 4.75 & 4.86 & 4.94 & 5.00 \\
7 +Bedrooms & 3456 & 2.12 & 1.56 & 0.00 & 1.00 & 2.00 & 3.00 & 25.00 \\
8 +Bathrooms & 3456 & 1.30 & 1.09 & 0.00 & 1.00 & 1.00 & 2.00 & 24.00 \\
9 +Max guests & 3456 & 5.40 & 3.41 & 1.00 & 2.00 & 4.00 & 7.00 & 16.00 \\
10 +Amenities count & 3456 & 45.78 & 15.74 & 7.00 & 35.00 & 46.00 & 57.00 & 109.00 \\
11 +Number of images & 3456 & 28.20 & 19.17 & 0.00 & 15.00 & 24.00 & 36.00 & 199.00 \\
12 +Number of reviews & 3456 & 53.40 & 74.80 & 0.00 & 6.00 & 26.00 & 71.00 & 986.00 \\
13 +Quality score & 3456 & 96.82 & 38.79 & 0.00 & 95.60 & 108.40 & 121.10 & 139.80 \\
14 +\bottomrule
15 +\end{tabular}
added results/tables/summary_stats_rent.csv +4 −0
@@ -0,0 +1,4 @@
1 +Variable,N,Mean,SD,Min,P25,Median,P75,Max
2 +Monthly rent (CAD),8303,2141.8125641334454,764.8694691020839,995.0799999999999,1615.0,1950.0,2495.0,5000.0
3 +Bedrooms,7925,2.052744479495268,0.9421661386175844,1.0,1.0,2.0,3.0,10.0
4 +Bathrooms,8303,1.215705166807178,0.4739252693779812,0.0,1.0,1.0,1.0,8.0
added results/tables/summary_stats_rent.tex +9 −0
@@ -0,0 +1,9 @@
1 +\begin{tabular}{lrrrrrrrr}
2 +\toprule
3 +Variable & N & Mean & SD & Min & P25 & Median & P75 & Max \\
4 +\midrule
5 +Monthly rent (CAD) & 8303 & 2141.81 & 764.87 & 995.08 & 1615.00 & 1950.00 & 2495.00 & 5000.00 \\
6 +Bedrooms & 7925 & 2.05 & 0.94 & 1.00 & 1.00 & 2.00 & 3.00 & 10.00 \\
7 +Bathrooms & 8303 & 1.22 & 0.47 & 0.00 & 1.00 & 1.00 & 1.00 & 8.00 \\
8 +\bottomrule
9 +\end{tabular}
added scripts/01_inspect_raw_data.py +177 −0
@@ -0,0 +1,177 @@
1 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +"""
3 +01_inspect_raw_data.py
4 +----------------------
5 +Load both raw datasets (data/raw/airbnb.csv, data/raw/rent.json), print
6 +inspection summaries, and save a data-dictionary / inspection log to
7 +results/logs/data_inspection.txt.
8 +
9 +Note: the raw files are not distributed with the repository (see
10 +data/raw/README.md). This step is only needed to rebuild the processed
11 +parquet files from scratch; results/logs/data_inspection.txt already
12 +contains its output from the original run.
13 +"""
14 +
15 +import json
16 +import sys
17 +from io import StringIO
18 +from pathlib import Path
19 +
20 +sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
21 +
22 +import pandas as pd
23 +
24 +from src.config import AIRBNB_RAW, RENT_RAW, LOG_DIR, require
25 +
26 +RAW_HINT = ("Place the original raw files in data/raw/ to run steps 01-03. "
27 + "The processed parquet files in data/processed/ already allow "
28 + "running steps 04-10.")
29 +
30 +LOG_PATH = LOG_DIR / "data_inspection.txt"
31 +
32 +# ── helper to capture + print simultaneously ─────────────────────────────────
33 +log_buf = StringIO()
34 +
35 +
36 +def log(msg: str = "") -> None:
37 + """Print to stdout and buffer for the log file."""
38 + print(msg)
39 + log_buf.write(msg + "\n")
40 +
41 +
42 +def inspect_airbnb() -> None:
43 + """Inspect the raw Airbnb CSV: dtypes, nulls, summaries, value counts."""
44 + log("=" * 80)
45 + log("AIRBNB DATASET — airbnb.csv")
46 + log("=" * 80)
47 +
48 + ab = pd.read_csv(AIRBNB_RAW)
49 +
50 + log(f"\nShape: {ab.shape[0]} rows x {ab.shape[1]} columns\n")
51 +
52 + # dtypes
53 + log("--- Dtypes ---")
54 + for col in ab.columns:
55 + log(f" {col:30s} {str(ab[col].dtype)}")
56 +
57 + # nulls
58 + log("\n--- Null counts ---")
59 + nulls = ab.isnull().sum()
60 + for col in ab.columns:
61 + n = nulls[col]
62 + pct = 100 * n / len(ab)
63 + log(f" {col:30s} {n:6d} ({pct:5.1f}%)")
64 +
65 + # numeric summary
66 + log("\n--- Numeric summary ---")
67 + log(ab.describe().to_string())
68 +
69 + # key categorical value counts
70 + for col in ["city", "property_type", "price_category", "rating_category"]:
71 + log(f"\n--- Value counts: {col} ---")
72 + vc = ab[col].value_counts()
73 + for val, cnt in vc.items():
74 + log(f" {str(val):50s} {cnt}")
75 +
76 + # boolean columns
77 + for col in ["is_superhost", "is_guest_favorite", "pets_allowed"]:
78 + log(f"\n--- Value counts: {col} ---")
79 + vc = ab[col].value_counts()
80 + for val, cnt in vc.items():
81 + log(f" {str(val):10s} {cnt}")
82 +
83 + # sample rows
84 + log("\n--- First 5 rows ---")
85 + log(ab.head().to_string())
86 +
87 +
88 +def inspect_rent() -> None:
89 + """Inspect the raw Realtor.ca JSON: structure, missingness, patterns."""
90 + log("\n" + "=" * 80)
91 + log("RENT DATASET — rent.json")
92 + log("=" * 80)
93 +
94 + with open(RENT_RAW, "r", encoding="utf-8") as f:
95 + rent_raw = json.load(f)
96 +
97 + log(f"\nTotal records: {len(rent_raw)}")
98 +
99 + # Flatten key fields into a DataFrame for inspection
100 + rows = []
101 + for rec in rent_raw:
102 + prop = rec.get("Property", {})
103 + addr = prop.get("Address", {})
104 + bld = rec.get("Building", {})
105 + rows.append({
106 + "address_text": addr.get("AddressText", ""),
107 + "latitude": addr.get("Latitude", ""),
108 + "longitude": addr.get("Longitude", ""),
109 + "property_type": prop.get("Type", ""),
110 + "lease_rent": prop.get("LeaseRent", ""),
111 + "lease_rent_unformatted": prop.get("LeaseRentUnformattedValue", ""),
112 + "building_type": bld.get("Type", ""),
113 + "bedrooms": bld.get("Bedrooms", ""),
114 + "bathrooms_total": bld.get("BathroomTotal", ""),
115 + "size_interior": bld.get("SizeInterior", ""),
116 + "stories_total": bld.get("StoriesTotal", ""),
117 + "postal_code": rec.get("PostalCode", ""),
118 + "province": rec.get("ProvinceName", ""),
119 + "scraped_at": rec.get("scrapedAt", ""),
120 + })
121 +
122 + rt = pd.DataFrame(rows)
123 +
124 + log(f"Shape (flattened): {rt.shape[0]} rows x {rt.shape[1]} columns\n")
125 +
126 + # dtypes
127 + log("--- Dtypes ---")
128 + for col in rt.columns:
129 + log(f" {col:30s} {str(rt[col].dtype)}")
130 +
131 + # blanks / nulls
132 + log("\n--- Empty-string + null counts ---")
133 + for col in rt.columns:
134 + n_null = rt[col].isnull().sum()
135 + n_blank = (rt[col] == "").sum()
136 + total_missing = n_null + n_blank
137 + pct = 100 * total_missing / len(rt)
138 + log(f" {col:30s} null={n_null:5d} blank={n_blank:5d} "
139 + f"total={total_missing:5d} ({pct:5.1f}%)")
140 +
141 + # value counts for key categoricals
142 + for col in ["property_type", "building_type"]:
143 + log(f"\n--- Value counts: {col} ---")
144 + vc = rt[col].value_counts()
145 + for val, cnt in vc.items():
146 + log(f" {str(val):40s} {cnt}")
147 +
148 + # lease_rent patterns
149 + log("\n--- LeaseRent frequency patterns (top 10) ---")
150 + patterns = rt["lease_rent"].str.extract(r"(\$[\d,]+/\w+)")[0].value_counts().head(10)
151 + for val, cnt in patterns.items():
152 + log(f" {val:30s} {cnt}")
153 +
154 + # check rent period distribution
155 + log("\n--- LeaseRent period distribution ---")
156 + for period in ["Monthly", "Yearly", "Weekly"]:
157 + cnt = rt["lease_rent"].str.contains(period, na=False).sum()
158 + log(f" {period:15s} {cnt}")
159 +
160 + # sample rows
161 + log("\n--- First 5 rows (flattened) ---")
162 + log(rt.head().to_string())
163 +
164 +
165 +def main() -> None:
166 + require(AIRBNB_RAW, RAW_HINT)
167 + require(RENT_RAW, RAW_HINT)
168 +
169 + inspect_airbnb()
170 + inspect_rent()
171 +
172 + LOG_PATH.write_text(log_buf.getvalue(), encoding="utf-8")
173 + log(f"\n>>> Inspection log saved to {LOG_PATH}")
174 +
175 +
176 +if __name__ == "__main__":
177 + main()
added scripts/02_clean_airbnb.py +118 −0
@@ -0,0 +1,118 @@
1 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +"""
3 +02_clean_airbnb.py
4 +------------------
5 +Clean and standardize the Airbnb dataset.
6 +
7 +Steps
8 + 1. Standardize city names (merge accented / unaccented variants).
9 + 2. Clean price_numeric: drop null / non-positive, winsorize at p1/p99.
10 + 3. Create log_price = ln(price_numeric).
11 + 4. Clean rating and num_reviews (fill NaN with median for rating, 0 for
12 + num_reviews — rationale documented below).
13 + 5. Create is_entire_home indicator from property_type.
14 + 6. Save to data/processed/airbnb_clean.parquet.
15 +"""
16 +
17 +import sys
18 +from pathlib import Path
19 +
20 +sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
21 +
22 +import numpy as np
23 +import pandas as pd
24 +
25 +from src.config import AIRBNB_RAW, AIRBNB_CLEAN, require
26 +
27 +RAW_HINT = ("Place the original airbnb.csv in data/raw/ to rebuild "
28 + "airbnb_clean.parquet; data/processed/ already contains the "
29 + "committed version.")
30 +
31 +# Mapping for known duplicates / accent variants.
32 +CITY_MAP = {
33 + "Montréal": "Montreal",
34 + "Québec City": "Québec",
35 + "Quebec City": "Québec",
36 + "Quebec": "Québec",
37 + "Levis": "Lévis",
38 + "Saint Come": "Saint-Côme",
39 + "Sainte Adele": "Sainte-Adèle",
40 + "Sainte-Adele": "Sainte-Adèle",
41 + "Saint Sauveur": "Saint-Sauveur",
42 + "Saint-Sauveur-des-Monts": "Saint-Sauveur",
43 + "Ste-Adèle": "Sainte-Adèle",
44 + "Ste-Agathe-des-Monts": "Sainte-Agathe-des-Monts",
45 +}
46 +
47 +# Entire-home types: House, Cabin/Chalet, Condo — i.e. standalone units that
48 +# are typically rented in their entirety. "Rental unit" and "Apartment" are
49 +# often private rooms inside a larger building; "Other" is ambiguous.
50 +ENTIRE_HOME_TYPES = {"House", "Cabin/Chalet", "Condo"}
51 +
52 +
53 +def main() -> None:
54 + require(AIRBNB_RAW, RAW_HINT)
55 +
56 + # 1. Load
57 + df = pd.read_csv(AIRBNB_RAW)
58 + print(f"[load] Raw shape: {df.shape}")
59 +
60 + # 2. Standardize city names
61 + df["city"] = df["city"].str.strip()
62 + df["city"] = df["city"].replace(CITY_MAP)
63 +
64 + print(f"[city] Unique cities after standardisation: {df['city'].nunique()}")
65 + print(f"[city] Top 10 cities:\n{df['city'].value_counts().head(10).to_string()}\n")
66 +
67 + # 3. Clean price_numeric
68 + n_before = len(df)
69 + n_null_price = df["price_numeric"].isnull().sum()
70 + n_nonpos = (df["price_numeric"] <= 0).sum()
71 + print(f"[price] Null prices: {n_null_price}")
72 + print(f"[price] Non-positive prices: {n_nonpos}")
73 +
74 + # Drop rows with null or non-positive price
75 + df = df.dropna(subset=["price_numeric"])
76 + df = df[df["price_numeric"] > 0].copy()
77 + print(f"[price] Rows dropped: {n_before - len(df)} -> remaining: {len(df)}")
78 +
79 + # Winsorize at 1st and 99th percentile
80 + p01 = df["price_numeric"].quantile(0.01)
81 + p99 = df["price_numeric"].quantile(0.99)
82 + print(f"[price] Winsorize bounds: p1={p01:.2f}, p99={p99:.2f}")
83 + df["price_numeric"] = df["price_numeric"].clip(lower=p01, upper=p99)
84 +
85 + # Log transform
86 + df["log_price"] = np.log(df["price_numeric"])
87 + print(f"[price] price_numeric mean={df['price_numeric'].mean():.2f} "
88 + f"median={df['price_numeric'].median():.2f}")
89 +
90 + # 4. Clean rating and num_reviews
91 + # Rating: Fill NaN with the *median* of observed ratings.
92 + # Rationale: missing ratings typically mean "not yet rated", which may not
93 + # be 0. Using the median avoids pulling the distribution toward zero and
94 + # is a conservative imputation that preserves the central tendency.
95 + rating_median = df["rating"].median()
96 + n_rating_null = df["rating"].isnull().sum()
97 + df["rating"] = df["rating"].fillna(rating_median)
98 + print(f"[rating] Filled {n_rating_null} NaN with median={rating_median:.2f}")
99 +
100 + # num_reviews: Fill NaN with 0.
101 + # Rationale: missing review count almost certainly means zero reviews.
102 + n_reviews_null = df["num_reviews"].isnull().sum()
103 + df["num_reviews"] = df["num_reviews"].fillna(0)
104 + print(f"[num_reviews] Filled {n_reviews_null} NaN with 0")
105 +
106 + # 5. Create is_entire_home indicator
107 + df["is_entire_home"] = df["property_type"].isin(ENTIRE_HOME_TYPES)
108 + print(f"[is_entire_home] True: {df['is_entire_home'].sum()}, "
109 + f"False: {(~df['is_entire_home']).sum()}")
110 +
111 + # 6. Save
112 + df.to_parquet(AIRBNB_CLEAN, index=False)
113 + print(f"\n>>> Saved cleaned Airbnb data ({len(df)} rows) to {AIRBNB_CLEAN}")
114 + print(f" Columns: {list(df.columns)}")
115 +
116 +
117 +if __name__ == "__main__":
118 + main()
added scripts/03_clean_rent.py +274 −0
@@ -0,0 +1,274 @@
1 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +"""
3 +03_clean_rent.py
4 +----------------
5 +Clean and standardize the Realtor.ca rental dataset (data/raw/rent.json).
6 +
7 +Steps
8 + 1. Load rent.json.
9 + 2. Filter to Property.Type == "Single Family" and LeaseRent containing "Monthly".
10 + 3. Parse monthly rent (remove $, commas, split on /).
11 + 4. Extract city from the LAST pipe-segment of AddressText (before the comma).
12 + 5. Standardize city names to match Airbnb conventions (collapse Montreal
13 + boroughs, Québec arrondissements, Longueuil boroughs, etc.).
14 + 6. Parse lat/lon to float.
15 + 7. Parse bedrooms, bathrooms, SizeInterior to numeric.
16 + 8. Winsorize extreme rents at 1st/99th percentile.
17 + 9. Create log_rent = ln(monthly_rent).
18 + 10. Save to data/processed/rent_clean.parquet.
19 +"""
20 +
21 +import json
22 +import re
23 +import sys
24 +from pathlib import Path
25 +
26 +sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
27 +
28 +import numpy as np
29 +import pandas as pd
30 +
31 +from src.config import RENT_RAW, RENT_CLEAN, require
32 +
33 +RAW_HINT = ("Place the original rent.json in data/raw/ to rebuild "
34 + "rent_clean.parquet; data/processed/ already contains the "
35 + "committed version.")
36 +
37 +
38 +def parse_monthly_rent(raw: str) -> float:
39 + """Extract numeric monthly rent from strings like '$1,950/Monthly'."""
40 + if not isinstance(raw, str) or "Monthly" not in raw:
41 + return np.nan
42 + # Remove $ and commas, take part before first /
43 + raw = raw.replace("$", "").replace(",", "")
44 + parts = raw.split("/")
45 + try:
46 + return float(parts[0])
47 + except (ValueError, IndexError):
48 + return np.nan
49 +
50 +
51 +def extract_city_raw(address_text: str) -> str:
52 + """
53 + AddressText format:
54 + "Street|[#Unit|]City (Borough), Province PostalCode"
55 + The city info is in the LAST pipe-segment, before the comma.
56 + """
57 + if not isinstance(address_text, str):
58 + return ""
59 + parts = address_text.split("|")
60 + last_segment = parts[-1].strip()
61 + # Split at comma to drop province + postal code
62 + if "," in last_segment:
63 + city_part = last_segment.split(",")[0].strip()
64 + else:
65 + city_part = last_segment.strip()
66 + return city_part
67 +
68 +
69 +def extract_borough(city_raw: str) -> str:
70 + """Return the borough from 'CityName (Borough)' or empty string."""
71 + m = re.search(r"\((.+)\)", city_raw)
72 + return m.group(1).strip() if m else ""
73 +
74 +
75 +def standardize_city(city_raw: str) -> str:
76 + """
77 + Map Realtor address-level city names to the canonical city names
78 + used in the Airbnb dataset.
79 + """
80 + cr = city_raw.strip()
81 +
82 + # -- Montreal boroughs -> "Montreal"
83 + if cr.startswith("Montréal"):
84 + return "Montreal"
85 + if cr in ("Montréal", "Montreal"):
86 + return "Montreal"
87 +
88 + # -- Québec arrondissements -> "Québec"
89 + if cr.startswith("Québec"):
90 + return "Québec"
91 +
92 + # -- Longueuil boroughs -> "Longueuil"
93 + if cr.startswith("Longueuil"):
94 + return "Longueuil"
95 +
96 + # -- Laval arrondissements -> "Laval"
97 + if cr.startswith("Laval"):
98 + return "Laval"
99 +
100 + # -- Gatineau sectors -> "Gatineau"
101 + if cr.startswith("Gatineau"):
102 + return "Gatineau"
103 +
104 + # -- Saguenay boroughs -> "Saguenay"
105 + if cr.startswith("Saguenay"):
106 + return "Saguenay"
107 +
108 + # -- Sherbrooke boroughs -> "Sherbrooke"
109 + if cr.startswith("Sherbrooke"):
110 + return "Sherbrooke"
111 +
112 + # -- Lévis boroughs -> "Lévis"
113 + if cr.startswith("Lévis"):
114 + return "Lévis"
115 +
116 + # -- Terrebonne sectors -> "Terrebonne"
117 + if cr.startswith("Terrebonne"):
118 + return "Terrebonne"
119 +
120 + # -- Repentigny sectors -> "Repentigny"
121 + if cr.startswith("Repentigny"):
122 + return "Repentigny"
123 +
124 + # Explicit one-off mappings
125 + city_map = {
126 + "Trois-Rivières": "Trois-Rivières",
127 + "Mont-Royal": "Mont-Royal",
128 + "Westmount": "Westmount",
129 + "Côte-Saint-Luc": "Côte-Saint-Luc",
130 + "Montréal-Ouest": "Montréal-Ouest",
131 + "Dollard-des-Ormeaux": "Dollard-Des Ormeaux",
132 + }
133 + if cr in city_map:
134 + return city_map[cr]
135 +
136 + # Strip parenthetical borough for any remaining cities
137 + base = re.sub(r"\s*\(.+\)\s*", "", cr).strip()
138 + return base
139 +
140 +
141 +def parse_size(raw: str) -> float:
142 + """Parse SizeInterior strings like '850 sqft' to float square feet."""
143 + if not isinstance(raw, str) or raw.strip() == "":
144 + return np.nan
145 + cleaned = raw.lower().replace("sqft", "").replace("sq ft", "").replace(",", "").strip()
146 + try:
147 + return float(cleaned)
148 + except ValueError:
149 + return np.nan
150 +
151 +
152 +def main() -> None:
153 + require(RENT_RAW, RAW_HINT)
154 +
155 + # 1. Load
156 + with open(RENT_RAW, "r", encoding="utf-8") as f:
157 + rent_raw = json.load(f)
158 +
159 + print(f"[load] Total records in rent.json: {len(rent_raw)}")
160 +
161 + # 2. Filter: Single Family + Monthly
162 + filtered = []
163 + for rec in rent_raw:
164 + prop = rec.get("Property", {})
165 + if prop.get("Type") != "Single Family":
166 + continue
167 + lease_rent = prop.get("LeaseRent", "")
168 + if "Monthly" not in lease_rent:
169 + continue
170 + filtered.append(rec)
171 +
172 + print(f"[filter] After Single Family + Monthly: {len(filtered)}")
173 +
174 + # 3. Flatten into a DataFrame
175 + rows = []
176 + for rec in filtered:
177 + prop = rec.get("Property", {})
178 + addr = prop.get("Address", {})
179 + bld = rec.get("Building", {})
180 + rows.append({
181 + "address_text": addr.get("AddressText", ""),
182 + "lat_raw": addr.get("Latitude", ""),
183 + "lon_raw": addr.get("Longitude", ""),
184 + "lease_rent_raw": prop.get("LeaseRent", ""),
185 + "lease_rent_unformatted": prop.get("LeaseRentUnformattedValue", ""),
186 + "building_type": bld.get("Type", ""),
187 + "bedrooms_raw": bld.get("Bedrooms", ""),
188 + "bathrooms_raw": bld.get("BathroomTotal", ""),
189 + "size_interior_raw": bld.get("SizeInterior", ""),
190 + "postal_code": rec.get("PostalCode", ""),
191 + "province": rec.get("ProvinceName", ""),
192 + "mls_number": rec.get("MlsNumber", ""),
193 + })
194 +
195 + df = pd.DataFrame(rows)
196 + print(f"[flatten] Shape: {df.shape}")
197 +
198 + # 4. Parse monthly rent
199 + df["monthly_rent"] = df["lease_rent_raw"].apply(parse_monthly_rent)
200 +
201 + n_parsed = df["monthly_rent"].notna().sum()
202 + n_failed = df["monthly_rent"].isna().sum()
203 + print(f"[rent] Parsed: {n_parsed}, failed: {n_failed}")
204 +
205 + # Drop rows where rent could not be parsed or is non-positive
206 + df = df.dropna(subset=["monthly_rent"])
207 + df = df[df["monthly_rent"] > 0].copy()
208 + print(f"[rent] After dropping null/non-positive rents: {len(df)}")
209 +
210 + # 5. Extract city and borough from AddressText
211 + df["city_raw"] = df["address_text"].apply(extract_city_raw)
212 + df["borough"] = df["city_raw"].apply(extract_borough)
213 +
214 + # 6. Standardize city names to match Airbnb conventions
215 + df["city"] = df["city_raw"].apply(standardize_city)
216 +
217 + print(f"[city] Unique cities after standardisation: {df['city'].nunique()}")
218 + print(f"[city] Top 10:\n{df['city'].value_counts().head(10).to_string()}\n")
219 +
220 + # 7. Parse lat / lon to float
221 + df["lat"] = pd.to_numeric(df["lat_raw"], errors="coerce")
222 + df["lon"] = pd.to_numeric(df["lon_raw"], errors="coerce")
223 +
224 + n_latlon_null = df[["lat", "lon"]].isnull().any(axis=1).sum()
225 + print(f"[latlon] Null lat or lon: {n_latlon_null}")
226 +
227 + # 8. Parse bedrooms, bathrooms, SizeInterior
228 + df["bedrooms"] = pd.to_numeric(df["bedrooms_raw"], errors="coerce")
229 + df["bathrooms"] = pd.to_numeric(df["bathrooms_raw"], errors="coerce")
230 + df["size_interior_sqft"] = df["size_interior_raw"].apply(parse_size)
231 +
232 + print(f"[bedrooms] non-null: {df['bedrooms'].notna().sum()}, "
233 + f"null: {df['bedrooms'].isna().sum()}")
234 + print(f"[bathrooms] non-null: {df['bathrooms'].notna().sum()}, "
235 + f"null: {df['bathrooms'].isna().sum()}")
236 + print(f"[size] non-null: {df['size_interior_sqft'].notna().sum()}, "
237 + f"null: {df['size_interior_sqft'].isna().sum()}")
238 +
239 + # 9. Winsorize extreme rents at 1st / 99th percentile
240 + p01 = df["monthly_rent"].quantile(0.01)
241 + p99 = df["monthly_rent"].quantile(0.99)
242 + print(f"[winsorize] Rent bounds: p1={p01:.2f}, p99={p99:.2f}")
243 + df["monthly_rent"] = df["monthly_rent"].clip(lower=p01, upper=p99)
244 +
245 + # 10. Create log_rent
246 + df["log_rent"] = np.log(df["monthly_rent"])
247 +
248 + print(f"[rent] monthly_rent mean={df['monthly_rent'].mean():.2f} "
249 + f"median={df['monthly_rent'].median():.2f}")
250 +
251 + # 11. Select final columns and save
252 + keep_cols = [
253 + "mls_number",
254 + "city",
255 + "borough",
256 + "building_type",
257 + "monthly_rent",
258 + "log_rent",
259 + "bedrooms",
260 + "bathrooms",
261 + "size_interior_sqft",
262 + "lat",
263 + "lon",
264 + "postal_code",
265 + ]
266 + df_out = df[keep_cols].copy()
267 +
268 + df_out.to_parquet(RENT_CLEAN, index=False)
269 + print(f"\n>>> Saved cleaned rent data ({len(df_out)} rows) to {RENT_CLEAN}")
270 + print(f" Columns: {list(df_out.columns)}")
271 +
272 +
273 +if __name__ == "__main__":
274 + main()
added scripts/04_merge_data.py +332 −0
@@ -0,0 +1,332 @@
1 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +"""
3 +04_merge_data.py
4 +================
5 +Merge Airbnb and rental listing data using three linkage strategies:
6 + 1. Spatial buffer merge (Haversine distance at 250m, 500m, 1km, 2km)
7 + 2. City/borough-level aggregation
8 + 3. Combined analysis file
9 +
10 +Inputs:
11 + data/processed/airbnb_clean.parquet
12 + data/processed/rent_clean.parquet
13 +
14 +Outputs:
15 + data/processed/merged_spatial.parquet
16 + data/processed/merged_neighborhood.parquet
17 + data/processed/merged_analysis.parquet
18 +"""
19 +
20 +import sys
21 +from pathlib import Path
22 +
23 +sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
24 +
25 +import numpy as np
26 +import pandas as pd
27 +
28 +from src.config import (
29 + AIRBNB_CLEAN,
30 + RENT_CLEAN,
31 + MERGED_SPATIAL,
32 + MERGED_NEIGHBORHOOD,
33 + MERGED_ANALYSIS,
34 + BUFFER_KM,
35 + CHUNK_SIZE,
36 + require,
37 +)
38 +from src.geo import haversine_matrix
39 +
40 +PROCESSED_HINT = ("Run scripts/02_clean_airbnb.py and scripts/03_clean_rent.py "
41 + "first (requires the raw data), or restore the committed "
42 + "parquet files in data/processed/.")
43 +
44 +
45 +# ===================================================================
46 +# Strategy 1 — spatial buffer merge
47 +# ===================================================================
48 +
49 +def spatial_buffer_merge(rent: pd.DataFrame, airbnb: pd.DataFrame) -> pd.DataFrame:
50 + """
51 + For each rental listing, compute Airbnb exposure metrics within
52 + multiple distance buffers.
53 + """
54 + n_rent = len(rent)
55 + n_airbnb = len(airbnb)
56 +
57 + # Pre-extract numpy arrays for speed
58 + rent_lat = rent["lat"].values.astype(np.float64)
59 + rent_lon = rent["lon"].values.astype(np.float64)
60 + airbnb_lat = airbnb["lat"].values.astype(np.float64)
61 + airbnb_lon = airbnb["lon"].values.astype(np.float64)
62 +
63 + airbnb_price = airbnb["price_numeric"].values.astype(np.float64)
64 + airbnb_entire = airbnb["is_entire_home"].values.astype(np.float64)
65 + airbnb_rating = airbnb["rating"].values.astype(np.float64)
66 + airbnb_superhost = airbnb["is_superhost"].values.astype(np.float64)
67 +
68 + # Initialise result columns
69 + result_cols = {}
70 + for buf in BUFFER_KM:
71 + tag = f"{int(buf * 1000)}m"
72 + result_cols[f"airbnb_count_{tag}"] = np.zeros(n_rent, dtype=np.int32)
73 + result_cols[f"airbnb_density_{tag}"] = np.zeros(n_rent, dtype=np.float64)
74 + result_cols[f"mean_airbnb_price_{tag}"] = np.full(n_rent, np.nan, dtype=np.float64)
75 + result_cols[f"share_entire_home_{tag}"] = np.full(n_rent, np.nan, dtype=np.float64)
76 + result_cols[f"mean_rating_{tag}"] = np.full(n_rent, np.nan, dtype=np.float64)
77 + result_cols[f"superhost_share_{tag}"] = np.full(n_rent, np.nan, dtype=np.float64)
78 +
79 + n_chunks = int(np.ceil(n_rent / CHUNK_SIZE))
80 + print(f" Processing {n_rent} rental listings in {n_chunks} chunks "
81 + f"against {n_airbnb} Airbnb listings ...")
82 +
83 + for c in range(n_chunks):
84 + start = c * CHUNK_SIZE
85 + end = min(start + CHUNK_SIZE, n_rent)
86 + idx = slice(start, end)
87 +
88 + # Distance matrix: (chunk_size, n_airbnb)
89 + dist = haversine_matrix(rent_lat[idx], rent_lon[idx],
90 + airbnb_lat, airbnb_lon)
91 +
92 + for buf in BUFFER_KM:
93 + tag = f"{int(buf * 1000)}m"
94 + within = dist <= buf # boolean mask (chunk, n_airbnb)
95 +
96 + counts = within.sum(axis=1)
97 + result_cols[f"airbnb_count_{tag}"][idx] = counts
98 + area = np.pi * buf ** 2
99 + result_cols[f"airbnb_density_{tag}"][idx] = counts / area
100 +
101 + # For each rental in the chunk, compute means over nearby Airbnb
102 + for i_local in range(end - start):
103 + mask = within[i_local]
104 + if mask.sum() == 0:
105 + continue
106 + i_global = start + i_local
107 +
108 + prices = airbnb_price[mask]
109 + valid_prices = prices[~np.isnan(prices)]
110 + if len(valid_prices) > 0:
111 + result_cols[f"mean_airbnb_price_{tag}"][i_global] = valid_prices.mean()
112 +
113 + result_cols[f"share_entire_home_{tag}"][i_global] = airbnb_entire[mask].mean()
114 +
115 + ratings = airbnb_rating[mask]
116 + valid_ratings = ratings[~np.isnan(ratings)]
117 + if len(valid_ratings) > 0:
118 + result_cols[f"mean_rating_{tag}"][i_global] = valid_ratings.mean()
119 +
120 + result_cols[f"superhost_share_{tag}"][i_global] = airbnb_superhost[mask].mean()
121 +
122 + if (c + 1) % 20 == 0 or (c + 1) == n_chunks:
123 + print(f" Chunk {c + 1}/{n_chunks} done.")
124 +
125 + # Attach to rent DataFrame
126 + spatial = rent.copy()
127 + for col, arr in result_cols.items():
128 + spatial[col] = arr
129 +
130 + return spatial
131 +
132 +
133 +# ===================================================================
134 +# Strategy 2 — city / borough aggregation
135 +# ===================================================================
136 +
137 +def city_borough_aggregation(rent: pd.DataFrame, airbnb: pd.DataFrame) -> pd.DataFrame:
138 + """
139 + Aggregate Airbnb metrics at the city level (and borough level for
140 + Montreal) and merge onto rental data.
141 + """
142 + # --- City-level aggregation ---
143 + city_key = "city_clean" if "city_clean" in airbnb.columns else "city"
144 +
145 + city_agg = airbnb.groupby(city_key).agg(
146 + airbnb_count_city=("price_numeric", "size"),
147 + mean_airbnb_price_city=("price_numeric", "mean"),
148 + median_airbnb_price_city=("price_numeric", "median"),
149 + share_entire_home_city=("is_entire_home", "mean"),
150 + mean_reviews_city=("num_reviews", "mean"),
151 + mean_rating_city=("rating", "mean"),
152 + ).reset_index()
153 +
154 + # Rename the grouping column for merging
155 + city_agg = city_agg.rename(columns={city_key: "city"})
156 +
157 + # Count rentals per city to compute density
158 + rent_city_counts = rent.groupby("city").size().reset_index(name="_n_rent_city")
159 + city_agg = city_agg.merge(rent_city_counts, on="city", how="left")
160 + city_agg["airbnb_density_per_1000_rentals_city"] = np.where(
161 + city_agg["_n_rent_city"] > 0,
162 + city_agg["airbnb_count_city"] / city_agg["_n_rent_city"] * 1000,
163 + np.nan,
164 + )
165 + city_agg = city_agg.drop(columns=["_n_rent_city"])
166 +
167 + # Merge onto rent (the rent file's 'city' column aligns with the
168 + # standardized Airbnb city names)
169 + neighborhood = rent.copy()
170 + neighborhood = neighborhood.merge(city_agg, on="city", how="left")
171 +
172 + # --- Borough-level aggregation (Montreal only) ---
173 + if "borough" in rent.columns:
174 + # Identify Montreal Airbnb listings based on city name
175 + mtl_variants = ["montreal", "montréal", "mtl"]
176 +
177 + if city_key in airbnb.columns:
178 + airbnb_city_lower = airbnb[city_key].str.lower().str.strip()
179 + else:
180 + airbnb_city_lower = airbnb["city"].str.lower().str.strip()
181 +
182 + airbnb_mtl = airbnb[airbnb_city_lower.isin(mtl_variants)].copy()
183 +
184 + if len(airbnb_mtl) > 0 and "borough" not in airbnb_mtl.columns:
185 + # Airbnb data might not have borough info; skip borough merge
186 + print(" Note: Airbnb data does not have 'borough' column; "
187 + "borough-level aggregation skipped.")
188 + elif len(airbnb_mtl) > 0 and "borough" in airbnb_mtl.columns:
189 + borough_agg = airbnb_mtl.groupby("borough").agg(
190 + airbnb_count_borough=("price_numeric", "size"),
191 + mean_airbnb_price_borough=("price_numeric", "mean"),
192 + median_airbnb_price_borough=("price_numeric", "median"),
193 + share_entire_home_borough=("is_entire_home", "mean"),
194 + mean_reviews_borough=("num_reviews", "mean"),
195 + mean_rating_borough=("rating", "mean"),
196 + ).reset_index()
197 +
198 + rent_borough_counts = (
199 + rent[rent["borough"].notna()]
200 + .groupby("borough")
201 + .size()
202 + .reset_index(name="_n_rent_borough")
203 + )
204 + borough_agg = borough_agg.merge(rent_borough_counts, on="borough", how="left")
205 + borough_agg["airbnb_density_per_1000_rentals_borough"] = np.where(
206 + borough_agg["_n_rent_borough"] > 0,
207 + borough_agg["airbnb_count_borough"] / borough_agg["_n_rent_borough"] * 1000,
208 + np.nan,
209 + )
210 + borough_agg = borough_agg.drop(columns=["_n_rent_borough"])
211 +
212 + neighborhood = neighborhood.merge(borough_agg, on="borough", how="left")
213 +
214 + return neighborhood
215 +
216 +
217 +# ===================================================================
218 +# Main
219 +# ===================================================================
220 +
221 +def main():
222 + print("=" * 70)
223 + print("04 MERGE DATA")
224 + print("=" * 70)
225 +
226 + # ------------------------------------------------------------------
227 + # Load data
228 + # ------------------------------------------------------------------
229 + print("\n[1] Loading cleaned data ...")
230 + airbnb = pd.read_parquet(require(AIRBNB_CLEAN, PROCESSED_HINT))
231 + rent = pd.read_parquet(require(RENT_CLEAN, PROCESSED_HINT))
232 +
233 + # Ensure coordinate column names are consistent
234 + if "long" in airbnb.columns and "lon" not in airbnb.columns:
235 + airbnb = airbnb.rename(columns={"long": "lon"})
236 +
237 + print(f" Airbnb : {airbnb.shape[0]:,} rows x {airbnb.shape[1]} cols")
238 + print(f" Rent : {rent.shape[0]:,} rows x {rent.shape[1]} cols")
239 +
240 + # Drop rows with missing coordinates
241 + airbnb_valid = airbnb.dropna(subset=["lat", "lon"])
242 + rent_valid = rent.dropna(subset=["lat", "lon"])
243 + print(f" Airbnb with valid coords: {len(airbnb_valid):,}")
244 + print(f" Rent with valid coords: {len(rent_valid):,}")
245 +
246 + # ------------------------------------------------------------------
247 + # Strategy 1: Spatial buffer merge
248 + # ------------------------------------------------------------------
249 + print("\n[2] Strategy 1 — Spatial buffer merge ...")
250 + spatial = spatial_buffer_merge(rent_valid, airbnb_valid)
251 +
252 + for buf in BUFFER_KM:
253 + tag = f"{int(buf * 1000)}m"
254 + col = f"airbnb_count_{tag}"
255 + print(f" Buffer {tag}: "
256 + f"mean count = {spatial[col].mean():.2f}, "
257 + f"median = {spatial[col].median():.0f}, "
258 + f"max = {spatial[col].max()}")
259 +
260 + spatial.to_parquet(MERGED_SPATIAL, index=False)
261 + print(f" Saved: {MERGED_SPATIAL}")
262 +
263 + # ------------------------------------------------------------------
264 + # Strategy 2: City/borough aggregation
265 + # ------------------------------------------------------------------
266 + print("\n[3] Strategy 2 — City/borough-level aggregation ...")
267 + neighborhood = city_borough_aggregation(rent_valid, airbnb_valid)
268 +
269 + city_cols = [c for c in neighborhood.columns if c.endswith("_city")]
270 + if city_cols:
271 + print(f" City-level columns added: {city_cols}")
272 + print(f" Rentals with city match: "
273 + f"{neighborhood['airbnb_count_city'].notna().sum():,} / {len(neighborhood):,}")
274 +
275 + borough_cols = [c for c in neighborhood.columns if c.endswith("_borough")]
276 + if borough_cols:
277 + print(f" Borough-level columns added: {borough_cols}")
278 + print(f" Rentals with borough match: "
279 + f"{neighborhood['airbnb_count_borough'].notna().sum():,} / {len(neighborhood):,}")
280 +
281 + neighborhood.to_parquet(MERGED_NEIGHBORHOOD, index=False)
282 + print(f" Saved: {MERGED_NEIGHBORHOOD}")
283 +
284 + # ------------------------------------------------------------------
285 + # Combined analysis file
286 + # ------------------------------------------------------------------
287 + print("\n[4] Creating combined analysis file ...")
288 +
289 + # Start from spatial (which already has rent + buffer vars) and add
290 + # neighborhood-level columns that are not already present. Both frames
291 + # are row-aligned because they started from the same rent_valid.
292 + spatial_cols = set(spatial.columns)
293 + extra_cols = [c for c in neighborhood.columns if c not in spatial_cols]
294 +
295 + analysis = spatial.copy()
296 + for col in extra_cols:
297 + analysis[col] = neighborhood[col].values
298 +
299 + analysis.to_parquet(MERGED_ANALYSIS, index=False)
300 + print(f" Saved: {MERGED_ANALYSIS}")
301 + print(f" Final shape: {analysis.shape[0]:,} rows x {analysis.shape[1]} cols")
302 +
303 + # ------------------------------------------------------------------
304 + # Summary diagnostics
305 + # ------------------------------------------------------------------
306 + print("\n" + "=" * 70)
307 + print("MERGE DIAGNOSTICS")
308 + print("=" * 70)
309 + print(f" Total rental listings: {len(rent):>8,}")
310 + print(f" With valid coordinates: {len(rent_valid):>8,}")
311 + print(f" Total Airbnb listings: {len(airbnb):>8,}")
312 + print(f" With valid coordinates: {len(airbnb_valid):>8,}")
313 + print()
314 +
315 + for buf in BUFFER_KM:
316 + tag = f"{int(buf * 1000)}m"
317 + col = f"airbnb_count_{tag}"
318 + n_zero = (analysis[col] == 0).sum()
319 + n_nonzero = (analysis[col] > 0).sum()
320 + print(f" Buffer {tag:>5s}: {n_nonzero:,} rentals have >= 1 Airbnb nearby, "
321 + f"{n_zero:,} have 0")
322 +
323 + print()
324 + print(" Columns in merged_analysis.parquet:")
325 + for i, col in enumerate(analysis.columns):
326 + print(f" {i + 1:3d}. {col}")
327 +
328 + print("\nDone.\n")
329 +
330 +
331 +if __name__ == "__main__":
332 + main()
added scripts/05_descriptive_analysis.py +370 −0
@@ -0,0 +1,370 @@
1 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +"""
3 +05_descriptive_analysis.py
4 +==========================
5 +Generate descriptive statistics tables and figures for the Airbnb-rent
6 +linkage analysis.
7 +
8 +Tables -> results/tables/ (.tex + .csv)
9 +Figures -> figures/ (.pdf)
10 +
11 +Inputs:
12 + data/processed/merged_analysis.parquet (preferred)
13 + data/processed/merged_spatial.parquet (fallback)
14 + data/processed/airbnb_clean.parquet
15 + data/processed/rent_clean.parquet
16 +"""
17 +
18 +import sys
19 +from pathlib import Path
20 +
21 +sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
22 +
23 +from src.plotting import use_descriptive_style
24 +
25 +use_descriptive_style()
26 +
27 +import matplotlib.pyplot as plt # noqa: E402
28 +import matplotlib.ticker as mticker # noqa: E402
29 +import pandas as pd # noqa: E402
30 +
31 +from src.config import ( # noqa: E402
32 + AIRBNB_CLEAN,
33 + RENT_CLEAN,
34 + MERGED_ANALYSIS,
35 + MERGED_SPATIAL,
36 + FIG_DIR,
37 + TABLE_DIR,
38 + require,
39 +)
40 +
41 +PROCESSED_HINT = "Run scripts/04_merge_data.py first."
42 +
43 +
44 +# ===================================================================
45 +# Helper: summary statistics
46 +# ===================================================================
47 +
48 +def summary_stats(df: pd.DataFrame, cols: list[str], labels: dict | None = None) -> pd.DataFrame:
49 + """
50 + Compute N, mean, sd, min, p25, median, p75, max for *cols*.
51 + Columns absent from *df* are silently skipped (matches the published
52 + tables, which omit variables not present in the processed data).
53 + """
54 + rows = []
55 + for c in cols:
56 + if c not in df.columns:
57 + continue
58 + s = df[c].dropna()
59 + label = (labels or {}).get(c, c)
60 + rows.append({
61 + "Variable": label,
62 + "N": int(len(s)),
63 + "Mean": s.mean(),
64 + "SD": s.std(),
65 + "Min": s.min(),
66 + "P25": s.quantile(0.25),
67 + "Median": s.median(),
68 + "P75": s.quantile(0.75),
69 + "Max": s.max(),
70 + })
71 + return pd.DataFrame(rows)
72 +
73 +
74 +def save_table(df: pd.DataFrame, name: str):
75 + """Save as .csv and .tex (a tabular fragment, wrapped by the paper)."""
76 + csv_path = TABLE_DIR / f"{name}.csv"
77 + tex_path = TABLE_DIR / f"{name}.tex"
78 + df.to_csv(csv_path, index=False)
79 + # LaTeX: format floats to 2 decimals except integers
80 + float_fmt = "%.2f"
81 + df.to_latex(tex_path, index=False, float_format=float_fmt)
82 + print(f" Saved {csv_path.name} + {tex_path.name}")
83 +
84 +
85 +# ===================================================================
86 +# Tables
87 +# ===================================================================
88 +
89 +def make_tables(airbnb: pd.DataFrame, rent: pd.DataFrame, merged: pd.DataFrame):
90 + print("\n--- Tables ---")
91 +
92 + # 1. Summary statistics: Airbnb
93 + airbnb_vars = [
94 + "price_numeric", "rating", "bedrooms", "bathrooms",
95 + "guests_count", "amenities_count", "num_images", "num_reviews",
96 + "quality_score",
97 + ]
98 + airbnb_labels = {
99 + "price_numeric": "Nightly price (CAD)",
100 + "rating": "Rating",
101 + "bedrooms": "Bedrooms",
102 + "bathrooms": "Bathrooms",
103 + "guests_count": "Max guests",
104 + "amenities_count": "Amenities count",
105 + "num_images": "Number of images",
106 + "num_reviews": "Number of reviews",
107 + "quality_score": "Quality score",
108 + }
109 + tbl_airbnb = summary_stats(airbnb, airbnb_vars, airbnb_labels)
110 + save_table(tbl_airbnb, "summary_stats_airbnb")
111 +
112 + # 2. Summary statistics: Rent
113 + rent_vars = [
114 + "monthly_rent", "bedrooms", "bathrooms", "size_sqft",
115 + ]
116 + rent_labels = {
117 + "monthly_rent": "Monthly rent (CAD)",
118 + "bedrooms": "Bedrooms",
119 + "bathrooms": "Bathrooms",
120 + "size_sqft": "Size (sq ft)",
121 + }
122 + tbl_rent = summary_stats(rent, rent_vars, rent_labels)
123 + save_table(tbl_rent, "summary_stats_rent")
124 +
125 + # 3. Correlation matrix
126 + corr_vars = []
127 + # Rent outcome
128 + for v in ["log_rent", "monthly_rent"]:
129 + if v in merged.columns:
130 + corr_vars.append(v)
131 + break # prefer log_rent
132 +
133 + # Airbnb exposure measures (500m buffer)
134 + for v in ["airbnb_count_500m", "airbnb_density_500m",
135 + "mean_airbnb_price_500m", "share_entire_home_500m"]:
136 + if v in merged.columns:
137 + corr_vars.append(v)
138 +
139 + # Property characteristics
140 + for v in ["bedrooms", "bathrooms", "size_sqft"]:
141 + if v in merged.columns:
142 + corr_vars.append(v)
143 +
144 + if len(corr_vars) >= 3:
145 + corr_df = merged[corr_vars].dropna()
146 + corr_mat = corr_df.corr()
147 + save_table(corr_mat.round(3).reset_index().rename(columns={"index": ""}),
148 + "correlation_matrix")
149 + else:
150 + print(" Skipping correlation matrix — not enough overlapping columns.")
151 +
152 +
153 +# ===================================================================
154 +# Figures
155 +# ===================================================================
156 +
157 +def fig_dist_airbnb_price(airbnb: pd.DataFrame):
158 + """Histogram of Airbnb nightly prices."""
159 + prices = airbnb["price_numeric"].dropna()
160 + # Trim extreme outliers (above 99th percentile) for readability
161 + p99 = prices.quantile(0.99)
162 + prices_trim = prices[prices <= p99]
163 +
164 + fig, ax = plt.subplots(figsize=(6, 4))
165 + ax.hist(prices_trim, bins=60, color="#4C72B0", edgecolor="white", linewidth=0.5)
166 + ax.set_xlabel("Nightly price (CAD)")
167 + ax.set_ylabel("Count")
168 + ax.set_title("Distribution of Airbnb Nightly Prices")
169 + ax.xaxis.set_major_formatter(mticker.StrMethodFormatter("${x:,.0f}"))
170 + fig.tight_layout()
171 + fig.savefig(FIG_DIR / "dist_airbnb_price.pdf")
172 + plt.close(fig)
173 + print(" dist_airbnb_price.pdf")
174 +
175 +
176 +def fig_dist_rent(rent: pd.DataFrame):
177 + """Histogram of monthly rents."""
178 + rents = rent["monthly_rent"].dropna()
179 + p99 = rents.quantile(0.99)
180 + rents_trim = rents[rents <= p99]
181 +
182 + fig, ax = plt.subplots(figsize=(6, 4))
183 + ax.hist(rents_trim, bins=60, color="#DD8452", edgecolor="white", linewidth=0.5)
184 + ax.set_xlabel("Monthly rent (CAD)")
185 + ax.set_ylabel("Count")
186 + ax.set_title("Distribution of Monthly Rents")
187 + ax.xaxis.set_major_formatter(mticker.StrMethodFormatter("${x:,.0f}"))
188 + fig.tight_layout()
189 + fig.savefig(FIG_DIR / "dist_rent.pdf")
190 + plt.close(fig)
191 + print(" dist_rent.pdf")
192 +
193 +
194 +def fig_airbnb_by_city(airbnb: pd.DataFrame):
195 + """Bar chart of Airbnb listing count by top 15 cities."""
196 + city_col = "city_clean" if "city_clean" in airbnb.columns else "city"
197 + counts = airbnb[city_col].value_counts().head(15)
198 +
199 + fig, ax = plt.subplots(figsize=(8, 5))
200 + counts.sort_values().plot.barh(ax=ax, color="#4C72B0", edgecolor="white")
201 + ax.set_xlabel("Number of listings")
202 + ax.set_ylabel("")
203 + ax.set_title("Airbnb Listings by City (Top 15)")
204 + fig.tight_layout()
205 + fig.savefig(FIG_DIR / "airbnb_by_city.pdf")
206 + plt.close(fig)
207 + print(" airbnb_by_city.pdf")
208 +
209 +
210 +def fig_rent_by_city(rent: pd.DataFrame):
211 + """Bar chart of mean rent by top 15 cities."""
212 + city_counts = rent["city"].value_counts()
213 + top_cities = city_counts.head(15).index
214 + sub = rent[rent["city"].isin(top_cities)]
215 + means = sub.groupby("city")["monthly_rent"].mean().sort_values()
216 +
217 + fig, ax = plt.subplots(figsize=(8, 5))
218 + means.plot.barh(ax=ax, color="#DD8452", edgecolor="white")
219 + ax.set_xlabel("Mean monthly rent (CAD)")
220 + ax.set_ylabel("")
221 + ax.set_title("Mean Monthly Rent by City (Top 15)")
222 + ax.xaxis.set_major_formatter(mticker.StrMethodFormatter("${x:,.0f}"))
223 + fig.tight_layout()
224 + fig.savefig(FIG_DIR / "rent_by_city.pdf")
225 + plt.close(fig)
226 + print(" rent_by_city.pdf")
227 +
228 +
229 +def fig_scatter_airbnb_rent(merged: pd.DataFrame):
230 + """Scatter plot of mean Airbnb count (500m) vs log rent
231 + at a neighbourhood level (postal code or small-area average)."""
232 + if "airbnb_count_500m" not in merged.columns:
233 + print(" Skipping scatter_airbnb_rent.pdf — no airbnb_count_500m column.")
234 + return
235 +
236 + rent_col = "log_rent" if "log_rent" in merged.columns else "monthly_rent"
237 +
238 + # Aggregate at postal-code level if available, else use city
239 + group_col = None
240 + for candidate in ["postal_code", "city"]:
241 + if candidate in merged.columns:
242 + group_col = candidate
243 + break
244 +
245 + if group_col is None:
246 + print(" Skipping scatter_airbnb_rent.pdf — no grouping column found.")
247 + return
248 +
249 + agg = merged.groupby(group_col).agg(
250 + mean_airbnb_count=("airbnb_count_500m", "mean"),
251 + mean_rent=(rent_col, "mean"),
252 + n=("airbnb_count_500m", "size"),
253 + ).reset_index()
254 +
255 + # Keep neighbourhoods with at least 5 observations
256 + agg = agg[agg["n"] >= 5]
257 +
258 + fig, ax = plt.subplots(figsize=(6, 5))
259 + ax.scatter(agg["mean_airbnb_count"], agg["mean_rent"],
260 + alpha=0.6, s=20, color="#4C72B0", edgecolors="none")
261 + ax.set_xlabel("Mean Airbnb count within 500 m")
262 + y_label = "Log monthly rent" if rent_col == "log_rent" else "Mean monthly rent (CAD)"
263 + ax.set_ylabel(y_label)
264 + ax.set_title("Airbnb Density vs. Rent")
265 + fig.tight_layout()
266 + fig.savefig(FIG_DIR / "scatter_airbnb_rent.pdf")
267 + plt.close(fig)
268 + print(" scatter_airbnb_rent.pdf")
269 +
270 +
271 +def fig_map_airbnb(airbnb: pd.DataFrame):
272 + """Scatter map of Airbnb listings coloured by price."""
273 + lon_col = "lon" if "lon" in airbnb.columns else "long"
274 + valid = airbnb.dropna(subset=["lat", lon_col, "price_numeric"])
275 +
276 + # Cap at 99th percentile for colour scale
277 + p99 = valid["price_numeric"].quantile(0.99)
278 +
279 + fig, ax = plt.subplots(figsize=(8, 7))
280 + sc = ax.scatter(
281 + valid[lon_col], valid["lat"],
282 + c=valid["price_numeric"].clip(upper=p99),
283 + cmap="YlOrRd", s=4, alpha=0.6, edgecolors="none",
284 + )
285 + cbar = fig.colorbar(sc, ax=ax, shrink=0.7)
286 + cbar.set_label("Nightly price (CAD)")
287 + ax.set_xlabel("Longitude")
288 + ax.set_ylabel("Latitude")
289 + ax.set_title("Airbnb Listings — Quebec")
290 + fig.tight_layout()
291 + fig.savefig(FIG_DIR / "map_airbnb.pdf")
292 + plt.close(fig)
293 + print(" map_airbnb.pdf")
294 +
295 +
296 +def fig_map_rent(rent: pd.DataFrame):
297 + """Scatter map of rental listings coloured by rent."""
298 + valid = rent.dropna(subset=["lat", "lon", "monthly_rent"])
299 + p99 = valid["monthly_rent"].quantile(0.99)
300 +
301 + fig, ax = plt.subplots(figsize=(8, 7))
302 + sc = ax.scatter(
303 + valid["lon"], valid["lat"],
304 + c=valid["monthly_rent"].clip(upper=p99),
305 + cmap="YlGnBu", s=4, alpha=0.6, edgecolors="none",
306 + )
307 + cbar = fig.colorbar(sc, ax=ax, shrink=0.7)
308 + cbar.set_label("Monthly rent (CAD)")
309 + ax.set_xlabel("Longitude")
310 + ax.set_ylabel("Latitude")
311 + ax.set_title("Rental Listings — Quebec")
312 + fig.tight_layout()
313 + fig.savefig(FIG_DIR / "map_rent.pdf")
314 + plt.close(fig)
315 + print(" map_rent.pdf")
316 +
317 +
318 +# ===================================================================
319 +# Main
320 +# ===================================================================
321 +
322 +def main():
323 + print("=" * 70)
324 + print("05 DESCRIPTIVE ANALYSIS")
325 + print("=" * 70)
326 +
327 + # ------------------------------------------------------------------
328 + # Load data
329 + # ------------------------------------------------------------------
330 + print("\n[1] Loading data ...")
331 +
332 + airbnb = pd.read_parquet(require(AIRBNB_CLEAN, PROCESSED_HINT))
333 + if "long" in airbnb.columns and "lon" not in airbnb.columns:
334 + airbnb = airbnb.rename(columns={"long": "lon"})
335 +
336 + rent = pd.read_parquet(require(RENT_CLEAN, PROCESSED_HINT))
337 +
338 + merged_path = MERGED_ANALYSIS
339 + if not merged_path.exists():
340 + merged_path = MERGED_SPATIAL
341 + merged = pd.read_parquet(require(merged_path, PROCESSED_HINT))
342 + if "long" in merged.columns and "lon" not in merged.columns:
343 + merged = merged.rename(columns={"long": "lon"})
344 +
345 + print(f" Airbnb : {airbnb.shape}")
346 + print(f" Rent : {rent.shape}")
347 + print(f" Merged : {merged.shape} ({merged_path.name})")
348 +
349 + # ------------------------------------------------------------------
350 + # Tables
351 + # ------------------------------------------------------------------
352 + make_tables(airbnb, rent, merged)
353 +
354 + # ------------------------------------------------------------------
355 + # Figures
356 + # ------------------------------------------------------------------
357 + print("\n--- Figures ---")
358 + fig_dist_airbnb_price(airbnb)
359 + fig_dist_rent(rent)
360 + fig_airbnb_by_city(airbnb)
361 + fig_rent_by_city(rent)
362 + fig_scatter_airbnb_rent(merged)
363 + fig_map_airbnb(airbnb)
364 + fig_map_rent(rent)
365 +
366 + print("\nDone.\n")
367 +
368 +
369 +if __name__ == "__main__":
370 + main()
added scripts/06_hedonic_models.py +307 −0
@@ -0,0 +1,307 @@
1 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +"""
3 +06_hedonic_models.py
4 +--------------------
5 +Hedonic regression models for the Airbnb-rent analysis.
6 +
7 +Model 1 (1a-1e): Baseline hedonic rent model (OLS, HC1)
8 +Model 2 (2a-2c): Hedonic Airbnb pricing model
9 +Model 3: City-level interaction (forward + reverse)
10 +
11 +Outputs:
12 + results/tables/hedonic_rent_baseline.tex
13 + results/tables/hedonic_airbnb_pricing.tex
14 + results/tables/city_level_interaction.tex
15 +"""
16 +
17 +import sys
18 +from pathlib import Path
19 +
20 +sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
21 +
22 +import numpy as np
23 +import pandas as pd
24 +import statsmodels.api as sm
25 +
26 +from src.config import AIRBNB_CLEAN, MERGED_ANALYSIS, TABLE_DIR, require
27 +from src.latex_tables import significance_star, results_to_latex
28 +
29 +PROCESSED_HINT = "Run scripts/04_merge_data.py first."
30 +
31 +
32 +def run_ols(df: pd.DataFrame, y_col: str, x_cols: list[str], label: str):
33 + """Run OLS with HC1 robust SE, dropping NaN rows for relevant cols."""
34 + cols = [y_col] + x_cols
35 + sub = df[cols].dropna()
36 + Y = sub[y_col]
37 + X = sm.add_constant(sub[x_cols])
38 + model = sm.OLS(Y, X).fit(cov_type="HC1")
39 + print(f"\n [{label}] N={int(model.nobs):,} R2={model.rsquared:.4f} "
40 + f"Adj-R2={model.rsquared_adj:.4f}")
41 + return model
42 +
43 +
44 +# ═════════════════════════════════════════════════════════════════════════════
45 +# MODEL 1: Baseline Hedonic Rent Model (1a – 1e)
46 +# ═════════════════════════════════════════════════════════════════════════════
47 +
48 +def model1_hedonic_rent(rent: pd.DataFrame) -> None:
49 + print("\n" + "-" * 72)
50 + print("MODEL 1: Baseline Hedonic Rent Model")
51 + print("-" * 72)
52 +
53 + # --- prepare variables -------------------------------------------------
54 + rent_m = rent.copy()
55 +
56 + # Building-type dummies
57 + if "building_type" in rent_m.columns:
58 + bt_dummies = pd.get_dummies(rent_m["building_type"], prefix="bt",
59 + drop_first=True, dtype=float)
60 + rent_m = pd.concat([rent_m, bt_dummies], axis=1)
61 + bt_cols = list(bt_dummies.columns)
62 + else:
63 + bt_cols = []
64 +
65 + # City dummies (fixed effects)
66 + if "city" in rent_m.columns:
67 + city_dummies = pd.get_dummies(rent_m["city"], prefix="city",
68 + drop_first=True, dtype=float)
69 + rent_m = pd.concat([rent_m, city_dummies], axis=1)
70 + city_fe_cols = list(city_dummies.columns)
71 + else:
72 + city_fe_cols = []
73 +
74 + controls = ["bedrooms", "bathrooms"] + bt_cols
75 +
76 + # 1a: No controls, no FE
77 + res_1a = run_ols(rent_m, "log_rent", ["airbnb_count_500m"], "1a")
78 +
79 + # 1b: With controls, no FE
80 + res_1b = run_ols(rent_m, "log_rent", ["airbnb_count_500m"] + controls, "1b")
81 +
82 + # 1c: With controls + city FE
83 + res_1c = run_ols(
84 + rent_m, "log_rent", ["airbnb_count_500m"] + controls + city_fe_cols, "1c"
85 + )
86 +
87 + # 1d: airbnb_density_500m instead
88 + res_1d = run_ols(
89 + rent_m, "log_rent", ["airbnb_density_500m"] + controls + city_fe_cols, "1d"
90 + )
91 +
92 + # 1e: share_entire_home_500m instead
93 + res_1e = run_ols(
94 + rent_m, "log_rent", ["share_entire_home_500m"] + controls + city_fe_cols, "1e"
95 + )
96 +
97 + # -- Key display variables (not all city/building dummies) --------------
98 + display_rent = (
99 + ["const", "airbnb_count_500m", "airbnb_density_500m",
100 + "share_entire_home_500m", "bedrooms", "bathrooms"]
101 + + bt_cols
102 + )
103 + all_res = [res_1a, res_1b, res_1c, res_1d, res_1e]
104 +
105 + results_to_latex(
106 + all_res,
107 + ["(1a)", "(1b)", "(1c)", "(1d)", "(1e)"],
108 + dep_var="log\\_rent",
109 + display_vars=[v for v in display_rent
110 + if any(v in r.params.index for r in all_res)],
111 + out_path=TABLE_DIR / "hedonic_rent_baseline.tex",
112 + note="Models (1c)-(1e) include city fixed effects (not shown).",
113 + )
114 +
115 +
116 +# ═════════════════════════════════════════════════════════════════════════════
117 +# MODEL 2: Hedonic Airbnb Pricing Model (2a – 2c)
118 +# ═════════════════════════════════════════════════════════════════════════════
119 +
120 +def model2_airbnb_pricing(rent: pd.DataFrame, airbnb: pd.DataFrame) -> None:
121 + print("\n" + "-" * 72)
122 + print("MODEL 2: Hedonic Airbnb Pricing Model")
123 + print("-" * 72)
124 +
125 + # Compute mean rent per city from rent data
126 + city_col_rent = "city"
127 + # Determine city column name in airbnb data
128 + city_col_ab = "city_clean" if "city_clean" in airbnb.columns else "city"
129 +
130 + mean_rent_city = (
131 + rent.groupby(city_col_rent)["log_rent"]
132 + .mean()
133 + .rename("mean_rent_city")
134 + .reset_index()
135 + .rename(columns={city_col_rent: city_col_ab})
136 + )
137 + print(f"\n Mean rent computed for {len(mean_rent_city)} cities")
138 +
139 + ab = airbnb.merge(mean_rent_city, on=city_col_ab, how="inner")
140 + print(f" Airbnb rows after merge: {len(ab):,}")
141 +
142 + # Ensure log_price exists
143 + if "log_price" not in ab.columns and "price_numeric" in ab.columns:
144 + ab["log_price"] = np.log(ab["price_numeric"].clip(lower=1))
145 +
146 + # City FE for Airbnb
147 + ab_city_dum = pd.get_dummies(ab[city_col_ab], prefix="acity",
148 + drop_first=True, dtype=float)
149 + ab = pd.concat([ab, ab_city_dum], axis=1)
150 + ab_city_fe = list(ab_city_dum.columns)
151 +
152 + ab_controls = [
153 + c for c in ["bedrooms", "bathrooms", "guests_count",
154 + "amenities_count", "is_superhost", "is_entire_home"]
155 + if c in ab.columns
156 + ]
157 +
158 + # Convert boolean controls to float
159 + for c in ab_controls:
160 + if ab[c].dtype == bool:
161 + ab[c] = ab[c].astype(float)
162 +
163 + # 2a: No controls
164 + res_2a = run_ols(ab, "log_price", ["mean_rent_city"], "2a")
165 +
166 + # 2b: With controls
167 + res_2b = run_ols(ab, "log_price", ["mean_rent_city"] + ab_controls, "2b")
168 +
169 + # 2c: With controls + city FE
170 + res_2c = run_ols(ab, "log_price",
171 + ["mean_rent_city"] + ab_controls + ab_city_fe, "2c")
172 +
173 + display_ab = ["const", "mean_rent_city"] + ab_controls
174 + all_res = [res_2a, res_2b, res_2c]
175 +
176 + results_to_latex(
177 + all_res,
178 + ["(2a)", "(2b)", "(2c)"],
179 + dep_var="log\\_price",
180 + display_vars=[v for v in display_ab
181 + if any(v in r.params.index for r in all_res)],
182 + out_path=TABLE_DIR / "hedonic_airbnb_pricing.tex",
183 + note="Model (2c) includes city fixed effects (not shown).",
184 + )
185 +
186 +
187 +# ═════════════════════════════════════════════════════════════════════════════
188 +# MODEL 3: City-level Interaction Model
189 +# ═════════════════════════════════════════════════════════════════════════════
190 +
191 +def model3_city_interaction(rent: pd.DataFrame) -> None:
192 + print("\n" + "-" * 72)
193 + print("MODEL 3: City-level Interaction")
194 + print("-" * 72)
195 +
196 + # Aggregate rent data to city level
197 + city_agg = (
198 + rent.groupby("city")
199 + .agg(
200 + mean_log_rent=("log_rent", "mean"),
201 + mean_bedrooms=("bedrooms", "mean"),
202 + mean_bathrooms=("bathrooms", "mean"),
203 + n_rent_listings=("log_rent", "count"),
204 + )
205 + .reset_index()
206 + )
207 +
208 + # Get airbnb_count_city from rent data (should be constant within a city)
209 + airbnb_city_vars = [c for c in rent.columns
210 + if c.startswith("airbnb_") and c.endswith("_city")]
211 + if airbnb_city_vars:
212 + city_airbnb = rent.groupby("city")[airbnb_city_vars].first().reset_index()
213 + city_agg = city_agg.merge(city_airbnb, on="city", how="left")
214 +
215 + print(f" City-level rows: {len(city_agg)}")
216 + print(f" Columns: {list(city_agg.columns)}")
217 +
218 + # Determine which airbnb count variable is available
219 + ab_count_col = "airbnb_count_city" if "airbnb_count_city" in city_agg.columns else None
220 + city_controls = ["mean_bedrooms", "mean_bathrooms"]
221 +
222 + if ab_count_col is None:
223 + print(" WARNING: airbnb_count_city not found — skipping Model 3.")
224 + return
225 +
226 + # 3-forward: mean_log_rent ~ airbnb_count_city + controls
227 + res_3fwd = run_ols(
228 + city_agg, "mean_log_rent", [ab_count_col] + city_controls, "3-fwd"
229 + )
230 + # 3-reverse: airbnb_count_city ~ mean_log_rent + controls
231 + res_3rev = run_ols(
232 + city_agg, ab_count_col, ["mean_log_rent"] + city_controls, "3-rev"
233 + )
234 +
235 + # LaTeX table (fragment: the paper supplies the table environment)
236 + lines: list[str] = []
237 + lines.append(r"\begin{tabular}{lcc}")
238 + lines.append(r"\toprule")
239 + lines.append(r" & \textbf{(3-fwd)} & \textbf{(3-rev)} \\")
240 + lines.append(
241 + r"Dep.\ var: & \textit{mean\_log\_rent} & \textit{airbnb\_count\_city} \\"
242 + )
243 + lines.append(r"\midrule")
244 +
245 + # Show all variables for both models
246 + all_vars_3 = list(dict.fromkeys(
247 + list(res_3fwd.params.index) + list(res_3rev.params.index)
248 + ))
249 + for var in all_vars_3:
250 + cells_coef = []
251 + cells_se = []
252 + for res in [res_3fwd, res_3rev]:
253 + if var in res.params.index:
254 + b = res.params[var]
255 + se = res.bse[var]
256 + p = res.pvalues[var]
257 + cells_coef.append(f"{b:.4f}{significance_star(p)}")
258 + cells_se.append(f"({se:.4f})")
259 + else:
260 + cells_coef.append("")
261 + cells_se.append("")
262 + vn = var.replace("_", r"\_")
263 + lines.append(f"{vn} & {cells_coef[0]} & {cells_coef[1]} " + r"\\")
264 + lines.append(f" & {cells_se[0]} & {cells_se[1]} " + r"\\[4pt]")
265 +
266 + lines.append(r"\midrule")
267 + lines.append(
268 + f"Observations & {int(res_3fwd.nobs)} & {int(res_3rev.nobs)} " + r"\\"
269 + )
270 + lines.append(
271 + f"R$^2$ & {res_3fwd.rsquared:.4f} & {res_3rev.rsquared:.4f} " + r"\\"
272 + )
273 + lines.append(r"\bottomrule")
274 + lines.append(r"\end{tabular}")
275 + lines.append(
276 + r"\parbox{\textwidth}{\footnotesize Robust (HC1) standard errors in "
277 + r"parentheses. $^{***}p<0.01$; $^{**}p<0.05$; $^{*}p<0.10$.}"
278 + )
279 +
280 + tex3 = "\n".join(lines) + "\n"
281 + out3 = TABLE_DIR / "city_level_interaction.tex"
282 + out3.write_text(tex3, encoding="utf-8")
283 + print(f" -> saved {out3}")
284 +
285 +
286 +def main() -> None:
287 + print("=" * 72)
288 + print("06 HEDONIC REGRESSION MODELS")
289 + print("=" * 72)
290 +
291 + rent = pd.read_parquet(require(MERGED_ANALYSIS, PROCESSED_HINT))
292 + airbnb = pd.read_parquet(require(AIRBNB_CLEAN, PROCESSED_HINT))
293 +
294 + print(f"\nRent data: {rent.shape[0]:,} rows, {rent.shape[1]} cols")
295 + print(f"Airbnb data: {airbnb.shape[0]:,} rows, {airbnb.shape[1]} cols")
296 +
297 + model1_hedonic_rent(rent)
298 + model2_airbnb_pricing(rent, airbnb)
299 + model3_city_interaction(rent)
300 +
301 + print("\n" + "=" * 72)
302 + print("06 DONE")
303 + print("=" * 72)
304 +
305 +
306 +if __name__ == "__main__":
307 + main()
added scripts/07_spatial_models.py +467 −0
@@ -0,0 +1,467 @@
1 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +"""
3 +07_spatial_models.py
4 +--------------------
5 +Spatial regression analysis for the Airbnb-rent study.
6 +
7 +Model 4: Spatial models
8 + - If libpysal + spreg available: SAR, SEM, and OLS comparison
9 + - Fallback: manual spatial-lag OLS ("poor man's spatial model")
10 + - Distance-buffer robustness (250m, 500m, 1km, 2km)
11 +
12 +Outputs:
13 + results/tables/spatial_models.tex
14 + results/tables/buffer_robustness.tex
15 + figures/coefficient_buffer_comparison.pdf
16 +"""
17 +
18 +import sys
19 +from pathlib import Path
20 +
21 +sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
22 +
23 +import matplotlib
24 +matplotlib.use("Agg")
25 +import matplotlib.pyplot as plt # noqa: E402
26 +import numpy as np # noqa: E402
27 +import pandas as pd # noqa: E402
28 +import scipy.stats # noqa: E402
29 +import statsmodels.api as sm # noqa: E402
30 +from scipy.spatial import cKDTree # noqa: E402
31 +
32 +from src.config import MERGED_ANALYSIS, TABLE_DIR, FIG_DIR, require # noqa: E402
33 +from src.latex_tables import significance_star as _star # noqa: E402
34 +
35 +PROCESSED_HINT = "Run scripts/04_merge_data.py first."
36 +
37 +# ── attempt to import spatial libraries ──────────────────────────────────────
38 +try:
39 + from libpysal.weights import KNN as Wknn
40 + import spreg
41 + HAS_SPATIAL = True
42 + print(" libpysal + spreg available — will run SAR / SEM")
43 +except ImportError:
44 + HAS_SPATIAL = False
45 + print(" libpysal/spreg not available — using fallback spatial approach")
46 +
47 +
48 +def run_ols_robust(df, y_col, x_cols, label):
49 + """OLS with HC1 robust SE."""
50 + sub = df[[y_col] + x_cols].dropna()
51 + Y = sub[y_col]
52 + X = sm.add_constant(sub[x_cols])
53 + res = sm.OLS(Y, X).fit(cov_type="HC1")
54 + print(f" [{label}] N={int(res.nobs):,} R2={res.rsquared:.4f}")
55 + return res
56 +
57 +
58 +def add_manual_spatial_lags(rent_sp: pd.DataFrame, coords: np.ndarray) -> None:
59 + """Add k=5 nearest-neighbour spatial lags of rent and Airbnb count in place."""
60 + tree = cKDTree(coords)
61 + _, idx = tree.query(coords, k=6) # k+1 because first neighbour is self
62 + idx_neighbours = idx[:, 1:] # drop self
63 + rent_sp["spatial_lag_rent"] = rent_sp["log_rent"].values[idx_neighbours].mean(axis=1)
64 + rent_sp["spatial_lag_airbnb"] = rent_sp["airbnb_count_500m"].values[idx_neighbours].mean(axis=1)
65 +
66 +
67 +def spatial_models_table(rent_sp, coords, res_ols, all_x_baseline,
68 + controls, city_fe_cols, bt_cols, display_vars):
69 + """
70 + Estimate the spatial models (SAR/SEM when spreg is available, manual
71 + spatial-lag OLS otherwise) and return the LaTeX table lines.
72 + """
73 + if HAS_SPATIAL:
74 + # ── Build KNN weights ────────────────────────────────────────────────
75 + print("\n Building KNN(k=5) spatial weights ...")
76 + w = Wknn.from_array(coords, k=5)
77 + w.transform = "r" # row-standardise
78 +
79 + Y_arr = rent_sp["log_rent"].values.reshape(-1, 1)
80 + X_arr = sm.add_constant(rent_sp[all_x_baseline].values)
81 + var_names = ["const"] + all_x_baseline
82 +
83 + # ── SAR (Spatial Lag Model) ──────────────────────────────────────────
84 + sar = None
85 + sem = None
86 + try:
87 + print(" Estimating Spatial Lag Model (SAR) ...")
88 + sar = spreg.GM_Lag(
89 + Y_arr, X_arr, w=w, name_y="log_rent", name_x=var_names
90 + )
91 + print(f" [SAR] N={sar.n} pseudo-R2={sar.pr2:.4f} rho={sar.betas[-1][0]:.4f}")
92 + except Exception as e:
93 + print(f" [SAR] Failed: {e}")
94 +
95 + # ── SEM (Spatial Error Model) ────────────────────────────────────────
96 + try:
97 + print(" Estimating Spatial Error Model (SEM) ...")
98 + sem = spreg.GM_Error(
99 + Y_arr, X_arr, w=w, name_y="log_rent", name_x=var_names
100 + )
101 + sem_lambda = sem.betas[-1][0]
102 + print(f" [SEM] N={sem.n} pseudo-R2={sem.pr2:.4f} lambda={sem_lambda:.4f}")
103 + except Exception as e:
104 + print(f" [SEM] Failed: {e}")
105 +
106 + # ── If SAR/SEM failed, fall back to manual spatial-lag approach ─────
107 + if sar is None or sem is None:
108 + print(" SAR/SEM failed — falling back to manual spatial lag approach ...")
109 + add_manual_spatial_lags(rent_sp, coords)
110 + fb_x = all_x_baseline + ["spatial_lag_rent", "spatial_lag_airbnb"]
111 + res_fb = run_ols_robust(rent_sp, "log_rent", fb_x, "OLS + spatial lags")
112 + lines: list[str] = []
113 + lines.append(r"\begin{tabular}{lcc}")
114 + lines.append(r"\toprule")
115 + lines.append(r" & \textbf{OLS Baseline} & \textbf{OLS + Spatial Lags} \\")
116 + lines.append(r"\midrule")
117 + ols_p = dict(zip(res_ols.model.exog_names, res_ols.params))
118 + ols_s = dict(zip(res_ols.model.exog_names, res_ols.bse))
119 + ols_pv = dict(zip(res_ols.model.exog_names, res_ols.pvalues))
120 + fb_p = dict(zip(res_fb.model.exog_names, res_fb.params))
121 + fb_s = dict(zip(res_fb.model.exog_names, res_fb.bse))
122 + fb_pv = dict(zip(res_fb.model.exog_names, res_fb.pvalues))
123 + show = (["const", "airbnb_count_500m", "bedrooms", "bathrooms"]
124 + + bt_cols + ["spatial_lag_rent", "spatial_lag_airbnb"])
125 + for var in show:
126 + c1 = f"{ols_p[var]:.4f}{_star(ols_pv[var])}" if var in ols_p else ""
127 + s1 = f"({ols_s[var]:.4f})" if var in ols_s else ""
128 + c2 = f"{fb_p[var]:.4f}{_star(fb_pv[var])}" if var in fb_p else ""
129 + s2 = f"({fb_s[var]:.4f})" if var in fb_s else ""
130 + vn = var.replace("_", r"\_")
131 + lines.append(f"{vn} & {c1} & {c2}" + r" \\")
132 + lines.append(f" & {s1} & {s2}" + r" \\[4pt]")
133 + lines.append(r"\midrule")
134 + lines.append(f"Observations & {int(res_ols.nobs)} & {int(res_fb.nobs)}" + r" \\")
135 + lines.append(f"R$^2$ & {res_ols.rsquared:.4f} & {res_fb.rsquared:.4f}" + r" \\")
136 + lines.append(r"\bottomrule")
137 + lines.append(r"\end{tabular}")
138 + lines.append(r"\parbox{\textwidth}{\footnotesize Robust (HC1) standard errors in parentheses. $^{***}p<0.01$; $^{**}p<0.05$; $^{*}p<0.10$. City FE included but not shown.}")
139 + return lines
140 +
141 + # ── Build LaTeX table: OLS vs SAR vs SEM ─────────────────────────────
142 + lines = []
143 + lines.append(r"\begin{tabular}{lccc}")
144 + lines.append(r"\toprule")
145 + lines.append(r" & \textbf{OLS} & \textbf{SAR (GM\_Lag)} & \textbf{SEM (GM\_Error)} \\")
146 + lines.append(r"Dep.\ var: & \multicolumn{3}{c}{\textit{log\_rent}} \\")
147 + lines.append(r"\midrule")
148 + ols_params = dict(zip(["const"] + all_x_baseline, res_ols.params))
149 + ols_se = dict(zip(["const"] + all_x_baseline, res_ols.bse))
150 + ols_pval = dict(zip(["const"] + all_x_baseline, res_ols.pvalues))
151 + sar_var_names = var_names + ["W_log_rent"]
152 + sar_params = {v: sar.betas[i][0] for i, v in enumerate(sar_var_names)}
153 + sar_se = {v: sar.std_err[i] for i, v in enumerate(sar_var_names)}
154 + sar_z = {v: sar.z_stat[i] for i, v in enumerate(sar_var_names)}
155 + sem_params = {v: sem.betas[i][0] for i, v in enumerate(var_names)}
156 + sem_se = {v: sem.std_err[i] for i, v in enumerate(var_names)}
157 + sem_z = {v: sem.z_stat[i] for i, v in enumerate(var_names)}
158 + show_vars = display_vars + ["W_log_rent"]
159 + for var in show_vars:
160 + cells_c, cells_s = [], []
161 + if var in ols_params:
162 + cells_c.append(f"{ols_params[var]:.4f}{_star(ols_pval[var])}")
163 + cells_s.append(f"({ols_se[var]:.4f})")
164 + else:
165 + cells_c.append("")
166 + cells_s.append("")
167 + if var in sar_params:
168 + p_sar = 2 * (1 - scipy.stats.norm.cdf(abs(sar_z[var][0])))
169 + cells_c.append(f"{sar_params[var]:.4f}{_star(p_sar)}")
170 + cells_s.append(f"({sar_se[var]:.4f})")
171 + else:
172 + cells_c.append("")
173 + cells_s.append("")
174 + if var in sem_params:
175 + p_sem = 2 * (1 - scipy.stats.norm.cdf(abs(sem_z[var][0])))
176 + cells_c.append(f"{sem_params[var]:.4f}{_star(p_sem)}")
177 + cells_s.append(f"({sem_se[var]:.4f})")
178 + else:
179 + cells_c.append("")
180 + cells_s.append("")
181 + vn = var.replace("_", r"\_")
182 + lines.append(f"{vn} & " + " & ".join(cells_c) + r" \\")
183 + lines.append(f" & " + " & ".join(cells_s) + r" \\[4pt]")
184 + lines.append(r"$\lambda$ (spatial error) & & & " + f"{sem.betas[-1][0]:.4f}" + r" \\")
185 + lines.append(r"\midrule")
186 + lines.append(f"Observations & {int(res_ols.nobs)} & {sar.n} & {sem.n} " + r"\\")
187 + lines.append(f"R$^2$ / pseudo-R$^2$ & {res_ols.rsquared:.4f} & {sar.pr2:.4f} & {sem.pr2:.4f} " + r"\\")
188 + lines.append(r"\bottomrule")
189 + lines.append(r"\end{tabular}")
190 + lines.append(r"\parbox{\textwidth}{\footnotesize Standard errors in parentheses. SAR estimated via GM\_Lag; SEM via GM\_Error. $^{***}p<0.01$; $^{**}p<0.05$; $^{*}p<0.10$. City FE included but not shown.}")
191 + return lines
192 +
193 + # ── Fallback: manual spatial-lag OLS ─────────────────────────────────────
194 + print("\n Building KNN(k=5) manually with scipy ...")
195 + add_manual_spatial_lags(rent_sp, coords)
196 +
197 + # Model with spatial lag of rent
198 + res_slag_rent = run_ols_robust(
199 + rent_sp, "log_rent",
200 + ["airbnb_count_500m", "spatial_lag_rent"] + controls + city_fe_cols,
201 + "OLS + spatial lag(rent)"
202 + )
203 +
204 + # Model with both spatial lags
205 + res_slag_both = run_ols_robust(
206 + rent_sp, "log_rent",
207 + ["airbnb_count_500m", "spatial_lag_rent", "spatial_lag_airbnb"]
208 + + controls + city_fe_cols,
209 + "OLS + spatial lag(rent, airbnb)"
210 + )
211 +
212 + show_vars_fb = [
213 + "const", "airbnb_count_500m", "spatial_lag_rent",
214 + "spatial_lag_airbnb", "bedrooms", "bathrooms",
215 + ] + bt_cols
216 +
217 + lines = []
218 + lines.append(r"\begin{tabular}{lccc}")
219 + lines.append(r"\toprule")
220 + lines.append(r" & \textbf{OLS Baseline} & \textbf{+Lag(rent)} & \textbf{+Lag(rent, airbnb)} \\")
221 + lines.append(r"Dep.\ var: & \multicolumn{3}{c}{\textit{log\_rent}} \\")
222 + lines.append(r"\midrule")
223 +
224 + for var in show_vars_fb:
225 + cells_c, cells_s = [], []
226 + for res in [res_ols, res_slag_rent, res_slag_both]:
227 + if var in res.params.index:
228 + b = res.params[var]
229 + se = res.bse[var]
230 + p = res.pvalues[var]
231 + cells_c.append(f"{b:.4f}{_star(p)}")
232 + cells_s.append(f"({se:.4f})")
233 + else:
234 + cells_c.append("")
235 + cells_s.append("")
236 + vn = var.replace("_", r"\_")
237 + lines.append(f"{vn} & " + " & ".join(cells_c) + r" \\")
238 + lines.append(f" & " + " & ".join(cells_s) + r" \\[4pt]")
239 +
240 + lines.append(r"\midrule")
241 + for label, accessor in [
242 + ("Observations", lambda r: f"{int(r.nobs)}"),
243 + ("R$^2$", lambda r: f"{r.rsquared:.4f}"),
244 + ("Adj.\\ R$^2$", lambda r: f"{r.rsquared_adj:.4f}"),
245 + ]:
246 + row = [label]
247 + for res in [res_ols, res_slag_rent, res_slag_both]:
248 + row.append(accessor(res))
249 + lines.append(" & ".join(row) + r" \\")
250 +
251 + lines.append(r"\bottomrule")
252 + lines.append(r"\end{tabular}")
253 + lines.append(
254 + r"\parbox{\textwidth}{\footnotesize Robust (HC1) standard errors in "
255 + r"parentheses. Spatial lag = mean of k=5 nearest neighbours. "
256 + r"City FE included but not shown. "
257 + r"$^{***}p<0.01$; $^{**}p<0.05$; $^{*}p<0.10$.}"
258 + )
259 + return lines
260 +
261 +
262 +def buffer_robustness(rent_m, controls, city_fe_cols) -> None:
263 + """Distance-buffer robustness: table + coefficient plot."""
264 + print("\n" + "-" * 72)
265 + print("Distance-buffer robustness (250m, 500m, 1km, 2km)")
266 + print("-" * 72)
267 +
268 + buffers = {
269 + "250m": "airbnb_count_250m",
270 + "500m": "airbnb_count_500m",
271 + "1km": "airbnb_count_1000m",
272 + "2km": "airbnb_count_2000m",
273 + }
274 +
275 + buffer_results = {}
276 + for buf_label, ab_var in buffers.items():
277 + if ab_var not in rent_m.columns:
278 + print(f" {ab_var} not found — skipping")
279 + continue
280 + x_cols = [ab_var] + controls + city_fe_cols
281 + res = run_ols_robust(rent_m, "log_rent", x_cols, f"buffer {buf_label}")
282 + buffer_results[buf_label] = (ab_var, res)
283 +
284 + if not buffer_results:
285 + print(" No buffer variables found — skipping robustness table & plot.")
286 + return
287 +
288 + # ── LaTeX table ──────────────────────────────────────────────────────────
289 + buf_labels = list(buffer_results.keys())
290 +
291 + lines = []
292 + col_spec = "l" + "c" * len(buf_labels)
293 + lines.append(r"\begin{tabular}{" + col_spec + "}")
294 + lines.append(r"\toprule")
295 + header = " & ".join([""] + [f"\\textbf{{{b}}}" for b in buf_labels]) + r" \\"
296 + lines.append(header)
297 + lines.append(
298 + " & ".join(["Dep.\\ var:"] + [r"\textit{log\_rent}"] * len(buf_labels))
299 + + r" \\"
300 + )
301 + lines.append(r"\midrule")
302 +
303 + # Show airbnb_count coefficient (the key variable differs per model)
304 + cells_c, cells_s = [], []
305 + for b in buf_labels:
306 + ab_var, res = buffer_results[b]
307 + bval = res.params[ab_var]
308 + se = res.bse[ab_var]
309 + p = res.pvalues[ab_var]
310 + cells_c.append(f"{bval:.6f}{_star(p)}")
311 + cells_s.append(f"({se:.6f})")
312 +
313 + lines.append("Airbnb count & " + " & ".join(cells_c) + r" \\")
314 + lines.append(" & " + " & ".join(cells_s) + r" \\[4pt]")
315 +
316 + # Controls row
317 + lines.append(
318 + "Controls & " + " & ".join(["Yes"] * len(buf_labels)) + r" \\"
319 + )
320 + lines.append(
321 + "City FE & " + " & ".join(["Yes"] * len(buf_labels)) + r" \\"
322 + )
323 +
324 + lines.append(r"\midrule")
325 + # N, R2
326 + for lbl, acc in [
327 + ("Observations", lambda r: f"{int(r.nobs):,}"),
328 + ("R$^2$", lambda r: f"{r.rsquared:.4f}"),
329 + ]:
330 + cells = [lbl]
331 + for b in buf_labels:
332 + cells.append(acc(buffer_results[b][1]))
333 + lines.append(" & ".join(cells) + r" \\")
334 +
335 + lines.append(r"\bottomrule")
336 + lines.append(r"\end{tabular}")
337 + lines.append(
338 + r"\parbox{\textwidth}{\footnotesize Robust (HC1) standard errors in "
339 + r"parentheses. Controls: bedrooms, bathrooms, building-type dummies. "
340 + r"$^{***}p<0.01$; $^{**}p<0.05$; $^{*}p<0.10$.}"
341 + )
342 +
343 + tex_buf = "\n".join(lines) + "\n"
344 + out_buf = TABLE_DIR / "buffer_robustness.tex"
345 + out_buf.write_text(tex_buf, encoding="utf-8")
346 + print(f" -> saved {out_buf}")
347 +
348 + # ── Coefficient plot ─────────────────────────────────────────────────────
349 + fig, ax = plt.subplots(figsize=(6, 4))
350 + x_pos = np.arange(len(buf_labels))
351 + coefs = []
352 + ci_lo = []
353 + ci_hi = []
354 + for b in buf_labels:
355 + ab_var, res = buffer_results[b]
356 + beta = res.params[ab_var]
357 + se = res.bse[ab_var]
358 + coefs.append(beta)
359 + ci_lo.append(beta - 1.96 * se)
360 + ci_hi.append(beta + 1.96 * se)
361 +
362 + coefs = np.array(coefs)
363 + ci_lo = np.array(ci_lo)
364 + ci_hi = np.array(ci_hi)
365 + err_lo = coefs - ci_lo
366 + err_hi = ci_hi - coefs
367 +
368 + ax.errorbar(
369 + x_pos, coefs,
370 + yerr=[err_lo, err_hi],
371 + fmt="o", capsize=5, capthick=1.5, color="steelblue", markersize=8,
372 + )
373 + ax.axhline(0, color="grey", linestyle="--", linewidth=0.7)
374 + ax.set_xticks(x_pos)
375 + ax.set_xticklabels(buf_labels)
376 + ax.set_xlabel("Buffer distance")
377 + ax.set_ylabel(r"$\beta$ (Airbnb count)")
378 + ax.set_title("Airbnb Count Coefficient by Buffer Distance (95% CI)")
379 + fig.tight_layout()
380 +
381 + fig_path = FIG_DIR / "coefficient_buffer_comparison.pdf"
382 + fig.savefig(fig_path, dpi=300)
383 + plt.close(fig)
384 + print(f" -> saved {fig_path}")
385 +
386 +
387 +def main() -> None:
388 + print("=" * 72)
389 + print("07 SPATIAL MODELS")
390 + print("=" * 72)
391 +
392 + rent = pd.read_parquet(require(MERGED_ANALYSIS, PROCESSED_HINT))
393 + print(f"\nRent data: {rent.shape[0]:,} rows")
394 +
395 + # ── Prepare common variables ────────────────────────────────────────────
396 + rent_m = rent.copy()
397 +
398 + # Building-type dummies
399 + if "building_type" in rent_m.columns:
400 + bt_dummies = pd.get_dummies(
401 + rent_m["building_type"], prefix="bt", drop_first=True, dtype=float
402 + )
403 + rent_m = pd.concat([rent_m, bt_dummies], axis=1)
404 + bt_cols = list(bt_dummies.columns)
405 + else:
406 + bt_cols = []
407 +
408 + # City FE — limit to top 15 cities to avoid singular matrices in spatial models
409 + if "city" in rent_m.columns:
410 + top_cities = rent_m["city"].value_counts().nlargest(15).index
411 + city_col_reduced = rent_m["city"].where(rent_m["city"].isin(top_cities), other="Other")
412 + city_dummies = pd.get_dummies(
413 + city_col_reduced, prefix="city", drop_first=True, dtype=float
414 + )
415 + rent_m = pd.concat([rent_m, city_dummies], axis=1)
416 + city_fe_cols = list(city_dummies.columns)
417 + else:
418 + city_fe_cols = []
419 +
420 + controls = ["bedrooms", "bathrooms"] + bt_cols
421 + all_x_baseline = ["airbnb_count_500m"] + controls + city_fe_cols
422 +
423 + # Key display variables (short list for tables)
424 + display_vars = ["const", "airbnb_count_500m", "bedrooms", "bathrooms"] + bt_cols
425 +
426 + # ═════════════════════════════════════════════════════════════════════════
427 + # MODEL 4: Spatial Analysis
428 + # ═════════════════════════════════════════════════════════════════════════
429 + print("\n" + "-" * 72)
430 + print("MODEL 4: Spatial Models")
431 + print("-" * 72)
432 +
433 + # Determine coordinate columns
434 + lat_col = "lat" if "lat" in rent_m.columns else "latitude"
435 + lon_col = "lon" if "lon" in rent_m.columns else ("long" if "long" in rent_m.columns else "longitude")
436 +
437 + # Subset to complete cases for spatial models
438 + spatial_cols = ["log_rent", lat_col, lon_col] + all_x_baseline
439 + rent_sp = rent_m.dropna(subset=spatial_cols).reset_index(drop=True)
440 + print(f" Complete cases for spatial analysis: {len(rent_sp):,}")
441 +
442 + coords = rent_sp[[lat_col, lon_col]].values
443 +
444 + # OLS baseline on the spatial subsample
445 + res_ols = run_ols_robust(rent_sp, "log_rent", all_x_baseline,
446 + "OLS baseline (spatial sample)")
447 +
448 + lines = spatial_models_table(rent_sp, coords, res_ols, all_x_baseline,
449 + controls, city_fe_cols, bt_cols, display_vars)
450 +
451 + tex_spatial = "\n".join(lines) + "\n"
452 + out_spatial = TABLE_DIR / "spatial_models.tex"
453 + out_spatial.write_text(tex_spatial, encoding="utf-8")
454 + print(f" -> saved {out_spatial}")
455 +
456 + # ═════════════════════════════════════════════════════════════════════════
457 + # DISTANCE-BUFFER ROBUSTNESS
458 + # ═════════════════════════════════════════════════════════════════════════
459 + buffer_robustness(rent_m, controls, city_fe_cols)
460 +
461 + print("\n" + "=" * 72)
462 + print("07 DONE")
463 + print("=" * 72)
464 +
465 +
466 +if __name__ == "__main__":
467 + main()
added scripts/08_quantile_models.py +254 −0
@@ -0,0 +1,254 @@
1 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +"""
3 +08_quantile_models.py
4 +---------------------
5 +Quantile regression analysis for the Airbnb-rent study.
6 +
7 +Model 5: Quantile regression across the conditional rent distribution.
8 +
9 + Q_tau(log_rent | X) = alpha_tau + beta_tau * airbnb_count_500m + X'gamma_tau
10 +
11 +for tau in {0.10, 0.25, 0.50, 0.75, 0.90}
12 +
13 +Outputs:
14 + results/tables/quantile_regression.tex
15 + figures/quantile_coefficients.pdf
16 +"""
17 +
18 +import sys
19 +from pathlib import Path
20 +
21 +sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
22 +
23 +import matplotlib
24 +matplotlib.use("Agg")
25 +import matplotlib.pyplot as plt # noqa: E402
26 +import numpy as np # noqa: E402
27 +import pandas as pd # noqa: E402
28 +import statsmodels.api as sm # noqa: E402
29 +from statsmodels.regression.quantile_regression import QuantReg # noqa: E402
30 +
31 +from src.config import MERGED_ANALYSIS, TABLE_DIR, FIG_DIR, require # noqa: E402
32 +from src.latex_tables import significance_star as _star # noqa: E402
33 +
34 +PROCESSED_HINT = "Run scripts/04_merge_data.py first."
35 +
36 +TAUS = [0.10, 0.25, 0.50, 0.75, 0.90]
37 +
38 +
39 +def prepare_data(rent: pd.DataFrame):
40 + """Build dummies and return the complete-case design (Y, X, x_cols, bt_cols)."""
41 + rent_m = rent.copy()
42 +
43 + # Building-type dummies
44 + if "building_type" in rent_m.columns:
45 + bt_dummies = pd.get_dummies(
46 + rent_m["building_type"], prefix="bt", drop_first=True, dtype=float
47 + )
48 + rent_m = pd.concat([rent_m, bt_dummies], axis=1)
49 + bt_cols = list(bt_dummies.columns)
50 + else:
51 + bt_cols = []
52 +
53 + # City dummies — use only the top 5 cities to keep manageable
54 + if "city" in rent_m.columns:
55 + top5 = rent_m["city"].value_counts().nlargest(5).index.tolist()
56 + print(f" Top-5 cities for quantile FE: {top5}")
57 + rent_m["city_top5"] = rent_m["city"].where(rent_m["city"].isin(top5), other="Other")
58 + city_dum = pd.get_dummies(
59 + rent_m["city_top5"], prefix="city", drop_first=True, dtype=float
60 + )
61 + rent_m = pd.concat([rent_m, city_dum], axis=1)
62 + city_fe_cols = list(city_dum.columns)
63 + else:
64 + city_fe_cols = []
65 +
66 + controls = ["bedrooms", "bathrooms"] + bt_cols
67 + x_cols = ["airbnb_count_500m"] + controls + city_fe_cols
68 +
69 + # Drop missing
70 + use_cols = ["log_rent"] + x_cols
71 + rent_q = rent_m[use_cols].dropna().reset_index(drop=True)
72 + print(f" Complete cases: {len(rent_q):,}")
73 +
74 + Y = rent_q["log_rent"]
75 + X = sm.add_constant(rent_q[x_cols])
76 + return Y, X, bt_cols
77 +
78 +
79 +def build_table(qr_results: dict, res_ols, bt_cols) -> None:
80 + """Write the quantile-regression LaTeX table."""
81 + print("\n Building LaTeX table ...")
82 +
83 + # Variables to display (omit city dummies for readability)
84 + display_vars = ["const", "airbnb_count_500m", "bedrooms", "bathrooms"] + bt_cols
85 +
86 + n_models = len(TAUS) + 1 # quantiles + OLS
87 + col_spec = "l" + "c" * n_models
88 + all_res = [qr_results[t] for t in TAUS] + [res_ols]
89 + col_headers = [f"$\\tau={t:.2f}$" for t in TAUS] + ["OLS"]
90 +
91 + lines: list[str] = []
92 + lines.append(r"\begin{tabular}{" + col_spec + "}")
93 + lines.append(r"\toprule")
94 + lines.append(
95 + " & ".join([""] + [f"\\textbf{{{h}}}" for h in col_headers]) + r" \\"
96 + )
97 + lines.append(
98 + " & ".join(["Dep.\\ var:"] + [r"\textit{log\_rent}"] * n_models) + r" \\"
99 + )
100 + lines.append(r"\midrule")
101 +
102 + for var in display_vars:
103 + cells_c = []
104 + cells_s = []
105 + for res in all_res:
106 + if var in res.params.index:
107 + b = res.params[var]
108 + se = res.bse[var]
109 + p = res.pvalues[var]
110 + cells_c.append(f"{b:.4f}{_star(p)}")
111 + cells_s.append(f"({se:.4f})")
112 + else:
113 + cells_c.append("")
114 + cells_s.append("")
115 + vn = var.replace("_", r"\_")
116 + lines.append(f"{vn} & " + " & ".join(cells_c) + r" \\")
117 + lines.append(f" & " + " & ".join(cells_s) + r" \\[4pt]")
118 +
119 + lines.append(r"\midrule")
120 +
121 + # City FE indicator
122 + lines.append(
123 + "City FE (top 5) & " + " & ".join(["Yes"] * n_models) + r" \\"
124 + )
125 +
126 + # N
127 + lines.append(
128 + "Observations & "
129 + + " & ".join([f"{int(res.nobs)}" for res in all_res])
130 + + r" \\"
131 + )
132 +
133 + # Pseudo-R2 / R2
134 + r2_cells = []
135 + for i, res in enumerate(all_res):
136 + if i < len(TAUS):
137 + r2_cells.append(f"{res.prsquared:.4f}")
138 + else:
139 + r2_cells.append(f"{res.rsquared:.4f}")
140 + lines.append("(Pseudo-)R$^2$ & " + " & ".join(r2_cells) + r" \\")
141 +
142 + lines.append(r"\bottomrule")
143 + lines.append(r"\end{tabular}")
144 + lines.append(
145 + r"\parbox{\textwidth}{\footnotesize Standard errors in parentheses. "
146 + r"OLS uses HC1 robust SE. "
147 + r"$^{***}p<0.01$; $^{**}p<0.05$; $^{*}p<0.10$. "
148 + r"City FE limited to the 5 largest cities (others grouped).}"
149 + )
150 +
151 + tex = "\n".join(lines) + "\n"
152 + out_table = TABLE_DIR / "quantile_regression.tex"
153 + out_table.write_text(tex, encoding="utf-8")
154 + print(f" -> saved {out_table}")
155 +
156 +
157 +def build_plot(qr_model: QuantReg, res_ols) -> None:
158 + """Coefficient plot: beta_tau(airbnb_count_500m) across a fine tau grid."""
159 + print(" Building coefficient plot ...")
160 +
161 + fine_taus = np.arange(0.05, 0.96, 0.05)
162 + fine_betas = []
163 + fine_ci_lo = []
164 + fine_ci_hi = []
165 +
166 + for tau in fine_taus:
167 + res = qr_model.fit(q=tau)
168 + beta = res.params["airbnb_count_500m"]
169 + se = res.bse["airbnb_count_500m"]
170 + fine_betas.append(beta)
171 + fine_ci_lo.append(beta - 1.96 * se)
172 + fine_ci_hi.append(beta + 1.96 * se)
173 +
174 + fine_betas = np.array(fine_betas)
175 + fine_ci_lo = np.array(fine_ci_lo)
176 + fine_ci_hi = np.array(fine_ci_hi)
177 +
178 + fig, ax = plt.subplots(figsize=(7, 4.5))
179 +
180 + # 95% CI band
181 + ax.fill_between(fine_taus, fine_ci_lo, fine_ci_hi, alpha=0.2, color="steelblue",
182 + label="95% CI")
183 +
184 + # Quantile regression line
185 + ax.plot(fine_taus, fine_betas, "o-", color="steelblue", markersize=4,
186 + label=r"$\beta_\tau$ (Quantile Reg.)")
187 +
188 + # OLS reference
189 + ols_beta = res_ols.params["airbnb_count_500m"]
190 + ols_se = res_ols.bse["airbnb_count_500m"]
191 + ax.axhline(ols_beta, color="firebrick", linestyle="--", linewidth=1.2,
192 + label=f"OLS estimate ({ols_beta:.5f})")
193 + ax.axhspan(ols_beta - 1.96 * ols_se, ols_beta + 1.96 * ols_se,
194 + color="firebrick", alpha=0.08)
195 +
196 + ax.axhline(0, color="grey", linestyle=":", linewidth=0.7)
197 + ax.set_xlabel(r"Quantile ($\tau$)")
198 + ax.set_ylabel(r"$\beta_\tau$ (airbnb\_count\_500m)")
199 + ax.set_title("Effect of Airbnb Count (500m) on Log Rent Across Quantiles")
200 + ax.legend(fontsize=8, loc="best")
201 + fig.tight_layout()
202 +
203 + fig_path = FIG_DIR / "quantile_coefficients.pdf"
204 + fig.savefig(fig_path, dpi=300)
205 + plt.close(fig)
206 + print(f" -> saved {fig_path}")
207 +
208 +
209 +def main() -> None:
210 + print("=" * 72)
211 + print("08 QUANTILE REGRESSION MODELS")
212 + print("=" * 72)
213 +
214 + rent = pd.read_parquet(require(MERGED_ANALYSIS, PROCESSED_HINT))
215 + print(f"\nRent data: {rent.shape[0]:,} rows")
216 +
217 + Y, X, bt_cols = prepare_data(rent)
218 +
219 + qr_model = QuantReg(Y, X)
220 + qr_results: dict[float, object] = {}
221 +
222 + print("\n" + "-" * 72)
223 + print("MODEL 5: Quantile Regressions")
224 + print("-" * 72)
225 +
226 + for tau in TAUS:
227 + res = qr_model.fit(q=tau)
228 + qr_results[tau] = res
229 + beta = res.params["airbnb_count_500m"]
230 + se = res.bse["airbnb_count_500m"]
231 + p = res.pvalues["airbnb_count_500m"]
232 + print(
233 + f" tau={tau:.2f} β(airbnb)={beta:.6f} SE={se:.6f} "
234 + f"p={p:.4f}{_star(p)} pseudo-R2={res.prsquared:.4f}"
235 + )
236 +
237 + # Also run OLS for comparison
238 + res_ols = sm.OLS(Y, X).fit(cov_type="HC1")
239 + print(
240 + f" OLS β(airbnb)={res_ols.params['airbnb_count_500m']:.6f} "
241 + f"SE={res_ols.bse['airbnb_count_500m']:.6f} "
242 + f"R2={res_ols.rsquared:.4f}"
243 + )
244 +
245 + build_table(qr_results, res_ols, bt_cols)
246 + build_plot(qr_model, res_ols)
247 +
248 + print("\n" + "=" * 72)
249 + print("08 DONE")
250 + print("=" * 72)
251 +
252 +
253 +if __name__ == "__main__":
254 + main()
added scripts/09_ml_robustness.py +275 −0
@@ -0,0 +1,275 @@
1 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +"""
3 +09_ml_robustness.py
4 +-------------------
5 +Model 6: ML Robustness Models.
6 +Predict log_rent using Airbnb exposure and property characteristics.
7 +Compare OLS, LASSO, Elastic Net, Random Forest, and Gradient Boosting.
8 +Compute SHAP values (if available) or sklearn feature importances.
9 +
10 +Outputs
11 +-------
12 +- results/tables/ml_comparison.tex
13 +- results/tables/ml_comparison.csv
14 +- figures/ml_predicted_vs_actual.pdf
15 +- figures/feature_importance.pdf
16 +- figures/shap_summary.pdf (if shap available)
17 +"""
18 +
19 +import sys
20 +import warnings
21 +from pathlib import Path
22 +
23 +sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
24 +
25 +from src.plotting import use_publication_style
26 +
27 +use_publication_style()
28 +
29 +import matplotlib.pyplot as plt # noqa: E402
30 +import numpy as np # noqa: E402
31 +import pandas as pd # noqa: E402
32 +from sklearn.ensemble import GradientBoostingRegressor, RandomForestRegressor # noqa: E402
33 +from sklearn.linear_model import ElasticNetCV, LassoCV, LinearRegression # noqa: E402
34 +from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score # noqa: E402
35 +from sklearn.model_selection import train_test_split # noqa: E402
36 +from sklearn.preprocessing import StandardScaler # noqa: E402
37 +
38 +from src.config import ( # noqa: E402
39 + MERGED_ANALYSIS,
40 + TABLE_DIR,
41 + FIG_DIR,
42 + RANDOM_STATE,
43 + require,
44 +)
45 +
46 +warnings.filterwarnings("ignore")
47 +
48 +PROCESSED_HINT = "Run scripts/04_merge_data.py first."
49 +
50 +FEATURES = [
51 + "airbnb_count_500m",
52 + "airbnb_density_500m",
53 + "mean_airbnb_price_500m",
54 + "share_entire_home_500m",
55 + "bedrooms",
56 + "bathrooms",
57 + "lat",
58 + "lon",
59 +]
60 +TARGET = "log_rent"
61 +
62 +# Linear models use standardized features; tree-based models use raw features
63 +USE_SCALED = {"OLS", "LASSO", "Elastic Net"}
64 +
65 +
66 +def train_models(X_train, X_test, y_train, y_test, X_train_sc, X_test_sc):
67 + """Train the five models and return (results DataFrame, fitted dict)."""
68 + models = {
69 + "OLS": LinearRegression(),
70 + "LASSO": LassoCV(cv=5, random_state=RANDOM_STATE, max_iter=10_000),
71 + "Elastic Net": ElasticNetCV(cv=5, random_state=RANDOM_STATE, max_iter=10_000),
72 + "Random Forest": RandomForestRegressor(
73 + n_estimators=500, max_depth=15, random_state=RANDOM_STATE, n_jobs=-1
74 + ),
75 + "Gradient Boosting": GradientBoostingRegressor(
76 + n_estimators=500, max_depth=5, learning_rate=0.05,
77 + random_state=RANDOM_STATE
78 + ),
79 + }
80 +
81 + results = []
82 + fitted = {}
83 +
84 + for name, model in models.items():
85 + print(f"Training {name} ...")
86 + Xtr = X_train_sc if name in USE_SCALED else X_train
87 + Xte = X_test_sc if name in USE_SCALED else X_test
88 +
89 + model.fit(Xtr, y_train)
90 + fitted[name] = model
91 +
92 + y_pred_train = model.predict(Xtr)
93 + y_pred_test = model.predict(Xte)
94 +
95 + r2_train = r2_score(y_train, y_pred_train)
96 + r2_test = r2_score(y_test, y_pred_test)
97 + rmse_test = np.sqrt(mean_squared_error(y_test, y_pred_test))
98 + mae_test = mean_absolute_error(y_test, y_pred_test)
99 +
100 + results.append({
101 + "Model": name,
102 + "R2_train": r2_train,
103 + "R2_test": r2_test,
104 + "RMSE_test": rmse_test,
105 + "MAE_test": mae_test,
106 + })
107 + print(f" R2 train={r2_train:.4f} test={r2_test:.4f} "
108 + f"RMSE={rmse_test:.4f} MAE={mae_test:.4f}")
109 +
110 + return pd.DataFrame(results), fitted
111 +
112 +
113 +def save_comparison_table(res_df: pd.DataFrame) -> None:
114 + """Write ml_comparison.csv and ml_comparison.tex."""
115 + res_df.to_csv(TABLE_DIR / "ml_comparison.csv", index=False)
116 +
117 + latex_rows = []
118 + latex_rows.append(r"\begin{tabular}{lcccc}")
119 + latex_rows.append(r"\toprule")
120 + latex_rows.append(r"Model & $R^2$ (Train) & $R^2$ (Test) & RMSE (Test) & MAE (Test) \\")
121 + latex_rows.append(r"\midrule")
122 + for _, row in res_df.iterrows():
123 + latex_rows.append(
124 + f"{row['Model']} & {row['R2_train']:.4f} & {row['R2_test']:.4f} "
125 + f"& {row['RMSE_test']:.4f} & {row['MAE_test']:.4f} \\\\"
126 + )
127 + latex_rows.append(r"\bottomrule")
128 + latex_rows.append(r"\end{tabular}")
129 + latex_rows.append(r"\begin{tablenotes}\small")
130 + latex_rows.append(r"\item \textit{Notes:} OLS, LASSO, and Elastic Net use "
131 + r"standardized features. Tree-based models use raw features. "
132 + r"Train/test split is 80/20 with random\_state=42.")
133 + latex_rows.append(r"\end{tablenotes}")
134 +
135 + (TABLE_DIR / "ml_comparison.tex").write_text("\n".join(latex_rows) + "\n",
136 + encoding="utf-8")
137 + print(f"\nSaved {TABLE_DIR / 'ml_comparison.tex'}")
138 + print(f"Saved {TABLE_DIR / 'ml_comparison.csv'}")
139 +
140 +
141 +def plot_predicted_vs_actual(res_df, fitted, X_test, X_test_sc, y_test) -> None:
142 + """Scatter of predicted vs actual log rent for the best model by test R2."""
143 + best_name = res_df.loc[res_df["R2_test"].idxmax(), "Model"]
144 + best_model = fitted[best_name]
145 + Xte_best = X_test_sc if best_name in USE_SCALED else X_test
146 + y_pred_best = best_model.predict(Xte_best)
147 +
148 + fig, ax = plt.subplots()
149 + ax.scatter(y_test, y_pred_best, alpha=0.3, s=8, edgecolors="none", c="#2166ac")
150 + mn, mx = min(y_test.min(), y_pred_best.min()), max(y_test.max(), y_pred_best.max())
151 + ax.plot([mn, mx], [mn, mx], "k--", lw=1, label="45-degree line")
152 + ax.set_xlabel("Actual log(rent)")
153 + ax.set_ylabel("Predicted log(rent)")
154 + ax.set_title(f"Predicted vs Actual — {best_name} "
155 + f"(test R²={res_df.loc[res_df['R2_test'].idxmax(), 'R2_test']:.4f})")
156 + ax.legend(loc="upper left", frameon=False)
157 + fig.savefig(FIG_DIR / "ml_predicted_vs_actual.pdf")
158 + plt.close(fig)
159 + print(f"Saved {FIG_DIR / 'ml_predicted_vs_actual.pdf'}")
160 +
161 +
162 +def plot_importances(fitted, X_test) -> None:
163 + """SHAP summary + importance plots, with sklearn fallback."""
164 + shap_available = False
165 + try:
166 + import shap
167 + shap_available = True
168 + print("\nSHAP library found — computing SHAP values ...")
169 + except ImportError:
170 + print("\nSHAP not available — using sklearn feature importances instead.")
171 +
172 + if shap_available:
173 + # Use the best tree model for SHAP; prefer Gradient Boosting, fall back RF
174 + for shap_model_name in ["Gradient Boosting", "Random Forest"]:
175 + if shap_model_name in fitted:
176 + break
177 + shap_model = fitted[shap_model_name]
178 + Xte_shap = X_test # tree models use raw features
179 +
180 + try:
181 + explainer = shap.TreeExplainer(shap_model)
182 + shap_values = explainer.shap_values(Xte_shap)
183 +
184 + # Summary bee-swarm plot
185 + plt.figure()
186 + shap.summary_plot(
187 + shap_values, Xte_shap, feature_names=FEATURES, show=False
188 + )
189 + plt.tight_layout()
190 + plt.savefig(FIG_DIR / "shap_summary.pdf")
191 + plt.close()
192 + print(f"Saved {FIG_DIR / 'shap_summary.pdf'}")
193 +
194 + # Bar plot of mean |SHAP|
195 + plt.figure()
196 + shap.summary_plot(
197 + shap_values, Xte_shap, feature_names=FEATURES,
198 + plot_type="bar", show=False
199 + )
200 + plt.tight_layout()
201 + plt.savefig(FIG_DIR / "feature_importance.pdf")
202 + plt.close()
203 + print(f"Saved {FIG_DIR / 'feature_importance.pdf'}")
204 +
205 + except Exception as e:
206 + print(f"SHAP computation failed ({e}); falling back to sklearn importances.")
207 + shap_available = False
208 +
209 + if not shap_available:
210 + # Fallback: sklearn feature importances from tree models
211 + for imp_model_name in ["Gradient Boosting", "Random Forest"]:
212 + if imp_model_name in fitted and hasattr(fitted[imp_model_name],
213 + "feature_importances_"):
214 + break
215 + imp_model = fitted[imp_model_name]
216 + importances = imp_model.feature_importances_
217 + idx = np.argsort(importances)[::-1]
218 +
219 + fig, ax = plt.subplots(figsize=(7, 5))
220 + ax.barh(
221 + range(len(FEATURES)),
222 + importances[idx[::-1]],
223 + color="#2166ac",
224 + edgecolor="white",
225 + )
226 + ax.set_yticks(range(len(FEATURES)))
227 + ax.set_yticklabels([FEATURES[i] for i in idx[::-1]])
228 + ax.set_xlabel("Feature Importance")
229 + ax.set_title(f"Feature Importance — {imp_model_name}")
230 + fig.savefig(FIG_DIR / "feature_importance.pdf")
231 + plt.close(fig)
232 + print(f"Saved {FIG_DIR / 'feature_importance.pdf'}")
233 +
234 +
235 +def main() -> None:
236 + # 1. Load and prepare data
237 + print("Loading data ...")
238 + df = pd.read_parquet(require(MERGED_ANALYSIS, PROCESSED_HINT))
239 +
240 + cols = FEATURES + [TARGET]
241 + df_ml = df[cols].dropna()
242 + print(f"Sample size after dropping NaN: {len(df_ml):,}")
243 +
244 + X = df_ml[FEATURES].values
245 + y = df_ml[TARGET].values
246 +
247 + # 2. Train / test split
248 + X_train, X_test, y_train, y_test = train_test_split(
249 + X, y, test_size=0.20, random_state=RANDOM_STATE
250 + )
251 + print(f"Train: {len(X_train):,} | Test: {len(X_test):,}")
252 +
253 + # 3. Standardize features
254 + scaler = StandardScaler()
255 + X_train_sc = scaler.fit_transform(X_train)
256 + X_test_sc = scaler.transform(X_test)
257 +
258 + # 4. Define and train models
259 + res_df, fitted = train_models(X_train, X_test, y_train, y_test,
260 + X_train_sc, X_test_sc)
261 +
262 + # 5. Save comparison table
263 + save_comparison_table(res_df)
264 +
265 + # 6. Predicted vs actual scatter (best model by test R²)
266 + plot_predicted_vs_actual(res_df, fitted, X_test, X_test_sc, y_test)
267 +
268 + # 7. SHAP values or sklearn feature importances
269 + plot_importances(fitted, X_test)
270 +
271 + print("\n=== 09_ml_robustness.py complete ===")
272 +
273 +
274 +if __name__ == "__main__":
275 + main()
added scripts/10_robustness_tables_figures.py +360 −0
@@ -0,0 +1,360 @@
1 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +"""
3 +10_robustness_tables_figures.py
4 +-------------------------------
5 +Generate additional publication-quality robustness tables and figures.
6 +
7 +Robustness tables
8 +-----------------
9 +1. Winsorization / outlier robustness -> results/tables/robustness_outliers.tex
10 +2. Subsample analysis -> results/tables/robustness_subsamples.tex
11 +
12 +Additional figures
13 +------------------
14 +1. figures/rent_airbnb_heatmap.pdf — hexbin of Montreal listings
15 +2. figures/coefficient_robustness.pdf — coefficient comparison plot
16 +3. figures/rent_by_airbnb_bins.pdf — mean rent by airbnb quintiles
17 +"""
18 +
19 +import sys
20 +import warnings
21 +from pathlib import Path
22 +
23 +sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
24 +
25 +from src.plotting import use_publication_style
26 +
27 +use_publication_style()
28 +
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 _stars_raw # noqa: E402
36 +
37 +warnings.filterwarnings("ignore")
38 +
39 +PROCESSED_HINT = "Run scripts/04_merge_data.py first."
40 +
41 +
42 +def _stars(pval: float) -> str:
43 + """Return significance stars (empty for NaN p-values)."""
44 + if pd.isna(pval):
45 + return ""
46 + return _stars_raw(pval)
47 +
48 +
49 +# ─────────────────────────────────────────────────────────────────────────────
50 +# Helpers: OLS with city fixed effects
51 +# ─────────────────────────────────────────────────────────────────────────────
52 +
53 +def run_ols_city_fe(data: pd.DataFrame, label: str,
54 + airbnb_var: str = "airbnb_count_500m",
55 + with_fit_stats: bool = True) -> dict:
56 + """
57 + OLS: log_rent ~ airbnb_var + bedrooms + bathrooms + city_FE.
58 + Returns dict with coefficient, std error, p-value (and N, R² when
59 + with_fit_stats is True).
60 + """
61 + needed = ["log_rent", airbnb_var, "bedrooms", "bathrooms", "city"]
62 + sub = data[needed].dropna()
63 + if len(sub) < 30:
64 + out = {"label": label, "coef": np.nan, "se": np.nan, "pval": np.nan}
65 + if with_fit_stats:
66 + out.update({"N": len(sub), "R2": np.nan})
67 + return out
68 + city_dummies = pd.get_dummies(sub["city"], prefix="city", drop_first=True, dtype=float)
69 + X = pd.concat([
70 + sub[[airbnb_var, "bedrooms", "bathrooms"]].astype(float).reset_index(drop=True),
71 + city_dummies.reset_index(drop=True),
72 + ], axis=1)
73 + X = sm.add_constant(X.astype(float))
74 + y = sub["log_rent"].astype(float).reset_index(drop=True)
75 + model = sm.OLS(y, X).fit(cov_type="HC1")
76 + out = {
77 + "label": label,
78 + "coef": model.params[airbnb_var],
79 + "se": model.bse[airbnb_var],
80 + "pval": model.pvalues[airbnb_var],
81 + }
82 + if with_fit_stats:
83 + out.update({"N": int(model.nobs), "R2": model.rsquared})
84 + return out
85 +
86 +
87 +# ═════════════════════════════════════════════════════════════════════════════
88 +# TABLE 1: Winsorization / outlier robustness
89 +# ═════════════════════════════════════════════════════════════════════════════
90 +
91 +def table_outlier_robustness(df: pd.DataFrame) -> None:
92 + print("\n--- Winsorization robustness ---")
93 +
94 + # (a) Full sample
95 + res_full = run_ols_city_fe(df, "Full sample")
96 +
97 + # (b) Winsorized at 5th / 95th percentile on monthly_rent
98 + p5 = df["monthly_rent"].quantile(0.05)
99 + p95 = df["monthly_rent"].quantile(0.95)
100 + df_win = df[(df["monthly_rent"] >= p5) & (df["monthly_rent"] <= p95)].copy()
101 + res_win = run_ols_city_fe(df_win, "Rent winsorized (5/95)")
102 +
103 + # (c) Excluding top / bottom 1% of airbnb_count_500m
104 + p1 = df["airbnb_count_500m"].quantile(0.01)
105 + p99 = df["airbnb_count_500m"].quantile(0.99)
106 + df_trim = df[(df["airbnb_count_500m"] >= p1) & (df["airbnb_count_500m"] <= p99)].copy()
107 + res_trim = run_ols_city_fe(df_trim, "Airbnb trimmed (1/99)")
108 +
109 + outlier_results = [res_full, res_win, res_trim]
110 + for r in outlier_results:
111 + print(f" {r['label']:30s} coef={r['coef']:.6f} N={r['N']}")
112 +
113 + # Build LaTeX table
114 + lines = []
115 + lines.append(r"\begin{tabular}{lccc}")
116 + lines.append(r"\toprule")
117 + lines.append(r" & (1) Full Sample & (2) Rent 5/95 & (3) Airbnb 1/99 \\")
118 + lines.append(r"\midrule")
119 + # Coefficient row
120 + coef_cells = " & ".join(
121 + f"{r['coef']:.6f}{_stars(r['pval'])}" for r in outlier_results
122 + )
123 + lines.append(f"Airbnb count (500m) & {coef_cells} \\\\")
124 + # SE row
125 + se_cells = " & ".join(f"({r['se']:.6f})" for r in outlier_results)
126 + lines.append(f" & {se_cells} \\\\")
127 + lines.append(r"\midrule")
128 + # N
129 + n_cells = " & ".join(f"{r['N']:,}" for r in outlier_results)
130 + lines.append(f"N & {n_cells} \\\\")
131 + # R²
132 + r2_cells = " & ".join(f"{r['R2']:.4f}" for r in outlier_results)
133 + lines.append(f"$R^2$ & {r2_cells} \\\\")
134 + lines.append(r"City FE & Yes & Yes & Yes \\")
135 + lines.append(r"\bottomrule")
136 + lines.append(r"\end{tabular}")
137 + lines.append(r"\begin{tablenotes}\small")
138 + lines.append(r"\item \textit{Notes:} Robust standard errors (HC1) in parentheses. "
139 + r"* $p<0.10$, ** $p<0.05$, *** $p<0.01$. "
140 + r"Column (2) winsorizes monthly rent at the 5th and 95th percentiles. "
141 + r"Column (3) trims the top and bottom 1\% of Airbnb listing counts.")
142 + lines.append(r"\end{tablenotes}")
143 +
144 + (TABLE_DIR / "robustness_outliers.tex").write_text("\n".join(lines) + "\n",
145 + encoding="utf-8")
146 + print(f"Saved {TABLE_DIR / 'robustness_outliers.tex'}")
147 +
148 +
149 +# ═════════════════════════════════════════════════════════════════════════════
150 +# TABLE 2: Subsample analysis
151 +# ═════════════════════════════════════════════════════════════════════════════
152 +
153 +def table_subsamples(df: pd.DataFrame) -> list[dict]:
154 + print("\n--- Subsample analysis ---")
155 +
156 + # Identify Montreal
157 + montreal_mask = df["city"].str.contains("Montr", case=False, na=False)
158 +
159 + sub_results = []
160 +
161 + # (a) Montreal only
162 + res_mtl = run_ols_city_fe(df[montreal_mask], "Montreal only")
163 + sub_results.append(res_mtl)
164 +
165 + # (b) Outside Montreal
166 + res_non = run_ols_city_fe(df[~montreal_mask], "Outside Montreal")
167 + sub_results.append(res_non)
168 +
169 + # (c) Apartments only
170 + apt_mask = df["building_type"].str.contains("Apartment", case=False, na=False)
171 + res_apt = run_ols_city_fe(df[apt_mask], "Apartments only")
172 + sub_results.append(res_apt)
173 +
174 + # (d) Houses only
175 + house_mask = df["building_type"].str.contains("House", case=False, na=False)
176 + res_house = run_ols_city_fe(df[house_mask], "Houses only")
177 + sub_results.append(res_house)
178 +
179 + for r in sub_results:
180 + print(f" {r['label']:30s} coef={r['coef']:.6f} N={r['N']}")
181 +
182 + # Build LaTeX table
183 + lines2 = []
184 + lines2.append(r"\begin{tabular}{lcccc}")
185 + lines2.append(r"\toprule")
186 + lines2.append(r" & (1) Montreal & (2) Outside Mtl & (3) Apartments & (4) Houses \\")
187 + lines2.append(r"\midrule")
188 + coef_cells2 = " & ".join(
189 + f"{r['coef']:.6f}{_stars(r['pval'])}" if not pd.isna(r['coef']) else "---"
190 + for r in sub_results
191 + )
192 + lines2.append(f"Airbnb count (500m) & {coef_cells2} \\\\")
193 + se_cells2 = " & ".join(
194 + f"({r['se']:.6f})" if not pd.isna(r['se']) else ""
195 + for r in sub_results
196 + )
197 + lines2.append(f" & {se_cells2} \\\\")
198 + lines2.append(r"\midrule")
199 + n_cells2 = " & ".join(
200 + f"{r['N']:,}" for r in sub_results
201 + )
202 + lines2.append(f"N & {n_cells2} \\\\")
203 + r2_cells2 = " & ".join(
204 + f"{r['R2']:.4f}" if not pd.isna(r['R2']) else "---"
205 + for r in sub_results
206 + )
207 + lines2.append(f"$R^2$ & {r2_cells2} \\\\")
208 + lines2.append(r"City FE & Yes & Yes & Yes & Yes \\")
209 + lines2.append(r"\bottomrule")
210 + lines2.append(r"\end{tabular}")
211 + lines2.append(r"\begin{tablenotes}\small")
212 + lines2.append(r"\item \textit{Notes:} Robust standard errors (HC1) in parentheses. "
213 + r"* $p<0.10$, ** $p<0.05$, *** $p<0.01$. "
214 + r"All specifications include city fixed effects, bedrooms, and bathrooms "
215 + r"as controls.")
216 + lines2.append(r"\end{tablenotes}")
217 +
218 + (TABLE_DIR / "robustness_subsamples.tex").write_text("\n".join(lines2) + "\n",
219 + encoding="utf-8")
220 + print(f"Saved {TABLE_DIR / 'robustness_subsamples.tex'}")
221 +
222 + return sub_results
223 +
224 +
225 +# ═════════════════════════════════════════════════════════════════════════════
226 +# FIGURE 1: Hexbin heatmap — Montreal area
227 +# ═════════════════════════════════════════════════════════════════════════════
228 +
229 +def figure_heatmap(df: pd.DataFrame) -> None:
230 + print("\n--- Hexbin heatmap (Montreal) ---")
231 +
232 + mtl_area = df[
233 + (df["lat"] >= 45.4) & (df["lat"] <= 45.6)
234 + & (df["lon"] >= -73.8) & (df["lon"] <= -73.5)
235 + ].dropna(subset=["lat", "lon", "airbnb_count_500m"])
236 +
237 + fig, ax = plt.subplots(figsize=(8, 6))
238 + hb = ax.hexbin(
239 + mtl_area["lon"], mtl_area["lat"],
240 + C=mtl_area["airbnb_count_500m"],
241 + reduce_C_function=np.mean,
242 + gridsize=40, cmap="YlOrRd", mincnt=1,
243 + )
244 + fig.colorbar(hb, ax=ax, label="Mean Airbnb count (500m)")
245 + ax.set_xlabel("Longitude")
246 + ax.set_ylabel("Latitude")
247 + ax.set_title("Airbnb Density — Montreal Area")
248 + fig.savefig(FIG_DIR / "rent_airbnb_heatmap.pdf")
249 + plt.close(fig)
250 + print(f"Saved {FIG_DIR / 'rent_airbnb_heatmap.pdf'}")
251 +
252 +
253 +# ═════════════════════════════════════════════════════════════════════════════
254 +# FIGURE 2: Coefficient robustness plot
255 +# ═════════════════════════════════════════════════════════════════════════════
256 +
257 +def figure_coefficient_robustness(df: pd.DataFrame, sub_results: list[dict]) -> None:
258 + print("\n--- Coefficient robustness plot ---")
259 +
260 + res_mtl, res_non, res_apt = sub_results[0], sub_results[1], sub_results[2]
261 +
262 + specs = []
263 + # Baseline (500m)
264 + specs.append(run_ols_city_fe(df, "Baseline (500m)"))
265 +
266 + # Buffer variants
267 + for buf, label in [("250m", "250m buffer"), ("1km", "1km buffer"), ("2km", "2km buffer")]:
268 + var = f"airbnb_count_{buf}"
269 + if var in df.columns:
270 + specs.append(run_ols_city_fe(df, label, airbnb_var=var,
271 + with_fit_stats=False))
272 +
273 + # Subsamples
274 + specs.append(res_mtl)
275 + specs.append(res_non)
276 + specs.append(res_apt)
277 +
278 + # Filter out NaN results
279 + specs = [s for s in specs if not pd.isna(s.get("coef", np.nan))]
280 +
281 + labels = [s["label"] for s in specs]
282 + coefs = np.array([s["coef"] for s in specs])
283 + ses = np.array([s["se"] for s in specs])
284 +
285 + fig, ax = plt.subplots(figsize=(7, 0.5 * len(specs) + 2))
286 + y_pos = np.arange(len(specs))
287 + ax.errorbar(
288 + coefs, y_pos, xerr=1.96 * ses,
289 + fmt="o", color="#2166ac", ecolor="#92c5de", capsize=4,
290 + markersize=6, elinewidth=1.5,
291 + )
292 + ax.axvline(0, color="grey", linestyle="--", linewidth=0.8)
293 + ax.set_yticks(y_pos)
294 + ax.set_yticklabels(labels)
295 + ax.set_xlabel(r"Coefficient on Airbnb count ($\beta$)")
296 + ax.set_title("Coefficient Robustness Across Specifications")
297 + ax.invert_yaxis()
298 + fig.savefig(FIG_DIR / "coefficient_robustness.pdf")
299 + plt.close(fig)
300 + print(f"Saved {FIG_DIR / 'coefficient_robustness.pdf'}")
301 +
302 +
303 +# ═════════════════════════════════════════════════════════════════════════════
304 +# FIGURE 3: Mean rent by Airbnb count quintiles
305 +# ═════════════════════════════════════════════════════════════════════════════
306 +
307 +def figure_rent_by_bins(df: pd.DataFrame) -> None:
308 + print("\n--- Rent by Airbnb quintiles ---")
309 +
310 + sub_q = df[["monthly_rent", "airbnb_count_500m"]].dropna()
311 + sub_q["quintile"] = pd.qcut(
312 + sub_q["airbnb_count_500m"], q=5, labels=False, duplicates="drop"
313 + )
314 + quintile_means = sub_q.groupby("quintile")["monthly_rent"].mean()
315 + quintile_labels = []
316 + for q in sorted(sub_q["quintile"].unique()):
317 + lo = sub_q.loc[sub_q["quintile"] == q, "airbnb_count_500m"].min()
318 + hi = sub_q.loc[sub_q["quintile"] == q, "airbnb_count_500m"].max()
319 + quintile_labels.append(f"Q{int(q)+1}\n[{lo:.0f}-{hi:.0f}]")
320 +
321 + fig, ax = plt.subplots(figsize=(7, 5))
322 + bars = ax.bar(
323 + range(len(quintile_means)),
324 + quintile_means.values,
325 + color="#2166ac",
326 + edgecolor="white",
327 + width=0.65,
328 + )
329 + # Add value labels on bars
330 + for bar, val in zip(bars, quintile_means.values):
331 + ax.text(
332 + bar.get_x() + bar.get_width() / 2, bar.get_height() + 10,
333 + f"${val:,.0f}", ha="center", va="bottom", fontsize=9,
334 + )
335 + ax.set_xticks(range(len(quintile_means)))
336 + ax.set_xticklabels(quintile_labels)
337 + ax.set_xlabel("Airbnb Count (500m) Quintile")
338 + ax.set_ylabel("Mean Monthly Rent ($)")
339 + ax.set_title("Mean Rent by Airbnb Exposure Quintile")
340 + fig.savefig(FIG_DIR / "rent_by_airbnb_bins.pdf")
341 + plt.close(fig)
342 + print(f"Saved {FIG_DIR / 'rent_by_airbnb_bins.pdf'}")
343 +
344 +
345 +def main() -> None:
346 + print("Loading data ...")
347 + df = pd.read_parquet(require(MERGED_ANALYSIS, PROCESSED_HINT))
348 + print(f"Full sample: {len(df):,} observations")
349 +
350 + table_outlier_robustness(df)
351 + sub_results = table_subsamples(df)
352 + figure_heatmap(df)
353 + figure_coefficient_robustness(df, sub_results)
354 + figure_rent_by_bins(df)
355 +
356 + print("\n=== 10_robustness_tables_figures.py complete ===")
357 +
358 +
359 +if __name__ == "__main__":
360 + main()
added src/__init__.py +2 −0
@@ -0,0 +1,2 @@
1 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +"""Shared modules for the Airbnb-rent analysis pipeline (UQO WP5)."""
added src/config.py +53 −0
@@ -0,0 +1,53 @@
1 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +"""
3 +Central configuration: project paths and pipeline constants.
4 +
5 +Every path is derived from the repository root so the pipeline can run from
6 +any working directory. Output directories are created on import.
7 +"""
8 +
9 +from pathlib import Path
10 +
11 +# ── repository layout ────────────────────────────────────────────────────────
12 +ROOT = Path(__file__).resolve().parent.parent
13 +
14 +DATA_RAW = ROOT / "data" / "raw"
15 +DATA_PROCESSED = ROOT / "data" / "processed"
16 +FIG_DIR = ROOT / "figures"
17 +TABLE_DIR = ROOT / "results" / "tables"
18 +LOG_DIR = ROOT / "results" / "logs"
19 +
20 +# Raw inputs (not distributed with the repository; see data/raw/README.md)
21 +AIRBNB_RAW = DATA_RAW / "airbnb.csv"
22 +RENT_RAW = DATA_RAW / "rent.json"
23 +
24 +# Processed datasets
25 +AIRBNB_CLEAN = DATA_PROCESSED / "airbnb_clean.parquet"
26 +RENT_CLEAN = DATA_PROCESSED / "rent_clean.parquet"
27 +MERGED_SPATIAL = DATA_PROCESSED / "merged_spatial.parquet"
28 +MERGED_NEIGHBORHOOD = DATA_PROCESSED / "merged_neighborhood.parquet"
29 +MERGED_ANALYSIS = DATA_PROCESSED / "merged_analysis.parquet"
30 +
31 +# ── pipeline constants ───────────────────────────────────────────────────────
32 +# Buffer radii (km) for the spatial merge and robustness checks
33 +BUFFER_KM = [0.25, 0.5, 1.0, 2.0]
34 +
35 +# Rental rows per chunk in the vectorised Haversine merge
36 +CHUNK_SIZE = 500
37 +
38 +EARTH_RADIUS_KM = 6_371.0
39 +
40 +# Random seed for the ML robustness models (train/test split and estimators)
41 +RANDOM_STATE = 42
42 +
43 +for _d in (DATA_PROCESSED, FIG_DIR, TABLE_DIR, LOG_DIR):
44 + _d.mkdir(parents=True, exist_ok=True)
45 +
46 +
47 +def require(path: Path, hint: str) -> Path:
48 + """Return *path* if it exists, otherwise abort with a clear message."""
49 + if not path.exists():
50 + raise SystemExit(
51 + f"Missing input: {path}\n{hint}"
52 + )
53 + return path
added src/geo.py +33 −0
@@ -0,0 +1,33 @@
1 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +"""Geospatial helpers."""
3 +
4 +import numpy as np
5 +
6 +from src.config import EARTH_RADIUS_KM
7 +
8 +
9 +def haversine_matrix(lat1, lon1, lat2, lon2):
10 + """
11 + Vectorised Haversine distance between every pair (i, j) where
12 + i indexes lat1/lon1 and j indexes lat2/lon2.
13 +
14 + Parameters
15 + ----------
16 + lat1, lon1 : np.ndarray, shape (n,)
17 + lat2, lon2 : np.ndarray, shape (m,)
18 +
19 + Returns
20 + -------
21 + dist : np.ndarray, shape (n, m) — distances in kilometres
22 + """
23 + lat1 = np.radians(lat1)[:, None]
24 + lon1 = np.radians(lon1)[:, None]
25 + lat2 = np.radians(lat2)[None, :]
26 + lon2 = np.radians(lon2)[None, :]
27 +
28 + dlat = lat2 - lat1
29 + dlon = lon2 - lon1
30 +
31 + a = np.sin(dlat / 2.0) ** 2 + np.cos(lat1) * np.cos(lat2) * np.sin(dlon / 2.0) ** 2
32 + c = 2.0 * np.arcsin(np.sqrt(a))
33 + return EARTH_RADIUS_KM * c
added src/latex_tables.py +129 −0
@@ -0,0 +1,129 @@
1 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +"""
3 +Shared LaTeX table helpers for the regression scripts.
4 +
5 +`significance_star` and `results_to_latex` reproduce byte-for-byte the table
6 +format of the published tables (stargazer-style booktabs tables with HC1
7 +robust standard errors in parentheses).
8 +
9 +Tables are written as *fragments* (no ``\\begin{table}`` wrapper): the paper
10 +wraps each ``\\input`` in its own ``table`` environment with caption and label.
11 +"""
12 +
13 +from pathlib import Path
14 +
15 +
16 +def significance_star(pval: float) -> str:
17 + """Return conventional significance stars for a p-value."""
18 + if pval < 0.01:
19 + return "***"
20 + elif pval < 0.05:
21 + return "**"
22 + elif pval < 0.10:
23 + return "*"
24 + return ""
25 +
26 +
27 +def results_to_latex(
28 + results_list: list,
29 + model_names: list[str],
30 + dep_var: str,
31 + display_vars: list[str] | None = None,
32 + out_path: Path | None = None,
33 + note: str = "",
34 +) -> str:
35 + """
36 + Produce a stargazer-style LaTeX table from a list of OLS results.
37 +
38 + Parameters
39 + ----------
40 + results_list : list of statsmodels RegressionResultsWrapper
41 + model_names : column headers for each model
42 + dep_var : dependent variable label for the table caption row
43 + display_vars : subset of variable names to display (None = all)
44 + out_path : file to write; None = no write
45 + note : optional footnote text
46 + """
47 + n_models = len(results_list)
48 +
49 + # Collect the union of variable names across all models
50 + if display_vars is None:
51 + all_vars: list[str] = []
52 + for res in results_list:
53 + for v in res.params.index:
54 + if v not in all_vars:
55 + all_vars.append(v)
56 + display_vars = all_vars
57 +
58 + # Build LaTeX
59 + col_spec = "l" + "c" * n_models
60 + lines: list[str] = []
61 + lines.append(r"\small")
62 + lines.append(r"\begin{tabular}{" + col_spec + "}")
63 + lines.append(r"\toprule")
64 +
65 + # Header
66 + header = " & ".join([""] + [f"\\textbf{{{mn}}}" for mn in model_names]) + r" \\"
67 + lines.append(header)
68 + lines.append(
69 + " & ".join(["Dep.\\ var:"] + [f"\\textit{{{dep_var}}}" for _ in model_names])
70 + + r" \\"
71 + )
72 + lines.append(r"\midrule")
73 +
74 + # Coefficients
75 + for var in display_vars:
76 + coef_cells = []
77 + se_cells = []
78 + for res in results_list:
79 + if var in res.params.index:
80 + b = res.params[var]
81 + se = res.bse[var]
82 + p = res.pvalues[var]
83 + star = significance_star(p)
84 + coef_cells.append(f"{b:.4f}{star}")
85 + se_cells.append(f"({se:.4f})")
86 + else:
87 + coef_cells.append("")
88 + se_cells.append("")
89 + # Pretty variable name
90 + vname = var.replace("_", r"\_")
91 + lines.append(" & ".join([vname] + coef_cells) + r" \\")
92 + lines.append(" & ".join([""] + se_cells) + r" \\[4pt]")
93 +
94 + lines.append(r"\midrule")
95 +
96 + # Fit statistics
97 + for label, accessor in [
98 + ("Observations", lambda r: f"{int(r.nobs)}"),
99 + ("R$^2$", lambda r: f"{r.rsquared:.4f}"),
100 + ("Adj.\\ R$^2$", lambda r: f"{r.rsquared_adj:.4f}"),
101 + ]:
102 + cells = [label]
103 + for res in results_list:
104 + try:
105 + cells.append(accessor(res))
106 + except Exception:
107 + cells.append("")
108 + lines.append(" & ".join(cells) + r" \\")
109 +
110 + lines.append(r"\bottomrule")
111 + lines.append(r"\end{tabular}")
112 +
113 + # Note
114 + if note:
115 + lines.append(r"\vspace{4pt}")
116 + lines.append(r"\parbox{\textwidth}{\footnotesize " + note + "}")
117 +
118 + lines.append(
119 + r"\parbox{\textwidth}{\footnotesize Robust (HC1) standard errors in "
120 + r"parentheses. $^{***}p<0.01$; $^{**}p<0.05$; $^{*}p<0.10$.}"
121 + )
122 +
123 + tex = "\n".join(lines) + "\n"
124 +
125 + if out_path is not None:
126 + out_path.write_text(tex, encoding="utf-8")
127 + print(f" -> saved {out_path}")
128 +
129 + return tex
added src/plotting.py +48 −0
@@ -0,0 +1,48 @@
1 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +"""
3 +Shared matplotlib configuration.
4 +
5 +Two publication styles are used in the original pipeline and are preserved
6 +exactly so regenerated figures match the originals:
7 +
8 +- `use_descriptive_style()` — seaborn whitegrid, used by the descriptive
9 + figures (script 05).
10 +- `use_publication_style()` — serif fonts, used by the ML robustness and
11 + additional-figures scripts (scripts 09 and 10).
12 +"""
13 +
14 +import matplotlib
15 +
16 +matplotlib.use("Agg") # non-interactive backend
17 +
18 +import matplotlib.pyplot as plt # noqa: E402 (backend must be set first)
19 +
20 +
21 +def use_descriptive_style() -> None:
22 + """Style for the descriptive tables/figures script."""
23 + plt.style.use("seaborn-v0_8-whitegrid")
24 + plt.rcParams.update({
25 + "font.size": 11,
26 + "axes.titlesize": 13,
27 + "axes.labelsize": 12,
28 + "xtick.labelsize": 10,
29 + "ytick.labelsize": 10,
30 + "figure.dpi": 150,
31 + "savefig.dpi": 300,
32 + "savefig.bbox": "tight",
33 + "pdf.fonttype": 42, # TrueType for journal submission
34 + })
35 +
36 +
37 +def use_publication_style() -> None:
38 + """Serif style for the ML and robustness figure scripts."""
39 + plt.rcParams.update({
40 + "font.family": "serif",
41 + "font.size": 10,
42 + "axes.titlesize": 12,
43 + "axes.labelsize": 11,
44 + "figure.figsize": (7, 5),
45 + "figure.dpi": 150,
46 + "savefig.bbox": "tight",
47 + "savefig.pad_inches": 0.05,
48 + })
49