SPB Git

spb/wp11_uqo Public

UQO Working Paper No. 11 — Half a million prices, twenty models: a systematic assessment of hedonic specifications.

TeX 54.7% Python 45.2%

WP11 — Half a Million Prices, Twenty Models: full research project

Controlled horse race of 20 hedonic models across four design axes
(functional form, time FE, spatial FE, estimation method) on 514,212
Quebec sales, random + forward-in-time holdouts, price indices,
implicit-price profiles, learning curves, segments; 9 figures,
7 tables, 23-page paper with literature review.

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

Showing 54 changed files with +3,213 and −0

added .gitignore +21 −0
@@ -0,0 +1,21 @@
1 +# Raw & processed data — transaction microdata are NOT redistributed
2 +data/raw/*.parquet
3 +data/raw/*.csv
4 +data/processed/*.parquet
5 +
6 +# Python
7 +__pycache__/
8 +*.pyc
9 +
10 +# LaTeX build artifacts (paper/main.pdf IS committed)
11 +paper/*.aux
12 +paper/*.log
13 +paper/*.out
14 +paper/*.fls
15 +paper/*.fdb_latexmk
16 +paper/*.bbl
17 +paper/*.blg
18 +paper/*.synctex.gz
19 +
20 +# macOS
21 +.DS_Store
added README.md +95 −0
@@ -0,0 +1,95 @@
1 +<!-- Author: Simon-Pierre Boucher — contact@spboucher.ai -->
2 +# 🏇 Half a Million Prices, Twenty Models
3 +
4 +**UQO Working Paper No. 11** — *A Systematic Assessment of Hedonic Specifications
5 +and Estimation Methods for the Quebec Housing Market, 2021–2026*
6 +
7 +[![Paper](https://img.shields.io/badge/paper-PDF%20(23%20p.)-b31b1b?logo=latex&logoColor=white)](paper/main.pdf)
8 +[![Python](https://img.shields.io/badge/python-3.11%2B-3776AB?logo=python&logoColor=white)](requirements.txt)
9 +[![Data](https://img.shields.io/badge/data-514%2C212%20sales%20·%20roll--matched-4051b5)](data/raw/README.md)
10 +[![Models](https://img.shields.io/badge/models-20%20·%204%20design%20axes-4051b5)](src/wp11/models.py)
11 +[![Reproducible](https://img.shields.io/badge/reproducible-end--to--end%20pipeline-2e7d32)](scripts/)
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)
15 +
16 +> **TL;DR** — A controlled horse race of **20 hedonic models** on 514,212 Quebec sales
17 +> with assessor-grade structural attributes: 6 functional forms × time-FE ladder ×
18 +> spatial-FE ladder × 6 estimation methods, all scored on the **same price-level
19 +> scoreboard** (Duan smearing / Box–Cox inversion) under **two holdouts** (random 80/20
20 +> and forward-in-time). Functional form is second-order (±2 pp of MdAPE); spatial
21 +> controls are first-order (~10 pp) but overfit at ~1 km granularity; **gradient
22 +> boosting wins under random validation** (MdAPE ≈ 14.4% vs 16.5% for the best linear
23 +> model) — but under the forward-in-time split *every* model degrades and the ML
24 +> advantage **reverses** (splines 19.1% vs boosting 21.2%, identical R²_ln): random
25 +> cross-validation is the wrong experiment for valuation models. Implied price indices
26 +> and implicit-price profiles agree across forms far more than the accuracy gap suggests.
27 +
28 +---
29 +
30 +## 🎯 The four design axes
31 +
32 +| Axis | Models | Question |
33 +|---|---|---|
34 +| **A. Functional form** | linear, semi-log, log-log, Box–Cox (λ̂ by profile likelihood), quadratics, cubic splines | does curvature matter? |
35 +| **B. Time effects** | none / year / quarter / month FE | how fine must the time dummies be? |
36 +| **C. Spatial controls** | none / municipality / ~5.5 km grid / ~1.1 km grid FE | how fine can location FE get before overfitting? |
37 +| **D. Estimation method** | OLS, ridge (CV), random forest, gradient boosting (± coordinates), spatial k-NN comparables | what do the machines actually buy? |
38 +
39 +Every model: same sales, same attributes (floor area, lot, age, storeys, units,
40 +class, physical link), metrics on price levels (MdAPE, MAPE, RMSE_ln, R²_ln),
41 +municipality-clustered design, and **two splits** — random 80/20 and
42 +train < 2025 / test 2025–26 with carry-forward time effects.
43 +
44 +## 🗂 Repository layout
45 +
46 +```
47 +wp11_uqo/
48 +├── data/raw/ # snapshot (NOT in git — see data/raw/README.md)
49 +├── src/wp11/ # config, sample, models (the harness), plotstyle
50 +├── scripts/
51 +│ ├── 01_build_sample.py # 745,119 → 514,212 sales
52 +│ ├── 02_horserace.py # 20 models × 2 splits + Box–Cox profile
53 +│ ├── 03_extensions.py # indices, implicit-price profiles, learning curves, segments
54 +│ ├── 04_make_figures.py # 9 journal-calibre figures
55 +│ └── 05_make_tables.py # 7 LaTeX tables
56 +├── results/{reproduced,tables}/
57 +├── figures/
58 +└── paper/ # main.tex + sections/ + references.bib → main.pdf
59 +```
60 +
61 +## 🚀 Quick start
62 +
63 +```bash
64 +cd wp11_uqo
65 +python3 -m pip install -r requirements.txt
66 +# place the parquet snapshot in data/raw/ (not distributed), then:
67 +for s in scripts/0*.py; do python3 "$s"; done
68 +cd paper && make
69 +```
70 +
71 +Runtime ≈ 10 minutes on an Apple-silicon laptop (the boosting and forest fits
72 +dominate); peak RAM ≈ 8 GB.
73 +
74 +## 📝 Citation
75 +
76 +```bibtex
77 +@techreport{boucher2026horserace,
78 + author = {Boucher, Simon-Pierre},
79 + title = {Half a Million Prices, Twenty Models: A Systematic Assessment
80 + of Hedonic Specifications and Estimation Methods for the
81 + Quebec Housing Market},
82 + institution = {Universit\'e du Qu\'ebec en Outaouais,
83 + D\'epartement des sciences administratives},
84 + type = {Working Paper},
85 + number = {11},
86 + year = {2026},
87 + month = {August}
88 +}
89 +```
90 +
91 +## 👤 Author & contact
92 +
93 +**Simon-Pierre Boucher** — Département des sciences administratives,
94 +Université du Québec en Outaouais (UQO), Gatineau, QC.
95 +📧 [contact@spboucher.ai](mailto:contact@spboucher.ai)
added data/raw/README.md +19 −0
@@ -0,0 +1,19 @@
1 +<!-- Author: Simon-Pierre Boucher — contact@spboucher.ai -->
2 +# Raw data
3 +
4 +`transactions_700k_avec_registre_foncier.parquet` — 745,119 Quebec
5 +residential-market transactions (January 2021 – July 2026) matched at the
6 +parcel level to the municipal assessment roll in force at the sale date.
7 +Same snapshot as UQO WP10; see that repository's data documentation for
8 +the full column dictionary.
9 +
10 +Columns used by WP11: sale price/date/coordinates, and the roll's
11 +structural descriptors — floor area (`role_aire_etages_m2`), lot area
12 +(`role_superficie_terrain_m2`), year built (`role_annee_construction`),
13 +units (`role_nb_logements`), storeys (`role_nb_etages`), physical link
14 +(`role_lien_physique`), use code (`role_cubf`), municipality
15 +(`role_code_mun`).
16 +
17 +The file is **not redistributed** with the repository (transaction
18 +microdata; 100 MB). Place it in this directory before running the
19 +pipeline.
added figures/fig_forms.png +0 −0

Binary file not shown.

added figures/fig_generalization.png +0 −0

Binary file not shown.

added figures/fig_index.png +0 −0

Binary file not shown.

added figures/fig_learning.png +0 −0

Binary file not shown.

added figures/fig_methods.png +0 −0

Binary file not shown.

added figures/fig_profiles.png +0 −0

Binary file not shown.

added figures/fig_segments.png +0 −0

Binary file not shown.

added figures/fig_space.png +0 −0

Binary file not shown.

added figures/fig_time.png +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 +9 −0
@@ -0,0 +1,9 @@
1 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +all:
3 + latexmk -pdf main.tex
4 +
5 +clean:
6 + latexmk -c
7 +
8 +distclean:
9 + latexmk -C
added paper/main.pdf +0 −0

Binary file not shown.

added paper/main.tex +118 −0
@@ -0,0 +1,118 @@
1 +% Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +% ============================================================================
3 +% UQO Working Paper No. 11
4 +% Half a Million Prices, Twenty Models
5 +%
6 +% Build: latexmk -pdf main.tex (or `make` in this directory)
7 +% Figures are read from ../figures/, tables from ../results/tables/.
8 +% ============================================================================
9 +\documentclass[12pt,letterpaper]{article}
10 +
11 +\usepackage[utf8]{inputenc}
12 +\usepackage[T1]{fontenc}
13 +\usepackage[english]{babel}
14 +
15 +\usepackage[letterpaper,margin=1in]{geometry}
16 +\usepackage{setspace}
17 +\onehalfspacing
18 +
19 +\usepackage{mathptmx}
20 +\usepackage{microtype}
21 +
22 +\usepackage{amsmath,amssymb,amsthm}
23 +
24 +\usepackage{booktabs}
25 +\usepackage{threeparttable}
26 +\usepackage{makecell}
27 +
28 +\usepackage{graphicx}
29 +\usepackage{subcaption}
30 +\graphicspath{{../figures/}{./}}
31 +
32 +\usepackage[font=small,labelfont=bf,labelsep=period,justification=justified,singlelinecheck=false]{caption}
33 +
34 +\usepackage[dvipsnames]{xcolor}
35 +\usepackage[colorlinks=true,linkcolor=NavyBlue,citecolor=NavyBlue,urlcolor=NavyBlue,breaklinks=true]{hyperref}
36 +
37 +\usepackage[authoryear,round,semicolon]{natbib}
38 +\bibliographystyle{aer}
39 +
40 +\usepackage{titlesec}
41 +\titleformat{\section}{\large\bfseries}{\thesection.}{0.5em}{}
42 +\titleformat{\subsection}{\normalsize\bfseries}{\thesubsection.}{0.5em}{}
43 +\titleformat{\subsubsection}{\normalsize\itshape}{\thesubsubsection.}{0.5em}{}
44 +\usepackage{fancyhdr}
45 +\pagestyle{fancy}\fancyhf{}
46 +\renewcommand{\headrulewidth}{0pt}
47 +\fancyfoot[C]{\thepage}
48 +\usepackage{enumitem}
49 +\setlist{nosep,leftmargin=*}
50 +
51 +% ============================================================================
52 +% METADATA
53 +% ============================================================================
54 +\newcommand{\WPnumber}{11}
55 +\newcommand{\WPtitle}{Half a Million Prices, Twenty Models}
56 +\newcommand{\WPsubtitle}{A Systematic Assessment of Hedonic Specifications and
57 + Estimation Methods for the Quebec Housing Market, 2021--2026}
58 +\newcommand{\WPdate}{August 2026}
59 +\newcommand{\WPversion}{1.0}
60 +\newcommand{\WPkeywords}{Hedonic pricing, Functional form, House price
61 + indexes, Machine learning, Automated valuation, Out-of-sample prediction}
62 +\newcommand{\WPjel}{R31, C21, C52, C53}
63 +
64 +\newcommand{\WPauthor}{Simon-Pierre Boucher}
65 +\newcommand{\WPaffiliation}{D\'epartement des sciences administratives\\
66 + Universit\'e du Qu\'ebec en Outaouais}
67 +\newcommand{\WPemail}{simon-pierre.boucher@uqo.ca}
68 +\newcommand{\WPaddress}{Gatineau -- Pavillon Alexandre-Tach\'e\\
69 + 283, boulevard Alexandre-Tach\'e\\ Gatineau, Qu\'ebec, Canada J9A 1L8}
70 +
71 +\newcommand{\WPabstract}{%
72 +Fifty years after \mbox{Rosen (1974)}, applied hedonic practice still rests
73 +on a series of specification choices --- functional form, temporal controls,
74 +spatial controls, estimation method --- that are usually made by convention
75 +and rarely stress-tested jointly. Using 514{,}212 Quebec residential sales
76 +(2021--2026) whose structural descriptors come from the provincial
77 +assessment roll, we run a controlled horse race of twenty hedonic models
78 +organised along those four axes, scoring every model on identical holdouts
79 +and on the same price-level scoreboard (log models are Duan-retransformed,
80 +Box--Cox inverted). Four lessons emerge. (1)~Functional form is a
81 +second-order choice: from linear to splines, the median absolute error
82 +spans 16.5--18.6\%, and the data-driven Box--Cox exponent
83 +($\hat\lambda=0.25$) buys nothing out of sample. (2)~Controls are
84 +first-order: dropping spatial fixed effects costs ten error points ---
85 +location granularity matters far more than curvature, but only up to the
86 +point where cells thin out (a $\sim$1~km grid \emph{overfits}).
87 +(3)~Under random validation, estimation method matters most: gradient
88 +boosting reaches 14.4\% --- a 13\% improvement over the best linear model
89 +--- with random forests close behind, and coordinates alone reproduce
90 +what thousands of dummies buy. (4)~But the ranking is an artifact of the
91 +split: under a forward-in-time holdout (train $<$~2025, test 2025--26)
92 +every model degrades and the machine-learning advantage \emph{reverses}
93 +--- the spline hedonic posts a lower median error than gradient boosting
94 +and an identical log-scale $R^2$ --- a stark warning against evaluating
95 +valuation models with random cross-validation alone. Constant-quality
96 +monthly indices implied by the competing forms agree to within a few
97 +index points, and the boosted model's implicit age and floor-area
98 +profiles track the quadratic OLS closely --- interpretation and
99 +prediction disagree less than the random-validation gap suggests.}
100 +
101 +\begin{document}
102 +
103 +\input{sections/titlepage}
104 +
105 +\setcounter{page}{1}
106 +\input{sections/introduction}
107 +\input{sections/literature}
108 +\input{sections/data}
109 +\input{sections/methodology}
110 +\input{sections/results}
111 +\input{sections/extensions}
112 +\input{sections/discussion}
113 +\input{sections/conclusion}
114 +
115 +\newpage
116 +\bibliography{references}
117 +
118 +\end{document}
added paper/references.bib +321 −0
@@ -0,0 +1,321 @@
1 +@comment{ Author: Simon-Pierre Boucher (contact at spboucher.ai) -- Bibliography for UQO Working Paper No 11 }
2 +
3 +@article{rosen1974hedonic,
4 + author = {Rosen, Sherwin},
5 + title = {Hedonic Prices and Implicit Markets: Product Differentiation in Pure Competition},
6 + journal = {Journal of Political Economy},
7 + year = {1974},
8 + volume = {82},
9 + number = {1},
10 + pages = {34--55}
11 +}
12 +
13 +@article{halvorsen1981choice,
14 + author = {Halvorsen, Robert and Pollakowski, Henry O.},
15 + title = {Choice of Functional Form for Hedonic Price Equations},
16 + journal = {Journal of Urban Economics},
17 + year = {1981},
18 + volume = {10},
19 + number = {1},
20 + pages = {37--49}
21 +}
22 +
23 +@article{cassel1985cost,
24 + author = {Cassel, Eric and Mendelsohn, Robert},
25 + title = {The Choice of Functional Forms for Hedonic Price Equations: Comment},
26 + journal = {Journal of Urban Economics},
27 + year = {1985},
28 + volume = {18},
29 + number = {2},
30 + pages = {135--142}
31 +}
32 +
33 +@article{cropper1988choice,
34 + author = {Cropper, Maureen L. and Deck, Leland B. and McConnell, Kenneth E.},
35 + title = {On the Choice of Functional Form for Hedonic Price Functions},
36 + journal = {Review of Economics and Statistics},
37 + year = {1988},
38 + volume = {70},
39 + number = {4},
40 + pages = {668--675}
41 +}
42 +
43 +@article{box1964analysis,
44 + author = {Box, George E. P. and Cox, David R.},
45 + title = {An Analysis of Transformations},
46 + journal = {Journal of the Royal Statistical Society, Series B},
47 + year = {1964},
48 + volume = {26},
49 + number = {2},
50 + pages = {211--252}
51 +}
52 +
53 +@incollection{malpezzi2003hedonic,
54 + author = {Malpezzi, Stephen},
55 + title = {Hedonic Pricing Models: A Selective and Applied Review},
56 + booktitle = {Housing Economics and Public Policy},
57 + editor = {O'Sullivan, Tony and Gibb, Kenneth},
58 + publisher = {Blackwell},
59 + address = {Oxford},
60 + year = {2003},
61 + pages = {67--89}
62 +}
63 +
64 +@article{sirmans2005composition,
65 + author = {Sirmans, G. Stacy and Macpherson, David A. and Zietz, Emily N.},
66 + title = {The Composition of Hedonic Pricing Models},
67 + journal = {Journal of Real Estate Literature},
68 + year = {2005},
69 + volume = {13},
70 + number = {1},
71 + pages = {1--44}
72 +}
73 +
74 +@article{duan1983smearing,
75 + author = {Duan, Naihua},
76 + title = {Smearing Estimate: A Nonparametric Retransformation Method},
77 + journal = {Journal of the American Statistical Association},
78 + year = {1983},
79 + volume = {78},
80 + number = {383},
81 + pages = {605--610}
82 +}
83 +
84 +@article{goodman1998housing,
85 + author = {Goodman, Allen C. and Thibodeau, Thomas G.},
86 + title = {Housing Market Segmentation},
87 + journal = {Journal of Housing Economics},
88 + year = {1998},
89 + volume = {7},
90 + number = {2},
91 + pages = {121--143}
92 +}
93 +
94 +@article{dubin1998spatial,
95 + author = {Dubin, Robin A.},
96 + title = {Spatial Autocorrelation: A Primer},
97 + journal = {Journal of Housing Economics},
98 + year = {1998},
99 + volume = {7},
100 + number = {4},
101 + pages = {304--327}
102 +}
103 +
104 +@book{anselin1988spatial,
105 + author = {Anselin, Luc},
106 + title = {Spatial Econometrics: Methods and Models},
107 + publisher = {Kluwer Academic},
108 + address = {Dordrecht},
109 + year = {1988}
110 +}
111 +
112 +@article{hill2013hedonic,
113 + author = {Hill, Robert J.},
114 + title = {Hedonic Price Indexes for Residential Housing: A Survey, Evaluation and Taxonomy},
115 + journal = {Journal of Economic Surveys},
116 + year = {2013},
117 + volume = {27},
118 + number = {5},
119 + pages = {879--914}
120 +}
121 +
122 +@article{silver2018house,
123 + author = {Silver, Mick},
124 + title = {How to Measure Hedonic Property Price Indexes Better},
125 + journal = {EURONA — Eurostat Review on National Accounts and Macroeconomic Indicators},
126 + year = {2018},
127 + volume = {1},
128 + pages = {35--66}
129 +}
130 +
131 +@article{diewert2004hedonic,
132 + author = {Diewert, W. Erwin},
133 + title = {Hedonic Regressions: A Review of Some Unresolved Issues},
134 + journal = {Mimeo, University of British Columbia},
135 + year = {2004},
136 + volume = {},
137 + pages = {}
138 +}
139 +
140 +@article{mullainathan2017machine,
141 + author = {Mullainathan, Sendhil and Spiess, Jann},
142 + title = {Machine Learning: An Applied Econometric Approach},
143 + journal = {Journal of Economic Perspectives},
144 + year = {2017},
145 + volume = {31},
146 + number = {2},
147 + pages = {87--106}
148 +}
149 +
150 +@article{athey2019machine,
151 + author = {Athey, Susan and Imbens, Guido W.},
152 + title = {Machine Learning Methods That Economists Should Know About},
153 + journal = {Annual Review of Economics},
154 + year = {2019},
155 + volume = {11},
156 + pages = {685--725}
157 +}
158 +
159 +@article{breiman2001random,
160 + author = {Breiman, Leo},
161 + title = {Random Forests},
162 + journal = {Machine Learning},
163 + year = {2001},
164 + volume = {45},
165 + number = {1},
166 + pages = {5--32}
167 +}
168 +
169 +@article{friedman2001greedy,
170 + author = {Friedman, Jerome H.},
171 + title = {Greedy Function Approximation: A Gradient Boosting Machine},
172 + journal = {Annals of Statistics},
173 + year = {2001},
174 + volume = {29},
175 + number = {5},
176 + pages = {1189--1232}
177 +}
178 +
179 +@article{ke2017lightgbm,
180 + author = {Ke, Guolin and Meng, Qi and Finley, Thomas and Wang, Taifeng and Chen, Wei and Ma, Weidong and Ye, Qiwei and Liu, Tie-Yan},
181 + title = {{LightGBM}: A Highly Efficient Gradient Boosting Decision Tree},
182 + journal = {Advances in Neural Information Processing Systems},
183 + year = {2017},
184 + volume = {30},
185 + pages = {3146--3154}
186 +}
187 +
188 +@article{mayer2019estimation,
189 + author = {Mayer, Michael and Bourassa, Steven C. and Hoesli, Martin and Scognamiglio, Donato},
190 + title = {Estimation and Updating Methods for Hedonic Valuation},
191 + journal = {Journal of European Real Estate Research},
192 + year = {2019},
193 + volume = {12},
194 + number = {1},
195 + pages = {134--150}
196 +}
197 +
198 +@article{bogin2020house,
199 + author = {Bogin, Alexander N. and Shui, Jessica},
200 + title = {Appraisal Accuracy and Automated Valuation Models in Rural Areas},
201 + journal = {Journal of Real Estate Finance and Economics},
202 + year = {2020},
203 + volume = {60},
204 + number = {1},
205 + pages = {40--52}
206 +}
207 +
208 +@article{steurer2021metrics,
209 + author = {Steurer, Miriam and Hill, Robert J. and Pfeifer, Norbert},
210 + title = {Metrics for Evaluating the Performance of Machine Learning Based Automated Valuation Models},
211 + journal = {Journal of Property Research},
212 + year = {2021},
213 + volume = {38},
214 + number = {2},
215 + pages = {99--129}
216 +}
217 +
218 +@article{pace1997spatial,
219 + author = {Pace, R. Kelley and Gilley, Otis W.},
220 + title = {Using the Spatial Configuration of the Data to Improve Estimation},
221 + journal = {Journal of Real Estate Finance and Economics},
222 + year = {1997},
223 + volume = {14},
224 + number = {3},
225 + pages = {333--340}
226 +}
227 +
228 +@article{case2004modeling,
229 + author = {Case, Bradford and Clapp, John and Dubin, Robin and Rodriguez, Mauricio},
230 + title = {Modeling Spatial and Temporal House Price Patterns: A Comparison of Four Models},
231 + journal = {Journal of Real Estate Finance and Economics},
232 + year = {2004},
233 + volume = {29},
234 + number = {2},
235 + pages = {167--191}
236 +}
237 +
238 +@article{bourassa2010predicting,
239 + author = {Bourassa, Steven C. and Cantoni, Eva and Hoesli, Martin},
240 + title = {Predicting House Prices with Spatial Dependence: A Comparison of Alternative Methods},
241 + journal = {Journal of Real Estate Research},
242 + year = {2010},
243 + volume = {32},
244 + number = {2},
245 + pages = {139--159}
246 +}
247 +
248 +@article{desrosiers2000hedonic,
249 + author = {Des Rosiers, Fran{\c c}ois and Th{\'e}riault, Marius and Villeneuve, Paul-Y.},
250 + title = {Sorting Out Access and Neighbourhood Factors in Hedonic Price Modelling},
251 + journal = {Journal of Property Investment \& Finance},
252 + year = {2000},
253 + volume = {18},
254 + number = {3},
255 + pages = {291--315}
256 +}
257 +
258 +@article{gencay1996nonlinear,
259 + author = {Gen{\c c}ay, Ramazan and Yang, Xian},
260 + title = {A Forecast Comparison of Residential Housing Prices by Parametric versus Semiparametric Conditional Mean Estimators},
261 + journal = {Economics Letters},
262 + year = {1996},
263 + volume = {52},
264 + number = {2},
265 + pages = {129--135}
266 +}
267 +
268 +@article{kok2017big,
269 + author = {Kok, Nils and Koponen, Eija-Leena and Mart{\'i}nez-Barbosa, Carmen Adriana},
270 + title = {Big Data in Real Estate? From Manual Appraisal to Automated Valuation},
271 + journal = {Journal of Portfolio Management},
272 + year = {2017},
273 + volume = {43},
274 + number = {6},
275 + pages = {202--211}
276 +}
277 +
278 +@article{hong2020machine,
279 + author = {Hong, Jengei and Choi, Heeyoul and Kim, Woo-sung},
280 + title = {A House Price Valuation Based on the Random Forest Approach: The Mass Appraisal of Residential Property in South Korea},
281 + journal = {International Journal of Strategic Property Management},
282 + year = {2020},
283 + volume = {24},
284 + number = {3},
285 + pages = {140--152}
286 +}
287 +
288 +@article{knight2003listing,
289 + author = {Knight, John R.},
290 + title = {Hedonic Modeling of the Home Selling Process},
291 + journal = {Journal of Real Estate Finance and Economics},
292 + year = {2003},
293 + volume = {},
294 + pages = {}
295 +}
296 +
297 +@book{iaao2013standard,
298 + author = {{International Association of Assessing Officers}},
299 + title = {Standard on Ratio Studies},
300 + publisher = {IAAO},
301 + address = {Kansas City, MO},
302 + year = {2013}
303 +}
304 +
305 +@article{clapp2002predicting,
306 + author = {Clapp, John M. and Giaccotto, Carmelo},
307 + title = {Evaluating House Price Forecasts},
308 + journal = {Journal of Real Estate Research},
309 + year = {2002},
310 + volume = {24},
311 + number = {1},
312 + pages = {1--26}
313 +}
314 +
315 +@techreport{oecd2013handbook,
316 + author = {{Eurostat, ILO, IMF, OECD, UNECE, World Bank}},
317 + title = {Handbook on Residential Property Prices Indices ({RPPIs})},
318 + institution = {Eurostat},
319 + address = {Luxembourg},
320 + year = {2013}
321 +}
added paper/sections/conclusion.tex +32 −0
@@ -0,0 +1,32 @@
1 +% Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +% ============================================================================
3 +\section{Conclusion}
4 +\label{sec:concl}
5 +
6 +We ran the hedonic specification debate as a controlled experiment:
7 +twenty models, four design axes, one attribute set, one scoreboard, two
8 +holdouts, half a million sales. The verdict inverts the profession's
9 +implicit priorities. The choices that consume the most methodological
10 +attention --- functional form, transformation parameters --- move
11 +out-of-sample accuracy by at most two points of median error, while the
12 +choices often made by default --- whether and how finely to absorb space
13 +and time --- move it by three to ten. Machine-learning estimators
14 +dominate the standard scoreboard, but the standard scoreboard asks the
15 +wrong question: when the test set lies in the future, as it always does
16 +in deployment, the boosted ensemble's advantage reverses against a
17 +spline hedonic with good controls, because most of what it learned was a
18 +spatially fine price configuration that does not transfer across the
19 +market cycle.
20 +
21 +Meanwhile the objects economists actually harvest from hedonic models
22 +prove reassuringly stable: constant-quality indices agree across forms
23 +to within a few points over a full boom--bust--recovery cycle, and the
24 +machine's implicit age and area gradients replicate the quadratic OLS.
25 +The practical synthesis is almost embarrassingly simple: a semi-log with
26 +splines, quarter effects, and spatial fixed effects at a granularity
27 +matched to market thickness is within two points of the frontier in
28 +deployment conditions --- transparent, auditable, and fast. Where the
29 +extra accuracy of learning methods is worth its retraining cadence ---
30 +high-frequency AVMs, collateral monitoring --- our decomposition says to
31 +spend the flexibility budget on the spatial surface, and to validate,
32 +always, against the future rather than against a shuffle of the past.
added paper/sections/data.tex +53 −0
@@ -0,0 +1,53 @@
1 +% Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +% ============================================================================
3 +\section{Data}
4 +\label{sec:data}
5 +
6 +\subsection{Sources}
7 +
8 +We use a registry of 745{,}119 Quebec residential-market transactions
9 +recorded between January 2021 and July 2026 --- sale price, date, address
10 +and coordinates --- each matched at the parcel level to the municipal
11 +assessment roll in force at the sale date (median match distance
12 +0.6~metres, validated against the roll's recorded value). The roll
13 +contributes the structural descriptors that are notoriously missing from
14 +transaction registries: total floor area, lot area, year of construction,
15 +number of dwelling units, number of storeys, physical configuration
16 +(detached, semi-detached, row, apartment-linked) and the standardized use
17 +code. This combination --- market prices with assessor-grade structure
18 +for half a million sales across an entire province --- is what makes a
19 +controlled specification race feasible: every model sees exactly the same
20 +attributes.
21 +
22 +\subsection{Sample}
23 +
24 +We keep residential use codes (dwellings, cottages, mobile homes), require
25 +a high-confidence roll match (distance $\le 50$~m, score $\ge 150$), a
26 +price of at least \$50{,}000, a plausible floor area (20--2{,}000~m$^2$)
27 +and a known year of construction, and trim prices and floor areas at the
28 +1st and 99th percentiles within each sale year. The estimation sample
29 +contains \textbf{514{,}212 sales}: 348{,}253 single-family homes, 75{,}734
30 +condominiums, 74{,}909 plexes (2--5 units), 10{,}349 cottages and 4{,}881
31 +mobile homes, spread over 1{,}115 municipalities, 5{,}255 grid cells of
32 +$\sim$5.5~km and 30{,}548 cells of $\sim$1.1~km.
33 +Table~\ref{tab:sumstats} reports the descriptives. Prices average
34 +\$462{,}000 (median \$399{,}000); the window covers the tail of the
35 +post-pandemic boom, the 2022--2023 rate correction and the subsequent
36 +recovery --- a demanding environment for any static price model, which we
37 +exploit in the forward-in-time evaluation.
38 +
39 +\begin{table}[t]
40 +\centering
41 +\begin{threeparttable}
42 +\caption{Summary statistics, estimation sample}
43 +\label{tab:sumstats}
44 +\small
45 +\input{../results/tables/summary_stats}
46 +\begin{tablenotes}[flushleft]\footnotesize
47 +\item \textit{Notes:} 514{,}212 residential sales, January 2021 -- July
48 +2026. Structural descriptors from the assessment roll in force at the
49 +sale date. Lot area is zero for condominiums and other units without an
50 +exclusive lot.
51 +\end{tablenotes}
52 +\end{threeparttable}
53 +\end{table}
added paper/sections/discussion.tex +81 −0
@@ -0,0 +1,81 @@
1 +% Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +% ============================================================================
3 +\section{Discussion}
4 +\label{sec:disc}
5 +
6 +\subsection{A hierarchy of specification choices}
7 +
8 +Read jointly, the four axes imply a clear hierarchy of returns to
9 +specification effort, useful to anyone building a hedonic model:
10 +
11 +\begin{enumerate}
12 +\item \textbf{Get location right} ($\sim$10 points of median error):
13 +any spatial absorption beats none by a margin that dwarfs every other
14 +choice; the optimal granularity is interior --- around 5~km here ---
15 +because finer cells trade bias for variance.
16 +\item \textbf{Include some time control} ($\sim$3 points): in a moving
17 +market, a model without time effects mistakes appreciation for
18 +attributes; granularity beyond quarters is cosmetic.
19 +\item \textbf{Choose the estimator for the job} ($\sim$2 points
20 +deployed, $\sim$4 under random validation): boosting buys real accuracy
21 +in interpolation-heavy uses (filling gaps within a period, mass
22 +appraisal with contemporaneous comparables) and much less when the
23 +target is the future.
24 +\item \textbf{Stop worrying about functional form} ($\lesssim$2
25 +points): splines are a cheap upgrade; the Box--Cox machinery is not
26 +worth its complexity, exactly as \citet{cropper1988choice} concluded
27 +from simulations four decades ago.
28 +\end{enumerate}
29 +
30 +\subsection{Why the machines lose their edge out of time}
31 +
32 +The decomposition rows explain the generalization gap. The boosting
33 +model's random-split advantage comes almost entirely from a flexible
34 +spatial surface (removing coordinates costs it 8.8 points). That surface
35 +is estimated \emph{jointly} with the time path, and trees clamp: beyond
36 +the last training month the model prices 2025--26 sales at a frozen
37 +level, exactly like the carry-forward linear models, while its
38 +fine-grained spatial fit --- calibrated on the 2021--24 price
39 +configuration --- partially decays as relative prices shift. The linear
40 +models, coarser but more rigid, carry a structure that transfers better.
41 +This is not an indictment of machine learning --- retrained monthly, the
42 +boosting model would presumably keep its interpolation advantage --- but
43 +it prices the \emph{retraining requirement} that random cross-validation
44 +hides, and it matches the practitioner evidence that AVM accuracy decays
45 +quickly out of sample period \citep{bogin2020house, kok2017big}.
46 +
47 +\subsection{Implications}
48 +
49 +\paragraph{For research.} Hedonic coefficients and indices are robust
50 +objects: form and even estimator perturb them little
51 +(Section~\ref{sec:ext}). Researchers using hedonics to \emph{measure}
52 +--- indices, implicit prices, capitalization effects --- can keep simple
53 +forms with good controls and report validation under a temporal split
54 +when prediction claims are made.
55 +
56 +\paragraph{For mass appraisal.} Quebec's assessors face exactly this
57 +design problem every three years. Our results suggest the largest
58 +accuracy gains lie not in exotic estimators but in spatial resolution
59 +chosen by market thickness --- and that any model, linear or boosted,
60 +frozen at a reference date degrades by 3--7 points of median error
61 +within two years. This quantifies the staleness mechanism behind the
62 +assessment inequities documented in our companion paper (UQO WP10): the
63 +valuation technology's out-of-time decay is of the same magnitude as the
64 +inequities measured there.
65 +
66 +\paragraph{For the AVM literature.} Every comparison should report a
67 +forward-in-time split alongside random cross-validation
68 +\citep{steurer2021metrics}. On our data, the choice of split changes not
69 +just magnitudes but the \emph{winner}.
70 +
71 +\subsection{Limitations}
72 +
73 +Our attribute set, though assessor-grade, omits listing-level quality
74 +(renovations, finishes, views); richer features would likely raise the
75 +machines' interpolation advantage. Hyper-parameters were deliberately
76 +conventional --- a tuned boosted model would gain somewhat, as would a
77 +spatially smarter linear model (e.g.\ kriging residuals,
78 +\citealp{case2004modeling}). The forward split covers one specific
79 +regime change (the 2025--26 recovery); other windows would shift
80 +magnitudes. And Quebec's institutional homogeneity aids transferability
81 +of results across municipalities but may limit it across countries.
added paper/sections/extensions.tex +137 −0
@@ -0,0 +1,137 @@
1 +% Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +% ============================================================================
3 +\section{Beyond the scoreboard: what the models imply}
4 +\label{sec:ext}
5 +
6 +Accuracy is not the only output economists take from a hedonic model. This
7 +section asks whether the specification choices that move the scoreboard
8 +also move the two objects hedonic models are most often built to deliver:
9 +constant-quality price indices and implicit attribute prices.
10 +
11 +\subsection{Price indices are form-robust}
12 +
13 +Figure~\ref{fig:index} plots the constant-quality monthly index implied by
14 +the month fixed effects of four linear forms --- semi-log, log-log,
15 +quadratic and spline --- together with a gradient-boosting index obtained
16 +by repricing a fixed 20{,}000-sale reference portfolio at each month
17 +(an imputation \emph{\`a la} \citealp{hill2013hedonic}). All five track
18 +the same cycle --- the 2021--22 boom, the 2022--23 correction, the
19 +2024--26 recovery --- and the four linear forms agree to within a few
20 +index points throughout. The choice that dominates the accuracy
21 +scoreboard barely perturbs the index: reassuring news for statistical
22 +agencies, and consistent with the RPPI handbook's pragmatism about form
23 +\citep{oecd2013handbook, silver2018house}.
24 +
25 +\begin{figure}[t]
26 +\centering
27 +\includegraphics[width=\textwidth]{fig_index.png}
28 +\caption{Constant-quality monthly price indices implied by competing
29 +specifications (January 2021 = 100). Linear forms: exponentiated month
30 +fixed effects. Gradient boosting: mean repriced value of a fixed
31 +20{,}000-sale reference portfolio.}
32 +\label{fig:index}
33 +\end{figure}
34 +
35 +\subsection{Implicit prices: the machine agrees with the quadratic}
36 +
37 +Figure~\ref{fig:profiles} compares the ln-price profiles in building age
38 +and floor area implied by the quadratic OLS coefficients with the partial-%
39 +dependence profiles of the boosting model \citep{friedman2001greedy}.
40 +The two agree over the bulk of the data: steep initial depreciation that
41 +flattens with age, and strongly concave returns to floor space. The
42 +visible divergences are where the quadratic \emph{cannot} bend --- the
43 +machine sees a milder gradient for very old stock (a vintage effect) and
44 +a small new-construction premium spike --- but through the ages and areas
45 +where 90\% of sales live, the curves sit within a tenth of a log point. The boosting model's advantage evidently comes from
46 +higher-order interactions and spatial flexibility, \emph{not} from a
47 +different view of the main attribute gradients --- so the interpretable
48 +quadratic form remains a faithful summary of the price surface even where
49 +it loses the prediction race \citep{mullainathan2017machine,
50 +athey2019machine}.
51 +
52 +\begin{figure}[t]
53 +\centering
54 +\includegraphics[width=\textwidth]{fig_profiles.png}
55 +\caption{Implicit ln-price profiles: quadratic OLS coefficients versus
56 +gradient-boosting partial dependence, each normalized to its leftmost
57 +point. Panel A: building age. Panel B: floor area.}
58 +\label{fig:profiles}
59 +\end{figure}
60 +
61 +\subsection{Learning curves: data beat flexibility only at scale}
62 +
63 +Figure~\ref{fig:learning} and Table~\ref{tab:learning} trace accuracy
64 +against training size from 10{,}000 to 400{,}000 sales. The OLS quadratic
65 +is essentially flat from the very first step (18.4\% at $n=10{,}000$,
66 +18.1\% at $n=400{,}000$) --- its bias floor binds almost immediately ---
67 +while the boosting model improves monotonically through the full sample
68 +(17.6\% $\to$ 14.5\%): the machine's advantage grows from under one
69 +error point at $n = 10{,}000$ to 3.6 points at
70 +$n = 400{,}000$. Flexible methods
71 +are not a free lunch in small markets: below a few tens of thousands of
72 +training sales, the machine's edge is modest, one reason rural and thin-%
73 +market AVMs underperform \citep{bogin2020house}.
74 +
75 +\begin{figure}[t]
76 +\centering
77 +\includegraphics[width=0.72\textwidth]{fig_learning.png}
78 +\caption{Learning curves: median absolute error against training-set
79 +size (random holdout fixed), OLS quadratic versus gradient boosting.}
80 +\label{fig:learning}
81 +\end{figure}
82 +
83 +\begin{table}[t]
84 +\centering
85 +\begin{threeparttable}
86 +\caption{Learning curves}
87 +\label{tab:learning}
88 +\small
89 +\input{../results/tables/learning}
90 +\begin{tablenotes}[flushleft]\footnotesize
91 +\item \textit{Notes:} MdAPE (\%) on the fixed random holdout, training on
92 +seeded subsamples of the indicated size.
93 +\end{tablenotes}
94 +\end{threeparttable}
95 +\end{table}
96 +
97 +\subsection{Where the machine wins: segments}
98 +
99 +Table~\ref{tab:segments} and Figure~\ref{fig:segments} decompose the
100 +random-holdout MdAPE by property class and municipality size, and the
101 +pattern identifies the machine's edge with striking precision: it is
102 +spatial resolution. The gap is enormous for condominiums (9.1\% versus
103 +20.4\%) and in the largest cities (11.9\% versus 17.8\%) --- segments
104 +where structure is homogeneous but \emph{micro}-location varies hugely
105 +within a municipality, exactly the variation that municipality dummies
106 +cannot see and raw coordinates can. Once location is resolved, a condo
107 +is the most predictable object in the market. At the other extreme,
108 +cottages defeat both models (32.1\% versus 33.9\%): idiosyncratic,
109 +waterfront-driven stock is hard for everyone, and the machine's spatial
110 +surface cannot rescue attributes it does not observe. The complement of
111 +the assessment-inequity anatomy in our companion paper (UQO WP10) is
112 +instructive: the segments where assessors are most regressive
113 +(heterogeneous, land-heavy stock) are also those where \emph{no}
114 +standard technology, linear or boosted, prices accurately from roll
115 +attributes alone.
116 +
117 +\begin{figure}[t]
118 +\centering
119 +\includegraphics[width=0.8\textwidth]{fig_segments.png}
120 +\caption{Median absolute error by market segment (random holdout):
121 +OLS quadratic versus gradient boosting.}
122 +\label{fig:segments}
123 +\end{figure}
124 +
125 +\begin{table}[t]
126 +\centering
127 +\begin{threeparttable}
128 +\caption{Accuracy by market segment}
129 +\label{tab:segments}
130 +\small
131 +\input{../results/tables/segments}
132 +\begin{tablenotes}[flushleft]\footnotesize
133 +\item \textit{Notes:} MdAPE (\%) on the random holdout, by property class
134 +and by the number of 2021--2026 sales in the municipality.
135 +\end{tablenotes}
136 +\end{threeparttable}
137 +\end{table}
added paper/sections/introduction.tex +94 −0
@@ -0,0 +1,94 @@
1 +% Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +% ============================================================================
3 +\section{Introduction}
4 +\label{sec:intro}
5 +
6 +Every applied hedonic study begins with a series of quiet decisions. Log
7 +the price or not? Dummies for years or for months? Neighbourhood fixed
8 +effects, and how fine? Least squares or, increasingly, a gradient-boosted
9 +ensemble? Fifty years after \citet{rosen1974hedonic} established that
10 +theory places no restriction on the shape of the price surface, these
11 +choices are still made largely by convention --- the semi-log survives as
12 +the default \citep{sirmans2005composition}, the classic form
13 +comparisons predate the out-of-sample era \citep{halvorsen1981choice,
14 +cropper1988choice}, and the machine-learning comparisons that have
15 +multiplied since \citet{mullainathan2017machine} typically change the
16 +sample, the features and the validation scheme all at once, making it
17 +impossible to say \emph{which} ingredient buys the reported gains.
18 +
19 +This paper runs the comparison the way one would design an experiment.
20 +From 514{,}212 Quebec residential sales (2021--2026) matched at the
21 +parcel level to the provincial assessment roll --- so that every model
22 +sees the same assessor-grade attribute set: floor area, lot area, age,
23 +storeys, units, property class, physical configuration --- we estimate
24 +\textbf{twenty models organised along four axes}, varying one design
25 +choice at a time: six functional forms (linear to Box--Cox to splines),
26 +a ladder of time controls (none to month fixed effects), a ladder of
27 +spatial controls (none to a $\sim$1~km grid), and six estimation methods
28 +(OLS to random forests, gradient boosting, and a $k$-nearest-neighbour
29 +comparables rule). Every model is scored on the same price-level
30 +scoreboard --- log models retransformed with Duan smearing, Box--Cox
31 +inverted --- and, crucially, under \textbf{two holdouts}: the standard
32 +random 80/20 split, and a forward-in-time split (train before 2025, test
33 +2025--26) that mimics how a valuation model is actually deployed.
34 +
35 +Four results organise the paper.
36 +
37 +\textbf{First, functional form is a second-order choice.} Across linear,
38 +semi-log, log-log, profile-likelihood Box--Cox
39 +($\hat\lambda = 0.25$), quadratic and spline specifications --- holding
40 +controls fixed --- the median absolute error spans 16.5\% to 18.6\%.
41 +Splines beat the textbook semi-log by 1.6 points; the data-driven
42 +Box--Cox transformation, the great hope of the 1980s debate, buys
43 +nothing out of sample, vindicating \citet{cropper1988choice} and
44 +\citet{cassel1985cost} on modern data at scale.
45 +
46 +\textbf{Second, controls are first-order --- with a twist.} Removing all
47 +spatial controls costs ten error points (27.9\% versus 17.5\%), an order
48 +of magnitude more than any curvature decision; a $\sim$5.5~km grid beats
49 +both municipality effects and, notably, a $\sim$1.1~km grid, whose
50 +30{,}548 cells overfit --- the bias--variance trade-off in absorbing
51 +location is real and non-monotonic. Time effects show the same shape in
52 +miniature: anything beats nothing (three points), granularity beyond
53 +quarters buys nothing.
54 +
55 +\textbf{Third, under random validation the estimation method dominates.}
56 +Gradient boosting reaches a median error of 14.4\% and random forests
57 +14.6\%, against 16.5\% for the best linear model --- a 13\% improvement
58 +--- and stripping the coordinates from the boosting model degrades it to
59 +23.2\%, showing that what the machine mainly learns is a flexible price
60 +surface over space \citep{bourassa2010predicting}. The na\"ive
61 +$k$-NN comparables rule (21.2\%) confirms that neither raw proximity nor
62 +raw flexibility suffices: the gains come from their combination.
63 +
64 +\textbf{Fourth --- our headline --- the ranking is an artifact of the
65 +validation scheme.} Under the forward-in-time split every model degrades,
66 +but not equally: the machine-learning advantage \emph{reverses} on the
67 +median error (gradient boosting 21.2\% versus 19.1\% for the spline
68 +hedonic) and equalizes exactly on log-scale $R^2$ (0.52 for both).
69 +Random cross-validation lets flexible models interpolate the price level
70 +of their own test period; when the test period lies in the future --- the
71 +only case that matters for deployment --- that advantage is leakage, not
72 +skill. Comparisons of valuation models that report random cross-validation
73 +alone, i.e.\ most of the applied ML literature, systematically overstate
74 +the practical value of model flexibility \citep{clapp2002predicting,
75 +steurer2021metrics}.
76 +
77 +The paper then asks what the specification choices do to the objects
78 +economists actually extract from hedonic models. Constant-quality monthly
79 +indices implied by the month effects of four linear forms --- and by
80 +repricing a fixed portfolio with the boosting model --- agree to within a
81 +few index points across the largest housing cycle in recent Canadian
82 +history. And the boosting model's partial-dependence profiles in age and
83 +floor area track the quadratic OLS closely: the machine agrees with the
84 +economist about the gradients and disagrees mainly about the residual
85 +surface. Interpretation and prediction, in other words, conflict far less
86 +than the accuracy scoreboard suggests.
87 +
88 +Section~\ref{sec:lit} situates the exercise in the functional-form,
89 +spatial-hedonic and ML-valuation literatures. Section~\ref{sec:data}
90 +describes the data, Section~\ref{sec:method} the experimental design.
91 +Section~\ref{sec:results} reports the horse race,
92 +Section~\ref{sec:ext} the index, implicit-price, learning-curve and
93 +segment extensions, Section~\ref{sec:disc} discusses implications, and
94 +Section~\ref{sec:concl} concludes.
added paper/sections/literature.tex +67 −0
@@ -0,0 +1,67 @@
1 +% Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +% ============================================================================
3 +\section{Related literature}
4 +\label{sec:lit}
5 +
6 +\subsection{The functional-form debate}
7 +
8 +Hedonic theory is silent about functional form. \citet{rosen1974hedonic}
9 +showed that the price surface is an equilibrium envelope of bids and
10 +offers, so nothing structural pins down its curvature ---
11 +form is an empirical choice. The classical exchange settled into a
12 +three-cornered debate: \citet{halvorsen1981choice} advocated data-driven
13 +Box--Cox transformations \citep{box1964analysis};
14 +\citet{cassel1985cost} cautioned that flexible transformations optimize
15 +in-sample fit while degrading the implicit prices researchers actually
16 +use; and \citet{cropper1988choice}, in an influential simulation study,
17 +found that simple forms --- linear and semi-log --- are the most robust to
18 +the misspecification and omitted variables that characterize real data.
19 +Fifty years on, the semi-log remains the profession's default
20 +\citep{malpezzi2003hedonic, sirmans2005composition}, but systematic
21 +head-to-head evidence on modern samples remains scarce, and the classic
22 +studies predate the out-of-sample validation culture. Two further
23 +technical strands feed our design: retransformation bias in log models
24 +\citep{duan1983smearing}, which we correct with the smearing factor so
25 +that every form competes in levels; and forecast-evaluation methodology
26 +for house prices \citep{clapp2002predicting, steurer2021metrics}.
27 +
28 +\subsection{Space and time in hedonic models}
29 +
30 +A second literature concerns what the covariates cannot capture. House
31 +prices are spatially autocorrelated at fine scale
32 +\citep{dubin1998spatial, anselin1988spatial}, and the practical remedies
33 +range from submarket segmentation \citep{goodman1998housing} to spatial
34 +econometrics and simple location fixed effects; comparative studies
35 +generally find that flexibly absorbing location buys more predictive
36 +accuracy than any parametric spatial process \citep{case2004modeling,
37 +bourassa2010predicting, pace1997spatial}. In the Quebec context,
38 +\citet{desrosiers2000hedonic} document the importance of access and
39 +neighbourhood attributes in Quebec City. On the time dimension, hedonic
40 +price-index theory --- the time-dummy versus imputation debate ---
41 +is surveyed by \citet{hill2013hedonic} and codified in the RPPI handbook
42 +\citep{oecd2013handbook, silver2018house}; a byproduct of our month-FE
43 +models is a direct test of how sensitive a constant-quality index is to
44 +the underlying form.
45 +
46 +\subsection{Machine learning and automated valuation}
47 +
48 +The third strand is the rapid colonization of mass valuation by machine
49 +learning. \citet{mullainathan2017machine} frame prediction as ML's
50 +comparative advantage, with hedonic pricing as their running example;
51 +\citet{athey2019machine} survey the toolkit. Applied comparisons ---
52 +random forests \citep{breiman2001random}, gradient boosting
53 +\citep{friedman2001greedy, ke2017lightgbm} --- consistently report 20--40\%
54 +error reductions over linear hedonics \citep{mayer2019estimation,
55 +hong2020machine, kok2017big}, with accuracy degrading in thin rural
56 +markets \citep{bogin2020house}; \citet{gencay1996nonlinear} anticipated
57 +the pattern with semiparametric estimators. Three gaps motivate our
58 +design. First, most comparisons change several ingredients at once
59 +(features, sample, validation scheme), so the \emph{sources} of the ML
60 +advantage --- curvature? interactions? spatial flexibility? --- are rarely
61 +isolated; our axis design holds features constant and varies one choice
62 +at a time. Second, almost all published comparisons use random
63 +cross-validation, which leaks future price levels into training and
64 +flatters any flexible model; we score every model under a
65 +forward-in-time split as well. Third, comparisons seldom examine what the
66 +flexible models \emph{imply} --- indices, implicit prices --- which is
67 +what economists need; our extensions section does exactly that.
added paper/sections/methodology.tex +82 −0
@@ -0,0 +1,82 @@
1 +% Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +% ============================================================================
3 +\section{Methodology: the design of the horse race}
4 +\label{sec:method}
5 +
6 +The comparison is organised so that each design choice varies while
7 +everything else is held fixed. All models price the same sales from the
8 +same attribute set; only the functional mapping changes.
9 +
10 +\subsection{The four axes}
11 +
12 +\paragraph{A. Functional form.} With municipality and quarter fixed
13 +effects and the base attributes (floor area, lot area, building age,
14 +storeys, units, property class, physical configuration), we estimate:
15 +(A1) fully linear in levels; (A2) semi-log, $\ln P$ on levels
16 +\citep{malpezzi2003hedonic}; (A3) log-log, $\ln P$ on logged areas;
17 +(A4) Box--Cox on the price, with $\lambda$ chosen by profile likelihood
18 +on the training set over a grid \citep{box1964analysis,
19 +halvorsen1981choice}; (A5) semi-log with quadratics in area and age; and
20 +(A6) semi-log with cubic $B$-splines (six knots) in area, age and lot.
21 +
22 +\paragraph{B. Time effects.} On the A5 backbone: no time controls,
23 +year fixed effects, quarter fixed effects, month fixed effects ---
24 +the granularity ladder of the time-dummy index literature
25 +\citep{hill2013hedonic}.
26 +
27 +\paragraph{C. Spatial controls.} On the A5 backbone with quarter FE:
28 +no spatial controls, municipality FE (1{,}115), a $\sim$5.5~km grid
29 +(5{,}255 cells), and a $\sim$1.1~km grid (30{,}548 cells) --- a
30 +granularity ladder that brackets the bias--variance trade-off in
31 +absorbing location \citep{case2004modeling, bourassa2010predicting}.
32 +
33 +\paragraph{D. Estimation method.} Holding the attribute set fixed:
34 +(D1) OLS with municipality and month FE; (D2) cross-validated ridge on
35 +the same standardized design; (D3) a random forest
36 +\citep{breiman2001random} and (D4) gradient boosting
37 +\citep{friedman2001greedy} on the attributes plus raw coordinates and a
38 +continuous month counter; (D5) gradient boosting \emph{without}
39 +coordinates, to price what spatial flexibility is worth to the machine;
40 +and (D6) a spatial $k$-nearest-neighbour comparables rule --- the
41 +distance-weighted price per square metre of the ten nearest training
42 +sales --- as the na\"ive appraiser benchmark.
43 +
44 +\subsection{One scoreboard: price levels}
45 +
46 +Comparing forms whose dependent variables differ requires care. Every
47 +model is scored on \emph{predicted price levels}: log models are
48 +retransformed with Duan's smearing factor estimated on training
49 +residuals \citep{duan1983smearing}; Box--Cox predictions are analytically
50 +inverted; level predictions are floored at \$20{,}000. We report the
51 +median and mean absolute percentage errors (MdAPE, MAPE) --- the
52 +automated-valuation industry's standards \citep{steurer2021metrics,
53 +iaao2013standard} --- together with the RMSE and $R^2$ of log predicted
54 +versus log actual prices, which are well-defined for every model.
55 +
56 +\subsection{Two holdouts}
57 +
58 +Each model is evaluated under two splits. The \emph{random} split holds
59 +out 20\% of sales (seeded). The \emph{forward-in-time} split trains on
60 +sales before January 2025 (355{,}824) and tests on 2025--2026
61 +(158{,}388) --- the deployment situation of any valuation model. Static
62 +models carry no forecast of the price level, so we apply the standard
63 +carry-forward rule: unseen time categories in the test period are priced
64 +at the last level observed in training; the machine-learning models
65 +receive the same information through the clamped month counter. Random
66 +cross-validation lets every model interpolate the price level of its own
67 +test period --- the forward split reveals how much of measured accuracy
68 +is such leakage \citep{mullainathan2017machine, clapp2002predicting}.
69 +
70 +\subsection{Estimation details}
71 +
72 +All linear models are estimated as sparse ridge regressions with a
73 +vanishing penalty ($\alpha = 10^{-6}$) --- numerically OLS, but robust to
74 +collinear dummies, and unseen categories at prediction time are priced at
75 +the reference level. Dummies are one-hot with a dropped reference; the
76 +ridge (D2) standardizes columns and cross-validates
77 +$\alpha \in [10^{-6}, 10^{2}]$. The forest uses 120 trees
78 +(minimum leaf 5, half the features per split); the boosting machine uses
79 +600 trees of at most 63 leaves (learning rate 0.08, minimum leaf 20).
80 +Hyper-parameters were fixed a priori at library-conventional values ---
81 +the exercise compares model \emph{classes} as used in practice, not tuned
82 +frontier performance.
added paper/sections/results.tex +193 −0
@@ -0,0 +1,193 @@
1 +% Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +% ============================================================================
3 +\section{The horse race}
4 +\label{sec:results}
5 +
6 +Table~\ref{tab:race_random} reports the full scoreboard under the random
7 +holdout (411{,}283 training sales, 102{,}929 test sales);
8 +Table~\ref{tab:race_temporal} repeats it under the forward-in-time split.
9 +Figures~\ref{fig:forms}--\ref{fig:methods} visualize each axis.
10 +
11 +\begin{table}[p]
12 +\centering
13 +\begin{threeparttable}
14 +\caption{The scoreboard, random 80/20 holdout}
15 +\label{tab:race_random}
16 +\small
17 +\input{../results/tables/horserace_random}
18 +\begin{tablenotes}[flushleft]\footnotesize
19 +\item \textit{Notes:} All metrics on the held-out 20\% (102{,}929 sales).
20 +MdAPE/MAPE: median/mean absolute percentage error on price levels (log
21 +models retransformed with Duan smearing; Box--Cox inverted).
22 +RMSE$_{\ln}$ and $R^2_{\ln}$: computed on log predicted vs.\ log actual
23 +prices. Backbone rows repeat across axes by construction (A5 $=$ B3 $=$
24 +C2; B4 $=$ D1). Bold: best in axis.
25 +\end{tablenotes}
26 +\end{threeparttable}
27 +\end{table}
28 +
29 +\begin{table}[p]
30 +\centering
31 +\begin{threeparttable}
32 +\caption{The scoreboard, forward-in-time holdout}
33 +\label{tab:race_temporal}
34 +\small
35 +\input{../results/tables/horserace_temporal}
36 +\begin{tablenotes}[flushleft]\footnotesize
37 +\item \textit{Notes:} Training on sales before January 2025 (355{,}824);
38 +testing on 2025--2026 (158{,}388). Unseen time categories are priced at
39 +the last training period (carry-forward); the machine-learning models
40 +receive the same information through the clamped month counter.
41 +\end{tablenotes}
42 +\end{threeparttable}
43 +\end{table}
44 +
45 +\subsection{Axis A: functional form is second-order}
46 +
47 +Under the random split (Figure~\ref{fig:forms}), the six forms span
48 +16.5--18.6\% MdAPE. The spline specification wins (16.5\%), the log-log
49 +is second (17.0\%) --- floor area enters multiplicatively, as intuition
50 +suggests --- and the linear, semi-log and quadratic forms cluster within
51 +half a point of each other. The Box--Cox transformation selects
52 +$\hat\lambda = 0.25$ by profile likelihood (Table~\ref{tab:boxcox}), i.e.\
53 +the data reject both the linear ($\lambda=1$) and the log ($\lambda=0$)
54 +--- and then \emph{underperforms} the simple semi-log out of sample
55 +(18.6\%), because optimizing curvature in-sample does not survive
56 +prediction, precisely the warning of \citet{cassel1985cost} and
57 +\citet{cropper1988choice}. The ordering is preserved under the temporal
58 +split. Two free lessons: curvature in \emph{attributes} (splines) is
59 +worth more than curvature in the \emph{price} (Box--Cox); and no form
60 +choice moves the needle by more than two points.
61 +
62 +\begin{figure}[t]
63 +\centering
64 +\includegraphics[width=0.85\textwidth]{fig_forms.png}
65 +\caption{Axis A --- functional form. MdAPE on the random holdout;
66 +municipality and quarter fixed effects and the base attribute set held
67 +fixed throughout.}
68 +\label{fig:forms}
69 +\end{figure}
70 +
71 +\begin{table}[t]
72 +\centering
73 +\begin{threeparttable}
74 +\caption{Box--Cox profile likelihood}
75 +\label{tab:boxcox}
76 +\small
77 +\input{../results/tables/boxcox}
78 +\begin{tablenotes}[flushleft]\footnotesize
79 +\item \textit{Notes:} $^{\dagger}$Thousands, relative to the maximum
80 +(zero = chosen $\lambda$). Estimated on the random-split training sample
81 +with the A-axis design.
82 +\end{tablenotes}
83 +\end{threeparttable}
84 +\end{table}
85 +
86 +\subsection{Axis B: time controls --- anything beats nothing}
87 +
88 +Omitting time controls entirely costs three points (21.1\% versus 18.1\%)
89 +in a window containing a boom, a correction and a recovery
90 +(Figure~\ref{fig:time}). But the granularity ladder flattens immediately:
91 +year, quarter and month effects are indistinguishable to within a quarter
92 +point under the random split. Under the temporal split the ordering
93 +inverts at the bottom --- \emph{no} time effects (25.4\%) is the worst
94 +choice again, but month effects with carry-forward (20.3\%) beat quarter
95 +effects (20.5\%) only marginally: once the level must be extrapolated,
96 +fine within-sample time resolution has little to offer.
97 +
98 +\begin{figure}[t]
99 +\centering
100 +\includegraphics[width=0.85\textwidth]{fig_time.png}
101 +\caption{Axis B --- time effects (quadratic semi-log backbone,
102 +municipality FE). MdAPE on the random holdout.}
103 +\label{fig:time}
104 +\end{figure}
105 +
106 +\subsection{Axis C: spatial controls are first-order --- up to a point}
107 +
108 +Location dominates every other design margin
109 +(Figure~\ref{fig:space}). Stripping all spatial controls costs
110 +\emph{ten} points (27.9\% versus 17.5\%) --- five times the entire
111 +functional-form range. The granularity ladder, however, is
112 +non-monotonic: the $\sim$5.5~km grid (5{,}255 cells, 17.5\%) beats
113 +municipality effects (18.1\%), but the $\sim$1.1~km grid (30{,}548
114 +cells, 19.4\%) \emph{overfits} --- its cells average seventeen K sales
115 +each, unseen cells in the holdout revert to the baseline, and estimated
116 +effects are noisy. The optimal spatial resolution is an interior
117 +solution, echoing \citet{goodman1998housing} on submarket definition:
118 +finer is better only while cells remain thick enough to estimate.
119 +
120 +\begin{figure}[t]
121 +\centering
122 +\includegraphics[width=0.85\textwidth]{fig_space.png}
123 +\caption{Axis C --- spatial controls (quadratic semi-log backbone,
124 +quarter FE). MdAPE on the random holdout.}
125 +\label{fig:space}
126 +\end{figure}
127 +
128 +\subsection{Axis D: the machines --- and what they actually buy}
129 +
130 +Under the random split (Figure~\ref{fig:methods}), gradient boosting
131 +posts 14.4\% and the random forest 14.6\%, against 16.5\% for the best
132 +linear model and 18.3\% for the textbook OLS: a 13--21\% error
133 +reduction, squarely in the range reported by the AVM literature
134 +\citep{mayer2019estimation, hong2020machine}. The decomposition rows
135 +identify the source. Boosting \emph{without coordinates} collapses to
136 +23.2\% --- worse than plain OLS with municipality dummies --- so the
137 +machine's edge is overwhelmingly a flexible surface over space, not
138 +exotic structure--attribute interactions. Pure proximity without
139 +structure (the $k$-NN comparables rule, 21.2\%) fails too: it is the
140 +\emph{combination} of spatial flexibility with attribute adjustment that
141 +wins. The cross-validated ridge (16.7\%) confirms that regularizing the
142 +high-dimensional dummy design buys a little; it cannot manufacture the
143 +missing interactions.
144 +
145 +\begin{figure}[t]
146 +\centering
147 +\includegraphics[width=0.85\textwidth]{fig_methods.png}
148 +\caption{Axis D --- estimation methods on the identical attribute set.
149 +MdAPE on the random holdout.}
150 +\label{fig:methods}
151 +\end{figure}
152 +
153 +\subsection{The generalization gap: when the test set is the future}
154 +
155 +Table~\ref{tab:generalization} and Figure~\ref{fig:generalization}
156 +contain the paper's central result. Moving from the random to the
157 +forward-in-time split degrades every model --- the price level of
158 +2025--26 must be carried forward, not interpolated --- but the
159 +degradation is systematically larger for the flexible methods.
160 +Gradient boosting loses 6.8 points of MdAPE (14.4 $\to$ 21.2) and the
161 +random forest 6.7, while the spline hedonic loses 2.6 (16.5 $\to$ 19.1)
162 +and the log-log 2.2. The ranking \emph{reverses}: the best linear models
163 +beat the machines on the median error in the deployment scenario, and
164 +tie them exactly on $R^2_{\ln}$ ($\approx 0.52$). The machines remain
165 +better on MAPE (29.7 versus 30.5), i.e.\ in the tails, but the headline
166 +random-validation gap --- the number the ML-valuation literature
167 +reports --- overstates their deployable advantage entirely. The lesson
168 +is methodological and general: for models whose job is to price the
169 +future, random cross-validation is the wrong experiment
170 +\citep{mullainathan2017machine, clapp2002predicting}.
171 +
172 +\begin{table}[t]
173 +\centering
174 +\begin{threeparttable}
175 +\caption{Random versus forward-in-time evaluation}
176 +\label{tab:generalization}
177 +\small
178 +\input{../results/tables/generalization}
179 +\begin{tablenotes}[flushleft]\footnotesize
180 +\item \textit{Notes:} Selected models (the backbone and the full D axis)
181 +under both splits.
182 +\end{tablenotes}
183 +\end{threeparttable}
184 +\end{table}
185 +
186 +\begin{figure}[t]
187 +\centering
188 +\includegraphics[width=0.85\textwidth]{fig_generalization.png}
189 +\caption{The generalization gap. MdAPE under the random holdout (filled
190 +circles) and the forward-in-time holdout (open squares); the grey
191 +segment joins the two evaluations of the same model.}
192 +\label{fig:generalization}
193 +\end{figure}
added paper/sections/titlepage.tex +70 −0
@@ -0,0 +1,70 @@
1 +% Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +% ============================================================================
3 +% Title and abstract pages
4 +% ============================================================================
5 +\thispagestyle{empty}
6 +
7 +\begin{center}
8 +
9 +\includegraphics[width=4cm]{uq_logo.jpg}
10 +
11 +\vspace{0.6cm}
12 +
13 +{\footnotesize\textsc{Universit\'e du Qu\'ebec en Outaouais}}\\[0.15cm]
14 +{\footnotesize\textsc{D\'epartement des sciences administratives}}
15 +
16 +\vspace{0.8cm}
17 +
18 +{\footnotesize\textsc{Working Paper No.~\WPnumber}}
19 +
20 +\vspace{1.2cm}
21 +
22 +{\LARGE\bfseries \WPtitle\par}
23 +
24 +\vspace{0.4cm}
25 +{\large\itshape \WPsubtitle\par}
26 +
27 +\vspace{1.2cm}
28 +
29 +{\large \WPauthor}\\[0.3cm]
30 +{\normalsize \WPaffiliation}\\[0.15cm]
31 +{\normalsize \href{mailto:\WPemail}{\WPemail}}\\[0.15cm]
32 +{\small \WPaddress}
33 +
34 +\vspace{0.8cm}
35 +
36 +{\normalsize \WPdate}\\[0.1cm]
37 +{\small Version~\WPversion}
38 +
39 +\end{center}
40 +
41 +\vfill
42 +
43 +\newpage
44 +
45 +% ---------------------------------------------------------------- abstract
46 +\thispagestyle{empty}
47 +
48 +\vspace*{1cm}
49 +
50 +\noindent\rule{\textwidth}{0.4pt}
51 +\vspace{0.3cm}
52 +
53 +\noindent\textbf{Abstract}
54 +
55 +\vspace{0.15cm}
56 +
57 +\noindent\WPabstract
58 +
59 +\vspace{0.4cm}
60 +
61 +\noindent\textbf{Keywords:} \WPkeywords
62 +
63 +\vspace{0.15cm}
64 +
65 +\noindent\textbf{JEL Classification:} \WPjel
66 +
67 +\vspace{0.3cm}
68 +\noindent\rule{\textwidth}{0.4pt}
69 +
70 +\newpage
added paper/uq_logo.jpg +0 −0

Binary file not shown.

added requirements.txt +7 −0
@@ -0,0 +1,7 @@
1 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +# Python >= 3.11. Versions pinned to the environment used for the analysis.
3 +numpy==2.4.4
4 +pandas==3.0.2
5 +pyarrow>=16.0
6 +scikit-learn==1.6.1
7 +matplotlib==3.10.9
added results/reproduced/boxcox.csv +8 −0
@@ -0,0 +1,8 @@
1 +lambda,loglik,chosen
2 +-0.5,-4986026.044606864,False
3 +-0.25,-4938790.1592812985,False
4 +0.0,-4896133.282457808,False
5 +0.25,-4879097.21142742,True
6 +0.5,-4882782.367887354,False
7 +0.75,-4893437.953075144,False
8 +1.0,-4924881.838890304,False
added results/reproduced/horserace_random.csv +21 −0
@@ -0,0 +1,21 @@
1 +name,group,mdape,mape,rmse_ln,r2_ln,seconds,n_train,n_test
2 +A1 Linear (levels),A. Functional form,18.247735441468322,35.554833553436424,0.4197326280373289,0.4770941777730925,0.5,411283,102929
3 +A2 Semi-log,A. Functional form,18.174885439840832,34.4795393377222,0.39663369462465925,0.5330641412991447,0.6,411283,102929
4 +A3 Log-log,A. Functional form,16.965877929132013,32.69009595755662,0.38125191583247026,0.5685782006102371,0.9,411283,102929
5 +A4 Box-Cox,A. Functional form,18.567718342220903,33.001281736849975,0.3903031252718359,0.5478504801090737,4.3,411283,102929
6 +A5 Semi-log + quadratics,A. Functional form,18.057575256256637,34.597939297430145,0.3967501193167754,0.5327899798098671,0.7,411283,102929
7 +A6 Semi-log + splines,A. Functional form,16.52578223473714,32.10851818544987,0.37644836156803374,0.5793810460819198,2.6,411283,102929
8 +B1 No time effects,B. Time effects,21.1450312090911,38.37918215557233,0.42321207728034005,0.4683888002641269,0.5,411283,102929
9 +B2 Year FE,B. Time effects,18.091245071945412,34.663597249791685,0.39704996735836223,0.5320835152583053,0.7,411283,102929
10 +B3 Quarter FE,B. Time effects,18.057575256256637,34.597939297430145,0.3967501193167754,0.5327899798098671,0.6,411283,102929
11 +B4 Month FE,B. Time effects,18.322750827125915,34.80915561246471,0.3982300277671944,0.5292980206991789,0.7,411283,102929
12 +C1 No spatial controls,C. Spatial controls,27.922352076812572,48.39219298797349,0.4944639622666287,0.2743164045351353,0.5,411283,102929
13 +C2 Municipality FE,C. Spatial controls,18.057575256256637,34.597939297430145,0.3967501193167754,0.5327899798098671,0.7,411283,102929
14 +C3 Grid-cell FE (~5.5 km),C. Spatial controls,17.53978076473437,34.03632099310664,0.3918420758470441,0.5442778333854188,0.7,411283,102929
15 +C4 Grid-cell FE (~1.1 km),C. Spatial controls,19.389847159546143,37.50800513353381,0.41828640746256024,0.48069139310554865,0.8,411283,102929
16 +D1 OLS (muni + month FE),D. Estimation method,18.322750827125915,34.80915561246471,0.3982300277671944,0.5292980206991789,0.7,411283,102929
17 +D2 Ridge (cross-validated),D. Estimation method,16.671722823569887,32.2521071548757,0.37758629277068595,0.5768343018663071,10.8,411283,102929
18 +D3 Random forest,D. Estimation method,14.62750218203232,28.181960170119,0.35257338948901984,0.6310418755396041,7.6,411283,102929
19 +D4 Gradient boosting,D. Estimation method,14.388300681085338,27.766499817027036,0.3471568687492957,0.6422912683312305,42.5,411283,102929
20 +"D5 Gradient boosting, no coordinates",D. Estimation method,23.225090677534933,38.30004358013209,0.4398837230475839,0.4256801846835939,17.7,411283,102929
21 +D6 Spatial k-NN comparables,D. Estimation method,21.18226600985226,36.73270267320528,0.43669681006238664,0.43397181715912814,0.1,411283,102929
added results/reproduced/horserace_temporal.csv +21 −0
@@ -0,0 +1,21 @@
1 +name,group,mdape,mape,rmse_ln,r2_ln,seconds,n_train,n_test
2 +A1 Linear (levels),A. Functional form,20.369511213481317,32.86005001443525,0.39728743801570204,0.4552239614410175,0.4,355824,158388
3 +A2 Semi-log,A. Functional form,20.611640132255836,33.09824319242239,0.3964345928808567,0.45756036006756484,0.5,355824,158388
4 +A3 Log-log,A. Functional form,19.189006525695262,30.749155508273613,0.3757555688314752,0.5126744490905257,0.8,355824,158388
5 +A4 Box-Cox,A. Functional form,20.957181720703545,31.940298396980698,0.38962419749983584,0.47603753989498354,3.8,355824,158388
6 +A5 Semi-log + quadratics,A. Functional form,20.4747492500219,32.08431941207347,0.3884352669819105,0.47923038313059785,0.6,355824,158388
7 +A6 Semi-log + splines,A. Functional form,19.097413080353302,30.49261711294047,0.37228529384601217,0.5216342331571537,2.1,355824,158388
8 +B1 No time effects,B. Time effects,25.384810383977687,33.162107080537304,0.41811180324070163,0.39661683797944225,0.5,355824,158388
9 +B2 Year FE,B. Time effects,21.006054587504984,32.62538772051225,0.39253658926914514,0.46817515745089067,0.6,355824,158388
10 +B3 Quarter FE,B. Time effects,20.4747492500219,32.08431941207347,0.3884352669819105,0.47923038313059785,0.6,355824,158388
11 +B4 Month FE,B. Time effects,20.347788061301657,32.47788422275795,0.38952457659313394,0.4763054439041552,0.6,355824,158388
12 +C1 No spatial controls,C. Spatial controls,26.45586672540444,41.108374756471996,0.45691339109589413,0.2794301211279968,0.4,355824,158388
13 +C2 Municipality FE,C. Spatial controls,20.4747492500219,32.08431941207347,0.3884352669819105,0.47923038313059785,0.6,355824,158388
14 +C3 Grid-cell FE (~5.5 km),C. Spatial controls,21.279243946393596,32.712624877555356,0.39371599730320833,0.464974534611328,0.6,355824,158388
15 +C4 Grid-cell FE (~1.1 km),C. Spatial controls,22.091478382056824,36.02185035008858,0.41700099046919065,0.39981863895351477,0.7,355824,158388
16 +D1 OLS (muni + month FE),D. Estimation method,20.347788061301657,32.47788422275795,0.38952457659313394,0.4763054439041552,0.6,355824,158388
17 +D2 Ridge (cross-validated),D. Estimation method,19.520136279141678,30.594351068378263,0.3747175016019065,0.5153633136379877,8.6,355824,158388
18 +D3 Random forest,D. Estimation method,21.34011982105225,29.76443050482997,0.37332513269156825,0.5189582317282835,6.8,355824,158388
19 +D4 Gradient boosting,D. Estimation method,21.21243467837447,29.71192710420828,0.37236811207724885,0.5214213758996993,41.5,355824,158388
20 +"D5 Gradient boosting, no coordinates",D. Estimation method,26.207233073287274,35.736727341535484,0.44725743322834494,0.3095639406099918,21.1,355824,158388
21 +D6 Spatial k-NN comparables,D. Estimation method,25.63120139114109,34.234843881635754,0.4653714833711972,0.2525057333957209,0.1,355824,158388
added results/reproduced/index.csv +336 −0
@@ -0,0 +1,336 @@
1 +month,form,index
2 +2021-01,Semi-log,100.0
3 +2021-02,Semi-log,101.58673535019909
4 +2021-03,Semi-log,105.15833463820046
5 +2021-04,Semi-log,110.05359361584941
6 +2021-05,Semi-log,113.3476197370347
7 +2021-06,Semi-log,116.78945849819837
8 +2021-07,Semi-log,117.25427261682117
9 +2021-08,Semi-log,117.57882194626126
10 +2021-09,Semi-log,114.84274152729432
11 +2021-10,Semi-log,115.67995573214243
12 +2021-11,Semi-log,117.22226898450423
13 +2021-12,Semi-log,119.13647099686591
14 +2022-01,Semi-log,117.6766710787268
15 +2022-02,Semi-log,114.79677886201225
16 +2022-03,Semi-log,120.9920404921965
17 +2022-04,Semi-log,125.75252465227376
18 +2022-05,Semi-log,130.42632038880743
19 +2022-06,Semi-log,135.47345849560585
20 +2022-07,Semi-log,132.98143525500856
21 +2022-08,Semi-log,132.8291602738497
22 +2022-09,Semi-log,127.81273393290273
23 +2022-10,Semi-log,125.67721580003195
24 +2022-11,Semi-log,124.5376598091201
25 +2022-12,Semi-log,121.42336425178925
26 +2023-01,Semi-log,122.52017019338413
27 +2023-02,Semi-log,116.32309831165357
28 +2023-03,Semi-log,122.7787681784388
29 +2023-04,Semi-log,127.81389693092997
30 +2023-05,Semi-log,130.97263662998472
31 +2023-06,Semi-log,136.65632652573856
32 +2023-07,Semi-log,134.83263792979906
33 +2023-08,Semi-log,136.96348385951148
34 +2023-09,Semi-log,132.91313088965063
35 +2023-10,Semi-log,133.2365475663534
36 +2023-11,Semi-log,130.5645082681891
37 +2023-12,Semi-log,129.4227692111657
38 +2024-01,Semi-log,130.85188265820088
39 +2024-02,Semi-log,125.99887235870726
40 +2024-03,Semi-log,133.3924105059048
41 +2024-04,Semi-log,137.96098170039664
42 +2024-05,Semi-log,143.7624116169281
43 +2024-06,Semi-log,147.13139249789288
44 +2024-07,Semi-log,144.83585652642233
45 +2024-08,Semi-log,146.8968076189641
46 +2024-09,Semi-log,142.64794058747063
47 +2024-10,Semi-log,144.351122714208
48 +2024-11,Semi-log,145.12652850202633
49 +2024-12,Semi-log,142.69800866412788
50 +2025-01,Semi-log,145.40663710499652
51 +2025-02,Semi-log,144.3591478393837
52 +2025-03,Semi-log,148.71265137564697
53 +2025-04,Semi-log,151.33940900295545
54 +2025-05,Semi-log,157.02547758982448
55 +2025-06,Semi-log,161.6883679603725
56 +2025-07,Semi-log,159.66796623712878
57 +2025-08,Semi-log,161.99037914121666
58 +2025-09,Semi-log,156.1589266096066
59 +2025-10,Semi-log,158.24670896609604
60 +2025-11,Semi-log,157.93907357133844
61 +2025-12,Semi-log,157.40164200715827
62 +2026-01,Semi-log,157.26405345738493
63 +2026-02,Semi-log,153.76917995549775
64 +2026-03,Semi-log,159.97375646706652
65 +2026-04,Semi-log,163.56733471065738
66 +2026-05,Semi-log,169.15316452282033
67 +2026-06,Semi-log,174.1961239017598
68 +2026-07,Semi-log,169.84650134335135
69 +2021-01,Log-log,100.0
70 +2021-02,Log-log,101.16133168740143
71 +2021-03,Log-log,104.68288933485195
72 +2021-04,Log-log,109.41941698870998
73 +2021-05,Log-log,112.69498141605109
74 +2021-06,Log-log,116.1652161651087
75 +2021-07,Log-log,116.48263736781233
76 +2021-08,Log-log,116.92062844228472
77 +2021-09,Log-log,114.27795754947768
78 +2021-10,Log-log,115.21217348466351
79 +2021-11,Log-log,116.6053300069431
80 +2021-12,Log-log,118.60818906058934
81 +2022-01,Log-log,117.08746382474162
82 +2022-02,Log-log,114.442914464912
83 +2022-03,Log-log,120.39020848352888
84 +2022-04,Log-log,125.00474627824644
85 +2022-05,Log-log,129.56534387932552
86 +2022-06,Log-log,134.46889998433656
87 +2022-07,Log-log,132.12966907890026
88 +2022-08,Log-log,131.9325308544368
89 +2022-09,Log-log,126.86433094068288
90 +2022-10,Log-log,124.88771889312255
91 +2022-11,Log-log,123.713591257508
92 +2022-12,Log-log,120.74808060473094
93 +2023-01,Log-log,121.42131218765795
94 +2023-02,Log-log,115.75618623462842
95 +2023-03,Log-log,121.85351692626989
96 +2023-04,Log-log,126.84974258215837
97 +2023-05,Log-log,129.74039397561964
98 +2023-06,Log-log,135.53464949369334
99 +2023-07,Log-log,133.849235386329
100 +2023-08,Log-log,135.9277878186335
101 +2023-09,Log-log,132.0159524794275
102 +2023-10,Log-log,132.2328522907643
103 +2023-11,Log-log,129.93287079815477
104 +2023-12,Log-log,128.66580504001163
105 +2024-01,Log-log,129.95742376250348
106 +2024-02,Log-log,125.53175903352141
107 +2024-03,Log-log,132.67490119743292
108 +2024-04,Log-log,136.88927173622355
109 +2024-05,Log-log,142.5768642960935
110 +2024-06,Log-log,146.03332291132114
111 +2024-07,Log-log,143.63461559502682
112 +2024-08,Log-log,145.67190854722085
113 +2024-09,Log-log,141.74995383419818
114 +2024-10,Log-log,143.26759775226904
115 +2024-11,Log-log,143.96023462876832
116 +2024-12,Log-log,141.70609531013278
117 +2025-01,Log-log,144.33799468587358
118 +2025-02,Log-log,143.40444566855706
119 +2025-03,Log-log,147.61363528963724
120 +2025-04,Log-log,150.40148514375616
121 +2025-05,Log-log,155.81660754992984
122 +2025-06,Log-log,160.3941492790868
123 +2025-07,Log-log,158.5887517002987
124 +2025-08,Log-log,160.84916883944067
125 +2025-09,Log-log,155.1172806147217
126 +2025-10,Log-log,157.164153035006
127 +2025-11,Log-log,156.9922296423208
128 +2025-12,Log-log,156.51959529639282
129 +2026-01,Log-log,156.24625834416705
130 +2026-02,Log-log,152.87882718125252
131 +2026-03,Log-log,158.98909028605362
132 +2026-04,Log-log,162.38523086106298
133 +2026-05,Log-log,167.88888535753074
134 +2026-06,Log-log,173.0699056577055
135 +2026-07,Log-log,168.63140563125273
136 +2021-01,Semi-log + quadratics,100.0
137 +2021-02,Semi-log + quadratics,101.0912732505944
138 +2021-03,Semi-log + quadratics,104.42262321513078
139 +2021-04,Semi-log + quadratics,109.37226200063293
140 +2021-05,Semi-log + quadratics,112.59748004514188
141 +2021-06,Semi-log + quadratics,116.30435317237504
142 +2021-07,Semi-log + quadratics,116.47173029607461
143 +2021-08,Semi-log + quadratics,116.97293425396234
144 +2021-09,Semi-log + quadratics,114.45575447119076
145 +2021-10,Semi-log + quadratics,115.35952362304862
146 +2021-11,Semi-log + quadratics,116.78444933413272
147 +2021-12,Semi-log + quadratics,118.75914785626908
148 +2022-01,Semi-log + quadratics,116.93782165076203
149 +2022-02,Semi-log + quadratics,114.3386843694858
150 +2022-03,Semi-log + quadratics,120.32316864472405
151 +2022-04,Semi-log + quadratics,125.08636243558213
152 +2022-05,Semi-log + quadratics,129.73545311526638
153 +2022-06,Semi-log + quadratics,134.69981308856902
154 +2022-07,Semi-log + quadratics,132.37066721196783
155 +2022-08,Semi-log + quadratics,131.99550321336687
156 +2022-09,Semi-log + quadratics,127.28489718350421
157 +2022-10,Semi-log + quadratics,125.2851100278592
158 +2022-11,Semi-log + quadratics,124.18260462984489
159 +2022-12,Semi-log + quadratics,121.05919175735458
160 +2023-01,Semi-log + quadratics,121.55418103294132
161 +2023-02,Semi-log + quadratics,116.04686395173907
162 +2023-03,Semi-log + quadratics,122.01210808628566
163 +2023-04,Semi-log + quadratics,127.03041796151922
164 +2023-05,Semi-log + quadratics,130.09782563085352
165 +2023-06,Semi-log + quadratics,135.81357299276803
166 +2023-07,Semi-log + quadratics,133.99886985109012
167 +2023-08,Semi-log + quadratics,136.15514590130437
168 +2023-09,Semi-log + quadratics,132.31098740516646
169 +2023-10,Semi-log + quadratics,132.55793273829264
170 +2023-11,Semi-log + quadratics,130.17274128070497
171 +2023-12,Semi-log + quadratics,128.93669068862442
172 +2024-01,Semi-log + quadratics,130.30060804724272
173 +2024-02,Semi-log + quadratics,125.6663360800061
174 +2024-03,Semi-log + quadratics,133.07825950663835
175 +2024-04,Semi-log + quadratics,137.23362278542456
176 +2024-05,Semi-log + quadratics,143.04870094961964
177 +2024-06,Semi-log + quadratics,146.41659476532632
178 +2024-07,Semi-log + quadratics,144.01839530533434
179 +2024-08,Semi-log + quadratics,145.92847409272198
180 +2024-09,Semi-log + quadratics,142.14509783719683
181 +2024-10,Semi-log + quadratics,143.73179957797396
182 +2024-11,Semi-log + quadratics,144.6300279028595
183 +2024-12,Semi-log + quadratics,142.26528364486543
184 +2025-01,Semi-log + quadratics,144.9120001944002
185 +2025-02,Semi-log + quadratics,143.60284630600765
186 +2025-03,Semi-log + quadratics,147.82965565359797
187 +2025-04,Semi-log + quadratics,150.76832704543722
188 +2025-05,Semi-log + quadratics,156.37842118705146
189 +2025-06,Semi-log + quadratics,161.01977142532053
190 +2025-07,Semi-log + quadratics,159.02517078211636
191 +2025-08,Semi-log + quadratics,161.4582429851579
192 +2025-09,Semi-log + quadratics,155.89611291489777
193 +2025-10,Semi-log + quadratics,157.7415552940419
194 +2025-11,Semi-log + quadratics,157.33526323503708
195 +2025-12,Semi-log + quadratics,157.1024598292604
196 +2026-01,Semi-log + quadratics,156.89128899951555
197 +2026-02,Semi-log + quadratics,153.7323623687321
198 +2026-03,Semi-log + quadratics,159.8847880323458
199 +2026-04,Semi-log + quadratics,163.20838540709002
200 +2026-05,Semi-log + quadratics,168.76323617947057
201 +2026-06,Semi-log + quadratics,173.88636049318322
202 +2026-07,Semi-log + quadratics,169.35280403619177
203 +2021-01,Semi-log + splines,100.0
204 +2021-02,Semi-log + splines,101.86542395532908
205 +2021-03,Semi-log + splines,105.24287289345533
206 +2021-04,Semi-log + splines,110.0753748482258
207 +2021-05,Semi-log + splines,113.51226965711332
208 +2021-06,Semi-log + splines,117.12926449404905
209 +2021-07,Semi-log + splines,117.33124106236158
210 +2021-08,Semi-log + splines,117.91256336220968
211 +2021-09,Semi-log + splines,115.44205952163804
212 +2021-10,Semi-log + splines,116.2483941248449
213 +2021-11,Semi-log + splines,117.66086049567832
214 +2021-12,Semi-log + splines,119.75832998905855
215 +2022-01,Semi-log + splines,117.96452417745165
216 +2022-02,Semi-log + splines,115.24885761191399
217 +2022-03,Semi-log + splines,121.33105847128638
218 +2022-04,Semi-log + splines,126.09002074053011
219 +2022-05,Semi-log + splines,130.77542343594016
220 +2022-06,Semi-log + splines,135.7974268497703
221 +2022-07,Semi-log + splines,133.48756597492573
222 +2022-08,Semi-log + splines,132.9868190484367
223 +2022-09,Semi-log + splines,128.30100770145222
224 +2022-10,Semi-log + splines,126.28169354928811
225 +2022-11,Semi-log + splines,125.1044579005194
226 +2022-12,Semi-log + splines,122.04033159253818
227 +2023-01,Semi-log + splines,122.63688240412338
228 +2023-02,Semi-log + splines,116.92896754893749
229 +2023-03,Semi-log + splines,123.0294761555768
230 +2023-04,Semi-log + splines,128.00114663186017
231 +2023-05,Semi-log + splines,131.23083427450524
232 +2023-06,Semi-log + splines,136.97517974696984
233 +2023-07,Semi-log + splines,135.16601168892598
234 +2023-08,Semi-log + splines,137.36861294103227
235 +2023-09,Semi-log + splines,133.51256070039653
236 +2023-10,Semi-log + splines,133.54140440526615
237 +2023-11,Semi-log + splines,131.24473978397685
238 +2023-12,Semi-log + splines,130.10173643757705
239 +2024-01,Semi-log + splines,131.51492232192732
240 +2024-02,Semi-log + splines,126.9041346661383
241 +2024-03,Semi-log + splines,134.18315970301032
242 +2024-04,Semi-log + splines,138.5664529434532
243 +2024-05,Semi-log + splines,144.2536503040894
244 +2024-06,Semi-log + splines,147.6632328263899
245 +2024-07,Semi-log + splines,145.27442862198907
246 +2024-08,Semi-log + splines,147.2348987165921
247 +2024-09,Semi-log + splines,143.49222253101885
248 +2024-10,Semi-log + splines,145.0684610323443
249 +2024-11,Semi-log + splines,145.87718550298047
250 +2024-12,Semi-log + splines,143.6071311658821
251 +2025-01,Semi-log + splines,146.1699022929873
252 +2025-02,Semi-log + splines,144.94008820347236
253 +2025-03,Semi-log + splines,149.17131940453797
254 +2025-04,Semi-log + splines,152.18333061668295
255 +2025-05,Semi-log + splines,157.72024490030327
256 +2025-06,Semi-log + splines,162.4653006702696
257 +2025-07,Semi-log + splines,160.45226600771628
258 +2025-08,Semi-log + splines,162.88604107331653
259 +2025-09,Semi-log + splines,157.23067628044703
260 +2025-10,Semi-log + splines,159.134855162167
261 +2025-11,Semi-log + splines,158.87144641498278
262 +2025-12,Semi-log + splines,158.45430448942963
263 +2026-01,Semi-log + splines,158.33352096831015
264 +2026-02,Semi-log + splines,155.21935155939082
265 +2026-03,Semi-log + splines,161.38038945070147
266 +2026-04,Semi-log + splines,164.7454033908841
267 +2026-05,Semi-log + splines,170.3218592099857
268 +2026-06,Semi-log + splines,175.55732652817687
269 +2026-07,Semi-log + splines,170.9761927074845
270 +2021-01,Gradient boosting,100.0
271 +2021-02,Gradient boosting,98.81093967843219
272 +2021-03,Gradient boosting,102.04903427895738
273 +2021-04,Gradient boosting,107.27637825324533
274 +2021-05,Gradient boosting,111.64259460314013
275 +2021-06,Gradient boosting,115.28081932192127
276 +2021-07,Gradient boosting,114.65823177639984
277 +2021-08,Gradient boosting,114.81671708425627
278 +2021-09,Gradient boosting,113.48392784769084
279 +2021-10,Gradient boosting,113.52504569352182
280 +2021-11,Gradient boosting,115.17934858225296
281 +2021-12,Gradient boosting,115.65180328106477
282 +2022-01,Gradient boosting,115.26424839066175
283 +2022-02,Gradient boosting,114.72480050047574
284 +2022-03,Gradient boosting,119.04674291815279
285 +2022-04,Gradient boosting,123.82509452745393
286 +2022-05,Gradient boosting,126.4829518746023
287 +2022-06,Gradient boosting,132.16310661784829
288 +2022-07,Gradient boosting,130.00851314807352
289 +2022-08,Gradient boosting,128.7864941203398
290 +2022-09,Gradient boosting,124.43627137411872
291 +2022-10,Gradient boosting,123.748568288378
292 +2022-11,Gradient boosting,121.88952975280279
293 +2022-12,Gradient boosting,118.77376517016326
294 +2023-01,Gradient boosting,118.19441603622532
295 +2023-02,Gradient boosting,118.28793615042244
296 +2023-03,Gradient boosting,118.5149581464299
297 +2023-04,Gradient boosting,124.7545613709178
298 +2023-05,Gradient boosting,126.12206249461191
299 +2023-06,Gradient boosting,131.2717630732005
300 +2023-07,Gradient boosting,131.19793605157096
301 +2023-08,Gradient boosting,131.19130175687172
302 +2023-09,Gradient boosting,128.619821814608
303 +2023-10,Gradient boosting,128.60461738160117
304 +2023-11,Gradient boosting,127.85718637615204
305 +2023-12,Gradient boosting,127.37452603809588
306 +2024-01,Gradient boosting,126.46943297873648
307 +2024-02,Gradient boosting,126.00157862927615
308 +2024-03,Gradient boosting,127.28267199034268
309 +2024-04,Gradient boosting,132.24998091010997
310 +2024-05,Gradient boosting,139.7906201202798
311 +2024-06,Gradient boosting,141.48131606446856
312 +2024-07,Gradient boosting,139.83825978883485
313 +2024-08,Gradient boosting,140.07061407756328
314 +2024-09,Gradient boosting,139.40875369614565
315 +2024-10,Gradient boosting,139.77690306753448
316 +2024-11,Gradient boosting,139.79308784958624
317 +2024-12,Gradient boosting,139.4527803418063
318 +2025-01,Gradient boosting,139.57443965589766
319 +2025-02,Gradient boosting,139.0724018182482
320 +2025-03,Gradient boosting,142.79288446020632
321 +2025-04,Gradient boosting,144.98390219097055
322 +2025-05,Gradient boosting,149.42841324546987
323 +2025-06,Gradient boosting,153.80976280002778
324 +2025-07,Gradient boosting,153.2091824004246
325 +2025-08,Gradient boosting,154.12363532269353
326 +2025-09,Gradient boosting,150.80575599999182
327 +2025-10,Gradient boosting,151.28464473812545
328 +2025-11,Gradient boosting,151.30710195621506
329 +2025-12,Gradient boosting,151.24197247127526
330 +2026-01,Gradient boosting,149.09185237388857
331 +2026-02,Gradient boosting,148.7062004952243
332 +2026-03,Gradient boosting,153.6606237883838
333 +2026-04,Gradient boosting,154.7372061662432
334 +2026-05,Gradient boosting,159.71561841103357
335 +2026-06,Gradient boosting,163.30359451297505
336 +2026-07,Gradient boosting,160.46582864536248
added results/reproduced/learning.csv +13 −0
@@ -0,0 +1,13 @@
1 +n_train,model,mdape,mape,rmse_ln,r2_ln
2 +10000,OLS quadratic (muni+quarter FE),18.38436183742385,34.439941317616764,0.39722768960842764,0.531664536345561
3 +10000,Gradient boosting,17.55817415103931,31.777076909845096,0.386462587914794,0.5567048988691092
4 +25000,OLS quadratic (muni+quarter FE),18.13123412223344,34.71752541710843,0.3987497135923394,0.5280686972389094
5 +25000,Gradient boosting,16.117235891494122,29.823687390173077,0.36598650222996054,0.6024349909662393
6 +50000,OLS quadratic (muni+quarter FE),17.85098430954251,34.291813175933555,0.394668048572139,0.5376807754235925
7 +50000,Gradient boosting,15.46029775645706,29.149938590306927,0.3602557920963508,0.6147878645125984
8 +100000,OLS quadratic (muni+quarter FE),17.858528109660003,33.97231033671203,0.39154828747222153,0.5449609437080014
9 +100000,Gradient boosting,15.201224181879457,28.789207604531935,0.35629888749041316,0.6232034228186022
10 +200000,OLS quadratic (muni+quarter FE),17.926781291106366,34.286171684612285,0.3940853634933891,0.5390448972702662
11 +200000,Gradient boosting,14.75286425569271,28.18065043214689,0.3510692811837181,0.6341831746695208
12 +400000,OLS quadratic (muni+quarter FE),18.06403273608106,34.590640697337626,0.3967353319490308,0.5328248061516689
13 +400000,Gradient boosting,14.485229661097984,27.850855998019064,0.3478423367115942,0.640877268082553
added results/reproduced/profiles.csv +81 −0
@@ -0,0 +1,81 @@
1 +var,x,model,y
2 +age,0,OLS quadratic,0.0
3 +age,5,OLS quadratic,-0.04546016510740408
4 +age,10,OLS quadratic,-0.08890937386177093
5 +age,15,OLS quadratic,-0.13034762626310056
6 +age,20,OLS quadratic,-0.16977492231139296
7 +age,25,OLS quadratic,-0.2071912620066481
8 +age,30,OLS quadratic,-0.2425966453488661
9 +age,35,OLS quadratic,-0.27599107233804676
10 +age,40,OLS quadratic,-0.3073745429741903
11 +age,45,OLS quadratic,-0.3367470572572966
12 +age,50,OLS quadratic,-0.3641086151873656
13 +age,55,OLS quadratic,-0.3894592167643974
14 +age,60,OLS quadratic,-0.41279886198839205
15 +age,65,OLS quadratic,-0.4341275508593494
16 +age,70,OLS quadratic,-0.4534452833772695
17 +age,75,OLS quadratic,-0.4707520595421524
18 +age,80,OLS quadratic,-0.4860478793539982
19 +age,85,OLS quadratic,-0.4993327428128066
20 +age,90,OLS quadratic,-0.5106066499185778
21 +age,95,OLS quadratic,-0.5198696006713118
22 +age,100,OLS quadratic,-0.5271215950710086
23 +age,105,OLS quadratic,-0.5323626331176683
24 +age,110,OLS quadratic,-0.5355927148112904
25 +age,115,OLS quadratic,-0.5368118401518757
26 +age,120,OLS quadratic,-0.5360200091394236
27 +area,50,OLS quadratic,0.0
28 +area,75,OLS quadratic,0.1540482284114693
29 +area,100,OLS quadratic,0.29507549352288237
30 +area,125,OLS quadratic,0.4230817953342389
31 +area,150,OLS quadratic,0.5380671338455387
32 +area,175,OLS quadratic,0.6400315090567827
33 +area,200,OLS quadratic,0.7289749209679699
34 +area,225,OLS quadratic,0.8048973695791006
35 +area,250,OLS quadratic,0.8677988548901752
36 +area,275,OLS quadratic,0.9176793769011928
37 +area,300,OLS quadratic,0.9545389356121543
38 +area,325,OLS quadratic,0.9783775310230596
39 +area,350,OLS quadratic,0.9891951631339087
40 +area,375,OLS quadratic,0.9869918319447009
41 +area,400,OLS quadratic,0.9717675374554366
42 +age,0,Gradient boosting,0.0
43 +age,5,Gradient boosting,0.06022362794499614
44 +age,10,Gradient boosting,-0.005032206446413667
45 +age,15,Gradient boosting,-0.05994112503617899
46 +age,20,Gradient boosting,-0.07127803977000902
47 +age,25,Gradient boosting,-0.08746246223557996
48 +age,30,Gradient boosting,-0.13688306112882742
49 +age,35,Gradient boosting,-0.1651092607357718
50 +age,40,Gradient boosting,-0.19815957087055303
51 +age,45,Gradient boosting,-0.23304192483616326
52 +age,50,Gradient boosting,-0.25315924378123533
53 +age,55,Gradient boosting,-0.27590408944412204
54 +age,60,Gradient boosting,-0.28428243040351475
55 +age,65,Gradient boosting,-0.319171736553443
56 +age,70,Gradient boosting,-0.3431363607058149
57 +age,75,Gradient boosting,-0.37182171513782336
58 +age,80,Gradient boosting,-0.39166657486250145
59 +age,85,Gradient boosting,-0.4038026638189365
60 +age,90,Gradient boosting,-0.4051548521390185
61 +age,95,Gradient boosting,-0.40635586541562674
62 +age,100,Gradient boosting,-0.4076605522328567
63 +age,105,Gradient boosting,-0.4139522349833751
64 +age,110,Gradient boosting,-0.4180651627994525
65 +age,115,Gradient boosting,-0.4328961974199519
66 +age,120,Gradient boosting,-0.4362886014528371
67 +area,50,Gradient boosting,0.0
68 +area,75,Gradient boosting,0.18829979560266175
69 +area,100,Gradient boosting,0.34718915583661314
70 +area,125,Gradient boosting,0.4609024483577322
71 +area,150,Gradient boosting,0.5737430678733197
72 +area,175,Gradient boosting,0.6473792262306208
73 +area,200,Gradient boosting,0.714730774490473
74 +area,225,Gradient boosting,0.7759301267880261
75 +area,250,Gradient boosting,0.8380377927153972
76 +area,275,Gradient boosting,0.8772665644381696
77 +area,300,Gradient boosting,0.9163093623875032
78 +area,325,Gradient boosting,0.9397872441141804
79 +area,350,Gradient boosting,0.9519873322394119
80 +area,375,Gradient boosting,0.9529180642873598
81 +area,400,Gradient boosting,0.9599879330265484
added results/reproduced/sample_counts.csv +11 −0
@@ -0,0 +1,11 @@
1 +,0
2 +n_sales,514212
3 +n_munis,1115
4 +n_grid,30548
5 +n_grid5,5255
6 +n_condo,75734
7 +n_cottage,10349
8 +n_mobile,4881
9 +n_other,86
10 +n_plex,74909
11 +n_single_family,348253
added results/reproduced/segments.csv +15 −0
@@ -0,0 +1,15 @@
1 +segment,model,n,mdape,mape,rmse_ln,r2_ln
2 +Class: single_family,OLS quadratic,69835,17.15697828017771,35.09144196354416,0.4018837509330969,0.5269797760968626
3 +Class: single_family,Gradient boosting,69835,15.08887366879076,29.429305197147464,0.3608165339944521,0.6187132829338855
4 +Class: condo,OLS quadratic,14848,20.42743440870622,27.094418864984917,0.32651881058598764,0.4074914996647663
5 +Class: condo,Gradient boosting,14848,9.071686847966884,14.485209967611992,0.20596083242967297,0.7642523075225905
6 +Class: plex,OLS quadratic,15115,18.50099130749293,36.98046001501068,0.41103973211699246,0.5454994242149013
7 +Class: plex,Gradient boosting,15115,16.52761611544543,30.817583909526963,0.36806946728568257,0.635559683430239
8 +Class: cottage,OLS quadratic,2109,33.91947109274449,53.22661347754297,0.5482899168598493,0.3136130552394867
9 +Class: cottage,Gradient boosting,2109,32.09640332106328,45.661889354803385,0.5034612691648649,0.4212638262473417
10 +Muni < 1k sales,OLS quadratic,29253,23.257955712558935,42.00606759369818,0.4521889409919308,0.4895905571585859
11 +Muni < 1k sales,Gradient boosting,29253,18.686729079471053,32.7580249582477,0.38939943295154483,0.621496886358641
12 +Muni 1k–10k sales,OLS quadratic,37419,15.193131785630404,32.29520768278317,0.3776824491741606,0.5133709854088806
13 +Muni 1k–10k sales,Gradient boosting,37419,14.504639160801878,28.498332907599565,0.3521566764912874,0.576926089020657
14 +Muni > 10k sales,OLS quadratic,36257,17.82460628493704,30.99741904583036,0.3669850806325136,0.46899139390723055
15 +Muni > 10k sales,Gradient boosting,36257,11.91348390081652,22.983932385296278,0.30299483947274597,0.6380278097783667
added results/reproduced/summary_stats.csv +7 −0
@@ -0,0 +1,7 @@
1 +variable,n,mean,sd,p10,p50,p90
2 +Sale price ($),514212,440207.3699233001,245715.68560162513,170000.0,397375.0,759000.0
3 +Floor area (m2),514212,132.3595075571943,61.5418900414627,76.9,111.7,217.4
4 +Lot area (m2),514212,1059.7684570760696,1783.1060765884122,114.46100000000006,557.4,2473.8
5 +Building age (years),514212,43.90011318288955,29.78225299985444,10.0,40.0,80.0
6 +Storeys,514212,1.361457920079656,0.5123188318543891,1.0,1.0,2.0
7 +Dwelling units,514212,1.2390025903712865,0.6855596301480114,1.0,1.0,2.0
added results/tables/boxcox.tex +7 −0
@@ -0,0 +1,7 @@
1 +\begin{tabular}{lccccccc}
2 +\toprule
3 +$\lambda$ & -0.50 & -0.25 & 0.00 & 0.25 & 0.50 & 0.75 & 1.00 \\
4 +\midrule
5 +Profile log-likelihood$^{\dagger}$ & -106.9 & -59.7 & -17.0 & 0.0 & -3.7 & -14.3 & -45.8 \\
6 +\bottomrule
7 +\end{tabular}
\ No newline at end of file
added results/tables/generalization.tex +17 −0
@@ -0,0 +1,17 @@
1 +\begin{tabular}{lcc c cc}
2 +\toprule
3 + & \multicolumn{2}{c}{Random holdout} & & \multicolumn{2}{c}{Forward-in-time} \\
4 +\cmidrule{2-3}\cmidrule{5-6}
5 +Model & MdAPE (\%) & $R^2_{\ln}$ & & MdAPE (\%) & $R^2_{\ln}$ \\
6 +\midrule
7 +A5 Semi-log + quadratics & 18.1 & 0.533 & & 20.5 & 0.479 \\
8 +B4 Month FE & 18.3 & 0.529 & & 20.3 & 0.476 \\
9 +C4 Grid-cell FE ($\sim$1.1 km) & 19.4 & 0.481 & & 22.1 & 0.400 \\
10 +D1 OLS (muni + month FE) & 18.3 & 0.529 & & 20.3 & 0.476 \\
11 +D2 Ridge (cross-validated) & 16.7 & 0.577 & & 19.5 & 0.515 \\
12 +D3 Random forest & 14.6 & 0.631 & & 21.3 & 0.519 \\
13 +D4 Gradient boosting & 14.4 & 0.642 & & 21.2 & 0.521 \\
14 +D5 Gradient boosting, no coordinates & 23.2 & 0.426 & & 26.2 & 0.310 \\
15 +D6 Spatial k-NN comparables & 21.2 & 0.434 & & 25.6 & 0.253 \\
16 +\bottomrule
17 +\end{tabular}
\ No newline at end of file
added results/tables/horserace_random.tex +33 −0
@@ -0,0 +1,33 @@
1 +\begin{tabular}{lcccc c}
2 +\toprule
3 +Model & MdAPE (\%) & MAPE (\%) & RMSE$_{\ln}$ & $R^2_{\ln}$ & Fit (s) \\
4 +\midrule
5 +\multicolumn{6}{l}{\itshape A. Functional form}\\
6 +\quad A1 Linear (levels) & 18.2 & 35.6 & 0.420 & 0.477 & 0 \\
7 +\quad A2 Semi-log & 18.2 & 34.5 & 0.397 & 0.533 & 1 \\
8 +\quad A3 Log-log & 17.0 & 32.7 & 0.381 & 0.569 & 1 \\
9 +\quad A4 Box-Cox & 18.6 & 33.0 & 0.390 & 0.548 & 4 \\
10 +\quad A5 Semi-log + quadratics & 18.1 & 34.6 & 0.397 & 0.533 & 1 \\
11 +\quad A6 Semi-log + splines & \textbf{16.5} & 32.1 & 0.376 & 0.579 & 3 \\
12 +\midrule
13 +\multicolumn{6}{l}{\itshape B. Time effects}\\
14 +\quad B1 No time effects & 21.1 & 38.4 & 0.423 & 0.468 & 0 \\
15 +\quad B2 Year FE & 18.1 & 34.7 & 0.397 & 0.532 & 1 \\
16 +\quad B3 Quarter FE & \textbf{18.1} & 34.6 & 0.397 & 0.533 & 1 \\
17 +\quad B4 Month FE & 18.3 & 34.8 & 0.398 & 0.529 & 1 \\
18 +\midrule
19 +\multicolumn{6}{l}{\itshape C. Spatial controls}\\
20 +\quad C1 No spatial controls & 27.9 & 48.4 & 0.494 & 0.274 & 0 \\
21 +\quad C2 Municipality FE & 18.1 & 34.6 & 0.397 & 0.533 & 1 \\
22 +\quad C3 Grid-cell FE ($\sim$5.5 km) & \textbf{17.5} & 34.0 & 0.392 & 0.544 & 1 \\
23 +\quad C4 Grid-cell FE ($\sim$1.1 km) & 19.4 & 37.5 & 0.418 & 0.481 & 1 \\
24 +\midrule
25 +\multicolumn{6}{l}{\itshape D. Estimation method}\\
26 +\quad D1 OLS (muni + month FE) & 18.3 & 34.8 & 0.398 & 0.529 & 1 \\
27 +\quad D2 Ridge (cross-validated) & 16.7 & 32.3 & 0.378 & 0.577 & 11 \\
28 +\quad D3 Random forest & 14.6 & 28.2 & 0.353 & 0.631 & 8 \\
29 +\quad D4 Gradient boosting & \textbf{14.4} & 27.8 & 0.347 & 0.642 & 42 \\
30 +\quad D5 Gradient boosting, no coordinates & 23.2 & 38.3 & 0.440 & 0.426 & 18 \\
31 +\quad D6 Spatial k-NN comparables & 21.2 & 36.7 & 0.437 & 0.434 & 0 \\
32 +\bottomrule
33 +\end{tabular}
\ No newline at end of file
added results/tables/horserace_temporal.tex +33 −0
@@ -0,0 +1,33 @@
1 +\begin{tabular}{lcccc c}
2 +\toprule
3 +Model & MdAPE (\%) & MAPE (\%) & RMSE$_{\ln}$ & $R^2_{\ln}$ & Fit (s) \\
4 +\midrule
5 +\multicolumn{6}{l}{\itshape A. Functional form}\\
6 +\quad A1 Linear (levels) & 20.4 & 32.9 & 0.397 & 0.455 & 0 \\
7 +\quad A2 Semi-log & 20.6 & 33.1 & 0.396 & 0.458 & 0 \\
8 +\quad A3 Log-log & 19.2 & 30.7 & 0.376 & 0.513 & 1 \\
9 +\quad A4 Box-Cox & 21.0 & 31.9 & 0.390 & 0.476 & 4 \\
10 +\quad A5 Semi-log + quadratics & 20.5 & 32.1 & 0.388 & 0.479 & 1 \\
11 +\quad A6 Semi-log + splines & \textbf{19.1} & 30.5 & 0.372 & 0.522 & 2 \\
12 +\midrule
13 +\multicolumn{6}{l}{\itshape B. Time effects}\\
14 +\quad B1 No time effects & 25.4 & 33.2 & 0.418 & 0.397 & 0 \\
15 +\quad B2 Year FE & 21.0 & 32.6 & 0.393 & 0.468 & 1 \\
16 +\quad B3 Quarter FE & 20.5 & 32.1 & 0.388 & 0.479 & 1 \\
17 +\quad B4 Month FE & \textbf{20.3} & 32.5 & 0.390 & 0.476 & 1 \\
18 +\midrule
19 +\multicolumn{6}{l}{\itshape C. Spatial controls}\\
20 +\quad C1 No spatial controls & 26.5 & 41.1 & 0.457 & 0.279 & 0 \\
21 +\quad C2 Municipality FE & \textbf{20.5} & 32.1 & 0.388 & 0.479 & 1 \\
22 +\quad C3 Grid-cell FE ($\sim$5.5 km) & 21.3 & 32.7 & 0.394 & 0.465 & 1 \\
23 +\quad C4 Grid-cell FE ($\sim$1.1 km) & 22.1 & 36.0 & 0.417 & 0.400 & 1 \\
24 +\midrule
25 +\multicolumn{6}{l}{\itshape D. Estimation method}\\
26 +\quad D1 OLS (muni + month FE) & 20.3 & 32.5 & 0.390 & 0.476 & 1 \\
27 +\quad D2 Ridge (cross-validated) & \textbf{19.5} & 30.6 & 0.375 & 0.515 & 9 \\
28 +\quad D3 Random forest & 21.3 & 29.8 & 0.373 & 0.519 & 7 \\
29 +\quad D4 Gradient boosting & 21.2 & 29.7 & 0.372 & 0.521 & 42 \\
30 +\quad D5 Gradient boosting, no coordinates & 26.2 & 35.7 & 0.447 & 0.310 & 21 \\
31 +\quad D6 Spatial k-NN comparables & 25.6 & 34.2 & 0.465 & 0.253 & 0 \\
32 +\bottomrule
33 +\end{tabular}
\ No newline at end of file
added results/tables/learning.tex +12 −0
@@ -0,0 +1,12 @@
1 +\begin{tabular}{rcc}
2 +\toprule
3 +Training sales & Gradient boosting & OLS quadratic (muni+quarter FE) \\
4 +\midrule
5 +10,000 & 17.6 & 18.4 \\
6 +25,000 & 16.1 & 18.1 \\
7 +50,000 & 15.5 & 17.9 \\
8 +100,000 & 15.2 & 17.9 \\
9 +200,000 & 14.8 & 17.9 \\
10 +400,000 & 14.5 & 18.1 \\
11 +\bottomrule
12 +\end{tabular}
\ No newline at end of file
added results/tables/segments.tex +13 −0
@@ -0,0 +1,13 @@
1 +\begin{tabular}{lrcc}
2 +\toprule
3 +Segment & $n$ (test) & OLS quadratic & Gradient boosting \\
4 +\midrule
5 +Condo & 14,848 & 20.4 & 9.1 \\
6 +Cottage & 2,109 & 33.9 & 32.1 \\
7 +Plex & 15,115 & 18.5 & 16.5 \\
8 +Single family & 69,835 & 17.2 & 15.1 \\
9 +Muni 1k--10k sales & 37,419 & 15.2 & 14.5 \\
10 +Muni $<$ 1k sales & 29,253 & 23.3 & 18.7 \\
11 +Muni $>$ 10k sales & 36,257 & 17.8 & 11.9 \\
12 +\bottomrule
13 +\end{tabular}
\ No newline at end of file
added results/tables/summary_stats.tex +12 −0
@@ -0,0 +1,12 @@
1 +\begin{tabular}{lrrrrrr}
2 +\toprule
3 +Variable & $n$ & Mean & SD & P10 & Median & P90 \\
4 +\midrule
5 +Sale price (\$) & 514,212 & 440,207 & 245,716 & 170,000 & 397,375 & 759,000 \\
6 +Floor area (m$^2$) & 514,212 & 132.36 & 61.54 & 76.90 & 111.70 & 217.40 \\
7 +Lot area (m$^2$) & 514,212 & 1,060 & 1,783 & 114 & 557 & 2,474 \\
8 +Building age (years) & 514,212 & 43.90 & 29.78 & 10.00 & 40.00 & 80.00 \\
9 +Storeys & 514,212 & 1.36 & 0.51 & 1.00 & 1.00 & 2.00 \\
10 +Dwelling units & 514,212 & 1.24 & 0.69 & 1.00 & 1.00 & 2.00 \\
11 +\bottomrule
12 +\end{tabular}
\ No newline at end of file
added scripts/01_build_sample.py +25 −0
@@ -0,0 +1,25 @@
1 +#!/usr/bin/env python3
2 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
3 +"""Step 01 — Build the estimation sample.
4 +
5 +Usage: python scripts/01_build_sample.py
6 +"""
7 +import sys
8 +from pathlib import Path
9 +
10 +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
11 +
12 +from wp11 import sample # noqa: E402
13 +
14 +
15 +def main() -> None:
16 + s = sample.build_and_save()
17 + print(f"\nEstimation sample written: {len(s):,} sales")
18 + print(f" municipalities : {s['muni'].nunique():,}")
19 + print(f" grid cells : {s['grid'].nunique():,}")
20 + print(f" grid5 cells : {s['grid5'].nunique():,}")
21 + print(f" classes : {s.groupby('prop_class').size().to_dict()}")
22 +
23 +
24 +if __name__ == "__main__":
25 + main()
added scripts/02_horserace.py +64 −0
@@ -0,0 +1,64 @@
1 +#!/usr/bin/env python3
2 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
3 +"""Step 02 — The horse race.
4 +
5 +Fits the twenty registry models under (i) a random 80/20 holdout and
6 +(ii) a forward-in-time holdout (train < 2025, test 2025–2026), plus the
7 +Box–Cox profile likelihood and summary statistics.
8 +
9 +Writes: horserace_random.csv, horserace_temporal.csv, boxcox.csv,
10 +summary_stats.csv, sample_counts.csv
11 +
12 +Usage: python scripts/02_horserace.py
13 +"""
14 +import sys
15 +from pathlib import Path
16 +
17 +import pandas as pd
18 +
19 +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
20 +
21 +from wp11 import config, models, sample # noqa: E402
22 +
23 +
24 +def main() -> None:
25 + config.ensure_dirs()
26 + df = sample.load()
27 + out = config.REPRODUCED
28 +
29 + # ------------------------------------------------------------ Table 1
30 + desc = {"price": "Sale price ($)", "area": "Floor area (m2)",
31 + "lot": "Lot area (m2)", "age": "Building age (years)",
32 + "floors": "Storeys", "units": "Dwelling units"}
33 + rows = []
34 + for var, label in desc.items():
35 + s = df[var]
36 + rows.append({"variable": label, "n": len(s), "mean": s.mean(),
37 + "sd": s.std(), "p10": s.quantile(.10), "p50": s.median(),
38 + "p90": s.quantile(.90)})
39 + pd.DataFrame(rows).to_csv(out / "summary_stats.csv", index=False)
40 + counts = {"n_sales": len(df), "n_munis": df["muni"].nunique(),
41 + "n_grid": df["grid"].nunique(),
42 + "n_grid5": df["grid5"].nunique()}
43 + for k, v in df.groupby("prop_class").size().items():
44 + counts[f"n_{k}"] = int(v)
45 + pd.Series(counts).to_csv(out / "sample_counts.csv")
46 +
47 + # ------------------------------------------------------------ Box–Cox profile
48 + d = models.add_derived(df)
49 + d["sale_year_c"] = d["sale_year"].astype(str)
50 + train, _ = models.split(d, "random")
51 + lam, prof = models.fit_boxcox(train, models.NUM_LEVELS,
52 + models.CATS + ["muni", "quarter"])
53 + prof["chosen"] = prof["lambda"] == lam
54 + prof.to_csv(out / "boxcox.csv", index=False)
55 + print(f"Box–Cox lambda* = {lam}")
56 +
57 + # ------------------------------------------------------------ the race
58 + for scheme in ("random", "temporal"):
59 + res = models.run_horserace(df, scheme)
60 + res.to_csv(out / f"horserace_{scheme}.csv", index=False)
61 +
62 +
63 +if __name__ == "__main__":
64 + main()
added scripts/03_extensions.py +198 −0
@@ -0,0 +1,198 @@
1 +#!/usr/bin/env python3
2 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
3 +"""Step 03 — Extensions beyond the scoreboard.
4 +
5 +1. Constant-quality price indices implied by the month fixed effects of
6 + four linear functional forms, plus a gradient-boosting index obtained
7 + by repricing a fixed reference sample at each month.
8 +2. Implicit-price comparison: the age and floor-area profiles implied by
9 + the quadratic OLS versus the partial-dependence profile of the
10 + gradient-boosting model.
11 +3. Learning curves: accuracy versus training-set size for OLS and GB.
12 +4. Segment analysis: MdAPE by property class and municipality size.
13 +
14 +Writes: index.csv, profiles.csv, learning.csv, segments.csv
15 +
16 +Usage: python scripts/03_extensions.py
17 +"""
18 +import sys
19 +from pathlib import Path
20 +
21 +import numpy as np
22 +import pandas as pd
23 +
24 +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
25 +
26 +from wp11 import config, models, sample # noqa: E402
27 +from sklearn.ensemble import HistGradientBoostingRegressor # noqa: E402
28 +
29 +
30 +def month_index_from_linear(spec, train, months):
31 + """Fit a month-FE linear model and read the index off the dummies.
32 +
33 + Numeric regressors are z-scored first: month-dummy coefficients are
34 + invariant to that reparametrization, but the conjugate-gradient
35 + solver needs the improved conditioning for the individual
36 + coefficients (not just the fit) to be accurate when levels like lot
37 + area (up to 10^5 m2) share the design with dummies.
38 + """
39 + d2 = train.copy()
40 + for c in spec.get("numeric", []):
41 + sd = d2[c].std()
42 + if sd > 0:
43 + d2[c] = (d2[c] - d2[c].mean()) / sd
44 + for c in spec.get("spline", []):
45 + d2[c] = train[c] # splines keep their raw support
46 + train = d2
47 + pred, pipe = models.fit_predict(dict(spec), train, train.head(50))
48 + ct = pipe.named_steps["ct"]
49 + names = ct.get_feature_names_out()
50 + coefs = pipe.named_steps["reg"].coef_
51 + idx = {}
52 + for nm, c in zip(names, coefs):
53 + if "month_" in nm:
54 + idx[nm.split("month_")[-1]] = float(c)
55 + base = sorted(months)[0]
56 + ref = idx.get(base, 0.0)
57 + return {m: 100 * float(np.exp(idx.get(m, 0.0) - ref)) for m in months}
58 +
59 +
60 +def main() -> None:
61 + config.ensure_dirs()
62 + df = sample.load()
63 + out = config.REPRODUCED
64 + d = models.add_derived(df)
65 + d["sale_year_c"] = d["sale_year"].astype(str)
66 + months = sorted(d["month"].unique())
67 +
68 + # ------------------------------------------------------------ 1. indices
69 + forms = {
70 + "Semi-log": dict(kind="linear_log", numeric=models.NUM_LEVELS,
71 + fe=["muni", "month"]),
72 + "Log-log": dict(kind="linear_log", numeric=models.NUM_LOGS,
73 + fe=["muni", "month"]),
74 + "Semi-log + quadratics": dict(kind="linear_log",
75 + numeric=models.NUM_QUAD,
76 + fe=["muni", "month"]),
77 + "Semi-log + splines": dict(kind="linear_log",
78 + numeric=models.NUM_LEVELS,
79 + fe=["muni", "month"],
80 + spline=["area", "age", "lot"]),
81 + }
82 + rows = []
83 + for label, spec in forms.items():
84 + spec["name"], spec["group"] = label, "index"
85 + idx = month_index_from_linear(spec, d, months)
86 + rows += [{"month": m, "form": label, "index": v}
87 + for m, v in idx.items()]
88 + print(f" index from {label}: done")
89 +
90 + # gradient boosting: reprice a fixed 20k reference sample each month
91 + rng = np.random.default_rng(config.SEED)
92 + hgb = HistGradientBoostingRegressor(max_iter=600, learning_rate=0.08,
93 + max_leaf_nodes=63, min_samples_leaf=20,
94 + l2_regularization=1e-2,
95 + random_state=config.SEED)
96 + hgb.fit(models._ml_frame(d), d["ln_price"].to_numpy())
97 + ref = d.sample(20_000, random_state=config.SEED).copy()
98 + t_by_month = d.groupby("month")["t"].mean()
99 + base_val = None
100 + for m in months:
101 + ref["t"] = t_by_month[m]
102 + v = float(np.exp(hgb.predict(models._ml_frame(ref))).mean())
103 + base_val = base_val or v
104 + rows.append({"month": m, "form": "Gradient boosting",
105 + "index": 100 * v / base_val})
106 + pd.DataFrame(rows).to_csv(out / "index.csv", index=False)
107 + print(" index from Gradient boosting: done")
108 +
109 + # ------------------------------------------------------------ 2. profiles
110 + train, _ = models.split(d, "random")
111 + spec = dict(name="A5", group="x", kind="linear_log",
112 + numeric=models.NUM_QUAD, fe=["muni", "quarter"])
113 + _, pipe = models.fit_predict(spec, train, train.head(50))
114 + names = pipe.named_steps["ct"].get_feature_names_out()
115 + coefs = dict(zip(names, pipe.named_steps["reg"].coef_))
116 + prof_rows = []
117 + ages = np.arange(0, 121, 5)
118 + b_age = coefs.get("num__age", 0.0)
119 + b_age2 = coefs.get("num__age2", 0.0)
120 + for a in ages:
121 + y = b_age * a + b_age2 * (a / 10.0) ** 2
122 + prof_rows.append({"var": "age", "x": a, "model": "OLS quadratic",
123 + "y": y - (b_age * ages[0])})
124 + areas = np.arange(50, 401, 25)
125 + b_ar = coefs.get("num__area", 0.0)
126 + b_ar2 = coefs.get("num__area2", 0.0)
127 + y0 = b_ar * areas[0] + b_ar2 * (areas[0] / 100.0) ** 2
128 + for a in areas:
129 + y = b_ar * a + b_ar2 * (a / 100.0) ** 2
130 + prof_rows.append({"var": "area", "x": a, "model": "OLS quadratic",
131 + "y": y - y0})
132 +
133 + # partial dependence of the GB model (average prediction on a grid)
134 + sub = models._ml_frame(train.sample(30_000, random_state=config.SEED))
135 + for var, grid in (("age", ages), ("area", areas)):
136 + vals = []
137 + for g in grid:
138 + s2 = sub.copy()
139 + s2[var] = g
140 + vals.append(float(hgb.predict(s2).mean()))
141 + v0 = vals[0]
142 + prof_rows += [{"var": var, "x": g, "model": "Gradient boosting",
143 + "y": v - v0} for g, v in zip(grid, vals)]
144 + pd.DataFrame(prof_rows).to_csv(out / "profiles.csv", index=False)
145 + print(" implicit-price profiles: done")
146 +
147 + # ------------------------------------------------------------ 3. learning curves
148 + train_full, test = models.split(d, "random")
149 + y_test = test["price"].to_numpy(float)
150 + rows = []
151 + for n in config.LEARNING_SIZES:
152 + if n > len(train_full):
153 + continue
154 + tr = train_full.sample(n, random_state=config.SEED)
155 + for label, spec in (
156 + ("OLS quadratic (muni+quarter FE)",
157 + dict(name="A5", group="x", kind="linear_log",
158 + numeric=models.NUM_QUAD, fe=["muni", "quarter"])),
159 + ("Gradient boosting",
160 + dict(name="D4", group="x", kind="hgb"))):
161 + pred, _ = models.fit_predict(spec, tr, test)
162 + met = models._metrics(y_test, pred)
163 + rows.append({"n_train": n, "model": label, **met})
164 + print(f" learning n={n:>7,} {label:<34} "
165 + f"MdAPE={met['mdape']:.1f}%")
166 + pd.DataFrame(rows).to_csv(out / "learning.csv", index=False)
167 +
168 + # ------------------------------------------------------------ 4. segments
169 + rows = []
170 + muni_sales = d.groupby("muni")["muni"].transform("size")
171 + segs = {f"Class: {c}": d["prop_class"] == c
172 + for c in ("single_family", "condo", "plex", "cottage")}
173 + segs.update({"Muni < 1k sales": muni_sales < 1_000,
174 + "Muni 1k–10k sales": muni_sales.between(1_000, 10_000),
175 + "Muni > 10k sales": muni_sales > 10_000})
176 + train, test = models.split(d, "random")
177 + preds = {}
178 + for label, spec in (
179 + ("OLS quadratic", dict(name="A5", group="x", kind="linear_log",
180 + numeric=models.NUM_QUAD,
181 + fe=["muni", "quarter"])),
182 + ("Gradient boosting", dict(name="D4", group="x", kind="hgb"))):
183 + preds[label], _ = models.fit_predict(spec, train, test)
184 + y_test = test["price"].to_numpy(float)
185 + for seg, mask in segs.items():
186 + m = mask.loc[test.index].to_numpy()
187 + if m.sum() < 500:
188 + continue
189 + for label, p in preds.items():
190 + met = models._metrics(y_test[m], p[m])
191 + rows.append({"segment": seg, "model": label, "n": int(m.sum()),
192 + **met})
193 + pd.DataFrame(rows).to_csv(out / "segments.csv", index=False)
194 + print(" segment analysis: done")
195 +
196 +
197 +if __name__ == "__main__":
198 + main()
added scripts/04_make_figures.py +203 −0
@@ -0,0 +1,203 @@
1 +#!/usr/bin/env python3
2 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
3 +"""Step 04 — Publication figures (print-journal calibre).
4 +
5 +Reads the step-02/03 outputs and writes eight PNG figures to ``figures/``.
6 +Same conventions as WP10: no in-figure titles on single panels, bold
7 +"Panel A/B" headers on multi-panel figures, one accent hue doubled by a
8 +marker/linestyle difference, direct labels over legend boxes.
9 +
10 +Usage: python scripts/04_make_figures.py
11 +"""
12 +import sys
13 +from pathlib import Path
14 +
15 +import numpy as np
16 +import pandas as pd
17 +
18 +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
19 +
20 +from wp11 import config, sample # noqa: E402
21 +from wp11.plotstyle import (BLUE, BLUES, GREY, INK, LIGHT, RED, TEXTWIDTH, # noqa: E402
22 + apply_style, panel_label, ygrid)
23 +
24 +import matplotlib.pyplot as plt # noqa: E402
25 +
26 +OUT = config.FIGURES
27 +RES = config.REPRODUCED
28 +
29 +
30 +def _save(fig, name):
31 + fig.savefig(OUT / name, bbox_inches="tight", pad_inches=0.02)
32 + plt.close(fig)
33 +
34 +
35 +def _forest(ax, res, metric="mdape", xlabel="Median absolute error (%)"):
36 + y = np.arange(len(res))[::-1].astype(float)
37 + ax.barh(y, res[metric], height=0.62, color=LIGHT, edgecolor=INK,
38 + linewidth=0.4)
39 + for yy, v in zip(y, res[metric]):
40 + ax.text(v + 0.25, yy, f"{v:.1f}", va="center", fontsize=7.5)
41 + ax.set_yticks(y)
42 + ax.set_yticklabels(res["name"], fontsize=8)
43 + ax.set_xlabel(xlabel)
44 + ax.spines["left"].set_visible(False)
45 + ax.tick_params(axis="y", length=0)
46 +
47 +
48 +# ---------------------------------------------------------------- fig 1-4
49 +def fig_groups():
50 + """One figure per design axis: MdAPE bars, random holdout."""
51 + r = pd.read_csv(RES / "horserace_random.csv")
52 + for gkey, fname in (("A.", "fig_forms.png"), ("B.", "fig_time.png"),
53 + ("C.", "fig_space.png"), ("D.", "fig_methods.png")):
54 + res = r[r["group"].str.startswith(gkey)].reset_index(drop=True)
55 + fig, ax = plt.subplots(figsize=(0.85 * TEXTWIDTH,
56 + 0.55 + 0.42 * len(res)))
57 + _forest(ax, res)
58 + ax.set_xlim(0, res["mdape"].max() * 1.15)
59 + _save(fig, fname)
60 +
61 +
62 +# ---------------------------------------------------------------- fig 5
63 +def fig_random_vs_temporal():
64 + """The generalization gap: random vs forward-in-time holdout."""
65 + a = pd.read_csv(RES / "horserace_random.csv")
66 + b = pd.read_csv(RES / "horserace_temporal.csv")
67 + m = a.merge(b, on=["name", "group"], suffixes=("_r", "_t"))
68 + keep = m["name"].str.match(r"(A5|B4|C4|D)")
69 + m = m[keep].reset_index(drop=True)
70 + fig, ax = plt.subplots(figsize=(0.85 * TEXTWIDTH, 3.7))
71 + y = np.arange(len(m))[::-1].astype(float)
72 + ax.scatter(m["mdape_r"], y + 0.14, s=22, color=BLUE, zorder=3,
73 + label="Random 80/20 holdout")
74 + ax.scatter(m["mdape_t"], y - 0.14, s=24, facecolor="white",
75 + edgecolor=RED, marker="s", zorder=3,
76 + label="Train $<$ 2025, test 2025–26")
77 + for yy, r0, t0 in zip(y, m["mdape_r"], m["mdape_t"]):
78 + ax.plot([r0, t0], [yy + 0.14, yy - 0.14], color=GREY, lw=0.6,
79 + zorder=2)
80 + ax.set_yticks(y)
81 + ax.set_yticklabels(m["name"], fontsize=8)
82 + ax.set_xlabel("Median absolute error (%)")
83 + ax.legend(loc="upper left", fontsize=8, bbox_to_anchor=(0.02, 0.35))
84 + ax.spines["left"].set_visible(False)
85 + ax.tick_params(axis="y", length=0)
86 + _save(fig, "fig_generalization.png")
87 +
88 +
89 +# ---------------------------------------------------------------- fig 6
90 +def fig_index():
91 + """Constant-quality monthly indices across specifications."""
92 + ix = pd.read_csv(RES / "index.csv")
93 + ix["date"] = pd.PeriodIndex(ix["month"], freq="M").to_timestamp()
94 + fig, ax = plt.subplots(figsize=(TEXTWIDTH, 3.2))
95 + forms = ["Semi-log", "Log-log", "Semi-log + quadratics",
96 + "Semi-log + splines", "Gradient boosting"]
97 + styles = [(BLUES[2], "-"), (BLUES[3], (0, (5, 2))), (BLUES[4], "-"),
98 + (BLUES[5], (0, (1, 1.2))), (RED, "-")]
99 + for f, (c, ls) in zip(forms, styles):
100 + g = ix[ix["form"] == f].sort_values("date")
101 + lw = 1.5 if f == "Gradient boosting" else 1.0
102 + ax.plot(g["date"], g["index"], color=c, ls=ls, lw=lw)
103 + # the four linear forms end within ~2 points — one bundle label
104 + lin_end = (ix[ix["form"] != "Gradient boosting"]
105 + .sort_values("date").groupby("form")["index"].last())
106 + gb = ix[ix["form"] == "Gradient boosting"].sort_values("date")
107 + last_date = gb["date"].iloc[-1]
108 + ax.annotate("Four linear forms", xy=(last_date, lin_end.mean()),
109 + xytext=(6, 4), textcoords="offset points", fontsize=7.5,
110 + color=BLUES[4], va="center")
111 + ax.annotate("Gradient boosting", xy=(last_date, gb["index"].iloc[-1]),
112 + xytext=(6, -4), textcoords="offset points", fontsize=7.5,
113 + color=RED, va="center")
114 + ax.set_ylabel("Constant-quality index (Jan 2021 = 100)")
115 + ax.margins(x=0.14)
116 + ygrid(ax)
117 + _save(fig, "fig_index.png")
118 +
119 +
120 +# ---------------------------------------------------------------- fig 7
121 +def fig_profiles():
122 + """Implicit ln-price profiles: OLS quadratic vs gradient boosting."""
123 + p = pd.read_csv(RES / "profiles.csv")
124 + fig, axes = plt.subplots(1, 2, figsize=(TEXTWIDTH, 3.0))
125 + for ax, var, xlabel, ptitle in (
126 + (axes[0], "age", "Building age (years)", "Panel A. Age profile"),
127 + (axes[1], "area", "Floor area (m$^2$)",
128 + "Panel B. Floor-area profile")):
129 + for model, color, ls in (("OLS quadratic", BLUE, "-"),
130 + ("Gradient boosting", RED, (0, (5, 2)))):
131 + g = p[(p["var"] == var) & (p["model"] == model)]
132 + ax.plot(g["x"], g["y"], color=color, ls=ls, lw=1.3, label=model)
133 + ax.set_xlabel(xlabel)
134 + ax.set_ylabel("ln price, relative to leftmost point")
135 + panel_label(ax, ptitle)
136 + axes[0].legend(fontsize=8)
137 + fig.subplots_adjust(wspace=0.28)
138 + _save(fig, "fig_profiles.png")
139 +
140 +
141 +# ---------------------------------------------------------------- fig 8
142 +def fig_learning():
143 + """Accuracy versus training-set size."""
144 + l = pd.read_csv(RES / "learning.csv")
145 + fig, ax = plt.subplots(figsize=(0.72 * TEXTWIDTH, 3.2))
146 + for model, color, mk in (("OLS quadratic (muni+quarter FE)", BLUE, "o"),
147 + ("Gradient boosting", RED, "s")):
148 + g = l[l["model"] == model].sort_values("n_train")
149 + ax.plot(g["n_train"], g["mdape"], marker=mk, ms=4.5, color=color,
150 + lw=1.2, mfc="white" if mk == "s" else color, label=model)
151 + ax.set_xscale("log")
152 + ax.set_xlabel("Training sales (log scale)")
153 + ax.set_ylabel("Median absolute error (%)")
154 + ax.legend(fontsize=8)
155 + ygrid(ax)
156 + _save(fig, "fig_learning.png")
157 +
158 +
159 +# ---------------------------------------------------------------- fig 9
160 +def fig_segments():
161 + """MdAPE by market segment, OLS vs GB."""
162 + s = pd.read_csv(RES / "segments.csv")
163 + order = [x for x in ["Class: single_family", "Class: condo",
164 + "Class: plex", "Class: cottage",
165 + "Muni < 1k sales", "Muni 1k–10k sales",
166 + "Muni > 10k sales"] if x in set(s["segment"])]
167 + labels = {"Class: single_family": "Single-family", "Class: condo":
168 + "Condominium", "Class: plex": "Plex", "Class: cottage":
169 + "Cottage", "Muni < 1k sales": "Muni $<$ 1k sales",
170 + "Muni 1k–10k sales": "Muni 1k–10k sales",
171 + "Muni > 10k sales": "Muni $>$ 10k sales"}
172 + y = np.arange(len(order))[::-1].astype(float)
173 + fig, ax = plt.subplots(figsize=(0.8 * TEXTWIDTH, 3.4))
174 + for model, color, mk, dy in (("OLS quadratic", BLUE, "o", 0.14),
175 + ("Gradient boosting", RED, "s", -0.14)):
176 + g = s[s["model"] == model].set_index("segment").loc[order]
177 + ax.scatter(g["mdape"], y + dy, s=22, color=color, marker=mk,
178 + facecolor="white" if mk == "s" else color,
179 + edgecolor=color, label=model, zorder=3)
180 + ax.set_yticks(y)
181 + ax.set_yticklabels([labels[o] for o in order], fontsize=8)
182 + ax.set_xlabel("Median absolute error (%)")
183 + ax.legend(fontsize=8, loc="lower right")
184 + ax.spines["left"].set_visible(False)
185 + ax.tick_params(axis="y", length=0)
186 + _save(fig, "fig_segments.png")
187 +
188 +
189 +def main() -> None:
190 + config.ensure_dirs()
191 + apply_style()
192 + fig_groups()
193 + fig_random_vs_temporal()
194 + fig_index()
195 + fig_profiles()
196 + fig_learning()
197 + fig_segments()
198 + made = sorted(p.name for p in OUT.glob("fig_*.png"))
199 + print(f"{len(made)} figures written:", ", ".join(made))
200 +
201 +
202 +if __name__ == "__main__":
203 + main()
added scripts/05_make_tables.py +150 −0
@@ -0,0 +1,150 @@
1 +#!/usr/bin/env python3
2 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
3 +"""Step 05 — LaTeX tables.
4 +
5 +Converts the step-02/03 CSVs into booktabs tables under
6 +``results/tables/``; the paper inputs these files directly.
7 +
8 +Usage: python scripts/05_make_tables.py
9 +"""
10 +import sys
11 +from pathlib import Path
12 +
13 +import pandas as pd
14 +
15 +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
16 +
17 +from wp11 import config # noqa: E402
18 +
19 +RES = config.REPRODUCED
20 +TAB = config.TABLES
21 +
22 +
23 +def _f(x, nd=3):
24 + return f"{x:,.{nd}f}"
25 +
26 +
27 +def _esc(s: str) -> str:
28 + return (s.replace("–", "--").replace("~", r"$\sim$")
29 + .replace("<", "$<$").replace(">", "$>$"))
30 +
31 +
32 +def t_summary():
33 + s = pd.read_csv(RES / "summary_stats.csv")
34 + lines = [r"\begin{tabular}{lrrrrrr}", r"\toprule",
35 + r"Variable & $n$ & Mean & SD & P10 & Median & P90 \\",
36 + r"\midrule"]
37 + for _, r in s.iterrows():
38 + nd = 0 if r["mean"] > 1000 else 2
39 + label = (str(r["variable"]).replace("($)", "(\\$)")
40 + .replace("(m2)", "(m$^2$)"))
41 + lines.append(
42 + f"{label} & {r['n']:,.0f} & {_f(r['mean'], nd)} & "
43 + f"{_f(r['sd'], nd)} & {_f(r['p10'], nd)} & {_f(r['p50'], nd)} & "
44 + f"{_f(r['p90'], nd)} \\\\")
45 + lines += [r"\bottomrule", r"\end{tabular}"]
46 + (TAB / "summary_stats.tex").write_text("\n".join(lines))
47 +
48 +
49 +def _race_table(fname_csv: str, fname_tex: str):
50 + r = pd.read_csv(RES / fname_csv)
51 + lines = [r"\begin{tabular}{lcccc c}", r"\toprule",
52 + r"Model & MdAPE (\%) & MAPE (\%) & RMSE$_{\ln}$ & "
53 + r"$R^2_{\ln}$ & Fit (s) \\"]
54 + for g in ["A. Functional form", "B. Time effects",
55 + "C. Spatial controls", "D. Estimation method"]:
56 + sub = r[r["group"] == g]
57 + if sub.empty:
58 + continue
59 + lines.append(r"\midrule")
60 + lines.append(r"\multicolumn{6}{l}{\itshape " + g + r"}\\")
61 + best = sub["mdape"].min()
62 + for _, x in sub.iterrows():
63 + name = _esc(str(x["name"]))
64 + md = f"\\textbf{{{_f(x['mdape'], 1)}}}" \
65 + if x["mdape"] == best else _f(x["mdape"], 1)
66 + lines.append(
67 + f"\\quad {name} & {md} & {_f(x['mape'], 1)} & "
68 + f"{_f(x['rmse_ln'])} & {_f(x['r2_ln'])} & "
69 + f"{_f(x['seconds'], 0)} \\\\")
70 + lines += [r"\bottomrule", r"\end{tabular}"]
71 + (TAB / fname_tex).write_text("\n".join(lines))
72 +
73 +
74 +def t_generalization():
75 + a = pd.read_csv(RES / "horserace_random.csv")
76 + b = pd.read_csv(RES / "horserace_temporal.csv")
77 + m = a.merge(b, on=["name", "group"], suffixes=("_r", "_t"))
78 + m = m[m["name"].str.match(r"(A5|B4|C4|D)")]
79 + lines = [r"\begin{tabular}{lcc c cc}", r"\toprule",
80 + r" & \multicolumn{2}{c}{Random holdout} & &"
81 + r" \multicolumn{2}{c}{Forward-in-time} \\",
82 + r"\cmidrule{2-3}\cmidrule{5-6}",
83 + r"Model & MdAPE (\%) & $R^2_{\ln}$ & & MdAPE (\%) & "
84 + r"$R^2_{\ln}$ \\", r"\midrule"]
85 + for _, x in m.iterrows():
86 + lines.append(
87 + f"{_esc(str(x['name']))} & {_f(x['mdape_r'], 1)} & "
88 + f"{_f(x['r2_ln_r'])} & & {_f(x['mdape_t'], 1)} & "
89 + f"{_f(x['r2_ln_t'])} \\\\")
90 + lines += [r"\bottomrule", r"\end{tabular}"]
91 + (TAB / "generalization.tex").write_text("\n".join(lines))
92 +
93 +
94 +def t_boxcox():
95 + b = pd.read_csv(RES / "boxcox.csv")
96 + lines = [r"\begin{tabular}{l" + "c" * len(b) + "}", r"\toprule",
97 + r"$\lambda$ & " + " & ".join(_f(x, 2) for x in b["lambda"])
98 + + r" \\", r"\midrule",
99 + r"Profile log-likelihood$^{\dagger}$ & "
100 + + " & ".join(f"{(x - b['loglik'].max())/1000:,.1f}"
101 + for x in b["loglik"]) + r" \\",
102 + r"\bottomrule", r"\end{tabular}"]
103 + (TAB / "boxcox.tex").write_text("\n".join(lines))
104 +
105 +
106 +def t_learning():
107 + l = pd.read_csv(RES / "learning.csv")
108 + piv = l.pivot_table(index="n_train", columns="model", values="mdape")
109 + cols = list(piv.columns)
110 + lines = [r"\begin{tabular}{r" + "c" * len(cols) + "}", r"\toprule",
111 + "Training sales & " + " & ".join(_esc(c) for c in cols) + r" \\",
112 + r"\midrule"]
113 + for n, row in piv.iterrows():
114 + lines.append(f"{n:,.0f} & "
115 + + " & ".join(_f(v, 1) for v in row) + r" \\")
116 + lines += [r"\bottomrule", r"\end{tabular}"]
117 + (TAB / "learning.tex").write_text("\n".join(lines))
118 +
119 +
120 +def t_segments():
121 + s = pd.read_csv(RES / "segments.csv")
122 + piv = s.pivot_table(index="segment", columns="model", values="mdape")
123 + ns = s.groupby("segment")["n"].first()
124 + lines = [r"\begin{tabular}{lrcc}", r"\toprule",
125 + r"Segment & $n$ (test) & OLS quadratic & Gradient boosting \\",
126 + r"\midrule"]
127 + for seg, row in piv.iterrows():
128 + label = _esc(str(seg)).replace("Class: ", "").replace("_", " ")
129 + lines.append(f"{label.capitalize()} & {ns[seg]:,.0f} & "
130 + f"{_f(row['OLS quadratic'], 1)} & "
131 + f"{_f(row['Gradient boosting'], 1)} \\\\")
132 + lines += [r"\bottomrule", r"\end{tabular}"]
133 + (TAB / "segments.tex").write_text("\n".join(lines))
134 +
135 +
136 +def main() -> None:
137 + config.ensure_dirs()
138 + t_summary()
139 + _race_table("horserace_random.csv", "horserace_random.tex")
140 + _race_table("horserace_temporal.csv", "horserace_temporal.tex")
141 + t_generalization()
142 + t_boxcox()
143 + t_learning()
144 + t_segments()
145 + made = sorted(p.name for p in TAB.glob("*.tex"))
146 + print(f"{len(made)} tables written:", ", ".join(made))
147 +
148 +
149 +if __name__ == "__main__":
150 + main()
added src/wp11/__init__.py +12 −0
@@ -0,0 +1,12 @@
1 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +"""WP11 — Half a Million Prices, Twenty Models.
3 +
4 +A systematic assessment of hedonic specifications and estimation methods
5 +for the Quebec housing market, 2021–2026: functional forms, time effects,
6 +spatial controls, and machine-learning estimators, evaluated under random
7 +and forward-in-time holdouts.
8 +"""
9 +
10 +__version__ = "1.0"
11 +__author__ = "Simon-Pierre Boucher"
12 +__email__ = "contact@spboucher.ai"
added src/wp11/config.py +47 −0
@@ -0,0 +1,47 @@
1 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +"""Paths and global constants for the WP11 pipeline.
3 +
4 +All paths are relative to the repository root; override with ``WP11_ROOT``.
5 +"""
6 +import os
7 +from pathlib import Path
8 +
9 +ROOT = Path(os.environ.get("WP11_ROOT", Path(__file__).resolve().parents[2]))
10 +
11 +RAW_PARQUET = ROOT / "data" / "raw" / "transactions_700k_avec_registre_foncier.parquet"
12 +PROCESSED = ROOT / "data" / "processed"
13 +ANALYSIS_PARQUET = PROCESSED / "analysis.parquet"
14 +FIGURES = ROOT / "figures"
15 +RESULTS = ROOT / "results"
16 +REPRODUCED = RESULTS / "reproduced"
17 +TABLES = RESULTS / "tables"
18 +
19 +# ---------------------------------------------------------------- sample
20 +RESIDENTIAL_CUBF = {"1000", "1100", "1211", "1990"}
21 +MATCH_MAX_DIST_M = 50.0
22 +MATCH_MIN_SCORE = 150.0
23 +PRICE_MIN = 50_000
24 +TRIM = (0.01, 0.99) # price and floor-area tails, within sale year
25 +AGE_MAX = 150
26 +GRID_DEG = 0.01 # ~1.1 km spatial grid cells for high-dim FE
27 +GRID5_DEG = 0.05 # ~5.5 km grid, mid-granularity spatial FE
28 +
29 +# ---------------------------------------------------------------- splits
30 +SEED = 20260809
31 +TEST_SHARE = 0.20 # random holdout
32 +TEMPORAL_CUTOFF = "2025-01-01" # train < cutoff, test >= cutoff
33 +
34 +# Box-Cox profile-likelihood grid
35 +BOXCOX_GRID = [-0.50, -0.25, 0.0, 0.25, 0.50, 0.75, 1.0]
36 +
37 +# Learning-curve training sizes
38 +LEARNING_SIZES = [10_000, 25_000, 50_000, 100_000, 200_000, 400_000]
39 +
40 +# Minimum predicted price when inverting level/Box-Cox models
41 +PRED_FLOOR = 20_000.0
42 +
43 +
44 +def ensure_dirs() -> None:
45 + """Create every output directory the pipeline writes to."""
46 + for p in (PROCESSED, FIGURES, REPRODUCED, TABLES):
47 + p.mkdir(parents=True, exist_ok=True)
added src/wp11/models.py +283 −0
@@ -0,0 +1,283 @@
1 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +"""The WP11 horse-race harness.
3 +
4 +Twenty hedonic models organised along four design axes:
5 +
6 + A. functional form — linear, semi-log, log-log, Box–Cox, quadratic,
7 + splines (linear estimator, muni + quarter FE)
8 + B. time effects — none / year / quarter / month FE (quadratic backbone)
9 + C. spatial controls — none / municipality / ~1.1 km grid / neighbourhood
10 + unit FE (quadratic backbone, quarter FE)
11 + D. estimation method — OLS, ridge, random forest, gradient boosting
12 + (with and without coordinates), spatial k-NN
13 + comparables
14 +
15 +Every linear model is estimated as a sparse ridge with a vanishing penalty
16 +(numerically OLS, but it (i) survives collinear dummies and (ii) predicts
17 +unseen categories at the reference level via ``handle_unknown='ignore'``).
18 +Every model is evaluated on identical train/test splits, and all metrics
19 +are computed on *price levels* — log models are retransformed with Duan's
20 +smearing factor, Box–Cox by analytic inversion — so that functional forms
21 +compete on the same scoreboard.
22 +"""
23 +from __future__ import annotations
24 +
25 +import time
26 +
27 +import numpy as np
28 +import pandas as pd
29 +from sklearn.compose import ColumnTransformer
30 +from sklearn.ensemble import (HistGradientBoostingRegressor,
31 + RandomForestRegressor)
32 +from sklearn.linear_model import Ridge, RidgeCV
33 +from sklearn.neighbors import KNeighborsRegressor
34 +from sklearn.pipeline import Pipeline
35 +from sklearn.preprocessing import OneHotEncoder, SplineTransformer
36 +
37 +from . import config
38 +
39 +NUM_LEVELS = ["area", "lot", "has_lot", "age", "floors", "units"]
40 +NUM_LOGS = ["ln_area", "ln_lot", "has_lot", "age", "floors", "units"]
41 +NUM_QUAD = NUM_LEVELS + ["area2", "age2"]
42 +CATS = ["prop_class", "link"]
43 +ML_NUM = ["area", "lot", "has_lot", "age", "floors", "units",
44 + "lat", "lng", "t"]
45 +
46 +
47 +def add_derived(df: pd.DataFrame) -> pd.DataFrame:
48 + """Quadratic terms, scaled to keep the design well conditioned."""
49 + out = df.copy()
50 + out["area2"] = (out["area"] / 100.0) ** 2
51 + out["age2"] = (out["age"] / 10.0) ** 2
52 + return out
53 +
54 +
55 +# ---------------------------------------------------------------- splits
56 +def split(df: pd.DataFrame, scheme: str):
57 + """Return (train, test) under the random or forward-in-time scheme."""
58 + if scheme == "random":
59 + rng = np.random.default_rng(config.SEED)
60 + mask = rng.random(len(df)) < config.TEST_SHARE
61 + return df[~mask], df[mask]
62 + if scheme == "temporal":
63 + cut = pd.Timestamp(config.TEMPORAL_CUTOFF)
64 + return df[df["sale_date"] < cut], df[df["sale_date"] >= cut]
65 + raise ValueError(scheme)
66 +
67 +
68 +# ---------------------------------------------------------------- pipelines
69 +def _linear_pipeline(numeric: list[str], cats: list[str],
70 + spline_cols: list[str] | None = None,
71 + alpha: float = 1e-6) -> Pipeline:
72 + transformers = [("cat",
73 + OneHotEncoder(handle_unknown="ignore", drop="first"),
74 + cats)]
75 + if spline_cols:
76 + transformers.append(
77 + ("spl", SplineTransformer(degree=3, n_knots=6), spline_cols))
78 + passthrough = [c for c in numeric if c not in spline_cols]
79 + else:
80 + passthrough = numeric
81 + if passthrough:
82 + transformers.append(("num", "passthrough", passthrough))
83 + ct = ColumnTransformer(transformers, sparse_threshold=1.0)
84 + return Pipeline([("ct", ct),
85 + ("reg", Ridge(alpha=alpha, solver="sparse_cg"))])
86 +
87 +
88 +def _ml_frame(df: pd.DataFrame, geo: bool = True) -> pd.DataFrame:
89 + cols = ML_NUM if geo else [c for c in ML_NUM if c not in ("lat", "lng")]
90 + X = df[cols].copy()
91 + for c in CATS:
92 + X[c] = df[c].astype("category").cat.codes
93 + return X
94 +
95 +
96 +# ---------------------------------------------------------------- metrics
97 +def _metrics(y_level: np.ndarray, pred_level: np.ndarray) -> dict:
98 + pred_level = np.clip(pred_level, config.PRED_FLOOR, None)
99 + ape = np.abs(pred_level - y_level) / y_level
100 + ln_y, ln_p = np.log(y_level), np.log(pred_level)
101 + err = ln_y - ln_p
102 + return {"mdape": float(np.median(ape) * 100),
103 + "mape": float(np.mean(ape) * 100),
104 + "rmse_ln": float(np.sqrt(np.mean(err ** 2))),
105 + "r2_ln": float(1 - np.sum(err ** 2)
106 + / np.sum((ln_y - ln_y.mean()) ** 2))}
107 +
108 +
109 +# ---------------------------------------------------------------- Box–Cox
110 +def _boxcox(y: np.ndarray, lam: float) -> np.ndarray:
111 + return np.log(y) if lam == 0 else (y ** lam - 1.0) / lam
112 +
113 +
114 +def _inv_boxcox(z: np.ndarray, lam: float) -> np.ndarray:
115 + if lam == 0:
116 + return np.exp(z)
117 + base = np.clip(lam * z + 1.0, 1e-6, None)
118 + return base ** (1.0 / lam)
119 +
120 +
121 +def fit_boxcox(train: pd.DataFrame, numeric: list[str], cats: list[str]):
122 + """Profile-likelihood choice of λ on the training sample."""
123 + y = train["price"].to_numpy(float)
124 + n = len(y)
125 + sum_ln_y = np.log(y).sum()
126 + rows = []
127 + for lam in config.BOXCOX_GRID:
128 + pipe = _linear_pipeline(numeric, cats)
129 + z = _boxcox(y, lam)
130 + pipe.fit(train, z)
131 + sse = float(np.sum((z - pipe.predict(train)) ** 2))
132 + ll = -0.5 * n * np.log(sse / n) + (lam - 1.0) * sum_ln_y
133 + rows.append({"lambda": lam, "loglik": ll})
134 + prof = pd.DataFrame(rows)
135 + lam_star = float(prof.loc[prof["loglik"].idxmax(), "lambda"])
136 + return lam_star, prof
137 +
138 +
139 +# ---------------------------------------------------------------- registry
140 +def model_registry() -> list[dict]:
141 + """The twenty models. ``fe`` lists categorical FE columns appended to
142 + the base dummies; ``numeric``/``spline`` define the design."""
143 + A = [
144 + dict(name="A1 Linear (levels)", group="A. Functional form",
145 + kind="linear_level", numeric=NUM_LEVELS, fe=["muni", "quarter"]),
146 + dict(name="A2 Semi-log", group="A. Functional form",
147 + kind="linear_log", numeric=NUM_LEVELS, fe=["muni", "quarter"]),
148 + dict(name="A3 Log-log", group="A. Functional form",
149 + kind="linear_log", numeric=NUM_LOGS, fe=["muni", "quarter"]),
150 + dict(name="A4 Box-Cox", group="A. Functional form",
151 + kind="boxcox", numeric=NUM_LEVELS, fe=["muni", "quarter"]),
152 + dict(name="A5 Semi-log + quadratics", group="A. Functional form",
153 + kind="linear_log", numeric=NUM_QUAD, fe=["muni", "quarter"]),
154 + dict(name="A6 Semi-log + splines", group="A. Functional form",
155 + kind="linear_log", numeric=NUM_LEVELS, fe=["muni", "quarter"],
156 + spline=["area", "age", "lot"]),
157 + ]
158 + B = [
159 + dict(name="B1 No time effects", group="B. Time effects",
160 + kind="linear_log", numeric=NUM_QUAD, fe=["muni"]),
161 + dict(name="B2 Year FE", group="B. Time effects",
162 + kind="linear_log", numeric=NUM_QUAD, fe=["muni", "sale_year_c"]),
163 + dict(name="B3 Quarter FE", group="B. Time effects",
164 + kind="linear_log", numeric=NUM_QUAD, fe=["muni", "quarter"]),
165 + dict(name="B4 Month FE", group="B. Time effects",
166 + kind="linear_log", numeric=NUM_QUAD, fe=["muni", "month"]),
167 + ]
168 + C = [
169 + dict(name="C1 No spatial controls", group="C. Spatial controls",
170 + kind="linear_log", numeric=NUM_QUAD, fe=["quarter"]),
171 + dict(name="C2 Municipality FE", group="C. Spatial controls",
172 + kind="linear_log", numeric=NUM_QUAD, fe=["muni", "quarter"]),
173 + dict(name="C3 Grid-cell FE (~5.5 km)", group="C. Spatial controls",
174 + kind="linear_log", numeric=NUM_QUAD, fe=["grid5", "quarter"]),
175 + dict(name="C4 Grid-cell FE (~1.1 km)", group="C. Spatial controls",
176 + kind="linear_log", numeric=NUM_QUAD, fe=["grid", "quarter"]),
177 + ]
178 + D = [
179 + dict(name="D1 OLS (muni + month FE)", group="D. Estimation method",
180 + kind="linear_log", numeric=NUM_QUAD, fe=["muni", "month"]),
181 + dict(name="D2 Ridge (cross-validated)", group="D. Estimation method",
182 + kind="ridge", numeric=NUM_QUAD, fe=["muni", "month"]),
183 + dict(name="D3 Random forest", group="D. Estimation method",
184 + kind="rf"),
185 + dict(name="D4 Gradient boosting", group="D. Estimation method",
186 + kind="hgb"),
187 + dict(name="D5 Gradient boosting, no coordinates",
188 + group="D. Estimation method", kind="hgb_nogeo"),
189 + dict(name="D6 Spatial k-NN comparables", group="D. Estimation method",
190 + kind="knn"),
191 + ]
192 + return A + B + C + D
193 +
194 +
195 +# ---------------------------------------------------------------- fit/eval
196 +def fit_predict(spec: dict, train: pd.DataFrame, test: pd.DataFrame):
197 + """Fit one registry entry, return level predictions on the test set."""
198 + kind = spec["kind"]
199 + y_train = train["price"].to_numpy(float)
200 +
201 + if kind in ("linear_level", "linear_log", "boxcox", "ridge"):
202 + cats = CATS + spec.get("fe", [])
203 + pipe = _linear_pipeline(spec["numeric"], cats,
204 + spline_cols=spec.get("spline"))
205 + if kind == "linear_level":
206 + pipe.fit(train, y_train)
207 + return pipe.predict(test), pipe
208 + if kind in ("linear_log", "ridge"):
209 + if kind == "ridge":
210 + from sklearn.preprocessing import StandardScaler
211 + pipe.steps[-1:] = [
212 + ("sc", StandardScaler(with_mean=False)),
213 + ("reg", RidgeCV(alphas=np.logspace(-6, 2, 17),
214 + cv=3))]
215 + z = np.log(y_train)
216 + pipe.fit(train, z)
217 + resid = z - pipe.predict(train)
218 + smear = float(np.mean(np.exp(resid))) # Duan (1983)
219 + return np.exp(pipe.predict(test)) * smear, pipe
220 + lam, _ = fit_boxcox(train, spec["numeric"], cats)
221 + pipe = _linear_pipeline(spec["numeric"], cats)
222 + pipe.fit(train, _boxcox(y_train, lam))
223 + spec["lambda"] = lam
224 + return _inv_boxcox(pipe.predict(test), lam), pipe
225 +
226 + if kind == "rf":
227 + m = RandomForestRegressor(n_estimators=120, min_samples_leaf=5,
228 + max_features=0.5, n_jobs=-1,
229 + random_state=config.SEED)
230 + m.fit(_ml_frame(train), np.log(y_train))
231 + return np.exp(m.predict(_ml_frame(test))), m
232 +
233 + if kind in ("hgb", "hgb_nogeo"):
234 + geo = kind == "hgb"
235 + m = HistGradientBoostingRegressor(
236 + max_iter=600, learning_rate=0.08, max_leaf_nodes=63,
237 + min_samples_leaf=20, l2_regularization=1e-2,
238 + random_state=config.SEED)
239 + m.fit(_ml_frame(train, geo), np.log(y_train))
240 + return np.exp(m.predict(_ml_frame(test, geo))), m
241 +
242 + if kind == "knn":
243 + # price-per-m2 of the 10 nearest sold neighbours (lat/lng, km-scaled)
244 + Xtr = train[["lat", "lng"]].to_numpy() * np.array([111.0, 78.0])
245 + Xte = test[["lat", "lng"]].to_numpy() * np.array([111.0, 78.0])
246 + m = KNeighborsRegressor(n_neighbors=10, weights="distance", n_jobs=-1)
247 + m.fit(Xtr, np.log(y_train) - train["ln_area"].to_numpy())
248 + return np.exp(m.predict(Xte) + test["ln_area"].to_numpy()), m
249 +
250 + raise ValueError(kind)
251 +
252 +
253 +def run_horserace(df: pd.DataFrame, scheme: str) -> pd.DataFrame:
254 + """Evaluate the full registry under one split scheme.
255 +
256 + Under the forward-in-time split, test-period time categories (months,
257 + quarters, years) were never seen in training. A one-hot encoder would
258 + silently price them at the *baseline* period; the honest forecasting
259 + rule for a static model is to freeze the price level at the last
260 + period observed in training, so unseen time labels are remapped to the
261 + latest training label before prediction.
262 + """
263 + d = add_derived(df)
264 + d["sale_year_c"] = d["sale_year"].astype(str)
265 + train, test = split(d, scheme)
266 + if scheme == "temporal":
267 + test = test.copy()
268 + for col in ("quarter", "month", "sale_year_c"):
269 + last = train[col].max()
270 + test.loc[~test[col].isin(train[col].unique()), col] = last
271 + y_test = test["price"].to_numpy(float)
272 + rows = []
273 + for spec in model_registry():
274 + t0 = time.time()
275 + pred, _ = fit_predict(dict(spec), train, test)
276 + met = _metrics(y_test, pred)
277 + rows.append({"name": spec["name"], "group": spec["group"],
278 + **met, "seconds": round(time.time() - t0, 1),
279 + "n_train": len(train), "n_test": len(test)})
280 + print(f" [{scheme}] {spec['name']:<38} "
281 + f"MdAPE={met['mdape']:5.1f}% R2_ln={met['r2_ln']:.3f} "
282 + f"({rows[-1]['seconds']}s)")
283 + return pd.DataFrame(rows)
added src/wp11/plotstyle.py +75 −0
@@ -0,0 +1,75 @@
1 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +"""Shared matplotlib style for all WP11 figures — print-journal calibre.
3 +
4 +Conventions (Journal of Finance house style):
5 + - no titles inside figures (captions carry the message); multi-panel
6 + figures use bold "Panel A." headers set flush left above each axes;
7 + - Times-compatible serif text with STIX math, 8–9 pt;
8 + - thin, recessive axes (0.6 pt), outward ticks, no top/right spines;
9 + - one accent hue per figure; a second hue only for genuine polarity or a
10 + second series, always doubled by a linestyle/marker difference;
11 + - shaded confidence bands rather than cap-heavy error bars;
12 + - direct labels instead of legend boxes wherever the geometry allows.
13 +
14 +The two data hues (#2e6da4, #c04848) pass the full colour-vision validation
15 +suite (lightness band, chroma floor, CVD separation, contrast) on a white
16 +surface; INK and GREY are reserved for marks-as-ink and reference lines.
17 +"""
18 +import matplotlib
19 +
20 +matplotlib.use("Agg")
21 +import matplotlib.pyplot as plt # noqa: E402
22 +
23 +INK = "#1a1a1a" # primary marks (points, bars, text)
24 +BLUE = "#2e6da4" # accent series / fitted lines
25 +RED = "#c04848" # contrast series / polarity
26 +GREY = "#8a8a8a" # reference lines
27 +LIGHT = "#d9d9d9" # fills, bands
28 +GRID = "#e3e3e3" # gridlines
29 +
30 +# Sequential blues for ordered series (light → dark, one hue)
31 +BLUES = ["#c6d7e8", "#9dbcd8", "#74a1c8", "#4b86b8", "#2e6da4", "#1d4a75"]
32 +
33 +TEXTWIDTH = 6.3 # \textwidth in inches (1in margins, letter paper)
34 +
35 +
36 +def apply_style() -> None:
37 + plt.rcParams.update({
38 + "font.family": "serif",
39 + "font.serif": ["Times New Roman", "Times", "STIXGeneral", "DejaVu Serif"],
40 + "mathtext.fontset": "stix",
41 + "font.size": 9,
42 + "axes.labelsize": 9,
43 + "xtick.labelsize": 8,
44 + "ytick.labelsize": 8,
45 + "legend.fontsize": 8,
46 + "figure.dpi": 150,
47 + "savefig.dpi": 300,
48 + "axes.spines.top": False,
49 + "axes.spines.right": False,
50 + "axes.linewidth": 0.6,
51 + "xtick.major.width": 0.6,
52 + "ytick.major.width": 0.6,
53 + "xtick.major.size": 3,
54 + "ytick.major.size": 3,
55 + "xtick.direction": "out",
56 + "ytick.direction": "out",
57 + "axes.grid": False,
58 + "grid.color": GRID,
59 + "grid.linewidth": 0.5,
60 + "legend.frameon": False,
61 + "lines.linewidth": 1.3,
62 + "lines.markersize": 4.5,
63 + "figure.constrained_layout.use": False,
64 + })
65 +
66 +
67 +def panel_label(ax, text: str) -> None:
68 + """Bold flush-left panel header above the axes (JoF style)."""
69 + ax.set_title(text, loc="left", fontsize=9, fontweight="bold", pad=8)
70 +
71 +
72 +def ygrid(ax) -> None:
73 + """Recessive horizontal gridlines drawn beneath the data."""
74 + ax.grid(axis="y", linewidth=0.5, color=GRID)
75 + ax.set_axisbelow(True)
added src/wp11/sample.py +111 −0
@@ -0,0 +1,111 @@
1 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +"""Estimation-sample construction for WP11.
3 +
4 +Residential arm's-length sales (2021–2026) with a high-confidence roll
5 +match and complete structural descriptors from the assessment roll:
6 +floor area, lot area, building age, dwelling class, physical configuration
7 +and coordinates. Writes ``data/processed/analysis.parquet``.
8 +"""
9 +import numpy as np
10 +import pandas as pd
11 +
12 +from . import config
13 +
14 +LINK_MAP = {"1": "detached", "2": "semi_detached", "3": "row_end",
15 + "4": "row", "5": "apartment_link"}
16 +
17 +
18 +def _property_class(df: pd.DataFrame) -> pd.Series:
19 + cls = pd.Series("other", index=df.index, dtype="object")
20 + cls[df["role_cubf"] == "1100"] = "cottage"
21 + cls[df["role_cubf"] == "1211"] = "mobile"
22 + is_dwelling = df["role_cubf"] == "1000"
23 + cls[is_dwelling & (df["propertyType"] == "condo")] = "condo"
24 + cls[is_dwelling & (df["propertyType"] == "plex")] = "plex"
25 + cls[is_dwelling & (df["propertyType"] == "unifamilial")] = "single_family"
26 + untyped = is_dwelling & (cls == "other")
27 + cls[untyped & (df["role_nb_logements"] == 1)] = "single_family"
28 + cls[untyped & (df["role_nb_logements"].between(2, 5))] = "plex"
29 + return cls
30 +
31 +
32 +def build(raw: pd.DataFrame | None = None) -> pd.DataFrame:
33 + df = raw if raw is not None else pd.read_parquet(config.RAW_PARQUET)
34 + log = [("raw snapshot", len(df))]
35 +
36 + df = df[df["role_cubf"].isin(config.RESIDENTIAL_CUBF)]
37 + log.append(("residential CUBF", len(df)))
38 +
39 + df = df[(df["match_dist_m"] <= config.MATCH_MAX_DIST_M)
40 + & (df["match_score"] >= config.MATCH_MIN_SCORE)]
41 + log.append(("high-confidence roll match", len(df)))
42 +
43 + df = df[(df["amount"] >= config.PRICE_MIN)
44 + & (df["role_aire_etages_m2"] > 20)
45 + & (df["role_aire_etages_m2"] < 2_000)]
46 + log.append(("valid price and floor area", len(df)))
47 +
48 + df = df.copy()
49 + df["sale_date"] = pd.to_datetime(df["date"])
50 + df["sale_year"] = df["tx_year"].astype(int)
51 + df["month"] = df["sale_date"].dt.to_period("M").astype(str)
52 + df["quarter"] = df["sale_date"].dt.to_period("Q").astype(str)
53 + df["t"] = ((df["sale_date"] - pd.Timestamp("2021-01-01")).dt.days
54 + / 30.44) # months since Jan 2021, continuous
55 +
56 + df["price"] = df["amount"].astype(float)
57 + df["ln_price"] = np.log(df["price"])
58 + df["area"] = df["role_aire_etages_m2"].astype(float)
59 + df["ln_area"] = np.log(df["area"])
60 + df["lot"] = df["role_superficie_terrain_m2"].fillna(0).clip(lower=0)
61 + df.loc[df["lot"] > df["lot"].quantile(0.995), "lot"] = np.nan
62 + df["lot"] = df["lot"].fillna(0)
63 + df["has_lot"] = (df["lot"] > 0).astype(float)
64 + df["ln_lot"] = np.log(df["lot"].where(df["lot"] > 0, 1.0))
65 +
66 + year_built = pd.to_numeric(df["role_annee_construction"], errors="coerce")
67 + df["age"] = (df["sale_year"] - year_built).clip(0, config.AGE_MAX)
68 + df = df[df["age"].notna()]
69 + log.append(("known building age", len(df)))
70 +
71 + df["floors"] = df["role_nb_etages"].clip(1, 6).fillna(1.0)
72 + df["units"] = df["role_nb_logements"].clip(1, 12).fillna(1.0)
73 + df["prop_class"] = _property_class(df)
74 + df["link"] = df["role_lien_physique"].map(LINK_MAP).fillna("unknown")
75 + df["muni"] = df["role_code_mun"]
76 + df["grid"] = ((df["lat"] / config.GRID_DEG).round().astype(int).astype(str)
77 + + "_" + (df["lng"] / config.GRID_DEG).round().astype(int).astype(str))
78 + df["grid5"] = ((df["lat"] / config.GRID5_DEG).round().astype(int).astype(str)
79 + + "_" + (df["lng"] / config.GRID5_DEG).round().astype(int).astype(str))
80 +
81 + lo, hi = config.TRIM
82 + for col in ("price", "area"):
83 + q = df.groupby("sale_year")[col].quantile([lo, hi]).unstack()
84 + df = df.join(q.rename(columns={lo: "_qlo", hi: "_qhi"}), on="sale_year")
85 + df = df[(df[col] >= df["_qlo"]) & (df[col] <= df["_qhi"])]
86 + df = df.drop(columns=["_qlo", "_qhi"])
87 + log.append((f"price & area inside [{lo:.0%}, {hi:.0%}] of sale year",
88 + len(df)))
89 +
90 + df.attrs["selection_log"] = log
91 + keep = ["id", "sale_date", "sale_year", "month", "quarter", "t",
92 + "price", "ln_price", "area", "ln_area", "lot", "ln_lot", "has_lot",
93 + "age", "floors", "units", "prop_class", "link",
94 + "muni", "grid", "grid5", "lat", "lng", "city",
95 + "role_municipalite"]
96 + return df[keep].reset_index(drop=True)
97 +
98 +
99 +def build_and_save() -> pd.DataFrame:
100 + config.ensure_dirs()
101 + s = build()
102 + for step, n in s.attrs["selection_log"]:
103 + print(f" {n:>9,} after: {step}")
104 + s.to_parquet(config.ANALYSIS_PARQUET, index=False)
105 + return s
106 +
107 +
108 +def load() -> pd.DataFrame:
109 + if not config.ANALYSIS_PARQUET.exists():
110 + return build_and_save()
111 + return pd.read_parquet(config.ANALYSIS_PARQUET)
112