SPB Git

spb/wp9_uqo Public

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

TeX 60.1% Python 39.8%
15.7 KB · 304 lines markdown
Rendered Raw Blame History
1# 🏘️ A Grand Hedonic Model of the Canadian Housing Market23**UQO Working Paper No. 9** — *Decomposing the Value of Structure and Location across4140,931 MLS Listings with High-Dimensional Neighbourhood Fixed Effects*56[![Paper](https://img.shields.io/badge/paper-PDF%20(26%20p.)-b31b1b?logo=latex&logoColor=white)](paper/main.pdf)7[![Python](https://img.shields.io/badge/python-3.11%2B-3776AB?logo=python&logoColor=white)](requirements.txt)8[![DuckDB](https://img.shields.io/badge/data-DuckDB%20·%20172%2C019%20listings-FFF000?logo=duckdb&logoColor=black)](data/raw/README.md)9[![Estimation](https://img.shields.io/badge/estimation-statsmodels%20·%20linearmodels%20(AbsorbingLS)-4051b5)](src/wp9/models.py)10[![Reproducible](https://img.shields.io/badge/reproducible-end--to--end%20pipeline-2e7d32)](scripts/)11[![Tables verified](https://img.shields.io/badge/tables-identical%20to%20published-2e7d32)](AUDIT.md)12[![Institution](https://img.shields.io/badge/UQO-D%C3%A9pt.%20des%20sciences%20administratives-16365c)](https://uqo.ca)13[![Author](https://img.shields.io/badge/author-Simon--Pierre%20Boucher-16365c)](mailto:contact@spboucher.ai)14[![Contact](https://img.shields.io/badge/contact-contact%40spboucher.ai-a02020?logo=maildotru&logoColor=white)](mailto:contact@spboucher.ai)1516> **TL;DR** — On 140,931 Canadian MLS listings, absorbing 1,153 neighbourhood (FSA) fixed17> effects lifts explained log-price variance from **46% → 77%**. Location alone is worth18> ~30 percentage points of R² — more than every structural attribute combined. The model19> values held-out homes with a median absolute error of **15.8%** (OOS R² = 0.764),20> competitive with commercial AVMs, while staying fully transparent.2122---2324## 📖 Table of contents25261. [What this paper does](#-what-this-paper-does)272. [Headline results](#-headline-results)283. [Repository layout](#-repository-layout)294. [Quick start](#-quick-start)305. [The pipeline, step by step](#-the-pipeline-step-by-step)316. [Data](#-data)327. [Methodology](#-methodology)338. [The two results tiers (reference vs. reproduced)](#-the-two-results-tiers-reference-vs-reproduced)349. [Figures & tables inventory](#-figures--tables-inventory)3510. [Reproduction verification](#-reproduction-verification)3611. [Limitations](#-limitations)3712. [Citation](#-citation)3813. [Author & contact](#-author--contact)3940---4142## 🎯 What this paper does4344A dwelling is the archetypal heterogeneous good: no two houses are identical, and the most45important attribute — *where it stands* — is unobservable as a scalar. This project46estimates a **semi-logarithmic hedonic price equation** at national scale for Canada:4748```49ln P_i = α + β·ln(Area_i) + x_i'γ + d_i'δ + μ_f(i) + ε_i50```5152where `μ_f(i)` is a fixed effect for the **Forward Sortation Area** (FSA — the first three53characters of the postal code) of listing *i*. With 1,153 absorbed FSA intercepts, every54neighbourhood gets an arbitrary price level that soaks up schools, transit, coastline,55employment density — observed or not — and the structural implicit prices (β, γ) are56identified purely from **within-neighbourhood** variation. Estimation is by absorbing57least squares (numerically identical to full-dummy OLS); inference is clustered by FSA.5859Beyond the headline decomposition the paper delivers: out-of-sample valuation accuracy60(80/20 split, Duan-smeared retransformation), robustness across six sample cuts, quantile61hedonic regressions, a quadratic test of diminishing returns to floor space, an urban62price gradient in distance to the nine major metros, Moran's I spatial diagnostics,63leave-one-province-out transferability, and a ranked map of Canada's most and least64expensive neighbourhoods net of structure.6566## 🏆 Headline results6768| Quantity | Value |69|---|---|70| Estimation sample | **140,931** listings — 82,334 houses, 57,857 condos, 9 provinces |71| Absorbed neighbourhood effects | **1,153** FSAs |72| R²: structural only (M1) | 0.464 |73| R²: + dwelling type & ownership (M2) | 0.469 |74| R²: + province FE (M3) | 0.567 |75| R²: grand model, FSA FE (M5) | **0.767** |76| Living-area elasticity (M5) | **0.547** (cluster SE 0.009) |77| Full-bathroom premium | **0.109 log points ≈ +11%** per bathroom |78| Bedrooms, conditional on area | ≈ 0 (the textbook hedonic result) |79| Out-of-sample R² (log price) | **0.764** |80| Median absolute valuation error | **15.8%** (59% of homes priced within ±20%) |81| Moran's I of residuals, structural → grand | **0.46 → 0.08** (−82%) |82| Urban gradient | −8.5% location premium per doubling of distance to metro |83| Neighbourhood premia span | ×9 between the most and least expensive FSAs |8485The most expensive neighbourhoods net of structure are all in the **City of Vancouver**86(V6S, V8E, V6T: +150–200% vs. the national median); the cheapest are in rural87Saskatchewan, Manitoba and Newfoundland (−60 to −67%).8889## 📁 Repository layout9091```92wp9_uqo/93├── README.md                  ← you are here94├── AUDIT.md                   forensic audit of the original project + verification95├── CHANGES.md                 restructuring report (what moved, what was rewritten)96├── requirements.txt           pinned Python dependencies97├── .gitignore98├── data/99│   ├── raw/100│   │   ├── realtor_mls_unique.duckdb   ⚠ 747 MB — NOT in git (see Data section)101│   │   └── README.md102│   └── processed/103│       └── analysis.parquet   estimation sample, 142k rows (generated by step 01)104├── src/wp9/                   analysis package105│   ├── config.py              paths, constants, seeds, metro coordinates106│   ├── parsing.py             parsers for the semi-structured MLS fields107│   ├── sample.py              raw DuckDB → estimation sample108│   ├── models.py              design matrix, M1–M5 ladder, AbsorbingLS, FE recovery109│   └── plotstyle.py           shared matplotlib style110├── scripts/                   numbered pipeline entry points111│   ├── 01_build_sample.py112│   ├── 02_estimate_core.py113│   ├── 03_estimate_extended.py114│   ├── 04_make_figures.py     (--results reference|reproduced)115│   └── 05_make_tables.py      (--results reference|reproduced)116├── figures/                   all 17 paper figures (PNG, generated)117├── results/118│   ├── reference/             ORIGINAL published outputs — canonical paper numbers119│   ├── reproduced/            outputs regenerated end-to-end by this pipeline120│   └── tables/                the 6 LaTeX tables consumed by the paper121└── paper/122    ├── main.tex               preamble + metadata; \input's the sections123    ├── main.pdf               compiled paper (26 pages)124    ├── sections/              titlepage, introduction, literature, data,125    │                          methodology, results, robustness, conclusion126    ├── references.bib         32 entries, natbib author-year, aer style127    ├── Makefile / .latexmkrc  build config128    └── uq_logo.jpg129```130131## 🚀 Quick start132133```bash134git clone https://github.com/spboucher-ai/wp9_uqo.git135cd wp9_uqo136python3 -m pip install -r requirements.txt137138# Full pipeline (needs data/raw/realtor_mls_unique.duckdb — see Data section):139python3 scripts/01_build_sample.py        # duckdb → data/processed/analysis.parquet140python3 scripts/02_estimate_core.py       # M1–M5 ladder → results/reproduced/141python3 scripts/03_estimate_extended.py   # OOS, robustness, quantile, Moran, LOPO…142python3 scripts/04_make_figures.py        # figures/ (17 PNG)143python3 scripts/05_make_tables.py         # results/tables/ (6 .tex)144145# Paper:146cd paper && latexmk -pdf main.tex         # or `make`147```148149Without the raw DuckDB you can still run steps **02→05**: the committed150`data/processed/analysis.parquet` (6 MB) is the estimation sample produced by step 01.151152## 🔬 The pipeline, step by step153154| Step | Script | Input | Output | Runtime* |155|---|---|---|---|---|156| 01 | `01_build_sample.py` | raw DuckDB (172,019 rows) | `analysis.parquet` (142k rows, 21 cols) | ~40 s |157| 02 | `02_estimate_core.py` | parquet | `fit.json`, `coef_M{1,2,3,5}.csv`, `grand_model.parquet`, `fsa_premia.csv` | ~1 min |158| 03 | `03_estimate_extended.py` | parquet + step 02 | `oos.json`, `robustness.csv`, `heterogeneity.csv`, `quantile.csv`, `lopo.csv`, `ext2.json`, Moran arrays, gradient/nonlinearity bands | ~6 min |159| 04 | `04_make_figures.py` | results tier + parquet | 17 PNG figures | ~30 s |160| 05 | `05_make_tables.py` | results tier | 6 LaTeX tables | ~5 s |161162\* Apple Silicon, single process.163164**Step 01 in detail** (documented in `src/wp9/parsing.py` / `sample.py`):165166- bedrooms `"3 + 1"` (main + basement) → summed;167- living area: prefers the explicit floor-area measurement; banded entries168  (`"1100-1500 sqft"`) map to the **upper bound** of the band; falls back to169  `size_interior`; sqft → m² at 0.0929;170- lot size parsed from free text (`"6000 sqft"`, `"0.14 ac"`, `"under 1/2 acre"`,171  `"50 x 120"` frontage×depth) with a missingness indicator; the top 1% of positive172  parsed lots (multi-acre rural acreage strings) treated as "no usable lot info";173- dwelling type consolidated to 8 groups, ownership to 6 groups;174- FSA from the postal code; FSAs with < 25 listings pooled into `<PROV>_other`;175- filters: strictly positive price, non-missing living area / bedrooms / bathrooms,176  valid FSA; then the extreme 1% tails of price and living area are trimmed.177178## 📊 Data179180**Source file:** `data/raw/realtor_mls_unique.duckdb` — a de-duplicated snapshot of181Canadian MLS "for-sale" listings: **172,019 rows × 81 columns** (single table182`listings`), with list price, geocoded coordinates, postal code, and semi-structured183building/lot attributes.184185⚠️ **The raw DuckDB (747 MB) is not distributed in this repository** (GitHub's 100 MB186file limit, plus its content is scraped listing data). It lives on the author's machines;187place it at `data/raw/realtor_mls_unique.duckdb` to run step 01. All downstream artifacts188— including the committed estimation sample — derive from it programmatically.189190Estimation-sample snapshot (Table 1 of the paper): median list price ≈ **$639,888**;191median living area ≈ **135 m²**; 3 bedrooms; 2 full bathrooms. Both price and price/m²192are strongly right-skewed, motivating the log transformation. Ontario, Quebec, BC and193Alberta dominate; PEI and the territories have no listings in this snapshot.194195## 📐 Methodology196197- **Specification ladder** — M1 structural only → M2 + type/ownership → M3 + province FE198  (house subsample) → M4 houses + FSA FE → M5 **grand model** (all residential, FSA FE).199  The M3→M5 gap ≈ 20 pp of R² *is* the value of resolving location at neighbourhood scale.200- **Absorbing least squares** (`linearmodels.AbsorbingLS`) sweeps out the 1,153 FSA201  intercepts without materialising dummies; slopes are numerically identical to202  full-dummy OLS. SEs clustered by FSA throughout.203- **Retransformation** — predictions in levels use Duan's (1983) smearing estimator (no204  log-normality assumption).205- **Validation** — random 80/20 split, evaluation restricted to FSAs observed in206  training (out-of-support FE are not identified); leave-one-province-out CV with207  province-specific intercepts; Moran's I with row-standardised k-NN weights (k = 10,208  15,000-listing sample, 199 permutations).209210## 🧭 The two results tiers (reference vs. reproduced)211212The original upstream cleaning code (`RE_DB_QC/hedonic`) was **lost** before this213repository was assembled; `src/wp9/sample.py` is a careful reconstruction from the214paper's own data section, calibrated against every stored output (see `AUDIT.md`). It215reproduces the published sample to **+0.86%** and all headline estimates closely — but216not to the last digit. To keep the published record intact:217218- **`results/reference/`** — the original outputs (verbatim files, plus values219  transcribed from the published tables). **Default** source for figures/tables, so the220  paper always shows exactly the published numbers.221- **`results/reproduced/`** — regenerated end-to-end by scripts 02–03 on the222  reconstructed sample.223224```bash225python3 scripts/04_make_figures.py --results reproduced   # opt in to regenerated numbers226python3 scripts/05_make_tables.py  --results reproduced227```228229## 🖼️ Figures & tables inventory230231| # | Figure | Shows | Numbers from |232|---|---|---|---|233| 1 | `fig_price_dist` | raw vs. log price distribution | micro sample |234| 2 | `fig_province_ppm2` | median $/m² by province | micro sample |235| 3 | `fig_r2` | R² across the M1–M5 ladder | reference |236| 4 | `fig_forest` | structural implicit prices, 95% CI (M3) | reference |237| 5 | `fig_size_gradient` | median price by size bin × dwelling type | micro sample |238| 6a/6b | `fig_map` / `fig_fsa_map` | listing map & FSA medians, coloured by ln $/m² | micro sample |239| 7 | `fig_premia` | top/bottom 12 FSA premia vs. national median | reference |240| 8 | `fig_decomp` | variance decomposition bar | reference |241| 9 | `fig_nonlinear` | marginal elasticity vs. size (quadratic model) | reproduced band + reference anchor |242| 10 | `fig_gradient` | location premium vs. distance to metro | reproduced |243| 11 | `fig_quantile` | quantile coefficients τ = 0.1…0.9 | reference |244| 12 | `fig_moran` | Moran scatterplots, structural vs. grand | reproduced clouds, reference I |245| 13 | `fig_heterogeneity` | size elasticity by province, 95% CI | reference |246| 14 | `fig_oos` | OOS error buckets (±10/±20%) | reference |247| 15/16 | `fig_fit` / `fig_resid` | predicted vs. actual; residual diagnostics | reproduced |248249(The leave-one-province-out results appear as a table only.)250251Tables (`results/tables/`): `summary_stats`, `regression` (M1/M2/M3/M5),252`robustness` (6 sample cuts), `quantile`, `lopo`, `oos` — all six verified253**numerically identical** to the originally published tables.254255## ✅ Reproduction verification256257Full side-by-side in [`AUDIT.md`](AUDIT.md) §5. Highlights (original → reproduced):258259- Sample: 140,931 → 142,146 (+0.86%); condos match to 3 listings.260- R² ladder: 0.464/0.469/0.567/0.762/0.767 → 0.467/0.470/0.569/0.766/0.770.261- OOS: R² 0.764 → 0.770; median APE 15.8% → 15.6%.262- Moran's I: 0.459→0.082 vs. 0.494→0.072 (same −82~85% conclusion).263- Known gaps (flagged, not hidden): M5 living elasticity 0.547 vs. 0.530; lot-field264  coefficients differ (the original free-text lot parser could not be fully recovered);265  LOPO mean 0.362 vs. 0.315 with identical province ranking.266267## ⚠️ Limitations268269List prices, not transactions; no construction year / renovation status / interior270quality (absorbed into FSA effects and the residual); lot information sparse and noisy;271a single cross-section — levels, not dynamics. See the paper's conclusion for the272research agenda these imply.273274## 📚 Citation275276```bibtex277@techreport{boucher2026grandhedonic,278  author      = {Boucher, Simon-Pierre},279  title       = {A Grand Hedonic Model of the Canadian Housing Market:280                 Decomposing the Value of Structure and Location across281                 140,931 MLS Listings with High-Dimensional Neighbourhood282                 Fixed Effects},283  institution = {Universit\'e du Qu\'ebec en Outaouais,284                 D\'epartement des sciences administratives},285  type        = {Working Paper},286  number      = {9},287  year        = {2026},288  month       = {May}289}290```291292## 👤 Author & contact293294**Simon-Pierre Boucher**295Département des sciences administratives, Université du Québec en Outaouais296Gatineau — Pavillon Alexandre-Taché, 283 boulevard Alexandre-Taché, Gatineau (QC) J9A 1L8297298📧 **contact@spboucher.ai** · 🌐 [spboucher.ai](https://www.spboucher.ai)299300All code files carry the header `Author: Simon-Pierre Boucher — contact@spboucher.ai`.301302© 2026 Simon-Pierre Boucher. All rights reserved. The listing data snapshot is not303redistributed; code and paper are shared for research reproducibility.304