SPB Git

spb/wp10_uqo Public

UQO Working Paper No. 10 — The assessment gap in Quebec: vertical and horizontal inequity in municipal property assessment.

TeX 55.9% Python 44%

WP10 — The Assessment Gap in Quebec: full research project

End-to-end pipeline (sample, IAAO diagnostics, Cheng FE / Clapp IV /
quantile / heterogeneity / horizontal / tax shift), 10 journal-calibre
figures, 10 LaTeX tables, 29-page paper with literature review.

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

Showing 65 changed files with +3,864 and −0

added .gitignore +20 −0
@@ -0,0 +1,20 @@
1 +# Raw data snapshot — 100 MB parquet, not redistributed
2 +data/raw/*.parquet
3 +data/processed/*.parquet
4 +
5 +# Python
6 +__pycache__/
7 +*.pyc
8 +
9 +# LaTeX build artifacts (paper/main.pdf IS committed)
10 +paper/*.aux
11 +paper/*.log
12 +paper/*.out
13 +paper/*.fls
14 +paper/*.fdb_latexmk
15 +paper/*.bbl
16 +paper/*.blg
17 +paper/*.synctex.gz
18 +
19 +# macOS
20 +.DS_Store
added README.md +200 −0
@@ -0,0 +1,200 @@
1 +<!-- Author: Simon-Pierre Boucher — contact@spboucher.ai -->
2 +# ⚖️ The Assessment Gap in Quebec
3 +
4 +**UQO Working Paper No. 10** — *Vertical and Horizontal Inequity in Municipal
5 +Property Valuation: Evidence from 522,769 Sales Matched to the Assessment Roll*
6 +
7 +[![Paper](https://img.shields.io/badge/paper-PDF%20(29%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-745%2C119%20matched%20sales%20·%20parquet-4051b5)](data/raw/README.md)
10 +[![Estimation](https://img.shields.io/badge/estimation-statsmodels%20·%20linearmodels%20(AbsorbingLS%20%2B%20IV2SLS)-4051b5)](src/wp10/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** — Matching 522,769 Quebec residential sales (2021–2026) to the triennial
17 +> assessment rolls that tax them, this paper delivers the **first province-wide audit
18 +> of property-assessment equity in Canada**. Within the same municipality × roll ×
19 +> year, the elasticity of the assessment ratio w.r.t. price is **−0.34** (Cheng FE)
20 +> and still **−0.08** under Clapp's measurement-error-robust IV. **99% of
21 +> municipalities** fail the IAAO uniformity standard, **95%** fail its vertical-equity
22 +> standard — Montréal is the lone progressive large market. The median dwelling in the
23 +> bottom local price decile pays **~65% more property tax** than uniform assessment
24 +> would imply; the top decile pays ~5% less.
25 +
26 +---
27 +
28 +## 📖 Table of contents
29 +
30 +1. [What this paper does](#-what-this-paper-does)
31 +2. [Headline results](#-headline-results)
32 +3. [Repository layout](#-repository-layout)
33 +4. [Quick start](#-quick-start)
34 +5. [The pipeline, step by step](#-the-pipeline-step-by-step)
35 +6. [Data](#-data)
36 +7. [Methodology](#-methodology)
37 +8. [Figures & tables inventory](#-figures--tables-inventory)
38 +9. [Limitations](#-limitations)
39 +10. [Citation](#-citation)
40 +11. [Author & contact](#-author--contact)
41 +
42 +---
43 +
44 +## 🎯 What this paper does
45 +
46 +Quebec taxes every dwelling in proportion to its **assessed value**, redrawn on a
47 +triennial roll whose values must, by statute (art. 46 LFM), reflect market conditions
48 +at a single reference date. If assessments are *regressive* — cheap homes overvalued
49 +relative to expensive ones — the effective tax rate falls with wealth, silently.
50 +This project measures that gap at province scale:
51 +
52 +```
53 +ln AV_i = α_c(i) + β · ln SP_i + ε_i (c = municipality × roll × sale-year)
54 +```
55 +
56 +- **γ = β − 1** is the elasticity of the assessment ratio w.r.t. price
57 + (γ < 0 ⇒ regressive), identified **within** 2,884 market-timing cells;
58 +- the **Clapp (1990) rank IV** purges the attenuation bias that pushes naïve
59 + ratio studies toward spurious regressivity;
60 +- the full **IAAO ratio-study battery** (median ratio, COD, PRD, PRB) with
61 + bootstrap CIs is computed for every municipality with ≥ 100 usable sales;
62 +- quantile profiles, subgroup elasticities, horizontal-dispersion regressions and
63 + an exact **intra-municipal tax-shift** calculation complete the audit.
64 +
65 +## 🏆 Headline results
66 +
67 +| Object | Estimate |
68 +|---|---|
69 +| Cheng elasticity, pooled OLS | β = 0.812 (0.029) |
70 +| Cheng elasticity, cell FE | **β = 0.656 (0.029)** → γ = −0.344 |
71 +| Clapp rank-IV (error-robust) | **β = 0.917 (0.021)** → γ = −0.083 |
72 +| Quantile profile β(τ), τ = .10 → .90 | 0.87 → **0.49** (failure concentrated at the top) |
73 +| Province-wide PRB (2021 / 2024) | −0.104 / −0.002 (staleness mechanism) |
74 +| Municipalities with PRB < 0 | **99%** (95% below IAAO band) |
75 +| Median municipality COD | 26 (IAAO ceiling: 15) |
76 +| Excess tax, bottom ↔ top local decile | **+65% ↔ −5%** (median) |
77 +| The exception | Montréal: PRB **+0.07**, progressive in all 6 years |
78 +
79 +Sub-group anatomy: condos γ = −0.12 vs single-family −0.45 / plex −0.49;
80 +land share > 0.4 → −0.50; age > 60 y → −0.43; large markets (>10k sales) −0.27.
81 +
82 +## 🗂 Repository layout
83 +
84 +```
85 +wp10_uqo/
86 +├── data/
87 +│ ├── raw/ # matched sales–roll snapshot (see data/raw/README.md)
88 +│ └── processed/ # analysis.parquet (built by step 01)
89 +├── src/wp10/ # reusable package
90 +│ ├── config.py # paths, thresholds, constants
91 +│ ├── sample.py # sample construction & variable definitions
92 +│ ├── iaao.py # COD / PRD / PRB + bootstrap CIs
93 +│ ├── models.py # Cheng FE, Clapp IV, quantiles, horizontal, tax shift
94 +│ └── plotstyle.py # journal-calibre matplotlib style (validated palette)
95 +├── scripts/ # the pipeline, in order
96 +│ ├── 01_build_sample.py
97 +│ ├── 02_iaao_stats.py
98 +│ ├── 03_estimate_regressions.py
99 +│ ├── 04_make_figures.py
100 +│ └── 05_make_tables.py
101 +├── results/
102 +│ ├── reproduced/ # every CSV the scripts emit
103 +│ └── tables/ # LaTeX tables input by the paper
104 +├── figures/ # 10 publication figures (PNG, 300 dpi)
105 +└── paper/ # main.tex + sections/ + references.bib → main.pdf
106 +```
107 +
108 +## 🚀 Quick start
109 +
110 +```bash
111 +cd wp10_uqo
112 +python3 -m pip install -r requirements.txt
113 +
114 +# place the parquet snapshot in data/raw/ (see data/raw/README.md), then:
115 +python3 scripts/01_build_sample.py # 745,119 → 522,769 sales
116 +python3 scripts/02_iaao_stats.py # IAAO diagnostics + bootstrap
117 +python3 scripts/03_estimate_regressions.py # FE / IV / quantile / heterogeneity / tax shift
118 +python3 scripts/04_make_figures.py # 10 figures
119 +python3 scripts/05_make_tables.py # 10 LaTeX tables
120 +
121 +cd paper && make # compile main.pdf (latexmk)
122 +```
123 +
124 +Total runtime ≈ 5 minutes on an Apple-silicon laptop; peak RAM ≈ 6 GB.
125 +
126 +## 🗃 Data
127 +
128 +745,119 residential-market transactions (Jan 2021 – Jul 2026) matched at the
129 +**parcel level** to the assessment roll in force at the sale date (median match
130 +distance 0.6 m; every retained sale reproduces the roll value exactly). From the
131 +roll: total/land/building assessed values, lot & floor areas, year built, unit
132 +count, CUBF use code, roll vintage and the statutory market-condition date.
133 +Sample cascade (details in the paper, §4): residential CUBF → match quality →
134 +valid AV/SP → 1/99% ratio trim within roll vintage → cells ≥ 20 sales
135 +**522,769 sales, 625 municipalities, 2,884 cells**.
136 +
137 +## 🔬 Methodology
138 +
139 +1. **IAAO diagnostics** (Standard on Ratio Studies, 2013) within
140 + municipality × sale-year blocks, aggregated by median — COD, PRD, PRB with
141 + percentile-bootstrap CIs (`src/wp10/iaao.py`).
142 +2. **Cheng (1974) log-log regression** with 2,884 absorbed fixed effects
143 + (linearmodels `AbsorbingLS`), SEs clustered on 625 municipalities.
144 +3. **Clapp (1990) rank IV** — Z ∈ {−1,0,+1} from within-cell rank agreement of
145 + ln AV and ln SP, 2SLS on demeaned data (`IV2SLS`) — the conservative bound.
146 +4. **Quantile regressions** on within-cell demeaned data (τ = .10….90).
147 +5. **Heterogeneity**: separate FE estimates by class, age, land share, roll lag,
148 + municipality size, sale year.
149 +6. **Horizontal inequity**: |ln r − cell median| regressed on characteristics.
150 +7. **Tax shift**: r / cell-median − 1 by within-cell price decile — exact %
151 + over/under-payment under Quebec's exemption-free ad valorem rule.
152 +
153 +## 🖼 Figures & tables inventory
154 +
155 +| Figure | Content |
156 +|---|---|
157 +| `fig_ratio_dist` | ratio distribution + by roll lag (Panels A/B) |
158 +| `fig_time` | median ratio by month × roll vintage (staleness drift) |
159 +| `fig_binscatter` | within-cell ln ratio vs ln price, 20 bins — the core fact |
160 +| `fig_quantile` | β(τ) profile with FE/IV benchmarks |
161 +| `fig_heterogeneity` | forest plot of subgroup γ |
162 +| `fig_prb_muni`, `fig_cod` | municipal PRB / COD distributions vs IAAO bands |
163 +| `fig_map` | province map of municipal PRB (Montréal exception) |
164 +| `fig_taxshift` | excess tax burden by local price decile |
165 +| `fig_robustness` | γ_FE vs γ_IV across 10 sample variants |
166 +
167 +Tables (`results/tables/*.tex`): summary stats, IAAO province-wide, ten largest
168 +markets, vertical regressions, quantiles, heterogeneity, horizontal, tax shift,
169 +robustness, Paglin–Fogarty.
170 +
171 +## ⚠️ Limitations
172 +
173 +- Sales < $50k absent from the source registry; residual non-arm's-length
174 + transfers may survive screens (bottom-decile shift = upper bound).
175 +- 2021–2026 is an unusually turbulent market — levels statistics are
176 + period-specific (the within-market elasticity is not).
177 +- No appeals, renovations, or owner demographics observed.
178 +- Montréal's progressivity may partly reflect borough-level composition.
179 +
180 +## 📝 Citation
181 +
182 +```bibtex
183 +@techreport{boucher2026assessmentgap,
184 + author = {Boucher, Simon-Pierre},
185 + title = {The Assessment Gap in Quebec: Vertical and Horizontal Inequity
186 + in Municipal Property Valuation},
187 + institution = {Universit\'e du Qu\'ebec en Outaouais,
188 + D\'epartement des sciences administratives},
189 + type = {Working Paper},
190 + number = {10},
191 + year = {2026},
192 + month = {August}
193 +}
194 +```
195 +
196 +## 👤 Author & contact
197 +
198 +**Simon-Pierre Boucher** — Département des sciences administratives,
199 +Université du Québec en Outaouais (UQO), Gatineau, QC.
200 +📧 [contact@spboucher.ai](mailto:contact@spboucher.ai)
added data/raw/README.md +18 −0
@@ -0,0 +1,18 @@
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 (rôle d'évaluation foncière)
7 +in force at the sale date.
8 +
9 +| Group | Key columns |
10 +|---|---|
11 +| Transaction | `id`, `date`, `amount`, `street`, `zipCode`, `city`, `lat`, `lng`, `propertyType`, `ownerType`, `tx_year` |
12 +| Roll identity | `role_id_provinc`, `role_matricule`, `role_code_mun`, `role_municipalite`, `role_anrole` (roll vintage), `role_date_cond_marche` (statutory market-condition reference date) |
13 +| Roll values | `role_valeur_immeuble` (total AV), `role_valeur_terrain` (land), `role_valeur_batiment` (building), `role_valeur_role_anterieur`, `previousValue`, `totalArValue` |
14 +| Roll structure | `role_cubf` (+ `role_cubf_libelle`), `role_superficie_terrain_m2`, `role_aire_etages_m2`, `role_annee_construction`, `role_nb_logements`, `role_nb_etages`, `role_lien_physique`, `role_genre_construction`, `role_unite_voisinage` |
15 +| Match quality | `match_dist_m` (median 0.6 m), `match_score` (max 220), `match_valeur_exacte` |
16 +
17 +The file is **not redistributed** with the repository (100 MB; see
18 +`.gitignore`). Place it in this directory before running the pipeline.
added figures/fig_binscatter.png +0 −0

Binary file not shown.

added figures/fig_cod.png +0 −0

Binary file not shown.

added figures/fig_heterogeneity.png +0 −0

Binary file not shown.

added figures/fig_map.png +0 −0

Binary file not shown.

added figures/fig_prb_muni.png +0 −0

Binary file not shown.

added figures/fig_quantile.png +0 −0

Binary file not shown.

added figures/fig_ratio_dist.png +0 −0

Binary file not shown.

added figures/fig_robustness.png +0 −0

Binary file not shown.

added figures/fig_taxshift.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 +124 −0
@@ -0,0 +1,124 @@
1 +% Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +% ============================================================================
3 +% UQO Working Paper No. 10
4 +% The Assessment Gap in Quebec: Vertical and Horizontal Inequity in
5 +% Municipal Property Valuation
6 +%
7 +% Build: latexmk -pdf main.tex (or `make` in this directory)
8 +% Figures are read from ../figures/, tables from ../results/tables/.
9 +% ============================================================================
10 +\documentclass[12pt,letterpaper]{article}
11 +
12 +% ---------------------------------------------------------------- encoding
13 +\usepackage[utf8]{inputenc}
14 +\usepackage[T1]{fontenc}
15 +\usepackage[english]{babel}
16 +
17 +% ---------------------------------------------------------------- layout
18 +\usepackage[letterpaper,margin=1in]{geometry}
19 +\usepackage{setspace}
20 +\onehalfspacing
21 +
22 +% ---------------------------------------------------------------- typography
23 +\usepackage{mathptmx}
24 +\usepackage{microtype}
25 +
26 +% ---------------------------------------------------------------- math
27 +\usepackage{amsmath,amssymb,amsthm}
28 +
29 +% ---------------------------------------------------------------- tables
30 +\usepackage{booktabs}
31 +\usepackage{threeparttable}
32 +\usepackage{makecell}
33 +
34 +% ---------------------------------------------------------------- figures
35 +\usepackage{graphicx}
36 +\usepackage{subcaption}
37 +\graphicspath{{../figures/}{./}}
38 +
39 +% ---------------------------------------------------------------- captions
40 +\usepackage[font=small,labelfont=bf,labelsep=period,justification=justified,singlelinecheck=false]{caption}
41 +
42 +% ---------------------------------------------------------------- colours & links
43 +\usepackage[dvipsnames]{xcolor}
44 +\usepackage[colorlinks=true,linkcolor=NavyBlue,citecolor=NavyBlue,urlcolor=NavyBlue,breaklinks=true]{hyperref}
45 +
46 +% ---------------------------------------------------------------- bibliography
47 +\usepackage[authoryear,round,semicolon]{natbib}
48 +\bibliographystyle{aer}
49 +
50 +% ---------------------------------------------------------------- headings & lists
51 +\usepackage{titlesec}
52 +\titleformat{\section}{\large\bfseries}{\thesection.}{0.5em}{}
53 +\titleformat{\subsection}{\normalsize\bfseries}{\thesubsection.}{0.5em}{}
54 +\titleformat{\subsubsection}{\normalsize\itshape}{\thesubsubsection.}{0.5em}{}
55 +\usepackage{fancyhdr}
56 +\pagestyle{fancy}\fancyhf{}
57 +\renewcommand{\headrulewidth}{0pt}
58 +\fancyfoot[C]{\thepage}
59 +\usepackage{enumitem}
60 +\setlist{nosep,leftmargin=*}
61 +
62 +% ============================================================================
63 +% METADATA
64 +% ============================================================================
65 +\newcommand{\WPnumber}{10}
66 +\newcommand{\WPtitle}{The Assessment Gap in Quebec}
67 +\newcommand{\WPsubtitle}{Vertical and Horizontal Inequity in Municipal Property
68 + Valuation: Evidence from 522{,}769 Sales Matched to the Assessment Roll}
69 +\newcommand{\WPdate}{August 2026}
70 +\newcommand{\WPversion}{1.0}
71 +\newcommand{\WPkeywords}{Property tax, Assessment, Vertical equity,
72 + Regressivity, Ratio study, Quebec}
73 +\newcommand{\WPjel}{H71, H22, R51, R31}
74 +
75 +\newcommand{\WPauthor}{Simon-Pierre Boucher}
76 +\newcommand{\WPaffiliation}{D\'epartement des sciences administratives\\
77 + Universit\'e du Qu\'ebec en Outaouais}
78 +\newcommand{\WPemail}{simon-pierre.boucher@uqo.ca}
79 +\newcommand{\WPaddress}{Gatineau -- Pavillon Alexandre-Tach\'e\\
80 + 283, boulevard Alexandre-Tach\'e\\ Gatineau, Qu\'ebec, Canada J9A 1L8}
81 +
82 +\newcommand{\WPabstract}{%
83 +Municipal property taxes are levied on assessed values, so any systematic
84 +relationship between assessment accuracy and market value silently
85 +redistributes the tax burden. Matching 522{,}769 residential transactions
86 +from 2021--2026 to the triennial assessment rolls of 625 Quebec
87 +municipalities at the parcel level, we provide the first province-wide
88 +evaluation of vertical and horizontal equity in Canadian property assessment.
89 +Assessment ratios decline steeply with price: within
90 +municipality~$\times$~roll~$\times$~sale-year markets, the elasticity of the
91 +assessment ratio with respect to the sale price is $-0.34$, and a rank-based
92 +instrumental-variable estimator in the spirit of \mbox{Clapp (1990)}---which
93 +purges the attenuation bias that pushes least-squares tests toward spurious
94 +regressivity---still yields $-0.08$ ($t\approx4$). IAAO ratio-study
95 +diagnostics place 95\% of municipalities below the acceptable price-related
96 +bias band and 99\% above the COD uniformity ceiling; Montr\'eal is the only
97 +large market whose assessments are progressive. Regressivity is strongest for
98 +single-family homes, older buildings, properties with a high assessed land
99 +share, and smaller municipalities, and it doubles in years when market prices
100 +decouple from the rolls' reference dates, pointing to infrequent revaluation
101 +as a first-order mechanism. The implied intra-municipal tax shift is large:
102 +the median dwelling in the bottom within-market price decile pays roughly
103 +65\% more property tax than it would under uniform assessment, while the top
104 +decile pays about 5\% less.}
105 +
106 +\begin{document}
107 +
108 +\input{sections/titlepage}
109 +
110 +\setcounter{page}{1}
111 +\input{sections/introduction}
112 +\input{sections/literature}
113 +\input{sections/institutional}
114 +\input{sections/data}
115 +\input{sections/methodology}
116 +\input{sections/results}
117 +\input{sections/robustness}
118 +\input{sections/discussion}
119 +\input{sections/conclusion}
120 +
121 +\newpage
122 +\bibliography{references}
123 +
124 +\end{document}
added paper/references.bib +402 −0
@@ -0,0 +1,402 @@
1 +@comment{ Author: Simon-Pierre Boucher (contact at spboucher.ai) -- Bibliography for UQO Working Paper No 10 }
2 +
3 +@article{paglin1972equity,
4 + author = {Paglin, Morton and Fogarty, Michael},
5 + title = {Equity and the Property Tax: A New Conceptual Focus},
6 + journal = {National Tax Journal},
7 + year = {1972},
8 + volume = {25},
9 + number = {4},
10 + pages = {557--565}
11 +}
12 +
13 +@article{cheng1974property,
14 + author = {Cheng, Pao Lun},
15 + title = {Property Taxation, Assessment Performance, and Its Measurement},
16 + journal = {Public Finance},
17 + year = {1974},
18 + volume = {29},
19 + number = {3},
20 + pages = {268--284}
21 +}
22 +
23 +@article{kochin1982vertical,
24 + author = {Kochin, Levis A. and Parks, Richard W.},
25 + title = {Vertical Equity in Real Estate Assessment: A Fair Appraisal},
26 + journal = {Economic Inquiry},
27 + year = {1982},
28 + volume = {20},
29 + number = {4},
30 + pages = {511--532}
31 +}
32 +
33 +@article{bell1984administrative,
34 + author = {Bell, Earl J.},
35 + title = {Administrative Inequity and Property Assessment: The Case for the Traditional Approach},
36 + journal = {Property Tax Journal},
37 + year = {1984},
38 + volume = {3},
39 + number = {2},
40 + pages = {123--131}
41 +}
42 +
43 +@article{kennedy1984unfair,
44 + author = {Kennedy, Peter},
45 + title = {On an Unfair Appraisal of Vertical Equity in Real Estate Assessment},
46 + journal = {Economic Inquiry},
47 + year = {1984},
48 + volume = {22},
49 + number = {2},
50 + pages = {287--290}
51 +}
52 +
53 +@article{sunderman1990testing,
54 + author = {Sunderman, Mark A. and Birch, John W. and Cannaday, Roger E. and Hamilton, Thomas W.},
55 + title = {Testing for Vertical Inequity in Property Tax Systems},
56 + journal = {Journal of Real Estate Research},
57 + year = {1990},
58 + volume = {5},
59 + number = {3},
60 + pages = {319--334}
61 +}
62 +
63 +@article{clapp1990new,
64 + author = {Clapp, John M.},
65 + title = {A New Test for Equitable Real Estate Tax Assessment},
66 + journal = {Journal of Real Estate Finance and Economics},
67 + year = {1990},
68 + volume = {3},
69 + number = {3},
70 + pages = {233--249}
71 +}
72 +
73 +@article{sirmans1995vertical,
74 + author = {Sirmans, G. Stacy and Diskin, Barry A. and Friday, H. Swint},
75 + title = {Vertical Inequity in the Taxation of Real Property},
76 + journal = {National Tax Journal},
77 + year = {1995},
78 + volume = {48},
79 + number = {1},
80 + pages = {71--84}
81 +}
82 +
83 +@article{allen2002measuring,
84 + author = {Allen, Marcus T. and Dare, William H.},
85 + title = {Identifying Determinants of Horizontal Property Tax Inequity: Evidence from Florida},
86 + journal = {Journal of Real Estate Research},
87 + year = {2002},
88 + volume = {24},
89 + number = {2},
90 + pages = {153--164}
91 +}
92 +
93 +@article{sirmans2008vertical,
94 + author = {Sirmans, G. Stacy and Gatzlaff, Dean H. and Macpherson, David A.},
95 + title = {Horizontal and Vertical Inequity in Real Property Taxation},
96 + journal = {Journal of Real Estate Literature},
97 + year = {2008},
98 + volume = {16},
99 + number = {2},
100 + pages = {167--180}
101 +}
102 +
103 +@article{mcmillen2008thin,
104 + author = {McMillen, Daniel P. and Weber, Rachel N.},
105 + title = {Thin Markets and Property Tax Inequities: A Multinomial Logit Approach},
106 + journal = {National Tax Journal},
107 + year = {2008},
108 + volume = {61},
109 + number = {4},
110 + pages = {653--671}
111 +}
112 +
113 +@article{mcmillen2011assessment,
114 + author = {McMillen, Daniel P.},
115 + title = {Assessment Regressivity: A Tale of Two {Illinois} Counties},
116 + journal = {Land Lines},
117 + year = {2011},
118 + volume = {23},
119 + number = {1},
120 + pages = {9--15}
121 +}
122 +
123 +@article{hodge2017assessment,
124 + author = {Hodge, Timothy R. and McMillen, Daniel P. and Sands, Gary and Skidmore, Mark},
125 + title = {Assessment Inequity in a Declining Housing Market: The Case of {Detroit}},
126 + journal = {Real Estate Economics},
127 + year = {2017},
128 + volume = {45},
129 + number = {2},
130 + pages = {237--258}
131 +}
132 +
133 +@article{mcmillen2020assessment,
134 + author = {McMillen, Daniel and Singh, Ruchi},
135 + title = {Assessment Regressivity and Property Taxation},
136 + journal = {Journal of Real Estate Finance and Economics},
137 + year = {2020},
138 + volume = {60},
139 + number = {1},
140 + pages = {155--169}
141 +}
142 +
143 +@article{avenancio2022assessment,
144 + author = {Avenancio-Le{\'o}n, Carlos F. and Howard, Troup},
145 + title = {The Assessment Gap: Racial Inequalities in Property Taxation},
146 + journal = {Quarterly Journal of Economics},
147 + year = {2022},
148 + volume = {137},
149 + number = {3},
150 + pages = {1383--1434}
151 +}
152 +
153 +@techreport{berry2021reassessing,
154 + author = {Berry, Christopher R.},
155 + title = {Reassessing the Property Tax},
156 + institution = {University of Chicago, Harris School of Public Policy},
157 + type = {Working Paper},
158 + year = {2021}
159 +}
160 +
161 +@techreport{amornsiripanitch2022residential,
162 + author = {Amornsiripanitch, Natee},
163 + title = {Why Are Residential Property Tax Rates Regressive?},
164 + institution = {Federal Reserve Bank of Philadelphia},
165 + type = {Working Paper},
166 + number = {22-02},
167 + year = {2022}
168 +}
169 +
170 +@article{mcmillen2022allocative,
171 + author = {McMillen, Daniel and Singh, Ruchi},
172 + title = {Measures of Vertical Inequality in Assessments},
173 + journal = {Journal of Real Estate Finance and Economics},
174 + year = {2022},
175 + volume = {online first},
176 + pages = {1--29}
177 +}
178 +
179 +@book{iaao2013standard,
180 + author = {{International Association of Assessing Officers}},
181 + title = {Standard on Ratio Studies},
182 + publisher = {IAAO},
183 + address = {Kansas City, MO},
184 + year = {2013}
185 +}
186 +
187 +@book{eckert1990property,
188 + author = {Eckert, Joseph K. and Gloudemans, Robert J. and Almy, Richard R.},
189 + title = {Property Appraisal and Assessment Administration},
190 + publisher = {International Association of Assessing Officers},
191 + address = {Chicago},
192 + year = {1990}
193 +}
194 +
195 +@book{gloudemans2011fundamentals,
196 + author = {Gloudemans, Robert and Almy, Richard},
197 + title = {Fundamentals of Mass Appraisal},
198 + publisher = {International Association of Assessing Officers},
199 + address = {Kansas City, MO},
200 + year = {2011}
201 +}
202 +
203 +@article{oates1969effects,
204 + author = {Oates, Wallace E.},
205 + title = {The Effects of Property Taxes and Local Public Spending on Property Values: An Empirical Study of Tax Capitalization and the {Tiebout} Hypothesis},
206 + journal = {Journal of Political Economy},
207 + year = {1969},
208 + volume = {77},
209 + number = {6},
210 + pages = {957--971}
211 +}
212 +
213 +@article{oates2016local,
214 + author = {Oates, Wallace E. and Fischel, William A.},
215 + title = {Are Local Property Taxes Regressive, Progressive, or What?},
216 + journal = {National Tax Journal},
217 + year = {2016},
218 + volume = {69},
219 + number = {2},
220 + pages = {415--434}
221 +}
222 +
223 +@article{rosen1974hedonic,
224 + author = {Rosen, Sherwin},
225 + title = {Hedonic Prices and Implicit Markets: Product Differentiation in Pure Competition},
226 + journal = {Journal of Political Economy},
227 + year = {1974},
228 + volume = {82},
229 + number = {1},
230 + pages = {34--55}
231 +}
232 +
233 +@incollection{malpezzi2003hedonic,
234 + author = {Malpezzi, Stephen},
235 + title = {Hedonic Pricing Models: A Selective and Applied Review},
236 + booktitle = {Housing Economics and Public Policy},
237 + editor = {O'Sullivan, Tony and Gibb, Kenneth},
238 + publisher = {Blackwell},
239 + address = {Oxford},
240 + year = {2003},
241 + pages = {67--89}
242 +}
243 +
244 +@article{carbonnier2024property,
245 + author = {Carbonnier, Cl{\'e}ment},
246 + title = {Property Tax Regressivity, the Case of {Qu{\'e}bec}},
247 + journal = {Public Finance Review},
248 + year = {2024},
249 + volume = {52},
250 + number = {2},
251 + pages = {155--181}
252 +}
253 +
254 +@techreport{slack2014political,
255 + author = {Slack, Enid and Bird, Richard M.},
256 + title = {The Political Economy of Property Tax Reform},
257 + institution = {OECD},
258 + type = {OECD Working Papers on Fiscal Federalism},
259 + number = {18},
260 + year = {2014}
261 +}
262 +
263 +@book{youngman2016good,
264 + author = {Youngman, Joan},
265 + title = {A Good Tax: Legal and Policy Issues for the Property Tax in the {United States}},
266 + publisher = {Lincoln Institute of Land Policy},
267 + address = {Cambridge, MA},
268 + year = {2016}
269 +}
270 +
271 +@techreport{found2017property,
272 + author = {Found, Adam and Tomlinson, Peter},
273 + title = {Business Tax Burdens in {Canada's} Major Cities: The 2017 Report Card},
274 + institution = {C.D. Howe Institute},
275 + type = {E-Brief},
276 + year = {2017}
277 +}
278 +
279 +@techreport{mpac2022vertical,
280 + author = {{Municipal Property Assessment Corporation}},
281 + title = {Vertical Equity Review of Residential Assessed Values},
282 + institution = {MPAC},
283 + address = {Toronto},
284 + type = {Technical Report},
285 + year = {2022}
286 +}
287 +
288 +@article{goolsby1997assessment,
289 + author = {Goolsby, William C.},
290 + title = {Assessment Error in the Valuation of Owner-Occupied Housing},
291 + journal = {Journal of Real Estate Research},
292 + year = {1997},
293 + volume = {13},
294 + number = {1},
295 + pages = {33--45}
296 +}
297 +
298 +@article{quintos2020prb,
299 + author = {Quintos, Carmela},
300 + title = {A Gradient Boosting Approach to Understanding Vertical and Horizontal Inequity in Property Assessments},
301 + journal = {Journal of Property Tax Assessment \& Administration},
302 + year = {2020},
303 + volume = {17},
304 + number = {2},
305 + pages = {1--28}
306 +}
307 +
308 +@article{ross2012assessor,
309 + author = {Ross, Justin M.},
310 + title = {Interjurisdictional Determinants of Property Assessment Regressivity},
311 + journal = {Land Economics},
312 + year = {2012},
313 + volume = {88},
314 + number = {1},
315 + pages = {28--42}
316 +}
317 +
318 +@article{plummer2014evidence,
319 + author = {Plummer, Elizabeth},
320 + title = {The Effects of Property Tax Protests on the Assessment Uniformity of Residential Properties},
321 + journal = {Real Estate Economics},
322 + year = {2014},
323 + volume = {42},
324 + number = {4},
325 + pages = {900--937}
326 +}
327 +
328 +@article{clapp1990methods,
329 + author = {Clapp, John M.},
330 + title = {A Methodology for Constructing Vacant Land Price Indices},
331 + journal = {Real Estate Economics},
332 + year = {1990},
333 + volume = {18},
334 + number = {3},
335 + pages = {274--293}
336 +}
337 +
338 +@misc{lfm2026,
339 + author = {{Gouvernement du Qu\'ebec}},
340 + title = {Loi sur la fiscalit\'e municipale, {RLRQ}, c.\ {F-2.1}},
341 + howpublished = {L\'egisQu\'ebec},
342 + year = {2026},
343 + note = {Articles 42--46 (triennial rolls and market-condition reference date)}
344 +}
345 +
346 +@misc{mamh2024manuel,
347 + author = {{Minist\`ere des Affaires municipales et de l'Habitation}},
348 + title = {Manuel d'\'evaluation fonci\`ere du {Qu\'ebec}},
349 + howpublished = {Gouvernement du Qu\'ebec},
350 + address = {Qu\'ebec},
351 + year = {2024}
352 +}
353 +
354 +@article{desrosiers2000hedonic,
355 + author = {Des Rosiers, Fran{\c c}ois and Th{\'e}riault, Marius and Villeneuve, Paul-Y.},
356 + title = {Sorting Out Access and Neighbourhood Factors in Hedonic Price Modelling},
357 + journal = {Journal of Property Investment \& Finance},
358 + year = {2000},
359 + volume = {18},
360 + number = {3},
361 + pages = {291--315}
362 +}
363 +
364 +@article{bostic2007land,
365 + author = {Bostic, Raphael W. and Longhofer, Stanley D. and Redfearn, Christian L.},
366 + title = {Land Leverage: Decomposing Home Price Dynamics},
367 + journal = {Real Estate Economics},
368 + year = {2007},
369 + volume = {35},
370 + number = {2},
371 + pages = {183--208}
372 +}
373 +
374 +@article{ihlanfeldt2023appraisal,
375 + author = {Ihlanfeldt, Keith and Rodgers, Luke P.},
376 + title = {Homestead Exemptions, Heterogeneous Assessment, and Property Tax Progressivity},
377 + journal = {National Tax Journal},
378 + year = {2022},
379 + volume = {75},
380 + number = {1},
381 + pages = {7--31}
382 +}
383 +
384 +@article{baar1981property,
385 + author = {Baar, Kenneth K.},
386 + title = {Property Tax Assessment Discrimination Against Low-Income Neighborhoods},
387 + journal = {The Urban Lawyer},
388 + year = {1981},
389 + volume = {13},
390 + number = {3},
391 + pages = {333--406}
392 +}
393 +
394 +@article{engle1975deassessment,
395 + author = {Engle, Robert F.},
396 + title = {De Facto Discrimination in Residential Assessments: {Boston}},
397 + journal = {National Tax Journal},
398 + year = {1975},
399 + volume = {28},
400 + number = {4},
401 + pages = {445--451}
402 +}
added paper/sections/conclusion.tex +37 −0
@@ -0,0 +1,37 @@
1 +% Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +% ============================================================================
3 +\section{Conclusion}
4 +\label{sec:concl}
5 +
6 +Matching more than half a million residential sales to the assessment
7 +rolls that tax them, this paper delivers the first province-wide audit of
8 +assessment equity in Canada. The verdict is unambiguous. Quebec's
9 +property-tax base is systematically regressive: within the same
10 +municipality, roll and year, the assessment ratio falls by 34\% per
11 +log-point of price in the descriptive fixed-effects estimate, and by 8\%
12 +under the most conservative measurement-error correction available.
13 +Ninety-five percent of municipalities fail the IAAO vertical-equity
14 +standard, ninety-nine percent fail its uniformity standard, and the median
15 +dwelling in the bottom local price decile pays roughly two thirds more
16 +property tax than uniform assessment would imply, while the top decile
17 +pays less. The inequity concentrates where mass appraisal is hardest ---
18 +old houses, plexes, land-heavy and luxury properties, thin markets ---
19 +doubles when triennial rolls fall behind a booming market, and disappears
20 +almost nowhere except Montr\'eal.
21 +
22 +These findings reframe the presumption that centralized, professionally
23 +regulated assessment systems are immune to the pathologies documented in
24 +the fragmented U.S. system. Quebec has uniform statutes, certified
25 +evaluators and statutory market-vintage synchronization --- and produces
26 +regressivity of U.S. magnitude anyway. The binding constraint is not
27 +governance but valuation technology and revaluation frequency, which is
28 +in fact encouraging: cycle length, pooled assessment services and
29 +vertical audit standards are policy variables, and inexpensive ones
30 +relative to the redistribution at stake.
31 +
32 +The matched sale--roll architecture built here supports a research agenda
33 +beyond this audit: linking assessment gaps to neighbourhood
34 +socio-demographics, exploiting the discrete arrival of new rolls to study
35 +price anchoring, and using the land--building decomposition to measure
36 +land-value dynamics at the parcel level. We pursue these in companion
37 +work.
added paper/sections/data.tex +101 −0
@@ -0,0 +1,101 @@
1 +% Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +% ============================================================================
3 +\section{Data and sample construction}
4 +\label{sec:data}
5 +
6 +\subsection{Sources and matching}
7 +
8 +We start from a compiled registry of 745{,}119 residential-market
9 +transactions recorded in Quebec between January 2021 and July 2026, each
10 +carrying the sale price, sale date, civic address, geographic coordinates,
11 +and a listing-derived property type. Each transaction has been matched at
12 +the parcel level to the municipal assessment roll in force on the sale date.
13 +The match keys on geographic proximity between the transaction's coordinates
14 +and the roll unit's coordinates and is validated against the roll's recorded
15 +value; a composite score (maximum 220) summarizes the quality of the
16 +address, distance and value agreement. Matches are extremely tight: the
17 +median distance between the transaction and the matched roll unit is
18 +0.6~metres, and every observation retained in the estimation sample
19 +reproduces the roll's assessed value exactly.
20 +
21 +From the roll we observe the taxable value of the property
22 +($AV$, \texttt{valeur immeuble}), its land and building components, lot
23 +area, total floor area, year of construction, number of dwelling units, the
24 +standardized use code (CUBF), the roll vintage, and the statutory
25 +market-condition reference date discussed in Section~\ref{sec:inst}.
26 +
27 +\subsection{Sample restrictions}
28 +
29 +Table~\ref{tab:sumstats} describes the estimation sample; the selection
30 +cascade is as follows. We keep sales of residential use codes ---
31 +dwellings (CUBF 1000), cottages (1100), mobile homes (1211) and other
32 +residential (1990) --- which removes vacant land, commercial property and
33 +construction-in-progress (672{,}276 sales remain). We require a
34 +high-confidence roll match (distance $\le 50$~m and score $\ge 150$;
35 +568{,}711 sales), a positive assessed value and a price of at least
36 +\$50{,}000 --- the floor of the source registry, which also screens out
37 +most non-arm's-length transfers. Assessment ratios $r_i = AV_i/SP_i$ are
38 +trimmed at the 1st and 99th percentiles \emph{within each roll vintage}, so
39 +that the mechanical drift of ratio levels across vintages is not trimmed
40 +asymmetrically (557{,}325 sales). Finally, since all estimates compare
41 +sales within a municipality $\times$ roll $\times$ sale-year block
42 +(``cell''), we require at least 20 sales per cell. The estimation sample
43 +contains \textbf{522{,}769 sales} in 625 municipalities and 2{,}884 cells:
44 +345{,}053 single-family homes, 81{,}693 plexes (2--5 units), 81{,}461
45 +condominiums, 9{,}287 cottages and 4{,}701 mobile homes.
46 +
47 +\begin{table}[t]
48 +\centering
49 +\begin{threeparttable}
50 +\caption{Summary statistics, estimation sample}
51 +\label{tab:sumstats}
52 +\small
53 +\input{../results/tables/summary_stats}
54 +\begin{tablenotes}[flushleft]\footnotesize
55 +\item \textit{Notes:} 522{,}769 residential sales, January 2021 -- July
56 +2026, matched at the parcel level to the assessment roll in force at the
57 +sale date. The assessment ratio is assessed value divided by sale price.
58 +Roll lag is the number of months between the roll's statutory
59 +market-condition reference date (July 1) and the sale date. The assessed
60 +land share is the roll's land value divided by total assessed value.
61 +\end{tablenotes}
62 +\end{threeparttable}
63 +\end{table}
64 +
65 +\subsection{The raw pattern}
66 +
67 +The median assessment ratio is 0.78: the typical dwelling sells for about
68 +28\% more than its rolled value, the expected imprint of a rising market on
69 +back-dated rolls. Figure~\ref{fig:ratiodist} shows the distribution and its
70 +decomposition by roll lag: sales occurring within two years of the
71 +reference date centre near 0.87, while sales more than four years out
72 +centre near 0.64 --- the mechanical staleness gradient that our fixed
73 +effects absorb. Figure~\ref{fig:time} traces the same mechanics in
74 +calendar time: each roll vintage enters near parity with the market it was
75 +referenced on, then drifts down as prices rise, and the 2022--2023 rate
76 +shock is visible as a flattening of the drift. Everything that follows
77 +nets out this timing structure and asks a sharper question: \emph{within} a
78 +given municipality, roll, and year, do cheap and expensive homes face the
79 +same ratio?
80 +
81 +\begin{figure}[t]
82 +\centering
83 +\includegraphics[width=\textwidth]{fig_ratio_dist.png}
84 +\caption{Assessment ratios. Panel A: distribution of $AV/SP$ across the
85 +estimation sample; the median is 0.78 and the dashed line marks parity.
86 +Panel B: kernel of the same distribution split by the number of months
87 +between the roll's market-condition reference date and the sale; older
88 +rolls sit systematically further below parity.}
89 +\label{fig:ratiodist}
90 +\end{figure}
91 +
92 +\begin{figure}[t]
93 +\centering
94 +\includegraphics[width=\textwidth]{fig_time.png}
95 +\caption{Median assessment ratio by sale month and roll vintage. Each line
96 +follows sales assessed under one triennial vintage (labelled by entry
97 +year); monthly medians with fewer than 100 sales are suppressed. Vintages
98 +enter near their reference-date market level and drift down as prices
99 +rise; the flattening after 2022 reflects the interest-rate correction.}
100 +\label{fig:time}
101 +\end{figure}
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{Mechanisms}
7 +
8 +The evidence assembles into a coherent mechanism story with three layers.
9 +
10 +\paragraph{1. Hedonic difficulty, not assessor idiosyncrasy.} The
11 +heterogeneity profile tracks the difficulty of the valuation problem with
12 +almost embarrassing fidelity: condominiums (homogeneous, comparable-rich)
13 +are nearly equitable; old houses, plexes, cottages and high-land-share
14 +properties (heterogeneous, comparable-poor, unanchored by construction
15 +costs) are severely regressive. This is the pattern predicted if assessors
16 +run reasonable hedonic models whose unexplained component is largest ---
17 +and most value-correlated --- where the housing bundle is hardest to price
18 +\citep{amornsiripanitch2022residential, gloudemans2011fundamentals,
19 +bostic2007land}. Regression toward the mean in any imperfect valuation
20 +model over-values the cheap tail and under-values the expensive tail;
21 +our quantile profile, with proportionality holding at the bottom and
22 +collapsing at the top ($\beta(0.90) = 0.49$), shows Quebec's failure is
23 +concentrated where comparables are thinnest.
24 +
25 +\paragraph{2. Staleness amplifies the gap.} The doubling of the
26 +province-wide PRB in 2021--2022 shows that triennial rolls interact
27 +perversely with fast markets: when prices move 30--40\% between reference
28 +dates, the cross-sectional dispersion of appreciation --- which is itself
29 +correlated with price segment --- loads directly into measured inequity.
30 +Jurisdictions cannot control housing cycles, but they control revaluation
31 +frequency; annual-cycle systems mechanically cap this channel
32 +\citep{berry2021reassessing, ross2012assessor}.
33 +
34 +\paragraph{3. Scale helps: the Montr\'eal exception.} Montr\'eal ---
35 +the largest sales sample, a dedicated in-house assessment service, dense
36 +comparable markets in every segment --- is the only large jurisdiction
37 +with progressive assessments, in all six years. Mid-sized and small
38 +municipalities, many relying on contracted regional evaluators with
39 +limited modelling capacity, are uniformly regressive. The thin-market
40 +mechanism of \citet{mcmillen2008thin} operates within a single provincial
41 +rulebook: identical statutes, divergent outcomes, sorted by market depth
42 +and assessment resources. We note one caveat: Montr\'eal's measured
43 +progressivity may partly reflect within-city composition (its cell is the
44 +whole city, pooling boroughs), and borough-level analysis is a natural
45 +extension.
46 +
47 +\subsection{Policy implications}
48 +
49 +Three levers follow directly from the diagnosis, in increasing order of
50 +ambition. First, \textbf{shorten the cycle}: moving from triennial to
51 +annual (or indexed) rolls would eliminate the staleness component that
52 +dominated 2021--2022; several U.S. states and British Columbia already
53 +operate annual cycles. Second, \textbf{pool assessment capacity}: the
54 +regressivity gradient by municipality size argues for regional or
55 +provincial mass-appraisal services --- scale is the cheapest known
56 +technology for assessment quality. Third, \textbf{audit vertically, not
57 +just horizontally}: Quebec's oversight regime monitors median ratios and
58 +CODs but sets no explicit PRB requirement; adding the IAAO vertical
59 +standard to the ministry's audit criteria, with published
60 +municipality-level statistics like those in this paper, would make the
61 +equity dimension visible and contestable. Because low-priced-segment
62 +owners appeal least \citep{avenancio2022assessment, plummer2014evidence},
63 +supply-side correction --- better models --- will do more than
64 +demand-side remedies.
65 +
66 +\subsection{Limitations}
67 +
68 +Four limitations bound the interpretation. First, sale prices below
69 +\$50{,}000 are absent from the source registry and some residual
70 +non-arm's-length transfers may survive our screens; the robustness
71 +battery (dropping sub-\$100k sales, tightening trims) shows the
72 +conclusions survive, but the bottom-decile tax-shift magnitude should be
73 +read as an upper bound on that decile. Second, our data cover 2021--2026,
74 +an unusually turbulent market; the within-market elasticity is stable
75 +across the window, but levels statistics from calmer periods would differ.
76 +Third, we observe no appeals, renovations between assessment and sale, or
77 +conditions of sale; some of what we call horizontal noise is genuine
78 +unobserved quality change. Fourth, without owner demographics we cannot
79 +speak to the distributional incidence across income or racial groups as
80 +\citet{avenancio2022assessment} do --- linking these assessments to census
81 +tract characteristics is the obvious next step.
added paper/sections/institutional.tex +63 −0
@@ -0,0 +1,63 @@
1 +% Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +% ============================================================================
3 +\section{Institutional setting: property assessment in Quebec}
4 +\label{sec:inst}
5 +
6 +Quebec's municipal fiscal regime is governed by the \textit{Loi sur la
7 +fiscalit\'e municipale} (LFM) and operationalized by the province's
8 +assessment manual, the \textit{Manuel d'\'evaluation fonci\`ere du
9 +Qu\'ebec} \citep{lfm2026, mamh2024manuel}. Four features matter for the
10 +measurement design of this paper.
11 +
12 +\paragraph{Triennial rolls.} Every municipality's assessment roll is
13 +redrawn on a fixed three-year cycle by a municipal body or a contracted
14 +private firm under the responsibility of a chartered evaluator. The roll
15 +lists, for each assessment unit, the taxable value of the property and its
16 +decomposition into land and building components, along with the structural
17 +descriptors used in valuation (lot area, floor area, year of construction,
18 +number of dwelling units, physical configuration, and a standardized use
19 +code, the CUBF).
20 +
21 +\paragraph{A statutory market-condition date.} Article 46 of the LFM
22 +requires that the values entered on a roll reflect the state of the market
23 +\emph{eighteen months before} the roll takes effect: a roll entering force
24 +on January~1 of year $t$ must value every property as of July~1 of year
25 +$t-2$. This single reference date is printed on the roll itself and is
26 +common to every property in the municipality. Two implications follow.
27 +First, in a rising market the \emph{level} of assessment ratios drifts
28 +mechanically below one as sales occur further from the reference date ---
29 +between 18 and 54 months elapse between the reference date and a sale, 36
30 +months at the median in our sample. This drift is a feature of the system,
31 +not an inequity, and our empirical design absorbs it entirely with
32 +municipality~$\times$~roll~$\times$~sale-year fixed effects. Second, and
33 +crucially, \emph{within} such a block every property shares the same market
34 +vintage, so cross-sectional patterns in ratios cannot be attributed to
35 +timing.
36 +
37 +\paragraph{Uniform taxation of the rolled value.} The municipal levy on a
38 +residential property is the rolled taxable value multiplied by the
39 +municipal rate (plus school taxes levied on the same base). There are no
40 +assessment-growth caps, homestead exemptions, or acquisition-value rules of
41 +the Californian or Floridian type: relative taxable values within a
42 +municipality equal relative assessed values. This makes the mapping from
43 +assessment error to tax-burden redistribution exact --- a property assessed
44 +10\% above the jurisdiction's median ratio pays 10\% more tax than uniform
45 +assessment would imply --- and motivates the tax-shift calculation of
46 +Section~\ref{sec:results}.
47 +
48 +\paragraph{Oversight and appeal.} The Ministry of Municipal Affairs (MAMH)
49 +prescribes methods and audits rolls; owners may request an administrative
50 +review and appeal to the Tribunal administratif du Qu\'ebec. Appeal rates
51 +for residential property are low. Unlike several U.S. jurisdictions
52 +studied in the literature \citep{plummer2014evidence,
53 +avenancio2022assessment}, Quebec's appeal system plays a minor quantitative
54 +role for the housing stock at large, which makes the valuation model
55 +itself --- rather than post-assessment litigation --- the natural locus of
56 +any inequity we measure.
57 +
58 +Taken together, these institutions imply that Quebec should be a
59 +\emph{best-case} environment for assessment equity: uniform provincial
60 +methodology, professional certification, statutory synchronization of
61 +market vintage, and a tax that consumes the rolled value without
62 +exemption-driven distortions. The magnitude of the inequity we document
63 +below should be read against this backdrop.
added paper/sections/introduction.tex +127 −0
@@ -0,0 +1,127 @@
1 +% Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +% ============================================================================
3 +\section{Introduction}
4 +\label{sec:intro}
5 +
6 +The property tax is the fiscal backbone of Canadian local government: it
7 +finances roughly two thirds of municipal budgets and is, by statute, an
8 +\emph{ad valorem} tax --- every dwelling in a municipality is meant to be
9 +taxed in strict proportion to its market value. That proportionality rests
10 +entirely on the quality of the assessment roll. If assessors systematically
11 +overvalue inexpensive homes relative to expensive ones, the legal tax rate
12 +becomes a fiction: the effective tax rate falls with wealth, and the
13 +resulting redistribution --- from modest neighbourhoods toward affluent ones
14 +--- occurs silently, without any legislative decision, embedded in a
15 +technical document that few taxpayers ever contest. A rapidly growing
16 +literature documents exactly this pattern in the United States, where
17 +assessment regressivity has been measured at national scale
18 +\citep{berry2021reassessing, avenancio2022assessment,
19 +amornsiripanitch2022residential}. Whether the same pathology afflicts
20 +Canadian assessment systems --- centralized, professionally supervised, and
21 +widely presumed to be of high quality --- is essentially unknown.
22 +
23 +This paper provides the first province-wide answer for Quebec. We assemble
24 +522{,}769 residential transactions recorded between January 2021 and July
25 +2026 and match each sale, at the parcel level, to the triennial municipal
26 +assessment roll in force on the day of the sale. The match is performed on
27 +geographic coordinates and validated against the roll's recorded value; the
28 +median match distance is under one metre. The resulting file couples every
29 +sale price with the exact taxable value that generated the owner's property
30 +tax bill, together with the roll's decomposition of that value into land and
31 +building components, structural descriptors (lot area, floor area, year
32 +built, number of units), and the statutory market-condition reference date
33 +of the roll. The data span 625 municipalities --- from Montr\'eal
34 +(79{,}581 usable sales) to towns with barely a hundred --- and a housing
35 +cycle of unusual amplitude: the post-pandemic boom, the 2022--2023 interest
36 +rate shock, and the subsequent recovery.
37 +
38 +Quebec's institutional design makes it an unusually clean laboratory for
39 +equity measurement. Every municipality's roll is redone on a fixed
40 +three-year cycle, and by law (art.~46, \textit{Loi sur la fiscalit\'e
41 +municipale}) the values entered on a roll must reflect market conditions
42 +eighteen months before the roll takes effect --- a single, known reference
43 +date shared by every property in the municipality
44 +\citep{lfm2026, mamh2024manuel}. Within a municipality $\times$ roll
45 +$\times$ sale-year block, therefore, every assessment embodies the same
46 +market vintage, and any systematic relationship between assessment ratios
47 +and prices inside such a block is inequity, not timing. Our empirical design
48 +exploits exactly this structure: all regression estimates absorb 2{,}884
49 +municipality~$\times$~roll~$\times$~year fixed effects, so that vertical
50 +inequity is identified purely from comparisons between cheap and expensive
51 +dwellings facing the same assessor, the same roll, and the same market
52 +moment.
53 +
54 +Three findings emerge. First, assessment regressivity in Quebec is
55 +pervasive, large, and statistically unambiguous. The elasticity of the
56 +assessment ratio with respect to the sale price --- zero under proportional
57 +assessment --- is $-0.34$ (s.e.\ $0.03$) in the fixed-effects log-log
58 +regression. Because sale prices measure market value with noise, part of
59 +that estimate reflects mechanical attenuation \citep{kochin1982vertical,
60 +clapp1990new}; a rank-based instrumental-variables estimator in the spirit
61 +of \citet{clapp1990new} that purges this bias still yields $-0.08$
62 +(s.e.\ $0.02$). Under the IAAO ratio-study standards used by assessment
63 +authorities across North America \citep{iaao2013standard}, 95\% of Quebec
64 +municipalities fall outside the acceptable band for price-related bias and
65 +99\% exceed the uniformity (COD) ceiling. Strikingly, Montr\'eal --- the
66 +province's largest and best-resourced assessment jurisdiction --- is the
67 +\emph{only} major market whose assessments are progressive.
68 +
69 +Second, the inequity has a clear anatomy. Regressivity is roughly four times
70 +stronger for single-family homes and plexes than for condominiums, whose
71 +quasi-homogeneous units are easy to mass-appraise; it rises steeply with
72 +building age and with the assessed land share of the property, consistent
73 +with the difficulty of valuing land and depreciated structures
74 +\citep{bostic2007land, gloudemans2011fundamentals}; and it is worse in small
75 +municipalities, echoing the thin-market mechanism of \citet{mcmillen2008thin}.
76 +The quantile profile is equally telling: the log-log slope
77 +falls from $0.87$ at the first decile of the conditional value distribution
78 +to $0.49$ at the ninth, so the failure of proportionality is concentrated at
79 +the top --- expensive homes are not merely under-assessed, they are
80 +under-assessed at an accelerating rate.
81 +
82 +Third, the stakes are material. Within a taxing jurisdiction the levy is
83 +proportional to assessed value, so a property assessed above the
84 +jurisdiction median ratio pays exactly that percentage more tax than uniform
85 +assessment would imply. The median dwelling in the bottom within-market
86 +price decile pays roughly 65\% more property tax than under uniform
87 +assessment; the median dwelling in the top decile pays about 5\% less. Put
88 +differently, the effective tax schedule that Quebec's assessment machinery
89 +delivers is regressive enough to undo, within each municipality, a
90 +substantial share of whatever progressivity the rest of the fiscal system
91 +achieves --- a finding that complements \citet{carbonnier2024property}, who
92 +documents the regressivity of Quebec's property tax relative to income using
93 +survey data, but who could not observe the assessment channel isolated here.
94 +
95 +We also show \emph{when} the gap opens. Assessment ratios drift mechanically
96 +between reference dates, and the province-wide price-related bias doubles in
97 +2021--2022, precisely when the post-pandemic boom pulled market prices away
98 +from rolls anchored in 2018--2020 conditions --- direct evidence that
99 +infrequent revaluation is a first-order driver of measured inequity, as
100 +conjectured in the U.S. literature \citep{berry2021reassessing,
101 +ross2012assessor}. Because the diagnosis singles out stale rolls, thin
102 +markets, and hard-to-value property types --- rather than assessor
103 +discretion alone --- it maps directly into policy: shorter revaluation
104 +cycles, pooled assessment services for small municipalities, and targeted
105 +review of high-land-share and older properties.
106 +
107 +Beyond the Canadian evidence gap, the paper makes two methodological
108 +contributions to the ratio-study literature. It is, to our knowledge, the
109 +first large-scale equity study to exploit a statutory single-date reference
110 +regime to separate timing drift from genuine vertical inequity by design
111 +rather than by econometric correction; and it implements the full modern
112 +test battery --- IAAO diagnostics with bootstrap inference
113 +\citep{iaao2013standard}, the classical regression tests
114 +\citep{paglin1972equity, cheng1974property}, measurement-error-robust IV
115 +\citep{clapp1990new}, and quantile profiles \citep{mcmillen2020assessment}
116 +--- on more than half a million sales, allowing precise subgroup estimates
117 +that smaller samples cannot support.
118 +
119 +The remainder of the paper proceeds as follows. Section~\ref{sec:lit}
120 +positions the paper in the ratio-study and property-tax-equity literatures.
121 +Section~\ref{sec:inst} describes Quebec's assessment institutions.
122 +Section~\ref{sec:data} presents the data and the matched sample.
123 +Section~\ref{sec:method} lays out the diagnostics and econometric
124 +specifications. Section~\ref{sec:results} reports the results,
125 +Section~\ref{sec:robust} the robustness battery, Section~\ref{sec:disc}
126 +discusses mechanisms and policy implications, and Section~\ref{sec:concl}
127 +concludes.
added paper/sections/literature.tex +100 −0
@@ -0,0 +1,100 @@
1 +% Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +% ============================================================================
3 +\section{Related literature}
4 +\label{sec:lit}
5 +
6 +\subsection{Measuring vertical inequity: from ratio studies to modern tests}
7 +
8 +The empirical study of assessment equity is nearly as old as the modern
9 +property tax. Its workhorse is the \emph{sales ratio study}: for each sale
10 +$i$, compute the ratio $r_i = AV_i/SP_i$ of assessed value to sale price and
11 +ask whether $r_i$ is uniform across the value distribution. The
12 +practitioner's canon --- codified in the IAAO \textit{Standard on Ratio
13 +Studies} \citep{iaao2013standard} and the mass-appraisal manuals of
14 +\citet{eckert1990property} and \citet{gloudemans2011fundamentals} ---
15 +summarizes a jurisdiction with the median ratio, the coefficient of
16 +dispersion (COD) for horizontal uniformity, and the price-related
17 +differential (PRD) or, since 2013, the coefficient of price-related bias
18 +(PRB) for vertical equity. These diagnostics discipline assessment practice
19 +across North America, and we report all of them, with bootstrap confidence
20 +intervals, for every Quebec municipality with sufficient sales.
21 +
22 +The academic literature grew out of dissatisfaction with those summary
23 +measures. \citet{paglin1972equity} proposed regressing assessed value on
24 +sale price in levels and reading regressivity off a positive intercept;
25 +\citet{cheng1974property} moved the test to logarithms, where the slope
26 +$\beta$ has the clean interpretation of an elasticity and $\beta<1$ signals
27 +regressivity. Subsequent work refined the specification menu ---
28 +\citet{bell1984administrative} and \citet{sunderman1990testing} allowed
29 +splines and structural breaks, and \citet{sirmans1995vertical} surveyed the
30 +proliferating test battery. A decisive critique came from
31 +\citet{kochin1982vertical} and \citet{kennedy1984unfair}: because the sale
32 +price is itself a noisy measure of market value, the log-log slope is
33 +attenuated toward zero even under perfectly proportional assessment ---
34 +ratio studies are biased \emph{toward} finding regressivity.
35 +\citet{clapp1990new} answered with a rank-based instrumental-variables
36 +estimator whose coarse instrument is nearly orthogonal to transitory price
37 +noise; \citet{goolsby1997assessment} and \citet{mcmillen2020assessment}
38 +document how severely the correction can matter, and
39 +\citet{quintos2020prb} extends the toolkit with machine-learning
40 +diagnostics. Our design follows this lesson to the letter: we report the
41 +na\"ive estimates, the Clapp IV, and quantile profiles
42 +\citep{mcmillen2020assessment} side by side, and treat the IV as the lower
43 +bound on true regressivity.
44 +
45 +\subsection{Evidence: pervasive regressivity, mostly documented in the U.S.}
46 +
47 +Substantively, the modern consensus is that assessment regressivity is the
48 +rule rather than the exception. \citet{berry2021reassessing} computes ratio
49 +statistics for essentially every U.S. county and finds that the
50 +lowest-decile home within a jurisdiction faces an effective tax rate roughly
51 +double that of the top decile; \citet{avenancio2022assessment} show in the
52 +\textit{QJE} that the same machinery generates a 10--13\% assessment gap
53 +against Black and Hispanic homeowners, driven partly by unpriced
54 +neighbourhood attributes and partly by differential appeals;
55 +\citet{amornsiripanitch2022residential} attributes much of the gradient to
56 +assessors' inability to capture unobserved quality that scales with price.
57 +Earlier strands documented the same pattern city by city ---
58 +\citet{engle1975deassessment} in Boston, \citet{baar1981property} across
59 +low-income neighbourhoods, \citet{mcmillen2011assessment} and
60 +\citet{mcmillen2008thin} in Illinois, where thin markets and infrequent
61 +reassessment emerge as structural drivers, \citet{hodge2017assessment} in
62 +collapsing Detroit, and \citet{plummer2014evidence} and
63 +\citet{ihlanfeldt2023appraisal} on the role of appeals and exemptions.
64 +\citet{ross2012assessor} shows regressivity responds to the institutional
65 +incentives of assessors themselves. The mechanism menu that emerges ---
66 +stale valuations, hard-to-value heterogeneous properties, thin markets,
67 +asymmetric appeals --- guides our heterogeneity analysis directly.
68 +
69 +\subsection{Canada: a conspicuous gap}
70 +
71 +For Canada the cupboard is nearly bare. Ontario's assessment corporation
72 +publishes an internal vertical-equity review of its own values
73 +\citep{mpac2022vertical}, and \citet{found2017property} compare tax
74 +\emph{rates} across cities, but neither measures sale-level equity.
75 +For Quebec, \citet{carbonnier2024property} establishes with survey microdata
76 +that property tax payments are steeply regressive relative to
77 +\emph{income}; the assessment channel --- whether the tax base itself is
78 +mismeasured against \emph{market value} --- has never been examined at
79 +scale, for Quebec or for any Canadian province. This is the gap the present
80 +paper fills. The Canadian case is of independent interest precisely because
81 +its institutions differ from the fragmented U.S. county system on the
82 +dimensions the U.S. literature blames: Quebec assessment is governed by a
83 +uniform provincial statute and manual \citep{lfm2026, mamh2024manuel},
84 +performed by certified professional evaluators, and synchronized to a
85 +statutory market-condition date. Finding large regressivity \emph{despite}
86 +this apparatus sharpens the interpretation considerably: the problem is not
87 +lax governance but the intrinsic difficulty of mass appraisal, compounded by
88 +triennial staleness.
89 +
90 +\subsection{Hedonic foundations}
91 +
92 +Finally, the paper connects to the hedonic tradition. Mass appraisal is
93 +applied hedonics \citep{rosen1974hedonic, malpezzi2003hedonic}: the
94 +assessor's model prices a bundle of structural and locational attributes,
95 +and equity failures are hedonic specification failures --- unpriced
96 +land-share gradients \citep{bostic2007land}, depreciation curvature, or
97 +neighbourhood effects that Quebec practitioners have long modelled in the
98 +Quebec City market \citep{desrosiers2000hedonic}. Reading our subgroup
99 +estimates through this lens turns an audit into a diagnosis: the assessment
100 +gap is largest exactly where the hedonic problem is hardest.
added paper/sections/methodology.tex +108 −0
@@ -0,0 +1,108 @@
1 +% Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +% ============================================================================
3 +\section{Methodology}
4 +\label{sec:method}
5 +
6 +Let $AV_i$ denote the assessed value of property $i$ on the roll in force
7 +at its sale, $SP_i$ its sale price, and $r_i = AV_i/SP_i$ the assessment
8 +ratio. Under perfectly proportional (vertically equitable) assessment,
9 +$\mathbb{E}[r_i \mid SP_i]$ is constant. All tests below ask, in different
10 +ways, whether $r_i$ instead declines with value.
11 +
12 +\subsection{IAAO ratio-study diagnostics}
13 +
14 +For any group of sales we report the four statistics of the IAAO
15 +\textit{Standard on Ratio Studies} \citep{iaao2013standard}. The
16 +\emph{median ratio} measures the assessment level. The \emph{coefficient of
17 +dispersion},
18 +$\mathrm{COD} = 100 \cdot \operatorname{mean}\!\left(|r_i -
19 +\tilde r|\right) / \tilde r$ with $\tilde r$ the median ratio, measures
20 +horizontal uniformity; the standard deems residential CODs above 15
21 +unacceptable. The \emph{price-related differential},
22 +$\mathrm{PRD} = \bar r \,/\, (\sum_i AV_i / \sum_i SP_i)$, compares the
23 +unweighted and value-weighted mean ratios; PRD~$>1.03$ indicates
24 +regressivity. Because the PRD is sensitive to outliers, the standard's
25 +preferred vertical measure is the \emph{coefficient of price-related bias}
26 +(PRB), the slope $b$ in
27 +\begin{equation}
28 +\frac{r_i - \tilde r}{\tilde r}
29 + \;=\; a + b \cdot \log_2\!\Big(\tfrac{1}{2}SP_i +
30 + \tfrac{1}{2}\,AV_i/\tilde r\Big) + u_i ,
31 +\label{eq:prb}
32 +\end{equation}
33 +which measures the proportional change in the ratio per doubling of value;
34 +the acceptable band is $[-0.05, 0.05]$. We compute percentile-bootstrap
35 +confidence intervals for all four statistics. Because ratio levels drift
36 +with roll staleness, municipality-level diagnostics are computed within
37 +municipality~$\times$~sale-year blocks --- inside which a single roll is in
38 +force --- and aggregated across years by the median.
39 +
40 +\subsection{Regression tests with market-timing fixed effects}
41 +
42 +Our workhorse is the log-log specification of \citet{cheng1974property},
43 +\begin{equation}
44 +\ln AV_i \;=\; \alpha_{c(i)} + \beta \, \ln SP_i + \varepsilon_i ,
45 +\label{eq:cheng}
46 +\end{equation}
47 +where $c(i)$ indexes the municipality~$\times$~roll~$\times$~sale-year cell
48 +of sale $i$ and the $\alpha_c$ are 2{,}884 absorbed fixed effects.
49 +Proportionality implies $\beta = 1$; we report
50 +$\gamma \equiv \beta - 1$, the elasticity of the assessment \emph{ratio}
51 +with respect to price ($\gamma<0$: regressive). The fixed effects guarantee
52 +that $\gamma$ is identified only from comparisons of dwellings assessed by
53 +the same authority, on the same roll vintage, and sold in the same year ---
54 +purging the staleness drift, municipal composition, and aggregate market
55 +movements in one stroke. For completeness we also report the pooled
56 +regression without fixed effects and the levels test of
57 +\citet{paglin1972equity}. Inference is clustered at the municipality level
58 +throughout (625 clusters).
59 +
60 +\subsection{Measurement error and the Clapp instrument}
61 +
62 +Sale prices measure market value with idiosyncratic noise --- bilateral
63 +bargaining, unobserved conditions of sale --- so OLS on
64 +equation~\eqref{eq:cheng} suffers attenuation bias: $\hat\beta < 1$ even
65 +under proportional assessment \citep{kochin1982vertical, kennedy1984unfair}.
66 +Following \citet{clapp1990new}, we instrument $\ln SP_i$ with a coarse rank
67 +variable $Z_i \in \{-1, 0, +1\}$ that flags sales in the bottom or top
68 +third of \emph{both} the within-cell $\ln AV$ and $\ln SP$ distributions.
69 +Because $Z_i$ retains only ordinal information agreed on by both measures of
70 +value, it is (nearly) orthogonal to the transitory component of either, and
71 +the resulting two-stage least-squares estimate of $\beta$ is consistent
72 +under classical measurement error. The demeaned-within-cell implementation
73 +preserves the fixed-effects structure. Throughout the paper we treat
74 +$\gamma_{\text{IV}}$ as a conservative lower bound on regressivity and
75 +$\gamma_{\text{FE}}$ as the descriptive upper bound; the truth lies between.
76 +
77 +\subsection{Quantile profile and heterogeneity}
78 +
79 +Averaging can hide where proportionality fails. We estimate
80 +equation~\eqref{eq:cheng} by quantile regression on within-cell demeaned
81 +variables at $\tau \in \{0.10, 0.25, 0.50, 0.75, 0.90\}$
82 +\citep{mcmillen2020assessment}, tracing $\beta(\tau)$ across the
83 +conditional distribution of assessed values. We then re-estimate the
84 +fixed-effects model on subsamples --- property class, building-age bands,
85 +assessed-land-share bands, roll-lag bands, municipality size, and sale year
86 +--- keeping only cells that retain at least 20 sales, to locate the
87 +inequity where the mass-appraisal problem is hardest.
88 +
89 +\subsection{Horizontal inequity}
90 +
91 +Vertical tests concern the \emph{mean} of $r_i$ given value; horizontal
92 +equity concerns its \emph{dispersion} among comparable properties
93 +\citep{allen2002measuring, sirmans2008vertical}. Beyond the COD, we ask
94 +who receives noisy assessments: we regress the absolute deviation of a
95 +sale's log ratio from its cell median,
96 +$|\ln r_i - \operatorname{med}_{c(i)} \ln r|$, on property characteristics
97 +(age, land share, property-class indicators) with cell fixed effects.
98 +
99 +\subsection{The implied tax shift}
100 +
101 +Because Quebec taxes the rolled value without exemptions or caps
102 +(Section~\ref{sec:inst}), a property whose ratio exceeds its jurisdiction's
103 +median by $x\%$ pays exactly $x\%$ more tax than uniform assessment would
104 +imply. For each sale we compute the relative assessment error
105 +$e_i = r_i / \operatorname{med}_{c(i)}(r) - 1$ and average it by
106 +within-cell sale-price decile. This translates the econometrics into the
107 +policy-relevant object: the percentage over- or under-payment of property
108 +tax by position in the local price distribution.
added paper/sections/results.tex +298 −0
@@ -0,0 +1,298 @@
1 +% Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +% ============================================================================
3 +\section{Results}
4 +\label{sec:results}
5 +
6 +\subsection{Province-wide diagnostics}
7 +
8 +Table~\ref{tab:iaao} reports the IAAO statistics for the full sample and by
9 +sale year. Three facts stand out. First, uniformity is poor everywhere: the
10 +province-wide COD is 28.5, and even within municipality-year blocks the
11 +median municipality posts a COD of 26 --- nearly twice the IAAO ceiling of
12 +15 for residential property; 99\% of municipalities exceed it
13 +(Figure~\ref{fig:cod}). Second, every vertical measure points the same way:
14 +the PRD is 1.072 (acceptable range 0.98--1.03) and the PRB is $-0.029$ with
15 +a bootstrap confidence interval far from zero --- ratios fall by about 3\%
16 +of the median with every doubling of value. Third, the time pattern is
17 +diagnostic: the PRB collapses to $-0.104$ and $-0.117$ in 2021 and 2022 ---
18 +when the boom pulled prices away from rolls referenced on 2018--2020
19 +markets --- and shrinks to $-0.002$ in 2024 before widening again. Vertical
20 +inequity is not a fixed institutional constant; it breathes with the gap
21 +between the market and the roll's vintage.
22 +
23 +\begin{table}[t]
24 +\centering
25 +\begin{threeparttable}
26 +\caption{IAAO ratio-study diagnostics, province-wide}
27 +\label{tab:iaao}
28 +\small
29 +\input{../results/tables/iaao}
30 +\begin{tablenotes}[flushleft]\footnotesize
31 +\item \textit{Notes:} Median assessment ratio, coefficient of dispersion
32 +(COD), price-related differential (PRD) and coefficient of price-related
33 +bias (PRB), computed on the estimation sample; 95\% percentile-bootstrap
34 +confidence intervals (200 replications) in brackets. IAAO acceptable
35 +ranges: COD $\le 15$ (single-family residential), PRD $\in [0.98, 1.03]$,
36 +PRB $\in [-0.05, 0.05]$.
37 +\end{tablenotes}
38 +\end{threeparttable}
39 +\end{table}
40 +
41 +\begin{figure}[t]
42 +\centering
43 +\includegraphics[width=\textwidth]{fig_cod.png}
44 +\caption{Horizontal uniformity. Panel A: distribution of municipality-level
45 +CODs (median across sale-year blocks); the IAAO ceiling for residential
46 +property is 15. Panel B: COD against the number of sales in the
47 +municipality; dispersion is worst in the thinnest markets.}
48 +\label{fig:cod}
49 +\end{figure}
50 +
51 +Table~\ref{tab:cities} disaggregates the ten largest markets.
52 +Regressivity is significant in nine of them --- Sherbrooke ($-0.39$),
53 +Trois-Rivi\`eres ($-0.32$) and Terrebonne ($-0.29$) are the worst --- in
54 +every single sale year. The exception is Montr\'eal, whose PRB of $+0.07$
55 +makes it the lone \emph{progressive} large jurisdiction in the province, in
56 +all six years. We return to this contrast in Section~\ref{sec:disc}.
57 +Figure~\ref{fig:map} maps the municipal estimates: regressive (red)
58 +municipalities carpet the province, with the Montr\'eal agglomeration a
59 +solitary blue island, and Figure~\ref{fig:prbmuni} shows the distribution:
60 +99\% of the 261 municipalities with at least 100 usable sales have a
61 +negative PRB, and 95\% fall below the IAAO band.
62 +
63 +\begin{table}[t]
64 +\centering
65 +\begin{threeparttable}
66 +\caption{Assessment equity in the ten largest markets}
67 +\label{tab:cities}
68 +\small
69 +\input{../results/tables/iaao_cities}
70 +\begin{tablenotes}[flushleft]\footnotesize
71 +\item \textit{Notes:} Statistics computed within municipality
72 +$\times$ sale-year blocks (a single roll in force per block) and
73 +aggregated across years by the median; $n$ is the total number of sales.
74 +The last column is the share of the six sale years in which the
75 +municipality's PRB is negative.
76 +\end{tablenotes}
77 +\end{threeparttable}
78 +\end{table}
79 +
80 +\begin{figure}[t]
81 +\centering
82 +\includegraphics[width=0.72\textwidth]{fig_prb_muni.png}
83 +\caption{Distribution of municipality-level PRB across the 261
84 +municipalities with at least 100 usable sales. The shaded band is the IAAO
85 +acceptable range $[-0.05, 0.05]$.}
86 +\label{fig:prbmuni}
87 +\end{figure}
88 +
89 +\begin{figure}[t]
90 +\centering
91 +\includegraphics[width=\textwidth]{fig_map.png}
92 +\caption{The geography of vertical inequity. Municipality-level PRB
93 +(median across sale-year blocks); bubble area is proportional to the
94 +square root of the number of sales. Montr\'eal is the only large
95 +progressive jurisdiction.}
96 +\label{fig:map}
97 +\end{figure}
98 +
99 +\subsection{Regression estimates}
100 +
101 +Table~\ref{tab:vertical} presents the econometric core.
102 +Figure~\ref{fig:binscatter} shows the raw material: within
103 +municipality~$\times$~roll~$\times$~year cells, mean log assessment ratios
104 +fall monotonically --- and remarkably linearly through the central 90\% of
105 +the distribution --- as log prices rise. The pooled \citet{cheng1974property}
106 +elasticity is $\beta = 0.812$ (s.e.\ 0.029); absorbing the 2{,}884 cell
107 +fixed effects \emph{steepens} it to $\beta = 0.656$, i.e.\
108 +$\gamma_{\text{FE}} = -0.344$: within the same market and vintage, a home
109 +priced 10\% above its neighbours is assessed only about 6.6\% higher, so
110 +its ratio is 3.4\% lower. The levels test of \citet{paglin1972equity}
111 +agrees: the intercept is \$31{,}729 (s.e.\ \$5{,}378) --- inexpensive
112 +properties carry a fixed assessment premium.
113 +
114 +\begin{table}[t]
115 +\centering
116 +\begin{threeparttable}
117 +\caption{Vertical-inequity regressions}
118 +\label{tab:vertical}
119 +\small
120 +\input{../results/tables/vertical}
121 +\begin{tablenotes}[flushleft]\footnotesize
122 +\item \textit{Notes:} Dependent variable: $\ln AV$. Column (1): pooled
123 +OLS. Column (2): absorbing least squares with 2{,}884 municipality
124 +$\times$ roll $\times$ sale-year fixed effects. Column (3): within-cell
125 +2SLS using the \citet{clapp1990new} rank instrument
126 +$Z \in \{-1,0,+1\}$. Standard errors clustered by municipality (625
127 +clusters) in parentheses. Stars test $H_0\!: \beta = 1$ (proportional
128 +assessment); *** $p<0.01$.
129 +\end{tablenotes}
130 +\end{threeparttable}
131 +\end{table}
132 +
133 +\begin{figure}[t]
134 +\centering
135 +\includegraphics[width=0.72\textwidth]{fig_binscatter.png}
136 +\caption{Vertical inequity within markets. Mean of the within-cell demeaned
137 +log assessment ratio by vigintile of the within-cell demeaned log sale
138 +price; whiskers are 95\% confidence intervals (they are smaller than the
139 +markers). The fitted line is the sale-weighted least-squares slope.}
140 +\label{fig:binscatter}
141 +\end{figure}
142 +
143 +How much of this is measurement-error artefact? The \citet{clapp1990new}
144 +instrument delivers $\beta_{\text{IV}} = 0.917$ (s.e.\ 0.021):
145 +attenuation indeed accounts for the majority of the na\"ive gap, exactly
146 +as the critique of \citet{kochin1982vertical} predicts --- but the
147 +corrected elasticity remains four standard errors below one. Genuine
148 +regressivity of roughly $-0.08$ survives the harshest correction in the
149 +literature. An 8\% ratio penalty per log-point of value is economically
150 +large: across the interquartile price range within a typical market
151 +(roughly 0.8 log points), it generates a 6--7\% differential in effective
152 +tax rates from vertical inequity alone, before adding the much larger
153 +dispersion documented below.
154 +
155 +\subsection{Where proportionality fails: the quantile profile}
156 +
157 +Figure~\ref{fig:quantile} plots $\beta(\tau)$. The profile is strikingly
158 +asymmetric: $\beta$ is 0.87 at $\tau=0.10$ and 0.86 at $\tau=0.25$, then
159 +falls to 0.70 at $\tau=0.75$ and 0.49 at $\tau=0.90$
160 +(Table~\ref{tab:quantile} in the robustness section reports the
161 +coefficients). Assessment tracks the market tolerably well through the
162 +middle of the distribution and loses contact at the top: the most expensive
163 +homes within each market are under-assessed at an accelerating rate. This
164 +is the signature of hedonic truncation --- mass-appraisal models built on
165 +mid-market comparables extrapolate poorly into thin luxury segments
166 +\citep{amornsiripanitch2022residential, gloudemans2011fundamentals}.
167 +
168 +\begin{figure}[t]
169 +\centering
170 +\includegraphics[width=0.72\textwidth]{fig_quantile.png}
171 +\caption{Quantile profile of the Cheng elasticity. Quantile regressions of
172 +within-cell demeaned $\ln AV$ on demeaned $\ln SP$; the shaded band is the
173 +pointwise 95\% confidence interval. Reference lines mark proportionality
174 +($\beta=1$) and the FE and IV point estimates.}
175 +\label{fig:quantile}
176 +\end{figure}
177 +
178 +\subsection{The anatomy of regressivity}
179 +
180 +Figure~\ref{fig:het} and Table~\ref{tab:het} dissect
181 +$\gamma_{\text{FE}}$ by subsample, and the pattern reads like a map of
182 +mass-appraisal difficulty:
183 +
184 +\begin{itemize}
185 +\item \textbf{Property class.} Condominiums --- homogeneous units with
186 +abundant comparables --- show $\gamma = -0.12$, single-family homes
187 +$-0.45$ and plexes $-0.49$. The assessor's problem, not the assessor's
188 +jurisdiction, drives the gradient.
189 +\item \textbf{Building age.} Regressivity deepens from $-0.30$ (under 20
190 +years) to $-0.43$ (over 60): depreciation and renovation heterogeneity are
191 +hard to observe from the roll.
192 +\item \textbf{Land share.} The starkest gradient: $-0.26$ where land is
193 +under 20\% of assessed value versus $-0.50$ where it exceeds 40\%.
194 +Land is the component without construction-cost anchoring
195 +\citep{bostic2007land}, and properties whose value is mostly land are
196 +assessed worst.
197 +\item \textbf{Municipality size.} Large markets ($>$10k sales) do
198 +better ($-0.27$) than mid-sized ones ($-0.41$), consistent with
199 +\citet{mcmillen2008thin}.
200 +\item \textbf{Stability.} Estimates are remarkably stable across sale
201 +years ($-0.32$ to $-0.38$) and roll lags --- unlike the \emph{level}
202 +statistics, the within-market elasticity is a structural feature of the
203 +valuation technology, not of the cycle.
204 +\end{itemize}
205 +
206 +\begin{figure}[p]
207 +\centering
208 +\includegraphics[width=0.85\textwidth]{fig_heterogeneity.png}
209 +\caption{Heterogeneity of the within-market elasticity
210 +$\gamma$. Each point is a separate fixed-effects estimate of
211 +equation~\eqref{eq:cheng} on the indicated subsample; whiskers are 95\%
212 +confidence intervals with municipality-clustered standard errors.}
213 +\label{fig:het}
214 +\end{figure}
215 +
216 +\begin{table}[p]
217 +\centering
218 +\begin{threeparttable}
219 +\caption{Heterogeneity of vertical inequity}
220 +\label{tab:het}
221 +\small
222 +\input{../results/tables/heterogeneity}
223 +\begin{tablenotes}[flushleft]\footnotesize
224 +\item \textit{Notes:} Cheng fixed-effects estimates of
225 +$\gamma = \beta - 1$ by subsample; cells with fewer than 20 sales after
226 +masking are dropped. Municipality-clustered standard errors in
227 +parentheses. *, **, *** denote significance at 10\%, 5\%, 1\%.
228 +\end{tablenotes}
229 +\end{threeparttable}
230 +\end{table}
231 +
232 +\subsection{Horizontal inequity}
233 +
234 +Table~\ref{tab:horizontal} reports the dispersion regression. The mean
235 +absolute deviation of a sale's log ratio from its cell median is 0.188 ---
236 +the typical Quebec dwelling is assessed 19\% away from its market's norm,
237 +an enormous horizontal lottery consistent with the COD evidence. The
238 +deviation rises by 0.75~log points per decade of building age and by
239 +5.2~points for cottages, and falls by 5.5~points for condominiums.
240 +Old, idiosyncratic, land-heavy properties receive not only biased but
241 +\emph{noisy} valuations.
242 +
243 +\begin{table}[t]
244 +\centering
245 +\begin{threeparttable}
246 +\caption{Horizontal inequity: who receives noisy assessments?}
247 +\label{tab:horizontal}
248 +\small
249 +\input{../results/tables/horizontal}
250 +\begin{tablenotes}[flushleft]\footnotesize
251 +\item \textit{Notes:} Dependent variable:
252 +$|\ln r_i - \operatorname{med}_{c(i)} \ln r|$. Absorbing least squares
253 +with cell fixed effects; municipality-clustered standard errors.
254 +Omitted class: single-family. *, **, *** denote significance at 10\%,
255 +5\%, 1\%.
256 +\end{tablenotes}
257 +\end{threeparttable}
258 +\end{table}
259 +
260 +\subsection{The implied tax shift}
261 +
262 +Figure~\ref{fig:taxshift} and Table~\ref{tab:taxshift} translate the
263 +estimates into tax dollars. Within the median market, the median dwelling
264 +in the bottom price decile is assessed 65\% above the local norm --- and
265 +therefore pays 65\% more property tax than uniform assessment would imply.
266 +The overpayment falls to $+7\%$ in the second decile and crosses zero at
267 +the median; the top decile underpays by about 5\%. The redistribution is
268 +strongly convex: the burden of assessment error is overwhelmingly
269 +concentrated on the cheapest tenth of the housing stock --- precisely the
270 +segment where owners have the least capacity to appeal and the highest
271 +housing-cost burdens. The magnitudes rival those documented for the U.S.
272 +by \citet{berry2021reassessing}, in a system with none of the U.S.'s
273 +institutional fragmentation.
274 +
275 +\begin{figure}[t]
276 +\centering
277 +\includegraphics[width=0.85\textwidth]{fig_taxshift.png}
278 +\caption{The implied property-tax shift. Median relative assessment error
279 +$e_i = r_i/\operatorname{med}_{c(i)}(r) - 1$ by within-market sale-price
280 +decile. Because the levy is proportional to the rolled value, $e_i$ equals
281 +the percentage over- or under-payment relative to uniform assessment.}
282 +\label{fig:taxshift}
283 +\end{figure}
284 +
285 +\begin{table}[t]
286 +\centering
287 +\begin{threeparttable}
288 +\caption{Excess tax burden by within-market price decile}
289 +\label{tab:taxshift}
290 +\small
291 +\input{../results/tables/taxshift}
292 +\begin{tablenotes}[flushleft]\footnotesize
293 +\item \textit{Notes:} Relative assessment error by within-cell sale-price
294 +decile. The mean is sensitive to extreme low-price sales; the median is
295 +the preferred summary. Standard errors of the mean in parentheses.
296 +\end{tablenotes}
297 +\end{threeparttable}
298 +\end{table}
added paper/sections/robustness.tex +86 −0
@@ -0,0 +1,86 @@
1 +% Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +% ============================================================================
3 +\section{Robustness}
4 +\label{sec:robust}
5 +
6 +Table~\ref{tab:robust} and Figure~\ref{fig:robust} re-estimate the two
7 +headline elasticities --- $\gamma_{\text{FE}}$ and the Clapp
8 +$\gamma_{\text{IV}}$ --- across ten sample and measurement variants;
9 +Table~\ref{tab:quantile} reports the quantile coefficients underlying
10 +Figure~\ref{fig:quantile}.
11 +
12 +\begin{table}[t]
13 +\centering
14 +\begin{threeparttable}
15 +\caption{Robustness of the vertical-inequity elasticities}
16 +\label{tab:robust}
17 +\small
18 +\input{../results/tables/robustness}
19 +\begin{tablenotes}[flushleft]\footnotesize
20 +\item \textit{Notes:} Each row re-estimates the Cheng fixed-effects and
21 +Clapp IV regressions on the indicated variant; cells with fewer than 20
22 +sales after restriction are dropped. Municipality-clustered standard
23 +errors in parentheses. *, **, *** denote significance at 10\%, 5\%, 1\%
24 +against $\gamma = 0$.
25 +\end{tablenotes}
26 +\end{threeparttable}
27 +\end{table}
28 +
29 +\begin{figure}[t]
30 +\centering
31 +\includegraphics[width=0.85\textwidth]{fig_robustness.png}
32 +\caption{Stability of $\gamma$ across sample variants. Filled circles:
33 +fixed-effects estimates; open squares: Clapp IV. Whiskers are 95\%
34 +confidence intervals, municipality-clustered.}
35 +\label{fig:robust}
36 +\end{figure}
37 +
38 +\begin{table}[t]
39 +\centering
40 +\begin{threeparttable}
41 +\caption{Quantile-regression coefficients}
42 +\label{tab:quantile}
43 +\small
44 +\input{../results/tables/quantile}
45 +\begin{tablenotes}[flushleft]\footnotesize
46 +\item \textit{Notes:} Quantile regressions of within-cell demeaned
47 +$\ln AV$ on demeaned $\ln SP$, estimated on a seeded 250{,}000-sale
48 +subsample. Stars test $H_0\!:\beta(\tau)=1$.
49 +\end{tablenotes}
50 +\end{threeparttable}
51 +\end{table}
52 +
53 +\paragraph{Match quality.} Requiring the maximum matcher score of 220
54 +(210{,}820 sales) or a match distance under 10~metres changes
55 +$\gamma_{\text{FE}}$ to $-0.41$ and $-0.36$ respectively, and
56 +$\gamma_{\text{IV}}$ to $-0.10$ and $-0.09$: if anything, the cleanest
57 +matches show \emph{more} regressivity, ruling out mismatch noise as the
58 +source.
59 +
60 +\paragraph{Extreme sales.} Dropping all sales under \$100{,}000 --- the
61 +segment most likely to harbour residual non-arm's-length transfers ---
62 +attenuates the FE estimate modestly ($-0.30$) and the IV to $-0.05$, both
63 +still overwhelmingly significant. Tightening the ratio trim to the 5th--95th
64 +percentiles, which mechanically compresses dispersion, cuts
65 +$\gamma_{\text{FE}}$ to $-0.15$ and $\gamma_{\text{IV}}$ to $-0.04$; the
66 +qualitative conclusion is unchanged, and the ordering
67 +$|\gamma_{\text{IV}}| < |\gamma_{\text{FE}}|$ is preserved in every
68 +variant, as the measurement-error logic requires.
69 +
70 +\paragraph{Composition.} Single-family homes alone give
71 +$\gamma_{\text{FE}} = -0.45$; condominiums alone $-0.12$ (with an IV
72 +estimate indistinguishable from zero), confirming that the aggregate
73 +result is not an artefact of pooling heterogeneous property classes ---
74 +each class is regressive or neutral on its own, none is progressive.
75 +
76 +\paragraph{Time and market depth.} Splitting the window into 2021--2023
77 +and 2024--2026 yields $-0.35$ and $-0.34$ (FE); restricting to
78 +municipalities with at least 300 sales, or to cells with at least 50,
79 +moves the estimates by less than 0.005. The phenomenon is stable across
80 +the largest housing-cycle swing in recent Canadian history.
81 +
82 +\paragraph{Inference.} With 625 municipality clusters, the
83 +cluster-robust $t$-statistics on $\gamma_{\text{FE}}$ exceed 11 in every
84 +variant; the IV first stage is enormous (the rank instrument correlates
85 +with within-cell log price at $F$ far above conventional thresholds), so
86 +weak-instrument concerns do not arise.
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 +8 −0
@@ -0,0 +1,8 @@
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 +statsmodels==0.14.6
7 +linearmodels==7.0
8 +matplotlib==3.10.9
added results/reproduced/binscatter.csv +21 −0
@@ -0,0 +1,21 @@
1 +bin,x,y,se,n
2 +0,-1.1299388141576843,0.7401279154992733,0.003196992348982775,26139
3 +1,-0.6850118092956418,0.2574425293076139,0.0025583543636468675,26138
4 +2,-0.5073601868074681,0.10436219388765414,0.0020528587799829565,26153
5 +3,-0.39073743914550024,0.047860490092793556,0.001736111026990563,26124
6 +4,-0.30065659167407555,0.016570011132268175,0.0015548062368614822,26139
7 +5,-0.22425213523591567,-0.002513336220497968,0.0014291012118165137,26156
8 +6,-0.15838142150231835,-0.020241310853057217,0.0013378404347182862,26124
9 +7,-0.09992669391808244,-0.03963672880796096,0.0012165744895958942,26148
10 +8,-0.04710053458833093,-0.053894099650792227,0.001160689913664137,26200
11 +9,0.002122479477476558,-0.06362384340772381,0.0011248344303339988,26066
12 +10,0.049307323000753994,-0.07312651316133213,0.0010811167525577004,26137
13 +11,0.09626979736571095,-0.08352363160551252,0.001065184297317105,26142
14 +12,0.14419727346672664,-0.08918200392095645,0.0010663579136722928,26136
15 +13,0.19538855584502757,-0.09595623188857955,0.0010518993779298034,26136
16 +14,0.2517919431123114,-0.09939639689803642,0.0010652961471458247,26160
17 +15,0.31579361306351245,-0.10044775245396857,0.0010607621841663896,26122
18 +16,0.3909496467065449,-0.1001064460067215,0.0010745180988978066,26136
19 +17,0.48726172323242867,-0.10229496211595199,0.0010849459604205768,26136
20 +18,0.6279334261827585,-0.10814069396200333,0.0010932289509538405,26138
21 +19,0.9827359705440116,-0.13437611742305566,0.0011936815957342471,26139
added results/reproduced/heterogeneity.csv +23 −0
@@ -0,0 +1,23 @@
1 +group,gamma,se,n
2 +Single-family,-0.4537825487653664,0.017669914962190263,337855
3 +Condominium,-0.12464520941899382,0.01518233161943941,77976
4 +Plex (2–5 units),-0.48721753704516557,0.054393819641049175,69745
5 +Cottage,-0.28835281011784275,0.026012993411055806,2114
6 +Age < 20 y,-0.29681001316776945,0.04138996753677022,117898
7 +Age 20–60 y,-0.3838945768600194,0.03428322120554528,227857
8 +Age > 60 y,-0.4348917536559145,0.027878597780328678,108049
9 +Land share < 0.2,-0.26159665555927447,0.04182768275315659,119718
10 +Land share 0.2–0.4,-0.37212541838852264,0.03209301087096023,236923
11 +Land share > 0.4,-0.5032586691495551,0.030343186599237057,125714
12 +Roll lag < 24 m,-0.33521036537225046,0.027518136511668685,86001
13 +Roll lag 24–48 m,-0.3383010361647635,0.03253068680640818,327031
14 +Roll lag > 48 m,-0.36120558524361845,0.026496805098668315,91367
15 +Muni < 1k sales,-0.37853163229754105,0.007225289628016334,125139
16 +Muni 1k–10k sales,-0.40556881854947346,0.013683713072741294,185111
17 +Muni > 10k sales,-0.26678422681660763,0.04292003298652767,212519
18 +Sales 2021,-0.31582561216354454,0.02741379492805952,115221
19 +Sales 2022,-0.37986236880270274,0.03017363968613998,90627
20 +Sales 2023,-0.365252633545463,0.03376287524528814,74110
21 +Sales 2024,-0.33272608196080145,0.03235286456948262,89204
22 +Sales 2025,-0.33508012882403526,0.026198875018876387,102026
23 +Sales 2026,-0.3494522195906299,0.029905254010043902,51581
added results/reproduced/horizontal.csv +6 −0
@@ -0,0 +1,6 @@
1 +,coef,se,tstat
2 +age_dec,0.007468884136129831,0.0012336871606707124,6.0541151551494305
3 +land_share,0.0030967138766707955,0.02979602772694477,0.10393042673505146
4 +is_condo,-0.055121433754366414,0.0073939373971805885,-7.4549500209976065
5 +is_plex,0.02472595832635656,0.015081161360430577,1.6395261436053368
6 +is_cottage,0.0520317625932596,0.007306467785143703,7.121329228201921
added results/reproduced/horizontal_meta.csv +4 −0
@@ -0,0 +1,4 @@
1 +,0
2 +n,495092.0
3 +r2,0.04549344925077059
4 +mean_dep,0.18776662312587053
added results/reproduced/iaao_cities.csv +11 −0
@@ -0,0 +1,11 @@
1 +muni,n,n_years,median_ratio,cod,prd,prb,share_years_prb_neg
2 +Gatineau,21270,6,0.7984077215662176,16.931554716735143,1.0495027784666382,-0.13658868222945214,1.0
3 +Laval,23569,6,0.7292941176470589,20.99004120848114,1.07591200087497,-0.1677198430090882,1.0
4 +Longueuil,15343,6,0.6993231963838167,22.936569556574902,1.073902247304333,-0.12759857852649836,1.0
5 +Lévis,10622,6,0.8274403990009007,22.318404178238602,1.0892991972930153,-0.23689185802043072,1.0
6 +Montréal,79581,6,0.8922306974762114,24.895070443807846,1.0571629439855559,0.06966324444056746,0.0
7 +Québec,39927,6,0.8088761174968071,22.021089492553433,1.0711624215910516,-0.04798363355653862,1.0
8 +Saguenay,10794,6,0.7872989417989418,19.073245445985677,1.0638779683601711,-0.18854120739412214,1.0
9 +Sherbrooke,11413,6,0.7435681818181819,30.520246620851758,1.1372039979494142,-0.3872307527051138,1.0
10 +Terrebonne,8061,6,0.7949895317396622,23.75303561732275,1.0961290692775711,-0.28961986412143703,1.0
11 +Trois-Rivières,9070,6,0.7291707983633229,29.363374404233365,1.1225282017869498,-0.3166532132665827,1.0
added results/reproduced/iaao_muni.csv +262 −0
@@ -0,0 +1,262 @@
1 +muni,n,n_years,median_ratio,cod,prd,prb,share_years_prb_neg,lat,lng,name
2 +01023,539,5,0.6673333333333333,50.91474528551908,1.2248771217060996,-0.34468825127789776,1.0,47.373297415,-61.892867091,Les Îles-de-la-Madeleine
3 +02028,193,3,0.7335620300751879,24.924431206109645,1.0698721233673953,-0.18103197862957446,1.0,48.348539266,-64.682155177,Chandler
4 +03005,631,6,0.8679785511938487,32.09747197510151,1.1090280708711022,-0.18068585618165026,1.0,48.837329995,-64.464568935,Gaspé
5 +04037,400,5,0.6966666666666667,24.657677166061557,1.044320194185855,-0.029292963930198,0.8,49.125795114,-66.48063584,Sainte-Anne-des-Monts
6 +07047,154,2,0.8250403510712454,18.715867104102017,1.0429833315137729,-0.05918136199537266,1.0,48.4661869825,-67.4294386585,Amqui
7 +08053,924,6,0.7849542717864635,22.961756490553256,1.0676455781752932,-0.12514845360658441,1.0,48.844493941500005,-67.523459035,Matane
8 +09077,416,5,0.7596562499999999,21.291435789055335,1.0548283361050281,-0.12982997825324094,1.0,48.587353906,-68.191464128,Mont-Joli
9 +10043,2952,6,0.7817020537241743,32.027913818017566,1.1328015504123914,-0.3029817279665089,1.0,48.436574925,-68.5190762055,Rimouski
10 +12015,119,2,0.772315153471027,15.183800703538104,1.0416125937656937,-0.058507880001827606,1.0,47.773246705999995,-69.4710944155,Saint-Antonin
11 +12072,1128,6,0.7966340986130649,14.969808769491877,1.0265360685354072,-0.06693134453658334,1.0,47.8276978835,-69.5320477905,Rivière-du-Loup
12 +13073,143,2,0.7872480720728201,19.48365920891809,1.0294657084213812,-0.02696053619823202,0.5,47.679574698,-68.879280856,Témiscouata-sur-le-Lac
13 +15013,626,6,0.7636936026936028,23.977174989511976,1.084659312941719,-0.13630189728350917,0.6666666666666666,47.653839902499996,-70.154518171,La Malbaie
14 +16013,238,3,0.8511296296296296,32.56224604586135,1.0957467754441717,-0.1674954492469384,1.0,47.4433681525,-70.51061298799999,Baie-Saint-Paul
15 +18050,669,6,0.815526698145736,19.519300312098004,1.048588309170369,-0.0816733383768248,1.0,46.977625381,-70.557680462,Montmagny
16 +19062,226,4,0.8106281573263143,27.439661292313254,1.1094424684068356,-0.4294672773533285,0.75,46.63032661,-70.97501990949999,Saint-Anselme
17 +19068,301,4,0.8489275767899632,26.548869666269344,1.107642649780156,-0.29915439960145335,1.0,46.690995181,-71.07396918399999,Saint-Henri
18 +19105,101,2,0.847860831323592,23.395844030649442,1.1136408119218577,-0.2104739818721205,1.0,46.830723019000004,-71.00647754799999,Beaumont
19 +21010,483,5,0.776255707762557,25.54827289959927,1.0746565664467242,-0.007935727616828097,0.6,47.088081377500004,-70.88400634300001,Saint-Ferréol-les-Neiges
20 +21025,414,5,0.7632876712328767,19.1893209073912,1.0676453235538235,-0.042170544608549236,0.8,47.0531188095,-70.8950832745,Beaupré
21 +21035,283,4,0.8476034069736491,22.82023013038416,1.08235603365272,-0.23215142029214486,1.0,46.976472478000005,-71.0231973965,Château-Richer
22 +21045,709,6,0.8284982561670373,17.98839469618786,1.067921620148053,-0.08884275574512873,1.0,46.901759032,-71.143656871,Boischatel
23 +22005,860,6,0.7824572474617508,25.34594286142555,1.1054442494360623,-0.2780885837540912,1.0,46.8442399275,-71.6147577,Sainte-Catherine-de-la-Jacques-Cartier
24 +22010,111,2,0.7879374902272098,22.278392800147888,1.0942267327131154,-0.14510322400523507,1.0,46.880712103,-71.6133648635,Fossambault-sur-le-Lac
25 +22020,561,6,0.7876398038915275,27.994845862418558,1.13734252635859,-0.4505269529039112,1.0,46.883512871,-71.532064949,Shannon
26 +22035,931,6,0.7901038482590206,24.17844235537976,1.0956374310640464,-0.19103461724047766,1.0,47.008459672,-71.375813659,Stoneham-et-Tewkesbury
27 +22040,718,6,0.7630942346287174,28.260839719893976,1.1238613719535708,-0.20672851511595283,1.0,46.9485322015,-71.300988286,Lac-Beauport
28 +22045,904,6,0.7989815294450779,19.48589386774982,1.0674612417675986,-0.2483627463824416,1.0,46.996448249,-71.2022620465,Sainte-Brigitte-de-Laval
29 +23027,39927,6,0.8088761174968071,22.021089492553433,1.0711624215910516,-0.04798363355653862,1.0,46.845432242,-71.284341623,Québec
30 +23057,1256,6,0.7958226723756803,20.83415657438064,1.0839279319598174,-0.32141718627637206,1.0,46.8058055155,-71.3587655525,L'Ancienne-Lorette
31 +23072,1494,6,0.7916239054702947,18.91738691312515,1.0844010250630294,-0.1522959320199167,1.0,46.741946145,-71.392724151,Saint-Augustin-de-Desmaures
32 +25213,10622,6,0.8274403990009007,22.318404178238602,1.0892991972930153,-0.23689185802043072,1.0,46.734125024,-71.21804311950001,Lévis
33 +26030,817,6,0.8015482334259627,24.138578405030643,1.0968411674968217,-0.2225915996902068,1.0,46.446288901,-71.019009516,Sainte-Marie
34 +26070,433,5,0.8462264150943396,33.3912910416355,1.118002113482546,-0.3059912138982086,1.0,46.587031214,-71.222876646,Saint-Lambert-de-Lauzon
35 +27028,239,4,0.8086742424242425,29.11413633851084,1.123900641050577,-0.35346427459067,1.0,46.2083331485,-70.7789057075,Beauceville
36 +27043,221,4,0.7670741607157514,26.12427786850234,1.1054264617213942,-0.1822426203555372,1.0,46.307911006,-70.8754787755,Saint-Joseph-de-Beauce
37 +28020,118,2,0.7477937325513451,36.432113509221125,1.1462049821936644,-0.21638507574156152,1.0,46.212335225000004,-70.483038276,Saint-Prosper
38 +28053,264,4,0.7347851427131247,17.35436494841649,1.0454657317728653,-0.03620096106684201,1.0,46.397102893500005,-70.5098076245,Lac-Etchemin
39 +29073,2196,6,0.8083449413489736,25.755695026295424,1.0770891060813792,-0.10798779415583887,0.8333333333333334,46.1141325115,-70.66347340850001,Saint-Georges
40 +30030,367,5,0.7629411764705882,23.40617277896169,1.0725742458683925,-0.056711080582011186,0.8,45.580061028,-70.88744873600001,Lac-Mégantic
41 +31084,2083,6,0.7679633740288568,23.200578245790716,1.0632822378115123,-0.07864610355357327,1.0,46.100656922,-71.298757211,Thetford Mines
42 +32033,381,5,0.7161185770750988,25.36432789859908,1.0724563014146264,-0.22461012647627657,1.0,46.168170629,-71.880109919,Princeville
43 +32043,133,2,0.7078918774437928,21.547516555339485,1.0467423122026374,-0.05363017912735923,1.0,46.220329115,-71.77564931,Plessisville
44 +33035,118,2,0.813110300081103,24.25198091954615,1.1075171862309936,-0.4105833065487351,1.0,46.510338575,-71.363735332,Saint-Gilles
45 +33045,365,5,0.7725760286225403,24.777323647820566,1.093959945902275,-0.27699719905934855,1.0,46.558726373,-71.439719591,Saint-Agapit
46 +33090,801,6,0.7834754939542901,21.722636341184472,1.079801411095684,-0.18195529622856327,1.0,46.610267401,-71.504652032,Saint-Apollinaire
47 +34007,275,4,0.8409123376623376,23.602702704062967,1.100676310204747,-0.2278918738548532,1.0,46.7100307405,-71.585762454,Neuville
48 +34017,870,6,0.7965175280806989,23.617933054488034,1.0936190199222806,-0.3870384304969796,1.0,46.7532657545,-71.6892814165,Pont-Rouge
49 +34025,625,6,0.8213156424581005,22.5154051048244,1.0804485119666485,-0.1853439391006324,1.0,46.674770253,-71.722938844,Donnacona
50 +34030,127,2,0.8109785171936221,26.431353685573384,1.1088532854538589,-0.2728652908822181,1.0,46.6753289075,-71.76630738899999,Cap-Santé
51 +34048,200,3,0.8914040114613181,22.4419217687073,1.0777283157443607,-0.16351424193035438,1.0,46.707453458,-71.887872363,Portneuf
52 +34128,1058,6,0.7943707552421599,33.892653129910514,1.1411084126189521,-0.16653534972200065,1.0,46.887098,-71.817427818,Saint-Raymond
53 +36033,4203,6,0.7286444444444444,25.881872445103877,1.0841432224933971,-0.11015824114165505,1.0,46.573502159,-72.735323152,Shawinigan
54 +37067,9070,6,0.7291707983633229,29.363374404233365,1.1225282017869498,-0.3166532132665827,1.0,46.3598810855,-72.573001212,Trois-Rivières
55 +37235,379,6,0.698388888888889,34.58912731014522,1.1494863386313716,-0.19023003788389053,1.0,46.473336473,-72.681230831,Notre-Dame-du-Mont-Carmel
56 +38010,953,6,0.7378636526665802,29.34676894055913,1.1041531520581835,-0.11951993425484558,1.0,46.331892837,-72.436542647,Bécancour
57 +39062,3397,6,0.7725079365079366,21.167131268805907,1.0590575297473819,-0.11969422015713542,1.0,46.056750684,-71.955100058,Victoriaville
58 +40043,645,6,0.7140458839406207,25.050406547460213,1.081643635149071,-0.22814897980200044,1.0,45.774533753,-71.932177531,Val-des-Sources
59 +40047,236,4,0.6974422000860849,33.23499525532314,1.1015789067458372,-0.11661627086349244,1.0,45.785007603500006,-72.01360457550001,Danville
60 +41038,185,3,0.6448275862068965,38.606331978071005,1.1660730742063186,-0.3968719817353335,1.0,45.3844633695,-71.6295519155,Cookshire-Eaton
61 +41060,196,3,0.8599224137931034,35.53421652858063,1.1691374253389413,-0.4351925732778529,1.0,45.485394948,-71.6580871,East Angus
62 +41098,195,3,0.6753623188405797,31.53311390998279,1.1320286579703374,-0.17149384178633773,1.0,45.69994358,-71.445326867,Weedon
63 +42025,362,5,0.7549885680965192,35.203169940963946,1.1763525360553093,-0.24823982708549983,1.0,45.450530426,-72.090137863,Saint-Denis-de-Brompton
64 +42088,339,5,0.7247393919144671,25.079939475478415,1.088690267817179,-0.2944486676543964,1.0,45.572104515,-72.000885705,Windsor
65 +42098,120,2,0.7598296152643589,28.60356720524078,1.0901799648126935,-0.18915439032617962,1.0,45.661565373,-72.13979631699999,Richmond
66 +43027,11413,6,0.7435681818181819,30.520246620851758,1.1372039979494142,-0.3872307527051138,1.0,45.392633295,-71.930715774,Sherbrooke
67 +44037,630,6,0.7636818947432714,39.103320928672524,1.174166223587914,-0.5171929791954604,1.0,45.13080419,-71.80705083449999,Coaticook
68 +45030,135,2,0.7746368795305014,24.602025504275613,1.0656614832850893,-0.07064281825699523,1.0,45.0788365575,-72.3409277395,Potton
69 +45072,2576,6,0.6996702637889688,28.74585155682162,1.1231887982499333,-0.16961190181816477,1.0,45.269992867,-72.151298396,Magog
70 +45085,176,3,0.8217012726054923,29.60658953091504,1.1136327841600513,-0.05055215467062311,0.6666666666666666,45.254333104,-72.261598675,Austin
71 +45093,193,3,0.5888311688311688,37.122686831974555,1.160332247800896,-0.2906102901008709,1.0,45.313381840999995,-72.300395944,Eastman
72 +45115,679,6,0.6063801904310379,29.630617868711955,1.1389419969522057,-0.12787910511482986,1.0,45.316923081,-72.167720393,Orford
73 +46058,440,6,0.6716763687600644,35.0399114099244,1.1644503220221871,-0.3021844843769021,1.0,45.105737911,-72.6047236695,Sutton
74 +46075,507,5,0.6399901315789474,27.55162268404918,1.1477159758154432,-0.1968557522728025,1.0,45.238537717,-72.533189645,Lac-Brome
75 +46078,988,6,0.7235671402106036,25.82041311803009,1.1175358334345749,-0.20549848425065131,1.0,45.3095708915,-72.6607766245,Bromont
76 +46080,996,6,0.7869125951584968,26.198853324684954,1.1147771543579075,-0.3610004408751044,1.0,45.207637269,-72.74552933449999,Cowansville
77 +46112,860,6,0.7050468809797168,31.438957313985355,1.1301858043558206,-0.3655193836711927,1.0,45.2805323405,-72.97625546500001,Farnham
78 +47017,4901,6,0.7294086689011172,29.52259191115126,1.1118951327046869,-0.31021184964998916,1.0,45.400759665,-72.731849819,Granby
79 +47025,431,5,0.7389595410628019,30.393345512426347,1.1426260017062118,-0.6243582754165377,0.8,45.340766011,-72.523795612,Waterloo
80 +47035,551,6,0.6814398233345602,38.47509613172598,1.2034380183091464,-0.36554592129527963,0.8333333333333334,45.363765456,-72.617788786,Shefford
81 +47047,362,5,0.7858909678137878,42.382459905281635,1.2008030800169927,-0.3865934883575038,1.0,45.472375239,-72.66004383949999,Roxton Pond
82 +48028,559,6,0.7543902258274068,28.018807118374035,1.1091013787799113,-0.21701741650534076,1.0,45.647339981,-72.567065213,Acton Vale
83 +49048,181,3,0.7025,30.409774665224678,1.1497028833165948,-0.2823453496491139,0.6666666666666666,45.839364424,-72.56928357550001,Saint-Germain-de-Grantham
84 +49058,5438,6,0.780937265257802,28.156173910893166,1.0985192856127424,-0.15805926106005436,1.0,45.8636461665,-72.488506331,Drummondville
85 +49070,240,4,0.7331632399064072,28.26332419308384,1.0959426599044875,-0.17573180014727086,0.75,45.9286980065,-72.42569711600001,Saint-Cyrille-de-Wendover
86 +50072,536,6,0.7499419147961521,23.653935663973613,1.072662630300501,-0.11256109410431805,0.8333333333333334,46.234372841500004,-72.606647624,Nicolet
87 +51015,428,5,0.7371850087593252,36.04550143376593,1.1358027356965468,-0.22142935485853846,1.0,46.254489091,-72.945032821,Louiseville
88 +51065,343,5,0.7208805031446541,34.97788003353477,1.1447463757537515,-0.1445638307342712,0.8,46.4667905365,-73.147176442,Saint-Alexis-des-Monts
89 +51085,246,4,0.7110915228807202,36.60035597094894,1.1535195526106898,-0.27891311631728755,1.0,46.499025471,-72.826011591,Saint-Boniface
90 +51090,176,3,0.6595238095238095,46.32063307271163,1.2066301454514747,-0.2955329419528453,1.0,46.438600727,-72.7760082695,Saint-Étienne-des-Grès
91 +52007,1351,6,0.7811588002873564,18.604861636030577,1.0581659181587162,-0.25745548213469116,1.0,45.883310054,-73.289400883,Lavaltrie
92 +52017,454,6,0.7424664099602105,27.458078813029132,1.106305536658132,-0.23829756434408023,1.0,45.9622651545,-73.2234576355,Lanoraie
93 +52035,200,3,0.7861176470588235,22.655549017167036,1.091352261310293,-0.25046959637460336,1.0,46.083254857,-73.180119373,Berthierville
94 +52080,119,2,0.6955672547533602,28.708856460143494,1.0711018490504467,-0.0813118223932662,1.0,46.295695435,-73.38626004,Saint-Gabriel
95 +52085,177,3,0.727027027027027,27.88324615835368,1.0925763371789967,-0.14670307401976548,1.0,46.299322264,-73.400681704,Saint-Gabriel-de-Brandon
96 +52095,259,4,0.8312473389237706,30.540380247851083,1.1357915288335463,-0.14562631914040758,0.75,46.3822765345,-73.372126772,Mandeville
97 +53040,125,2,0.7212600732600732,20.859605403839247,1.0397414336249162,0.0195953827442501,0.5,45.89315123,-73.159513486,Saint-Roch-de-Richelieu
98 +53052,2900,6,0.7163174603174602,22.015700026909695,1.069732650385139,-0.15586730118239517,1.0,46.025134898999994,-73.120790133,Sorel-Tracy
99 +54008,509,6,0.7409696934629468,36.175329889110756,1.1485863416499411,-0.31318822759192966,1.0,45.506691818,-72.89942077,Saint-Pie
100 +54048,3188,6,0.7215852526947326,29.983735016934077,1.120637745311388,-0.4548109525949386,1.0,45.624822898000005,-72.94903571949999,Saint-Hyacinthe
101 +55023,436,6,0.7155175586947871,35.10632381575468,1.1429899815511533,-0.5190053063839385,1.0,45.4122608645,-73.0083647985,Saint-Césaire
102 +55048,988,6,0.6665826990500734,27.68549680670662,1.115740440522321,-0.3310324780296,1.0,45.431773133,-73.15662568,Marieville
103 +55057,210,3,0.7058823529411765,39.22349855731835,1.1791480122856313,-0.5927454024538161,1.0,45.437899329,-73.245698844,Richelieu
104 +55065,250,4,0.7067612524461839,32.49284470613207,1.132452062518392,-0.3582422420168257,1.0,45.4843284675,-73.2594419175,Saint-Mathias-sur-Richelieu
105 +56005,182,3,0.6952126233702937,25.981522684659765,1.0532080535284765,-0.004549080929276983,0.6666666666666666,45.075641823,-73.152962375,Venise-en-Québec
106 +56023,100,2,0.6628845451458486,32.81492586289752,1.1049183792145993,-0.159649344387989,1.0,45.078495171,-73.3724631485,Lacolle
107 +56083,6860,6,0.6857290061587995,26.58305679732141,1.1033053033841163,-0.27318761992146323,1.0,45.3173844665,-73.26839254449999,Saint-Jean-sur-Richelieu
108 +57005,2466,6,0.7958329542147105,27.67591821449598,1.1284734909133656,-0.30965970135070986,1.0,45.4386584325,-73.29915464550001,Chambly
109 +57010,1010,6,0.6948232586712788,26.000694608760995,1.1148493785861135,-0.2039855201907288,1.0,45.458347274,-73.33319411650001,Carignan
110 +57020,1203,6,0.662749373433584,28.651959329107765,1.1198338888029058,-0.36229490745193826,1.0,45.524009139,-73.289582455,Saint-Basile-le-Grand
111 +57025,348,5,0.665,26.685732093244475,1.1124067946453324,-0.6607363263019816,1.0,45.54996469,-73.23327609750001,McMasterville
112 +57030,744,6,0.6713241217798596,22.627379704392297,1.09090788751882,-0.3634820536670958,1.0,45.539227893,-73.2053586795,Otterburn Park
113 +57035,1502,6,0.7662368421052632,23.948325462191995,1.0928237725547036,-0.15912137452024816,1.0,45.5703179335,-73.18333605250001,Mont-Saint-Hilaire
114 +57040,2016,6,0.765013455787344,27.043523508117854,1.11590548883787,-0.5387275185042903,1.0,45.5766494225,-73.21203484899999,Beloeil
115 +58007,4943,6,0.7298304235121789,18.157822443347143,1.0517755883825637,-0.07511871550042507,1.0,45.456964004,-73.460176368,Brossard
116 +58012,1479,6,0.764075235109718,19.23169014322753,1.0562801725449358,-0.015803048035999905,0.8333333333333334,45.49607166,-73.502201617,Saint-Lambert
117 +58033,2818,6,0.7214520247728389,28.282212165129522,1.1241439694492887,-0.30088808757487173,1.0,45.5948568775,-73.43997271149999,Boucherville
118 +58037,1868,6,0.7155095152983274,23.403401825301806,1.0981337097937294,-0.2023309864341323,1.0,45.526278876999996,-73.343779198,Saint-Bruno-de-Montarville
119 +58227,15343,6,0.6993231963838167,22.936569556574902,1.073902247304333,-0.12759857852649836,1.0,45.511886497,-73.455769263,Longueuil
120 +59010,2186,6,0.6892368654082559,27.101368542651706,1.1305064886095173,-0.4208004484202777,1.0,45.58665754,-73.34013155,Sainte-Julie
121 +59015,969,5,0.6941578947368421,27.55841878174844,1.1062269197523142,-0.2654275934202815,1.0,45.641254737,-73.301101941,Saint-Amable
122 +59020,1445,6,0.699660685296846,24.833384416142934,1.1064585476041848,-0.3238212478103721,1.0,45.680047018,-73.42792562,Varennes
123 +59025,316,5,0.714789419619928,37.91237662771402,1.1764206667175272,-0.6104876127849174,1.0,45.7716982595,-73.359030094,Verchères
124 +59035,797,5,0.6441,20.7123316545552,1.0731133733528522,-0.20195773491992464,1.0,45.85806846,-73.228782724,Contrecoeur
125 +60005,358,5,0.7413559322033898,16.885494403406323,1.0479257422809722,-0.19259103274095157,0.8,45.726849001,-73.489765653,Charlemagne
126 +60013,6713,6,0.6672872479706728,21.0598366386061,1.074717623319002,-0.14803103612872476,1.0,45.763412589,-73.452409152,Repentigny
127 +60028,1816,6,0.6641461896639159,26.47252266986331,1.098006926018187,-0.37232052536630766,1.0,45.847046872,-73.42668152600001,L'Assomption
128 +60037,725,6,0.8081229328680933,26.03219665844953,1.1007082495328808,-0.4117621700398755,1.0,45.850995196,-73.493655426,L'Épiphanie
129 +61005,560,6,0.7581785130423317,25.33968913013652,1.0920848292142917,-0.2066034333520754,1.0,45.993637825,-73.44000416099999,Saint-Paul
130 +61013,171,3,0.6333333333333333,34.26526659043471,1.1392978456036058,-0.3231291053094608,1.0,45.966378601,-73.476801969,Crabtree
131 +61025,1239,6,0.7232373306724722,21.78002201339599,1.0773392447696346,-0.21467207149062822,1.0,46.019966328,-73.442065366,Joliette
132 +61030,714,6,0.753252407704655,19.795712908840187,1.0674690275569825,-0.2114305898722327,0.8333333333333334,46.0511858175,-73.43218235,Notre-Dame-des-Prairies
133 +61035,718,6,0.7409494773519164,20.1414097732401,1.046712724191733,-0.12750349507855074,1.0,46.048532085,-73.46625851499999,Saint-Charles-Borromée
134 +61040,116,2,0.8286152882205513,33.12170191273214,1.1315083813730664,-0.3073266401447655,1.0,46.0796626785,-73.5277993025,Saint-Ambroise-de-Kildare
135 +61050,119,2,0.6857176850171405,27.477878878609587,1.074415485857116,-0.17338003774365124,1.0,46.135610829,-73.52854585,Sainte-Mélanie
136 +62007,548,6,0.7623781687762731,25.150478859407567,1.0750411207032586,-0.14234524882818494,0.8333333333333334,46.1652538545,-73.438296302,Saint-Félix-de-Valois
137 +62015,491,5,0.7808888888888889,30.881862719342106,1.113263005777294,-0.155376216739658,0.8,46.254256657,-73.536897228,Saint-Jean-de-Matha
138 +62020,112,2,0.7460389512337418,30.265828679987003,1.0932190159392952,-0.07945162393825822,1.0,46.193276397,-73.61716932,Sainte-Béatrix
139 +62037,1364,6,0.7565606494746896,27.04102518019009,1.1077998636864796,-0.16603887711981763,1.0,46.048327736999994,-73.725588486,Rawdon
140 +62047,916,6,0.6686273865669214,34.70941173536347,1.1448167828128435,-0.19856673586099222,1.0,46.109469743000005,-73.895015133,Chertsey
141 +62060,794,6,0.7427972147972148,33.904336974441236,1.1834926479685908,-0.21682129664326572,1.0,46.318582148000004,-74.213703653,Saint-Donat
142 +62065,420,5,0.6465903582009157,34.457159167787715,1.1454066195583492,-0.20381444347154196,1.0,46.2776111765,-73.7758384495,Saint-Côme
143 +62070,108,2,0.6529564054139125,36.33391748449871,1.1417382804528984,-0.11266234441125024,0.5,46.321132874499995,-73.639100665,Sainte-Émélie-de-l'Énergie
144 +62075,386,5,0.7098152254249814,29.668639557181635,1.1299303238462528,-0.2023320820761078,1.0,46.328845049,-73.518669413,Saint-Damien
145 +63013,263,4,0.7297487480112332,30.749630320275678,1.135104477252584,-0.6872060215266782,1.0,45.950303911,-73.56989595,Saint-Jacques
146 +63035,259,4,0.7150263554216867,25.025814834852063,1.115795536117923,-0.4967946545942795,1.0,45.857606134,-73.596808363,Saint-Roch-de-l'Achigan
147 +63048,2657,6,0.6787482807072664,32.399731567075136,1.1426349702029932,-0.4409546828425126,1.0,45.829154267,-73.756059204,Saint-Lin--Laurentides
148 +63055,990,6,0.6991879004344631,34.772906014843656,1.1179805996735728,-0.12841835399662452,1.0,45.9496960915,-73.8518049635,Saint-Calixte
149 +63060,1376,6,0.6786177255124288,29.932403168703473,1.1084628215703884,-0.18243715273619399,1.0,45.9692136175,-73.721131893,Sainte-Julienne
150 +64008,8061,6,0.7949895317396622,23.75303561732275,1.0961290692775711,-0.28961986412143703,1.0,45.712940538,-73.677957948,Terrebonne
151 +64015,4084,6,0.6733830040040366,24.907510224848203,1.0966100878654021,-0.2790068811456091,1.0,45.742800969,-73.620258493,Mascouche
152 +65005,23569,6,0.7292941176470589,20.99004120848114,1.07591200087497,-0.1677198430090882,1.0,45.573336747,-73.753870945,Laval
153 +66007,122,2,0.7185924821990395,24.77281784551642,1.1040070044998445,-0.33961014858943006,1.0,45.631109163,-73.502026454,Montréal-Est
154 +66023,79581,6,0.8922306974762114,24.895070443807846,1.0571629439855559,0.06966324444056746,0.0,45.52859923,-73.587846245,Montréal
155 +66032,1001,6,0.9577432898937377,19.517732793938418,1.0650199449333386,-0.06690574065852856,1.0,45.484173923,-73.60049964,Westmount
156 +66058,1337,6,0.9308821671736927,13.371841924509315,1.0295750134873236,-0.03141725569656499,1.0,45.474598268,-73.662658253,Côte-Saint-Luc
157 +66062,119,2,0.828157191637994,15.169213801325093,1.0390621459475182,-0.08018877234711512,1.0,45.48090571,-73.646078423,Hampstead
158 +66072,919,6,0.9712154346775798,18.437910632001554,1.0586410740869279,-0.03509377230935708,0.8333333333333334,45.515282794,-73.646702467,Mont-Royal
159 +66087,960,6,0.9231119095396276,17.57375988436484,1.0422460191530596,-0.021726145155715075,0.6666666666666666,45.44579454,-73.7450680235,Dorval
160 +66097,1595,6,0.9147700349809617,16.01327842926532,1.0495921971184083,-0.13170475816618712,1.0,45.452228282,-73.811135622,Pointe-Claire
161 +66102,912,6,0.9157315185059659,15.222291389905148,1.03620196824576,-0.0937722845882564,1.0,45.44809616249999,-73.863656111,Kirkland
162 +66107,1099,6,0.9318441349917743,17.594256192232862,1.0390910992766764,-0.06141225217710209,0.8333333333333334,45.43256399,-73.866690441,Beaconsfield
163 +66142,1997,6,0.9090923582911031,17.722469897676014,1.0475652915979667,-0.11857595425804798,1.0,45.488650831,-73.825879925,Dollard-des-Ormeaux
164 +67010,712,6,0.684262379422226,25.33975426489651,1.101002924179236,-0.448853524468586,1.0,45.3677048265,-73.480672417,Saint-Philippe
165 +67015,1622,6,0.8212459879530496,22.693926604495797,1.1042615346863807,-0.2621491447773391,1.0,45.4090403705,-73.4802975005,La Prairie
166 +67020,1736,6,0.680335891313185,22.419102830071658,1.076173224217308,-0.12050021573808359,1.0,45.383528165,-73.513485181,Candiac
167 +67025,522,6,0.7268149330713496,25.35998209068471,1.0964745751820337,-0.539674897123865,1.0,45.37836885,-73.5442486825,Delson
168 +67030,1110,6,0.7798735661126965,22.248866183009056,1.096618639052464,-0.6889060659985939,1.0,45.3966073255,-73.56369906500001,Sainte-Catherine
169 +67035,1820,6,0.8063260280719035,19.269590412382986,1.080966359291669,-0.5027860383185769,1.0,45.377947198,-73.580077421,Saint-Constant
170 +67045,1127,6,0.6681714781145649,24.917527317624618,1.1097996485766655,-0.48606177990172883,1.0,45.314420404,-73.741056666,Mercier
171 +67050,3054,6,0.6863984355752648,23.963031662323864,1.0937004161347055,-0.4718981001477939,1.0,45.359037404,-73.736001287,Châteauguay
172 +68030,251,4,0.7097808837589821,30.663358396736708,1.1155094173546272,-0.5579209556372569,1.0,45.188883749,-73.401096402,Napierville
173 +68055,589,6,0.6838214033382906,26.415322729836625,1.078747858436056,-0.15287365173213993,0.8333333333333334,45.262373942,-73.616414923,Saint-Rémi
174 +69037,106,2,0.6881955099377458,37.92110737415667,1.1730260422804393,-0.4670013713958576,1.0,45.119982944,-73.992491204,Ormstown
175 +69070,193,3,0.6907325174825175,29.694453762884823,1.1121747313343102,-0.11765626471433004,1.0,45.116286833000004,-74.38926475950001,Saint-Anicet
176 +70012,309,5,0.7320459236326109,30.384012277045837,1.1280580748468252,-0.3575072591273072,1.0,45.2573235345,-73.79560172449999,Sainte-Martine
177 +70022,1075,6,0.7597574024047884,29.870884614022984,1.1144127568575457,-0.4358589181857655,1.0,45.312101516,-73.873625864,Beauharnois
178 +70052,3472,6,0.7403786669685437,31.742876750423854,1.129265554756235,-0.39542016635342087,1.0,45.2621602895,-74.12986425700001,Salaberry-de-Valleyfield
179 +71025,924,6,0.7541040226866018,34.38138604039217,1.1456583338332207,-0.4523567408963357,1.0,45.246927821,-74.246740692,Saint-Zotique
180 +71033,509,6,0.6692114261414426,25.803009197034513,1.080165640307659,-0.3618141624281369,0.8333333333333334,45.269131059,-74.226509361,Les Coteaux
181 +71040,471,6,0.6972763087740437,35.31534275886618,1.1650689893927204,-0.43853385299879444,1.0,45.296572945,-74.175489643,Coteau-du-Lac
182 +71050,524,6,0.811130446717522,24.906489020547767,1.0853439175884678,-0.15171924992358465,0.8333333333333334,45.3276066585,-74.0569555055,Les Cèdres
183 +71060,718,6,0.6973422430553382,24.315276991934034,1.0998484312304608,-0.23499118611460362,0.8333333333333334,45.3895503655,-73.952990984,L'Île-Perrot
184 +71065,723,6,0.691639751552795,21.134183691410264,1.0900101947935883,-0.2552472905767234,1.0,45.38125767,-73.923685459,Notre-Dame-de-l'Île-Perrot
185 +71070,958,6,0.6918983367809608,22.63951634518149,1.0818047696747568,-0.3296866908207592,1.0,45.378101038,-73.9810040255,Pincourt
186 +71083,2616,6,0.6987913699204021,20.423287853352477,1.0679629167870859,-0.201423129509599,1.0,45.391017535,-74.03721731499999,Vaudreuil-Dorion
187 +71100,440,5,0.657512202010254,21.139092496444366,1.0567610887847834,-0.06951265776759467,0.8,45.454409926,-74.148156067,Hudson
188 +71105,1501,6,0.6584680311149621,25.484719956369524,1.103918642345454,-0.3099676452402449,1.0,45.40426853,-74.147789979,Saint-Lazare
189 +71133,611,6,0.6784057357430919,24.228624394914526,1.0924866258655075,-0.1834992400459184,1.0,45.478344449,-74.301244119,Rigaud
190 +72005,2980,6,0.6602678170113345,27.26128613086194,1.1090164884002613,-0.39811780722269663,1.0,45.573503094,-73.9003547245,Saint-Eustache
191 +72010,1093,6,0.6784331943504811,29.31978303844349,1.131091676981932,-0.49115172208957975,1.0,45.543286433,-73.903958594,Deux-Montagnes
192 +72015,1568,6,0.6834799296337758,27.718407825988223,1.1143667823617305,-0.26218667134366386,1.0,45.53085263,-73.940682486,Sainte-Marthe-sur-le-Lac
193 +72020,589,6,0.742374040707374,29.13344857812509,1.117375821640409,-0.21563592347582894,1.0,45.497248842,-73.97750307,Pointe-Calumet
194 +72025,379,5,0.7158108589230086,36.251999901980206,1.1408136801835462,-0.18925690542877868,1.0,45.5231855765,-73.9802705435,Saint-Joseph-du-Lac
195 +72032,196,3,0.7348350197615494,29.765900135180456,1.1095989251399265,-0.4030715202592383,1.0,45.47335282,-74.081503438,Oka
196 +73005,1667,6,0.7697670591362542,25.07291047173648,1.1057264849673165,-0.3921155583951153,1.0,45.61848801,-73.841808155,Boisbriand
197 +73010,1766,6,0.7849619311211817,21.364045543447695,1.078015806539247,-0.16574586723421708,1.0,45.6438193675,-73.83684939700001,Sainte-Thérèse
198 +73015,4302,6,0.6642971636742129,23.040896754254227,1.0974052681194044,-0.18976729044074891,1.0,45.674802691,-73.8721633665,Blainville
199 +73020,947,6,0.7715263047359024,23.556732862555528,1.104648618295947,-0.29614415506310915,1.0,45.643460388,-73.794777382,Rosemère
200 +73025,749,6,0.6429262075370085,23.614168966233816,1.0945989327222034,-0.29535573454640485,1.0,45.670854774,-73.784177923,Lorraine
201 +73030,827,6,0.6672291272123052,21.611011856728346,1.0847893348004582,-0.24500182019431868,1.0,45.673537822,-73.764234148,Bois-des-Filion
202 +73035,1134,6,0.659490376984127,29.599130473734494,1.1208319661428983,-0.5384539363937264,1.0,45.7659122305,-73.81296885399999,Sainte-Anne-des-Plaines
203 +74005,4618,6,0.7749613542187931,25.169913073493312,1.107774565693448,-0.40041493495052627,1.0,45.673421485999995,-73.98505922449999,Mirabel
204 +75005,1621,6,0.6562752525252525,30.650915931084207,1.133435577808017,-0.28971919531263324,1.0,45.746477502,-74.112937545,Saint-Colomban
205 +75017,6616,6,0.7517893023113612,23.091842242510324,1.0834015969188,-0.24785057684142733,1.0,45.789913221,-74.0082415655,Saint-Jérôme
206 +75028,1832,6,0.673219696969697,34.519633166289346,1.1589579575042572,-0.4521593731818938,1.0,45.832600118,-73.9533649835,Sainte-Sophie
207 +75040,1292,6,0.6564135708058492,28.883399359732728,1.1250150841218194,-0.2923979410997817,1.0,45.860940030500004,-74.0554606795,Prévost
208 +75045,1478,6,0.7645713506986852,30.123502040027475,1.126414204841707,-0.1552297611761352,1.0,45.89739243,-73.98773667149999,Saint-Hippolyte
209 +76020,1178,6,0.7033883848863581,25.657210202235234,1.0943487239078933,-0.18994258509577283,1.0,45.651124736,-74.3435378865,Lachute
210 +76025,240,4,0.6969195176954546,32.67202626752153,1.1293551629367897,-0.17129339765566512,1.0,45.7657740385,-74.2481922565,Gore
211 +76043,681,6,0.7906597930839002,33.62984625478003,1.1317947395194912,-0.3000186607193363,1.0,45.677822306,-74.406445365,Brownsburg-Chatham
212 +76052,112,2,0.6842583441149015,28.727733669937138,1.0979075842302088,-0.10610838611500388,1.0,45.649283199,-74.647290242,Grenville-sur-la-Rouge
213 +77012,577,6,0.767416063397733,31.70987258356473,1.1178739679916119,-0.2180822273799095,0.8333333333333334,46.048795423,-74.076073038,Sainte-Marguerite-du-Lac-Masson
214 +77022,1813,6,0.7736937921287237,26.104447343065164,1.0829474852619216,-0.15219298535856018,1.0,45.953708666,-74.139983317,Sainte-Adèle
215 +77030,630,6,0.674526627218935,23.9838858690541,1.0680071983146502,-0.06951841047914054,1.0,45.892511213,-74.1345699675,Piedmont
216 +77035,421,5,0.6726760563380282,32.707153156198636,1.1487149068078237,-0.20639664687018483,0.8,45.859360428,-74.119898636,Sainte-Anne-des-Lacs
217 +77043,1679,6,0.6642491108705737,23.70281702578965,1.0734527565770051,-0.1685823132373225,1.0,45.885168363,-74.173904996,Saint-Sauveur
218 +77050,615,6,0.6425609382325694,32.995697452241316,1.111440447284858,-0.20400064585818517,1.0,45.90287985,-74.238219198,Morin-Heights
219 +77060,176,3,0.6742105263157895,48.283982620796436,1.2368582258892007,-0.25484431235044575,1.0,45.8555580205,-74.467661531,Wentworth-Nord
220 +77065,786,6,0.7760721311475409,31.111832805489417,1.1296188471049702,-0.08404023050555207,1.0,45.962066259,-74.345879549,Saint-Adolphe-d'Howard
221 +78005,260,4,0.788936974789916,29.460609073697377,1.105329878424848,-0.19843515170141063,1.0,46.002425485,-74.1954476,Val-Morin
222 +78010,564,6,0.7682692371448463,30.02851642067145,1.1025223014890941,-0.12295037894100631,1.0,46.027993698,-74.209720426,Val-David
223 +78032,1015,6,0.6855593767141084,25.791843718209538,1.0749411280220096,-0.11449565917287258,1.0,46.046276118,-74.281797912,Sainte-Agathe-des-Monts
224 +78047,413,5,0.7135,33.7908170202975,1.1300176688632169,-0.21575811886767085,0.8,46.114378271,-74.47546422100001,Mont-Blanc
225 +78095,171,3,0.8208829530699313,28.132199595941593,1.126098556497308,-0.13958716637973806,1.0,46.16770375,-74.470425115,Lac-Supérieur
226 +78102,1089,6,0.7311117614269789,30.794479048645194,1.1204382252400635,-0.12836311075483878,1.0,46.132329575,-74.593895639,Mont-Tremblant
227 +78120,196,3,0.762378947368421,42.255755825822746,1.2141461619843812,-0.2992448689549128,1.0,46.275642615,-74.734005549,Labelle
228 +79005,317,5,0.7562162162162163,30.136734693961536,1.1003274393901796,-0.06869114861633299,1.0,46.100329843500006,-75.6227275185,Notre-Dame-du-Laus
229 +79030,127,2,0.6468191065144411,33.48178439024826,1.093039579050811,-0.10737375244102947,1.0,46.398916586,-75.02818566100001,Nominingue
230 +79037,295,4,0.6846636546184739,35.025329593330426,1.1400383441487119,-0.1695172539305697,1.0,46.444012066,-74.891305735,Rivière-Rouge
231 +79088,1068,6,0.7740341365461847,24.302939834888655,1.069661991560468,-0.1465788154360099,1.0,46.552159219,-75.5027332425,Mont-Laurier
232 +79097,104,2,0.7422861778744132,26.572593593059842,1.1068266452312072,-0.09448456343410147,1.0,46.701718909,-75.451640396,Ferme-Neuve
233 +80027,143,2,0.6577200331373031,34.64365343512506,1.1540631770509941,-0.2563220112686172,1.0,45.7288633915,-75.056881545,Saint-André-Avellin
234 +80050,104,1,0.7304779411764706,20.060720680119704,1.0551305741351824,-0.22257162986162263,1.0,45.602108631,-75.243203534,Thurso
235 +81017,21270,6,0.7984077215662176,16.931554716735143,1.0495027784666382,-0.13658868222945214,1.0,45.478547612499995,-75.713496176,Gatineau
236 +82005,368,5,0.7394618996027447,15.758329630938155,1.022595285772932,-0.011565422335264312,0.6,45.607380499,-75.448113435,L'Ange-Gardien
237 +82015,1321,6,0.7915990277134624,20.436939014421327,1.0558473384677736,-0.10941019083278125,1.0,45.633673901,-75.645517707,Val-des-Monts
238 +82020,877,6,0.6638305562902231,19.66144762913082,1.068141505374912,-0.15680720575821785,1.0,45.554269142,-75.760626051,Cantley
239 +82025,533,5,0.7365128205128205,20.109427070252394,1.0711324084906955,-0.13228455957137367,0.6,45.507582942,-75.80418832,Chelsea
240 +82030,278,4,0.7453869047619048,24.653691366080988,1.0795465837628575,-0.10148238374645359,1.0,45.5065481265,-76.0379189075,Pontiac
241 +82035,715,6,0.6650178866263071,29.44183235849443,1.1144147254778574,-0.16212541696934157,1.0,45.656126335,-75.99370325,La Pêche
242 +83032,184,3,0.7973958333333333,34.92667040526475,1.1703174468795343,-0.07965466822893477,1.0,46.092894060000006,-76.047773002,Gracefield
243 +83065,218,3,0.7012959191727839,28.805661670739624,1.1015631923144458,-0.26803741432214934,1.0,46.3787703725,-75.974956507,Maniwaki
244 +86042,2724,6,0.8366583424300523,15.06147804754267,1.0368678256129944,-0.08939705321208859,1.0,48.234782821500005,-79.02640637,Rouyn-Noranda
245 +87090,438,5,0.7851063829787234,20.116819736961197,1.045655945310056,-0.09997817214443672,1.0,48.796851013,-79.203499652,La Sarre
246 +88057,218,2,0.811819632749137,18.0851627067117,1.0643777382946966,-0.2559327944639028,1.0,48.5760654345,-78.113034247,Amos
247 +89008,2324,6,0.8126685967539155,28.21645553056692,1.1161070235181083,-0.32663299095099524,1.0,48.102099980999995,-77.810123723,Val-d'Or
248 +89015,116,2,0.8094558101472995,20.431714403736038,1.072786429093131,-0.23917163934784105,1.0,48.140422102,-78.122193154,Malartic
249 +90012,824,6,0.749237588389274,25.46135544821528,1.0756222255829053,-0.1487413121568984,1.0,47.437275442499995,-72.779832883,La Tuque
250 +91025,664,6,0.8137683672076632,19.119137295400005,1.0635366819847192,-0.14286063685850484,0.8333333333333334,48.5130966975,-72.229779914,Roberval
251 +91042,749,6,0.8105882352941176,24.026771569803017,1.0853518553934969,-0.14987364945513987,1.0,48.654435423,-72.443420482,Saint-Félicien
252 +92022,1068,6,0.7948982699805068,18.574330434764217,1.056085669382861,-0.05639164920521421,1.0,48.880969938,-72.2172604135,Dolbeau-Mistassini
253 +93012,238,4,0.8150918603012337,25.382670531701578,1.1079238343881064,-0.12237404789260117,1.0,48.422557618,-71.87132776850001,Métabetchouan--Lac-à-la-Croix
254 +93042,2286,6,0.8025041313443663,29.775957583854023,1.1264432678832426,-0.33707942496195986,1.0,48.548642577,-71.64940249899999,Alma
255 +94068,10794,6,0.7872989417989418,19.073245445985677,1.0638779683601711,-0.18854120739412214,1.0,48.413850183,-71.114843926,Saguenay
256 +94240,433,5,0.7548717948717949,26.19976837183734,1.1064419574799171,-0.25623669173628566,1.0,48.531486353000005,-71.08104856349999,Saint-Honoré
257 +94245,204,3,0.6753375474083438,31.77265312960662,1.1134962112514815,-0.19640112322516234,1.0,48.636804908,-71.096657085,Saint-David-de-Falardeau
258 +94255,115,2,0.8416867842601852,19.91364075604485,1.0634758075346165,-0.17522623594205916,1.0,48.560310212,-71.322823794,Saint-Ambroise
259 +96020,1363,6,0.8078230923665707,17.345307803691497,1.0486315729197053,-0.15390315960400958,1.0,49.204002921,-68.190065087,Baie-Comeau
260 +97007,1757,6,0.8232628501201955,31.766836500102194,1.1429833751673635,-0.2706148488213218,1.0,50.217120775,-66.379328936,Sept-Îles
261 +97022,310,4,0.8620232267511514,26.468917141651318,1.075295425288986,-0.09639715621572498,1.0,50.023658767,-66.879255019,Port-Cartier
262 +99025,678,6,0.8681148247978436,24.873041560245397,1.08653566843947,-0.19736064134913345,1.0,49.913814422499996,-74.375484754,Chibougamau
added results/reproduced/iaao_overall.csv +8 −0
@@ -0,0 +1,8 @@
1 +group,n,median_ratio,cod,prd,prb,prb_se,median_lo,median_hi,cod_lo,cod_hi,prd_lo,prd_hi,prb_lo,prb_hi
2 +All sales 2021–2026,522769,0.7842696629213484,28.453606677566754,1.0720526265902222,-0.029231043995398567,0.001011694100494542,0.7836525,0.7849235679214402,28.32931767404846,28.565805731993105,1.0708980043366874,1.0730233208372144,-0.0311809979240684,-0.027240556459155452
3 +2021,115221,0.75875,26.56881340997751,1.0977977446595462,-0.10439995446335718,0.0018780611602475469,0.7573905876731963,0.76,26.351010556052213,26.81244648128587,1.096089395370703,1.0995245107667422,-0.10784763266408848,-0.10129939282929173
4 +2022,90627,0.6996330275229358,30.166845460558804,1.113909455968154,-0.11645566662177718,0.002565006647926422,0.6986641148325359,0.7010965205683608,29.860881164700064,30.500688556680952,1.1115376567937396,1.1168128266197954,-0.12086406687016825,-0.11072838334214413
5 +2023,74110,0.7678797912903873,30.158018383876275,1.0669797271549857,-0.014624967340268172,0.0029669111162585514,0.766664374101264,0.7692849755881734,29.831543249220744,30.421183921307005,1.064154926668826,1.0692306393998607,-0.01975682064425937,-0.008456585563497683
6 +2024,89204,0.7652173913043478,29.51630268694212,1.0608402697893429,-0.0022461064763566636,0.002801065827976032,0.7639511533088846,0.7668753472222222,29.231808427730112,29.784991266712524,1.0588161557648539,1.0631172201778432,-0.007092623264666497,0.003072440446322985
7 +2025,102026,0.8505050505050505,25.459880383502032,1.07018259382875,-0.038888882788478116,0.0026313803244376664,0.8495232422704633,0.8514549465240642,25.15070947846792,25.72947026310519,1.0682152399072007,1.072481009411764,-0.04395325683380448,-0.034422778196270945
8 +2026,51581,0.8571428571428571,25.489813580325944,1.067871694912302,-0.030491883244112023,0.0038787622967144447,0.8553654037886341,0.8584553928095873,25.057401870485677,25.86570274574058,1.0649438720501567,1.0709611050077859,-0.038324942561289106,-0.024024217864788658
added results/reproduced/paglin_fogarty.csv +8 −0
@@ -0,0 +1,8 @@
1 +,0
2 +estimator,Paglin–Fogarty (levels)
3 +intercept,31729.109668061967
4 +intercept_se,5377.612928331011
5 +slope,0.750538044579637
6 +slope_se,0.0234789080468955
7 +n,522769
8 +r2,0.765529044655609
added results/reproduced/quantile.csv +6 −0
@@ -0,0 +1,6 @@
1 +tau,beta,se,gamma,n
2 +0.1,0.8710259920099666,0.0011617888737700704,-0.12897400799003345,250000
3 +0.25,0.8576278086355942,0.0008188248060121729,-0.14237219136440582,250000
4 +0.5,0.8158799571081065,0.0008596249662814258,-0.18412004289189354,250000
5 +0.75,0.6977863205885624,0.001810213015287808,-0.3022136794114376,250000
6 +0.9,0.493980443120313,0.004939830233012593,-0.506019556879687,250000
added results/reproduced/robustness.csv +12 −0
@@ -0,0 +1,12 @@
1 +variant,gamma_fe,se_fe,gamma_iv,se_iv,n
2 +Baseline,-0.3441977186584467,0.029440415405950358,-0.08342441688397817,0.02111681371535637,522769
3 +Condominiums only,-0.12464520941899382,0.01518233161943941,0.05839388234158127,0.016310586343762663,77976
4 +Excluding sales < $100k,-0.2954021582133831,0.02620799864834414,-0.04761800229908353,0.017049185795619447,507024
5 +Match score = 220 (max),-0.41484792659187864,0.039909750823735164,-0.09508044808955152,0.029034367201517614,210820
6 +Match distance <= 10 m,-0.35544067100844223,0.03044054381932686,-0.0865157304085471,0.0217104651732468,481476
7 +Single-family only,-0.4537825487653664,0.017669914962190263,-0.14637837847309776,0.009674772614708964,337855
8 +Ratio trim 5/95,-0.14708370575153007,0.022273388305055954,-0.04156000317201847,0.01559802433181914,465318
9 +Sales 2021-2023,-0.34991990346605073,0.029902278574860372,-0.10123470423960601,0.02502546688053367,279958
10 +Sales 2024-2026,-0.3371827571771222,0.028989933590428545,-0.06223166153460358,0.01639409832734001,242811
11 +Munis >= 300 sales,-0.3409164404961804,0.032623407694126444,-0.07722611352301967,0.022313481326094283,474004
12 +Cells >= 50 sales,-0.33992010068755585,0.03216956767872976,-0.07761468372097924,0.02222025306569112,476001
added results/reproduced/sample_counts.csv +10 −0
@@ -0,0 +1,10 @@
1 +,0
2 +n_sales,522769
3 +n_munis,625
4 +n_cells,2884
5 +n_condo,81461
6 +n_cottage,9287
7 +n_mobile,4701
8 +n_other,574
9 +n_plex,81693
10 +n_single_family,345053
added results/reproduced/summary_stats.csv +9 −0
@@ -0,0 +1,9 @@
1 +variable,n,mean,sd,p10,p50,p90
2 +Sale price ($),522769,462131.02316128154,331668.8849792319,175000.0,400000.0,787500.0
3 +Assessed value ($),522769,378576.0241311172,284509.3908358621,155000.0,309400.0,664500.0
4 +Assessment ratio AV/SP,522769,0.8782215447418829,0.4287885762103099,0.5793890014020876,0.7842696629213484,1.137561073733976
5 +Roll lag (months),522769,37.06170101351715,11.313660102159131,22.470433639947437,36.16951379763469,50.65703022339027
6 +Assessed land share,519330,0.30981189756147676,0.14934088670854148,0.12636418307358985,0.2959155356170383,0.5106060606060606
7 +Building age (years),498458,43.62546694004309,30.107767330611793,10.0,39.0,79.0
8 +Lot area (m2),522475,1406.4376602899658,10905.966380926626,111.71,555.8,2257.6
9 +Floor area (m2),515958,137.64187220665247,84.87818498574318,75.9,112.0,225.9
added results/reproduced/taxshift.csv +11 −0
@@ -0,0 +1,11 @@
1 +decile,n,mean_rel,median_rel,se
2 +1,51007,1.0172224507626328,0.6488477482764501,0.00510136426265092
3 +2,52458,0.2415117210138423,0.0698547780946529,0.002333270560075986
4 +3,51976,0.11189928535329506,0.028146310809547326,0.0016347999883110573
5 +4,52385,0.06314511674551167,0.00854447439353101,0.0013607139199222748
6 +5,52879,0.030137573946786966,-0.008096278617191643,0.0011409831482484887
7 +6,51767,0.008282058967129717,-0.022904260192395776,0.0010397072479158753
8 +7,52236,-0.007197592292916026,-0.03155116674680325,0.0009408008322386948
9 +8,52364,-0.018627874530360817,-0.03821138759371412,0.0008698417304787702
10 +9,52123,-0.022000383569130387,-0.03809452119114054,0.0008369636786756912
11 +10,53574,-0.03607074517324711,-0.04728024331485037,0.0008015863097273057
added results/reproduced/vertical.csv +4 −0
@@ -0,0 +1,4 @@
1 +estimator,beta,se,gamma,n,r2
2 +Cheng OLS (pooled),0.8119241820644745,0.029259867729858405,-0.18807581793552552,522769,0.7053864314995516
3 +Cheng FE (cell),0.6558022813415533,0.029440415405950358,-0.3441977186584467,522769,0.7956390786292254
4 +Clapp IV (rank instrument),0.9165755831160218,0.02111681371535637,-0.08342441688397817,522769,0.4855697563551059
added results/tables/heterogeneity.tex +39 −0
@@ -0,0 +1,39 @@
1 +\begin{tabular}{lccr}
2 +\toprule
3 +Subsample & $\gamma$ & Std.\ error & $n$ \\
4 +\midrule
5 +\multicolumn{4}{l}{\itshape Property class}\\
6 +\quad Single-family & -0.454*** & (0.018) & 337,855 \\
7 +\quad Condominium & -0.125*** & (0.015) & 77,976 \\
8 +\quad Plex (2--5 units) & -0.487*** & (0.054) & 69,745 \\
9 +\quad Cottage & -0.288*** & (0.026) & 2,114 \\
10 +\midrule
11 +\multicolumn{4}{l}{\itshape Building age}\\
12 +\quad Age $<$ 20 y & -0.297*** & (0.041) & 117,898 \\
13 +\quad Age 20--60 y & -0.384*** & (0.034) & 227,857 \\
14 +\quad Age $>$ 60 y & -0.435*** & (0.028) & 108,049 \\
15 +\midrule
16 +\multicolumn{4}{l}{\itshape Assessed land share}\\
17 +\quad Land share $<$ 0.2 & -0.262*** & (0.042) & 119,718 \\
18 +\quad Land share 0.2--0.4 & -0.372*** & (0.032) & 236,923 \\
19 +\quad Land share $>$ 0.4 & -0.503*** & (0.030) & 125,714 \\
20 +\midrule
21 +\multicolumn{4}{l}{\itshape Roll lag at sale}\\
22 +\quad Roll lag $<$ 24 m & -0.335*** & (0.028) & 86,001 \\
23 +\quad Roll lag 24--48 m & -0.338*** & (0.033) & 327,031 \\
24 +\quad Roll lag $>$ 48 m & -0.361*** & (0.026) & 91,367 \\
25 +\midrule
26 +\multicolumn{4}{l}{\itshape Municipality size}\\
27 +\quad Muni $<$ 1k sales & -0.379*** & (0.007) & 125,139 \\
28 +\quad Muni 1k--10k sales & -0.406*** & (0.014) & 185,111 \\
29 +\quad Muni $>$ 10k sales & -0.267*** & (0.043) & 212,519 \\
30 +\midrule
31 +\multicolumn{4}{l}{\itshape Sale year}\\
32 +\quad Sales 2021 & -0.316*** & (0.027) & 115,221 \\
33 +\quad Sales 2022 & -0.380*** & (0.030) & 90,627 \\
34 +\quad Sales 2023 & -0.365*** & (0.034) & 74,110 \\
35 +\quad Sales 2024 & -0.333*** & (0.032) & 89,204 \\
36 +\quad Sales 2025 & -0.335*** & (0.026) & 102,026 \\
37 +\quad Sales 2026 & -0.349*** & (0.030) & 51,581 \\
38 +\bottomrule
39 +\end{tabular}
\ No newline at end of file
added results/tables/horizontal.tex +14 −0
@@ -0,0 +1,14 @@
1 +\begin{tabular}{lcc}
2 +\toprule
3 + & Coefficient & Std.\ error \\
4 +\midrule
5 +Building age (decades) & +0.0075*** & (0.0012) \\
6 +Assessed land share & +0.0031 & (0.0298) \\
7 +Condominium & -0.0551*** & (0.0074) \\
8 +Plex (2--5 units) & +0.0247 & (0.0151) \\
9 +Cottage & +0.0520*** & (0.0073) \\
10 +\midrule
11 +Mean of dependent variable & \multicolumn{2}{c}{0.188} \\
12 +Observations & \multicolumn{2}{c}{495,092} \\
13 +\bottomrule
14 +\end{tabular}
\ No newline at end of file
added results/tables/iaao.tex +20 −0
@@ -0,0 +1,20 @@
1 +\begin{tabular}{lrcccc}
2 +\toprule
3 +Sample & $n$ & Median ratio & COD & PRD & PRB \\
4 +\midrule
5 +All sales 2021–2026 & 522,769 & 0.784 & 28.5 & 1.072 & -0.0292 \\
6 + & & \footnotesize[0.784, 0.785] & \footnotesize[28.3, 28.6] & \footnotesize[1.071, 1.073] & \footnotesize[-0.0312, -0.0272] \\
7 +2021 & 115,221 & 0.759 & 26.6 & 1.098 & -0.1044 \\
8 + & & \footnotesize[0.757, 0.760] & \footnotesize[26.4, 26.8] & \footnotesize[1.096, 1.100] & \footnotesize[-0.1078, -0.1013] \\
9 +2022 & 90,627 & 0.700 & 30.2 & 1.114 & -0.1165 \\
10 + & & \footnotesize[0.699, 0.701] & \footnotesize[29.9, 30.5] & \footnotesize[1.112, 1.117] & \footnotesize[-0.1209, -0.1107] \\
11 +2023 & 74,110 & 0.768 & 30.2 & 1.067 & -0.0146 \\
12 + & & \footnotesize[0.767, 0.769] & \footnotesize[29.8, 30.4] & \footnotesize[1.064, 1.069] & \footnotesize[-0.0198, -0.0085] \\
13 +2024 & 89,204 & 0.765 & 29.5 & 1.061 & -0.0022 \\
14 + & & \footnotesize[0.764, 0.767] & \footnotesize[29.2, 29.8] & \footnotesize[1.059, 1.063] & \footnotesize[-0.0071, 0.0031] \\
15 +2025 & 102,026 & 0.851 & 25.5 & 1.070 & -0.0389 \\
16 + & & \footnotesize[0.850, 0.851] & \footnotesize[25.2, 25.7] & \footnotesize[1.068, 1.072] & \footnotesize[-0.0440, -0.0344] \\
17 +2026 & 51,581 & 0.857 & 25.5 & 1.068 & -0.0305 \\
18 + & & \footnotesize[0.855, 0.858] & \footnotesize[25.1, 25.9] & \footnotesize[1.065, 1.071] & \footnotesize[-0.0383, -0.0240] \\
19 +\bottomrule
20 +\end{tabular}
\ No newline at end of file
added results/tables/iaao_cities.tex +16 −0
@@ -0,0 +1,16 @@
1 +\begin{tabular}{lrccccc}
2 +\toprule
3 +Municipality & $n$ & Median ratio & COD & PRD & PRB & \makecell{Share of years\\PRB $<$ 0} \\
4 +\midrule
5 +Montréal & 79,581 & 0.892 & 24.9 & 1.057 & 0.0697 & 0.00 \\
6 +Québec & 39,927 & 0.809 & 22.0 & 1.071 & -0.0480 & 1.00 \\
7 +Laval & 23,569 & 0.729 & 21.0 & 1.076 & -0.1677 & 1.00 \\
8 +Gatineau & 21,270 & 0.798 & 16.9 & 1.050 & -0.1366 & 1.00 \\
9 +Longueuil & 15,343 & 0.699 & 22.9 & 1.074 & -0.1276 & 1.00 \\
10 +Sherbrooke & 11,413 & 0.744 & 30.5 & 1.137 & -0.3872 & 1.00 \\
11 +Saguenay & 10,794 & 0.787 & 19.1 & 1.064 & -0.1885 & 1.00 \\
12 +Lévis & 10,622 & 0.827 & 22.3 & 1.089 & -0.2369 & 1.00 \\
13 +Trois-Rivières & 9,070 & 0.729 & 29.4 & 1.123 & -0.3167 & 1.00 \\
14 +Terrebonne & 8,061 & 0.795 & 23.8 & 1.096 & -0.2896 & 1.00 \\
15 +\bottomrule
16 +\end{tabular}
\ No newline at end of file
added results/tables/paglin_fogarty.tex +9 −0
@@ -0,0 +1,9 @@
1 +\begin{tabular}{lcc}
2 +\toprule
3 + & Coefficient & Std.\ error \\
4 +\midrule
5 +Intercept ($\$$) & 31,729 & 5,378 \\
6 +Slope on sale price & 0.751 & 0.023 \\
7 +Observations & \multicolumn{2}{c}{522,769} \\
8 +\bottomrule
9 +\end{tabular}
\ No newline at end of file
added results/tables/quantile.tex +8 −0
@@ -0,0 +1,8 @@
1 +\begin{tabular}{lccccc}
2 +\toprule
3 +Quantile $\tau$ & 0.10 & 0.25 & 0.50 & 0.75 & 0.90 \\
4 +\midrule
5 +$\beta(\tau)$ & 0.871*** & 0.858*** & 0.816*** & 0.698*** & 0.494*** \\
6 + & (0.0012) & (0.0008) & (0.0009) & (0.0018) & (0.0049) \\
7 +\bottomrule
8 +\end{tabular}
\ No newline at end of file
added results/tables/robustness.tex +17 −0
@@ -0,0 +1,17 @@
1 +\begin{tabular}{lccccr}
2 +\toprule
3 +Sample variant & $\gamma_{\text{FE}}$ & Std.\ err. & $\gamma_{\text{IV}}$ & Std.\ err. & $n$ \\
4 +\midrule
5 +Baseline & -0.344*** & (0.029) & -0.083*** & (0.021) & 522,769 \\
6 +Condominiums only & -0.125*** & (0.015) & +0.058*** & (0.016) & 77,976 \\
7 +Excluding sales < \$100k & -0.295*** & (0.026) & -0.048*** & (0.017) & 507,024 \\
8 +Match score = 220 (max) & -0.415*** & (0.040) & -0.095*** & (0.029) & 210,820 \\
9 +Match distance $\le$ 10 m & -0.355*** & (0.030) & -0.087*** & (0.022) & 481,476 \\
10 +Single-family only & -0.454*** & (0.018) & -0.146*** & (0.010) & 337,855 \\
11 +Ratio trim 5/95 & -0.147*** & (0.022) & -0.042*** & (0.016) & 465,318 \\
12 +Sales 2021-2023 & -0.350*** & (0.030) & -0.101*** & (0.025) & 279,958 \\
13 +Sales 2024-2026 & -0.337*** & (0.029) & -0.062*** & (0.016) & 242,811 \\
14 +Munis $\ge$ 300 sales & -0.341*** & (0.033) & -0.077*** & (0.022) & 474,004 \\
15 +Cells $\ge$ 50 sales & -0.340*** & (0.032) & -0.078*** & (0.022) & 476,001 \\
16 +\bottomrule
17 +\end{tabular}
\ No newline at end of file
added results/tables/summary_stats.tex +14 −0
@@ -0,0 +1,14 @@
1 +\begin{tabular}{lrrrrrr}
2 +\toprule
3 +Variable & $n$ & Mean & SD & P10 & Median & P90 \\
4 +\midrule
5 +Sale price (\$) & 522,769 & 462,131 & 331,669 & 175,000 & 400,000 & 787,500 \\
6 +Assessed value (\$) & 522,769 & 378,576 & 284,509 & 155,000 & 309,400 & 664,500 \\
7 +Assessment ratio AV/SP & 522,769 & 0.878 & 0.429 & 0.579 & 0.784 & 1.138 \\
8 +Roll lag (months) & 522,769 & 37.1 & 11.3 & 22.5 & 36.2 & 50.7 \\
9 +Assessed land share & 519,330 & 0.310 & 0.149 & 0.126 & 0.296 & 0.511 \\
10 +Building age (years) & 498,458 & 43.6 & 30.1 & 10.0 & 39.0 & 79.0 \\
11 +Lot area (m$^2$) & 522,475 & 1,406 & 10,906 & 112 & 556 & 2,258 \\
12 +Floor area (m$^2$) & 515,958 & 137.6 & 84.9 & 75.9 & 112.0 & 225.9 \\
13 +\bottomrule
14 +\end{tabular}
\ No newline at end of file
added results/tables/taxshift.tex +16 −0
@@ -0,0 +1,16 @@
1 +\begin{tabular}{lcccc}
2 +\toprule
3 +Price decile & $n$ & \makecell{Mean excess\\burden (\%)} & \makecell{Median excess\\burden (\%)} & Std.\ error \\
4 +\midrule
5 +1 & 51,007 & +101.7 & +64.9 & (0.51) \\
6 +2 & 52,458 & +24.2 & +7.0 & (0.23) \\
7 +3 & 51,976 & +11.2 & +2.8 & (0.16) \\
8 +4 & 52,385 & +6.3 & +0.9 & (0.14) \\
9 +5 & 52,879 & +3.0 & -0.8 & (0.11) \\
10 +6 & 51,767 & +0.8 & -2.3 & (0.10) \\
11 +7 & 52,236 & -0.7 & -3.2 & (0.09) \\
12 +8 & 52,364 & -1.9 & -3.8 & (0.09) \\
13 +9 & 52,123 & -2.2 & -3.8 & (0.08) \\
14 +10 & 53,574 & -3.6 & -4.7 & (0.08) \\
15 +\bottomrule
16 +\end{tabular}
\ No newline at end of file
added results/tables/vertical.tex +15 −0
@@ -0,0 +1,15 @@
1 +\begin{tabular}{lccc}
2 +\toprule
3 + & (1) & (2) & (3) \\
4 + & Cheng OLS & Cheng FE & Clapp IV \\
5 +\midrule
6 +$\beta$ (ln sale price) & 0.812*** & 0.656*** & 0.917*** \\
7 + & (0.029) & (0.029) & (0.021) \\
8 +$\gamma = \beta - 1$ & -0.188 & -0.344 & -0.083 \\
9 +\midrule
10 +Cell fixed effects & No & Yes & Yes \\
11 +Error-in-price robust & No & No & Yes \\
12 +Observations & 522,769 & 522,769 & 522,769 \\
13 +$R^2$ & 0.705 & 0.796 & 0.486 \\
14 +\bottomrule
15 +\end{tabular}
\ No newline at end of file
added scripts/01_build_sample.py +31 −0
@@ -0,0 +1,31 @@
1 +#!/usr/bin/env python3
2 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
3 +"""Step 01 — Build the estimation sample.
4 +
5 +Reads the raw matched transaction–roll snapshot
6 +(``data/raw/transactions_700k_avec_registre_foncier.parquet``), keeps
7 +residential arm's-length sales with a high-confidence roll match, constructs
8 +the assessment ratio and derived regressors, applies the trims, and writes
9 +``data/processed/analysis.parquet``.
10 +
11 +Usage: python scripts/01_build_sample.py
12 +"""
13 +import sys
14 +from pathlib import Path
15 +
16 +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
17 +
18 +from wp10 import sample # noqa: E402
19 +
20 +
21 +def main() -> None:
22 + s = sample.build_and_save()
23 + print(f"\nEstimation sample written: {len(s):,} sales")
24 + print(f" municipalities : {s['muni'].nunique():,}")
25 + print(f" cells : {s['cell'].nunique():,}")
26 + print(f" classes : {s.groupby('prop_class').size().to_dict()}")
27 + print(f" median ratio : {s['ratio'].median():.3f}")
28 +
29 +
30 +if __name__ == "__main__":
31 + main()
added scripts/02_iaao_stats.py +113 −0
@@ -0,0 +1,113 @@
1 +#!/usr/bin/env python3
2 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
3 +"""Step 02 — Descriptive statistics and IAAO ratio-study diagnostics.
4 +
5 +Writes to ``results/reproduced/``:
6 + summary_stats.csv sample descriptives used in Table 1
7 + iaao_overall.csv province-wide median ratio / COD / PRD / PRB with
8 + bootstrap CIs, overall and by sale year
9 + iaao_cities.csv the ten largest markets
10 + iaao_muni.csv every municipality with ≥ 100 sales (maps + histograms)
11 +
12 +Usage: python scripts/02_iaao_stats.py
13 +"""
14 +import sys
15 +from pathlib import Path
16 +
17 +import numpy as np
18 +import pandas as pd
19 +
20 +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
21 +
22 +from wp10 import config, iaao, sample # noqa: E402
23 +
24 +
25 +def main() -> None:
26 + config.ensure_dirs()
27 + df = sample.load()
28 + out = config.REPRODUCED
29 +
30 + # ------------------------------------------------------------ Table 1
31 + desc_vars = {
32 + "amount": "Sale price ($)",
33 + "role_valeur_immeuble": "Assessed value ($)",
34 + "ratio": "Assessment ratio AV/SP",
35 + "lag_months": "Roll lag (months)",
36 + "land_share": "Assessed land share",
37 + "age": "Building age (years)",
38 + "role_superficie_terrain_m2": "Lot area (m2)",
39 + "role_aire_etages_m2": "Floor area (m2)",
40 + }
41 + rows = []
42 + for var, label in desc_vars.items():
43 + s = df[var].dropna()
44 + rows.append({"variable": label, "n": len(s), "mean": s.mean(),
45 + "sd": s.std(), "p10": s.quantile(.10), "p50": s.median(),
46 + "p90": s.quantile(.90)})
47 + pd.DataFrame(rows).to_csv(out / "summary_stats.csv", index=False)
48 +
49 + counts = {"n_sales": len(df), "n_munis": df["muni"].nunique(),
50 + "n_cells": df["cell"].nunique()}
51 + for k, v in df.groupby("prop_class").size().items():
52 + counts[f"n_{k}"] = int(v)
53 + pd.Series(counts).to_csv(out / "sample_counts.csv")
54 +
55 + # ------------------------------------------------------------ overall + by year
56 + blocks = [("All sales 2021–2026", df)]
57 + blocks += [(str(y), g) for y, g in df.groupby("sale_year")]
58 + rows = []
59 + for label, g in blocks:
60 + av = g["role_valeur_immeuble"].to_numpy(float)
61 + sp = g["amount"].to_numpy(float)
62 + r = av / sp
63 + b, se = iaao.prb(av, sp)
64 + row = {"group": label, "n": len(g),
65 + "median_ratio": float(np.median(r)), "cod": iaao.cod(r),
66 + "prd": iaao.prd(av, sp), "prb": b, "prb_se": se}
67 + cis = iaao.bootstrap_ci(av, sp, n_boot=200)
68 + for stat, (lo, hi) in cis.items():
69 + row[f"{stat}_lo"], row[f"{stat}_hi"] = lo, hi
70 + rows.append(row)
71 + print(f" {label:<22} n={row['n']:>8,} med={row['median_ratio']:.3f} "
72 + f"COD={row['cod']:.1f} PRD={row['prd']:.3f} PRB={row['prb']:+.4f}")
73 + pd.DataFrame(rows).to_csv(out / "iaao_overall.csv", index=False)
74 +
75 + # ---------------------------------------------------------------------
76 + # Municipality-level statistics. Within a municipality × sale-year block
77 + # a single roll is in force, so the roll lag is (nearly) constant and the
78 + # COD/PRB are not inflated by market-time drift. Annual blocks are then
79 + # aggregated to one row per municipality (median across years, total n).
80 + def annual_then_aggregate(data: pd.DataFrame, min_n: int) -> pd.DataFrame:
81 + blocks = iaao.group_metrics(data, ["muni", "sale_year"], min_n=min_n)
82 + blocks["muni"] = blocks["group"].str.rsplit("_", n=1).str[0]
83 + agg = (blocks.groupby("muni")
84 + .agg(n=("n", "sum"), n_years=("n", "size"),
85 + median_ratio=("median_ratio", "median"),
86 + cod=("cod", "median"), prd=("prd", "median"),
87 + prb=("prb", "median"))
88 + .reset_index())
89 + share_neg = (blocks.assign(neg=blocks["prb"] < 0)
90 + .groupby("muni")["neg"].mean().rename("share_years_prb_neg"))
91 + return agg.merge(share_neg, on="muni")
92 +
93 + # ------------------------------------------------------------ ten largest markets
94 + big = df[df["role_municipalite"].isin(config.BIG_CITIES)].copy()
95 + big["muni"] = big["role_municipalite"] # aggregate by display name
96 + tab = annual_then_aggregate(big, min_n=200)
97 + tab.to_csv(out / "iaao_cities.csv", index=False)
98 +
99 + # ------------------------------------------------------------ every muni ≥ 100 sales
100 + tab = annual_then_aggregate(df, min_n=50)
101 + tab = tab[tab["n"] >= config.MUNI_MIN_SALES]
102 + coords = df.groupby("muni")[["lat", "lng"]].median()
103 + names = df.groupby("muni")["role_municipalite"].first()
104 + tab = tab.merge(coords, left_on="muni", right_index=True)
105 + tab = tab.merge(names.rename("name"), left_on="muni", right_index=True)
106 + tab.to_csv(out / "iaao_muni.csv", index=False)
107 + print(f"\nMunicipality-level metrics: {len(tab)} municipalities "
108 + f"(median within-year COD {tab['cod'].median():.1f}, "
109 + f"share PRB<0: {(tab['prb'] < 0).mean():.1%})")
110 +
111 +
112 +if __name__ == "__main__":
113 + main()
added scripts/03_estimate_regressions.py +133 −0
@@ -0,0 +1,133 @@
1 +#!/usr/bin/env python3
2 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
3 +"""Step 03 — Vertical-inequity regressions, heterogeneity, horizontal
4 +inequity and the implied tax shift.
5 +
6 +Writes to ``results/reproduced/``:
7 + vertical.csv Cheng OLS / Cheng FE / Clapp IV / Paglin–Fogarty
8 + quantile.csv quantile-regression β(τ) on within-cell data
9 + heterogeneity.csv Cheng-FE γ by property class, age, land share, year,
10 + roll lag and municipality size
11 + binscatter.csv within-cell mean ln ratio by price vigintile
12 + horizontal.csv |deviation| regression (who gets noisy assessments)
13 + taxshift.csv over/under-taxation by within-cell price decile
14 + robustness.csv γ across sample and measurement variants
15 +
16 +Usage: python scripts/03_estimate_regressions.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 wp10 import config, models, sample # noqa: E402
27 +
28 +
29 +def main() -> None:
30 + config.ensure_dirs()
31 + df = sample.load()
32 + out = config.REPRODUCED
33 +
34 + # ------------------------------------------------------------ main table
35 + print("Vertical-inequity estimators")
36 + res = [models.cheng_pooled(df), models.cheng_fe(df), models.clapp_iv(df)]
37 + for r in res:
38 + print(f" {r['estimator']:<28} beta={r['beta']:.4f} ({r['se']:.4f}) "
39 + f"gamma={r['gamma']:+.4f} n={r['n']:,}")
40 + pf = models.paglin_fogarty(df)
41 + print(f" {pf['estimator']:<28} a={pf['intercept']:,.0f} "
42 + f"({pf['intercept_se']:,.0f}) b={pf['slope']:.4f}")
43 + pd.DataFrame(res).to_csv(out / "vertical.csv", index=False)
44 + pd.Series(pf).to_csv(out / "paglin_fogarty.csv")
45 +
46 + # ------------------------------------------------------------ quantiles
47 + qt = models.quantile_betas(df)
48 + qt.to_csv(out / "quantile.csv", index=False)
49 + print("Quantile betas:", {f"{r.tau:.2f}": round(r.beta, 3)
50 + for r in qt.itertuples()})
51 +
52 + # ------------------------------------------------------------ binscatter
53 + d = df.copy()
54 + d["lnr_w"] = d["ln_ratio"] - d.groupby("cell")["ln_ratio"].transform("mean")
55 + d["lnp_w"] = d["ln_price"] - d.groupby("cell")["ln_price"].transform("mean")
56 + d["bin"] = pd.qcut(d["lnp_w"], 20, labels=False)
57 + (d.groupby("bin")
58 + .agg(x=("lnp_w", "mean"), y=("lnr_w", "mean"),
59 + se=("lnr_w", lambda s: s.std() / np.sqrt(len(s))), n=("lnr_w", "size"))
60 + .reset_index()
61 + .to_csv(out / "binscatter.csv", index=False))
62 +
63 + # ------------------------------------------------------------ heterogeneity
64 + muni_sales = df.groupby("muni")["muni"].transform("size")
65 + groups = {
66 + "Single-family": df["prop_class"] == "single_family",
67 + "Condominium": df["prop_class"] == "condo",
68 + "Plex (2–5 units)": df["prop_class"] == "plex",
69 + "Cottage": df["prop_class"] == "cottage",
70 + "Age < 20 y": df["age"] < 20,
71 + "Age 20–60 y": df["age"].between(20, 60),
72 + "Age > 60 y": df["age"] > 60,
73 + "Land share < 0.2": df["land_share"] < 0.2,
74 + "Land share 0.2–0.4": df["land_share"].between(0.2, 0.4),
75 + "Land share > 0.4": df["land_share"] > 0.4,
76 + "Roll lag < 24 m": df["lag_months"] < 24,
77 + "Roll lag 24–48 m": df["lag_months"].between(24, 48),
78 + "Roll lag > 48 m": df["lag_months"] > 48,
79 + "Muni < 1k sales": muni_sales < 1_000,
80 + "Muni 1k–10k sales": muni_sales.between(1_000, 10_000),
81 + "Muni > 10k sales": muni_sales > 10_000,
82 + }
83 + groups.update({f"Sales {y}": df["sale_year"] == y
84 + for y in sorted(df["sale_year"].unique())})
85 + het = models.gamma_by_group(df, groups)
86 + het.to_csv(out / "heterogeneity.csv", index=False)
87 + print(f"Heterogeneity: {len(het)} subgroups estimated")
88 +
89 + # ------------------------------------------------------------ horizontal
90 + tab, meta = models.horizontal_dispersion(df)
91 + tab.to_csv(out / "horizontal.csv")
92 + pd.Series(meta).to_csv(out / "horizontal_meta.csv")
93 + print("Horizontal-dispersion regression:", meta)
94 +
95 + # ------------------------------------------------------------ tax shift
96 + ts = models.tax_shift(df)
97 + ts.to_csv(out / "taxshift.csv", index=False)
98 + print("Tax shift by decile (mean %):",
99 + {int(r.decile): f"{r.mean_rel:+.1%}" for r in ts.itertuples()})
100 +
101 + # ------------------------------------------------------------ robustness
102 + variants = {
103 + "Baseline": df,
104 + "Condominiums only": df[df["prop_class"] == "condo"],
105 + "Excluding sales < $100k": df[df["amount"] >= 100_000],
106 + "Match score = 220 (max)": df[df["match_score"] >= 219.9],
107 + "Match distance <= 10 m": df[df["match_dist_m"] <= 10],
108 + "Single-family only": df[df["prop_class"] == "single_family"],
109 + "Ratio trim 5/95": df[df["ratio"].between(
110 + df["ratio"].quantile(.05), df["ratio"].quantile(.95))],
111 + "Sales 2021-2023": df[df["sale_year"] <= 2023],
112 + "Sales 2024-2026": df[df["sale_year"] >= 2024],
113 + "Munis >= 300 sales": df[df.groupby("muni")["muni"]
114 + .transform("size") >= 300],
115 + "Cells >= 50 sales": df[df.groupby("cell")["cell"]
116 + .transform("size") >= 50],
117 + }
118 + rows = []
119 + for label, sub in variants.items():
120 + counts = sub.groupby("cell")["cell"].transform("size")
121 + sub = sub[counts >= config.CELL_MIN_OBS]
122 + est = models.cheng_fe(sub)
123 + iv = models.clapp_iv(sub)
124 + rows.append({"variant": label, "gamma_fe": est["gamma"],
125 + "se_fe": est["se"], "gamma_iv": iv["gamma"],
126 + "se_iv": iv["se"], "n": est["n"]})
127 + print(f" {label:<26} gamma_FE={est['gamma']:+.4f} "
128 + f"gamma_IV={iv['gamma']:+.4f} n={est['n']:,}")
129 + pd.DataFrame(rows).to_csv(out / "robustness.csv", index=False)
130 +
131 +
132 +if __name__ == "__main__":
133 + main()
added scripts/04_make_figures.py +346 −0
@@ -0,0 +1,346 @@
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 analysis sample and the step-02/03 outputs, writes ten PNG figures
6 +to ``figures/``. No titles are drawn inside single-panel figures — captions
7 +in the manuscript carry the message; multi-panel figures use bold
8 +"Panel A/B" headers. One accent hue per figure, doubled by linestyle or
9 +marker so nothing is encoded by colour alone.
10 +
11 +Usage: python scripts/04_make_figures.py
12 +"""
13 +import sys
14 +from pathlib import Path
15 +
16 +import numpy as np
17 +import pandas as pd
18 +
19 +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
20 +
21 +from wp10 import config, sample # noqa: E402
22 +from wp10.plotstyle import (BLUE, BLUES, GREY, INK, LIGHT, RED, TEXTWIDTH, # noqa: E402
23 + apply_style, panel_label, ygrid)
24 +
25 +import matplotlib.pyplot as plt # noqa: E402
26 +import matplotlib.dates as mdates # noqa: E402
27 +
28 +OUT = config.FIGURES
29 +RES = config.REPRODUCED
30 +
31 +
32 +def _save(fig, name):
33 + fig.savefig(OUT / name, bbox_inches="tight", pad_inches=0.02)
34 + plt.close(fig)
35 +
36 +
37 +# ---------------------------------------------------------------- fig 1
38 +def fig_ratio_dist(df):
39 + """Panel A: ratio histogram. Panel B: densities by roll lag."""
40 + fig, axes = plt.subplots(1, 2, figsize=(TEXTWIDTH, 2.9))
41 +
42 + ax = axes[0]
43 + ax.hist(df["ratio"], bins=120, range=(0, 2.5), color=LIGHT,
44 + edgecolor=INK, linewidth=0.25)
45 + med = df["ratio"].median()
46 + ax.axvline(med, color=INK, lw=0.9)
47 + ax.axvline(1.0, color=GREY, lw=0.8, ls=(0, (4, 3)))
48 + ax.text(med - 0.05, ax.get_ylim()[1] * 0.97, f"median = {med:.2f}",
49 + ha="right", va="top", fontsize=8)
50 + ax.text(1.04, ax.get_ylim()[1] * 0.75, "AV = SP", fontsize=8, color=GREY)
51 + ax.set_xlabel("Assessment ratio $AV/SP$")
52 + ax.set_ylabel("Sales")
53 + ax.set_yticks([])
54 + ax.spines["left"].set_visible(False)
55 + panel_label(ax, "Panel A. All sales")
56 +
57 + ax = axes[1]
58 + specs = [("Roll lag $<$ 24 m", df["lag_months"] < 24, BLUES[2], "-"),
59 + ("24–48 m", df["lag_months"].between(24, 48), BLUES[4], (0, (5, 2))),
60 + ("$>$ 48 m", df["lag_months"] > 48, BLUES[5], (0, (1, 1.2)))]
61 + for label, m, color, ls in specs:
62 + ax.hist(df.loc[m, "ratio"], bins=120, range=(0, 2.5), density=True,
63 + histtype="step", lw=1.2, color=color, ls=ls, label=label)
64 + ax.set_xlabel("Assessment ratio $AV/SP$")
65 + ax.set_ylabel("Density")
66 + ax.legend(loc="upper right", handlelength=2.4)
67 + panel_label(ax, "Panel B. By roll lag at sale")
68 + fig.subplots_adjust(wspace=0.25)
69 + _save(fig, "fig_ratio_dist.png")
70 +
71 +
72 +# ---------------------------------------------------------------- fig 2
73 +def fig_binscatter():
74 + """Within-cell binned scatter of ln ratio on ln price — the core fact."""
75 + b = pd.read_csv(RES / "binscatter.csv")
76 + fig, ax = plt.subplots(figsize=(0.72 * TEXTWIDTH, 3.3))
77 + ax.axhline(0, color=GREY, lw=0.7, ls=(0, (4, 3)))
78 + slope = np.polyfit(b["x"], b["y"], 1, w=b["n"])[0]
79 + icept = np.average(b["y"] - slope * b["x"], weights=b["n"])
80 + xs = np.linspace(b["x"].min(), b["x"].max(), 50)
81 + ax.plot(xs, slope * xs + icept, color=BLUE, lw=1.2, zorder=2)
82 + ax.errorbar(b["x"], b["y"], yerr=1.96 * b["se"], fmt="o", ms=4,
83 + mfc=INK, mec=INK, ecolor=INK, elinewidth=0.6, capsize=0,
84 + lw=0, zorder=3)
85 + ax.annotate(f"slope = ${slope:.3f}$", xy=(0.55, slope * 0.55 + icept),
86 + xytext=(0.32, 0.12), fontsize=8.5, color=BLUE,
87 + arrowprops=dict(arrowstyle="-", color=BLUE, lw=0.6,
88 + shrinkA=2, shrinkB=2))
89 + ax.set_xlabel("ln sale price (demeaned within municipality × roll × year)")
90 + ax.set_ylabel("ln assessment ratio (demeaned)")
91 + _save(fig, "fig_binscatter.png")
92 +
93 +
94 +# ---------------------------------------------------------------- fig 3
95 +def fig_time(df):
96 + """Median ratio by sale month; sequential blues by roll vintage,
97 + each segment labelled directly (no legend)."""
98 + d = df.copy()
99 + d["month"] = d["sale_date"].dt.to_period("M").dt.to_timestamp()
100 + fig, ax = plt.subplots(figsize=(TEXTWIDTH, 2.9))
101 + rolls = sorted(d["roll"].unique())
102 + for i, roll in enumerate(rolls):
103 + gg = d[d["roll"] == roll].groupby("month")["ratio"]
104 + g = gg.median()[gg.size() >= 100] # drop thin months (spurious spikes)
105 + g = g[g.index.notna()].sort_index()
106 + if len(g) < 3:
107 + continue
108 + color = BLUES[min(i, len(BLUES) - 1)]
109 + ax.plot(g.index, g.values, lw=1.3, color=color)
110 + ax.annotate(roll, xy=(g.index[-1], g.values[-1]),
111 + xytext=(3, 0), textcoords="offset points",
112 + fontsize=7.5, color=color, va="center")
113 + ax.axhline(1.0, color=GREY, lw=0.7, ls=(0, (4, 3)))
114 + ax.text(pd.Timestamp("2021-02-01"), 1.012, "parity", fontsize=7.5,
115 + color=GREY)
116 + ax.set_ylabel("Median assessment ratio")
117 + ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y"))
118 + ygrid(ax)
119 + _save(fig, "fig_time.png")
120 +
121 +
122 +# ---------------------------------------------------------------- fig 4
123 +def fig_prb_muni():
124 + """Distribution of municipality-level PRB."""
125 + m = pd.read_csv(RES / "iaao_muni.csv")
126 + fig, ax = plt.subplots(figsize=(0.72 * TEXTWIDTH, 3.1))
127 + ax.hist(m["prb"], bins=40, color=LIGHT, edgecolor=INK, linewidth=0.3)
128 + lo, hi = config.IAAO_PRB_RANGE
129 + ax.axvspan(lo, hi, color="0.92", zorder=0)
130 + ax.axvline(0, color=GREY, lw=0.7, ls=(0, (4, 3)))
131 + med = m["prb"].median()
132 + ax.axvline(med, color=RED, lw=1.0)
133 + ymax = ax.get_ylim()[1]
134 + ax.text(med - 0.008, ymax * 0.97, f"median = {med:.2f}", ha="right",
135 + va="top", fontsize=8, color=RED)
136 + ax.text((lo + hi) / 2, ymax * 0.55, "IAAO\nband", ha="center",
137 + fontsize=7.5, color="0.35")
138 + share = (m["prb"] < 0).mean()
139 + ax.text(0.02, 0.97, f"{share:.0%} of municipalities\nhave PRB $<$ 0",
140 + transform=ax.transAxes, fontsize=8, va="top")
141 + ax.set_xlabel("Municipality-level PRB (median across sale years)")
142 + ax.set_ylabel("Municipalities")
143 + ygrid(ax)
144 + _save(fig, "fig_prb_muni.png")
145 +
146 +
147 +# ---------------------------------------------------------------- fig 5
148 +def fig_map():
149 + """Municipal PRB across the province (diverging hue, neutral midpoint)."""
150 + m = pd.read_csv(RES / "iaao_muni.csv")
151 + fig, ax = plt.subplots(figsize=(TEXTWIDTH, 4.4))
152 + v = m["prb"].clip(-0.30, 0.10)
153 + sc = ax.scatter(m["lng"], m["lat"], c=v, s=np.sqrt(m["n"]) * 0.9,
154 + cmap="RdBu", vmin=-0.30, vmax=0.30,
155 + edgecolor=INK, linewidth=0.25, alpha=0.9)
156 + cb = fig.colorbar(sc, ax=ax, shrink=0.75, pad=0.02)
157 + cb.set_label("PRB (negative = regressive)", fontsize=8)
158 + cb.ax.tick_params(labelsize=7.5)
159 + cb.outline.set_linewidth(0.5)
160 + offsets = {"Montréal": (6, -14), "Québec": (8, 4), "Gatineau": (-8, -14),
161 + "Sherbrooke": (8, -10), "Saguenay": (8, 4)}
162 + for _, r in m.nlargest(12, "n").iterrows():
163 + if r["name"] in offsets:
164 + ax.annotate(r["name"], (r["lng"], r["lat"]),
165 + xytext=offsets[r["name"]], textcoords="offset points",
166 + fontsize=7.5)
167 + ax.set_xlabel("Longitude")
168 + ax.set_ylabel("Latitude")
169 + ax.set_xlim(-80, -63)
170 + ax.set_ylim(44.9, 49.6)
171 + _save(fig, "fig_map.png")
172 +
173 +
174 +# ---------------------------------------------------------------- fig 6
175 +def fig_quantile():
176 + """β(τ) with a shaded 95% band; FE/IV benchmarks labelled directly."""
177 + q = pd.read_csv(RES / "quantile.csv")
178 + v = pd.read_csv(RES / "vertical.csv")
179 + fe = v.loc[v["estimator"].str.startswith("Cheng FE"), "beta"].iloc[0]
180 + iv = v.loc[v["estimator"].str.startswith("Clapp"), "beta"].iloc[0]
181 + fig, ax = plt.subplots(figsize=(0.72 * TEXTWIDTH, 3.3))
182 + ax.fill_between(q["tau"], q["beta"] - 1.96 * q["se"],
183 + q["beta"] + 1.96 * q["se"], color=BLUE, alpha=0.18, lw=0)
184 + ax.plot(q["tau"], q["beta"], "o-", color=BLUE, ms=4, lw=1.3)
185 + x1, x0 = q["tau"].max(), q["tau"].min()
186 + for yv, lab, ls, xa, ha in [
187 + (1.0, r"$\beta = 1$ (proportional)", (0, (4, 3)), x1, "right"),
188 + (iv, f"Clapp IV ({iv:.2f})", (0, (1, 1.2)), x1, "right"),
189 + (fe, f"Cheng FE ({fe:.2f})", (0, (6, 2)), x0, "left")]:
190 + ax.axhline(yv, color=GREY, lw=0.8, ls=ls)
191 + ax.annotate(lab, xy=(xa, yv), xytext=(0, 3), textcoords="offset points",
192 + ha=ha, fontsize=7.5, color="0.35")
193 + ax.set_xlabel(r"Quantile $\tau$ of the conditional $\ln AV$ distribution")
194 + ax.set_ylabel(r"$\beta(\tau)$")
195 + ax.set_xticks(q["tau"])
196 + _save(fig, "fig_quantile.png")
197 +
198 +
199 +# ---------------------------------------------------------------- fig 7
200 +def fig_heterogeneity():
201 + """Forest plot of Cheng-FE γ by subgroup, with panel groupings."""
202 + h = pd.read_csv(RES / "heterogeneity.csv").set_index("group")
203 + panels = [
204 + ("Property class", ["Single-family", "Condominium", "Plex (2–5 units)",
205 + "Cottage"]),
206 + ("Building age", ["Age < 20 y", "Age 20–60 y", "Age > 60 y"]),
207 + ("Assessed land share", ["Land share < 0.2", "Land share 0.2–0.4",
208 + "Land share > 0.4"]),
209 + ("Roll lag at sale", ["Roll lag < 24 m", "Roll lag 24–48 m",
210 + "Roll lag > 48 m"]),
211 + ("Municipality size", ["Muni < 1k sales", "Muni 1k–10k sales",
212 + "Muni > 10k sales"]),
213 + ("Sale year", [f"Sales {y}" for y in range(2021, 2027)]),
214 + ]
215 + rows, ypos, headers, seps = [], [], [], []
216 + y = 0
217 + for name, keys in panels:
218 + headers.append((y, name))
219 + y -= 1
220 + for k in keys:
221 + if k in h.index:
222 + rows.append((y, k, h.loc[k]))
223 + ypos.append(y)
224 + y -= 1
225 + seps.append(y + 0.5)
226 + y -= 0.6
227 + fig, ax = plt.subplots(figsize=(0.85 * TEXTWIDTH, 5.6))
228 + for yy, k, r in rows:
229 + ax.errorbar(r["gamma"], yy, xerr=1.96 * r["se"], fmt="o", ms=3.8,
230 + mfc=INK, mec=INK, ecolor=INK, elinewidth=0.8, capsize=1.5)
231 + ax.axvline(0, color=GREY, lw=0.7, ls=(0, (4, 3)))
232 + for yy, name in headers:
233 + ax.text(-0.72, yy, name, fontsize=8.5, fontweight="bold", va="center")
234 + labels = {yy: k.replace("<", "$<$").replace(">", "$>$")
235 + for yy, k, _ in rows}
236 + ax.set_yticks(list(labels.keys()))
237 + ax.set_yticklabels(labels.values(), fontsize=8)
238 + ax.set_ylim(y + 0.4, 1.0)
239 + ax.set_xlim(-0.72, 0.12)
240 + ax.set_xlabel(r"$\gamma$ = elasticity of the assessment ratio with respect"
241 + " to price (negative = regressive)")
242 + ax.spines["left"].set_visible(False)
243 + ax.tick_params(axis="y", length=0)
244 + _save(fig, "fig_heterogeneity.png")
245 +
246 +
247 +# ---------------------------------------------------------------- fig 8
248 +def fig_taxshift():
249 + """Median excess tax burden by within-market price decile (polarity)."""
250 + t = pd.read_csv(RES / "taxshift.csv")
251 + fig, ax = plt.subplots(figsize=(0.85 * TEXTWIDTH, 3.1))
252 + vals = 100 * t["median_rel"]
253 + colors = [RED if v > 0 else BLUE for v in vals]
254 + ax.bar(t["decile"], vals, width=0.72, color=colors, edgecolor=INK,
255 + linewidth=0.4)
256 + ax.axhline(0, color=INK, lw=0.7)
257 + for d, v in zip(t["decile"], vals):
258 + va = "bottom" if v > 0 else "top"
259 + off = 1.2 if v > 0 else -1.2
260 + ax.text(d, v + off, f"{v:+.1f}", ha="center", va=va, fontsize=7.5)
261 + ax.text(2.5, 45, "over-taxed", fontsize=8, color=RED, ha="center")
262 + ax.text(8.5, 14, "under-taxed", fontsize=8, color=BLUE, ha="center")
263 + ax.set_xticks(t["decile"])
264 + ax.set_xlabel("Within-market sale-price decile")
265 + ax.set_ylabel("Median excess tax burden (%)")
266 + ax.set_ylim(min(vals) - 8, max(vals) + 9)
267 + _save(fig, "fig_taxshift.png")
268 +
269 +
270 +# ---------------------------------------------------------------- fig 9
271 +def fig_cod():
272 + """Panel A: COD distribution. Panel B: COD vs market size."""
273 + m = pd.read_csv(RES / "iaao_muni.csv")
274 + fig, axes = plt.subplots(1, 2, figsize=(TEXTWIDTH, 2.9))
275 +
276 + ax = axes[0]
277 + ax.hist(m["cod"], bins=40, color=LIGHT, edgecolor=INK, linewidth=0.3)
278 + ax.axvline(config.IAAO_COD_MAX_SF, color=RED, lw=1.0)
279 + ymax = ax.get_ylim()[1]
280 + ax.text(config.IAAO_COD_MAX_SF + 0.6, ymax * 0.95,
281 + f"IAAO ceiling ({config.IAAO_COD_MAX_SF:.0f})", fontsize=7.5,
282 + color=RED, va="top")
283 + med = m["cod"].median()
284 + ax.axvline(med, color=INK, lw=0.9, ls=(0, (5, 2)))
285 + ax.text(med + 0.6, ymax * 0.72, f"median = {med:.1f}", fontsize=7.5)
286 + ax.set_xlabel("Municipality COD (median across sale years)")
287 + ax.set_ylabel("Municipalities")
288 + panel_label(ax, "Panel A. Distribution")
289 +
290 + ax = axes[1]
291 + ax.scatter(m["n"], m["cod"], s=7, facecolor="none", edgecolor=INK,
292 + linewidth=0.5, alpha=0.65)
293 + ax.axhline(config.IAAO_COD_MAX_SF, color=RED, lw=0.9)
294 + ax.set_xscale("log")
295 + ax.set_xlabel("Sales in municipality, 2021–2026 (log scale)")
296 + ax.set_ylabel("COD")
297 + panel_label(ax, "Panel B. COD and market size")
298 + fig.subplots_adjust(wspace=0.25)
299 + _save(fig, "fig_cod.png")
300 +
301 +
302 +# ---------------------------------------------------------------- fig 10
303 +def fig_robustness():
304 + """γ across sample variants — FE (filled circles) vs IV (open squares)."""
305 + r = pd.read_csv(RES / "robustness.csv")
306 + fig, ax = plt.subplots(figsize=(0.85 * TEXTWIDTH, 3.6))
307 + y = np.arange(len(r))[::-1].astype(float)
308 + ax.errorbar(r["gamma_fe"], y + 0.16, xerr=1.96 * r["se_fe"], fmt="o",
309 + ms=4, mfc=BLUE, mec=BLUE, ecolor=BLUE, elinewidth=0.8,
310 + capsize=1.5, lw=0, label="Cheng FE")
311 + ax.errorbar(r["gamma_iv"], y - 0.16, xerr=1.96 * r["se_iv"], fmt="s",
312 + ms=4, mfc="white", mec=RED, ecolor=RED, elinewidth=0.8,
313 + capsize=1.5, lw=0, label="Clapp IV")
314 + ax.axvline(0, color=GREY, lw=0.7, ls=(0, (4, 3)))
315 + ax.set_yticks(y)
316 + labels = [str(v).replace("<=", "$\\leq$").replace(">=", "$\\geq$")
317 + .replace("<", "$<$").replace("$100k", "\\$100k")
318 + for v in r["variant"]]
319 + ax.set_yticklabels(labels, fontsize=8)
320 + ax.set_xlabel(r"$\gamma$ (negative = regressive)")
321 + ax.legend(loc="lower left", markerscale=1.1)
322 + ax.spines["left"].set_visible(False)
323 + ax.tick_params(axis="y", length=0)
324 + _save(fig, "fig_robustness.png")
325 +
326 +
327 +def main() -> None:
328 + config.ensure_dirs()
329 + apply_style()
330 + df = sample.load()
331 + fig_ratio_dist(df)
332 + fig_binscatter()
333 + fig_time(df)
334 + fig_prb_muni()
335 + fig_map()
336 + fig_quantile()
337 + fig_heterogeneity()
338 + fig_taxshift()
339 + fig_cod()
340 + fig_robustness()
341 + made = sorted(p.name for p in OUT.glob("fig_*.png"))
342 + print(f"{len(made)} figures written:", ", ".join(made))
343 +
344 +
345 +if __name__ == "__main__":
346 + main()
added scripts/05_make_tables.py +241 −0
@@ -0,0 +1,241 @@
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 CSV outputs into booktabs tables under
6 +``results/tables/``. The paper inputs these files directly, so every number
7 +in the manuscript is machine-generated.
8 +
9 +Usage: python scripts/05_make_tables.py
10 +"""
11 +import sys
12 +from pathlib import Path
13 +
14 +import pandas as pd
15 +
16 +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
17 +
18 +from wp10 import config # noqa: E402
19 +
20 +RES = config.REPRODUCED
21 +TAB = config.TABLES
22 +
23 +
24 +def _stars(coef: float, se: float) -> str:
25 + t = abs(coef / se) if se > 0 else 0
26 + return "***" if t > 2.576 else "**" if t > 1.96 else "*" if t > 1.645 else ""
27 +
28 +
29 +def _f(x, nd=3):
30 + return f"{x:,.{nd}f}"
31 +
32 +
33 +def t_summary():
34 + s = pd.read_csv(RES / "summary_stats.csv")
35 + lines = [r"\begin{tabular}{lrrrrrr}", r"\toprule",
36 + r"Variable & $n$ & Mean & SD & P10 & Median & P90 \\",
37 + r"\midrule"]
38 + for _, r in s.iterrows():
39 + big = r["mean"] > 1000
40 + nd = 0 if big else 3 if r["mean"] < 10 else 1
41 + label = (str(r["variable"]).replace("($)", "(\\$)")
42 + .replace("(m2)", "(m$^2$)"))
43 + lines.append(
44 + f"{label} & {r['n']:,.0f} & {_f(r['mean'], nd)} & "
45 + f"{_f(r['sd'], nd)} & {_f(r['p10'], nd)} & {_f(r['p50'], nd)} & "
46 + f"{_f(r['p90'], nd)} \\\\")
47 + lines += [r"\bottomrule", r"\end{tabular}"]
48 + (TAB / "summary_stats.tex").write_text("\n".join(lines))
49 +
50 +
51 +def t_iaao():
52 + d = pd.read_csv(RES / "iaao_overall.csv")
53 + lines = [r"\begin{tabular}{lrcccc}", r"\toprule",
54 + r"Sample & $n$ & Median ratio & COD & PRD & PRB \\",
55 + r"\midrule"]
56 + for _, r in d.iterrows():
57 + lines.append(
58 + f"{r['group']} & {r['n']:,.0f} & {_f(r['median_ratio'])} & "
59 + f"{_f(r['cod'], 1)} & {_f(r['prd'])} & {_f(r['prb'], 4)} \\\\")
60 + lines.append(
61 + f" & & \\footnotesize[{_f(r['median_lo'])}, {_f(r['median_hi'])}]"
62 + f" & \\footnotesize[{_f(r['cod_lo'], 1)}, {_f(r['cod_hi'], 1)}]"
63 + f" & \\footnotesize[{_f(r['prd_lo'])}, {_f(r['prd_hi'])}]"
64 + f" & \\footnotesize[{_f(r['prb_lo'], 4)}, {_f(r['prb_hi'], 4)}] \\\\")
65 + lines += [r"\bottomrule", r"\end{tabular}"]
66 + (TAB / "iaao.tex").write_text("\n".join(lines))
67 +
68 +
69 +def t_cities():
70 + d = pd.read_csv(RES / "iaao_cities.csv").sort_values("n", ascending=False)
71 + lines = [r"\begin{tabular}{lrccccc}", r"\toprule",
72 + r"Municipality & $n$ & Median ratio & COD & PRD & PRB & "
73 + r"\makecell{Share of years\\PRB $<$ 0} \\",
74 + r"\midrule"]
75 + for _, r in d.iterrows():
76 + lines.append(
77 + f"{r['muni']} & {r['n']:,.0f} & {_f(r['median_ratio'])} & "
78 + f"{_f(r['cod'], 1)} & {_f(r['prd'])} & {_f(r['prb'], 4)} & "
79 + f"{_f(r['share_years_prb_neg'], 2)} \\\\")
80 + lines += [r"\bottomrule", r"\end{tabular}"]
81 + (TAB / "iaao_cities.tex").write_text("\n".join(lines))
82 +
83 +
84 +def t_vertical():
85 + v = pd.read_csv(RES / "vertical.csv")
86 + pf = pd.read_csv(RES / "paglin_fogarty.csv", index_col=0).squeeze()
87 + pf = {k: (v if k == "estimator" else float(v)) for k, v in pf.items()}
88 + q = pd.read_csv(RES / "quantile.csv")
89 +
90 + def row(label, vals):
91 + return label + " & " + " & ".join(vals) + r" \\"
92 +
93 + cols = list(v["estimator"])
94 + lines = [r"\begin{tabular}{l" + "c" * len(cols) + "}", r"\toprule",
95 + row("", [f"({i+1})" for i in range(len(cols))]),
96 + row("", [c.replace(" (pooled)", "").replace(" (cell)", "")
97 + .replace(" (rank instrument)", "") for c in cols]),
98 + r"\midrule",
99 + row(r"$\beta$ (ln sale price)",
100 + [f"{_f(r['beta'])}{_stars(r['beta'] - 1, r['se'])}"
101 + for _, r in v.iterrows()]),
102 + row("", [f"({_f(r['se'])})" for _, r in v.iterrows()]),
103 + row(r"$\gamma = \beta - 1$",
104 + [f"{r['gamma']:+.3f}" for _, r in v.iterrows()]),
105 + r"\midrule",
106 + row("Cell fixed effects",
107 + ["No", "Yes", "Yes"]),
108 + row("Error-in-price robust", ["No", "No", "Yes"]),
109 + row("Observations", [f"{r['n']:,.0f}" for _, r in v.iterrows()]),
110 + row("$R^2$", [f"{_f(r['r2'])}" for _, r in v.iterrows()]),
111 + r"\bottomrule", r"\end{tabular}"]
112 + (TAB / "vertical.tex").write_text("\n".join(lines))
113 + # stars in this table test H0: beta = 1 (proportionality), noted in caption
114 +
115 + lines = [r"\begin{tabular}{lcc}", r"\toprule",
116 + r" & Coefficient & Std.\ error \\", r"\midrule",
117 + f"Intercept ($\\$$) & {pf['intercept']:,.0f} & "
118 + f"{pf['intercept_se']:,.0f} \\\\",
119 + f"Slope on sale price & {_f(pf['slope'])} & "
120 + f"{_f(pf['slope_se'])} \\\\",
121 + f"Observations & \\multicolumn{{2}}{{c}}{{{pf['n']:,.0f}}} \\\\",
122 + r"\bottomrule", r"\end{tabular}"]
123 + (TAB / "paglin_fogarty.tex").write_text("\n".join(lines))
124 +
125 + lines = [r"\begin{tabular}{lccccc}", r"\toprule",
126 + "Quantile $\\tau$ & " + " & ".join(f"{t:.2f}" for t in q["tau"])
127 + + r" \\", r"\midrule",
128 + r"$\beta(\tau)$ & " + " & ".join(
129 + f"{_f(r['beta'])}{_stars(r['beta'] - 1, r['se'])}"
130 + for _, r in q.iterrows()) + r" \\",
131 + " & " + " & ".join(f"({_f(r['se'], 4)})" for _, r in q.iterrows())
132 + + r" \\",
133 + r"\bottomrule", r"\end{tabular}"]
134 + (TAB / "quantile.tex").write_text("\n".join(lines))
135 +
136 +
137 +def t_heterogeneity():
138 + h = pd.read_csv(RES / "heterogeneity.csv")
139 + panels = {
140 + "Property class": ["Single-family", "Condominium", "Plex (2–5 units)",
141 + "Cottage"],
142 + "Building age": ["Age < 20 y", "Age 20–60 y", "Age > 60 y"],
143 + "Assessed land share": ["Land share < 0.2", "Land share 0.2–0.4",
144 + "Land share > 0.4"],
145 + "Roll lag at sale": ["Roll lag < 24 m", "Roll lag 24–48 m",
146 + "Roll lag > 48 m"],
147 + "Municipality size": ["Muni < 1k sales", "Muni 1k–10k sales",
148 + "Muni > 10k sales"],
149 + "Sale year": [f"Sales {y}" for y in range(2021, 2027)],
150 + }
151 + lines = [r"\begin{tabular}{lccr}", r"\toprule",
152 + r"Subsample & $\gamma$ & Std.\ error & $n$ \\"]
153 + for panel, keys in panels.items():
154 + lines.append(r"\midrule")
155 + lines.append(r"\multicolumn{4}{l}{\itshape " + panel + r"}\\")
156 + for k in keys:
157 + r = h[h["group"] == k]
158 + if r.empty:
159 + continue
160 + r = r.iloc[0]
161 + label = (k.replace("<", "$<$").replace(">", "$>$")
162 + .replace("–", "--"))
163 + lines.append(
164 + f"\\quad {label} & {r['gamma']:+.3f}"
165 + f"{_stars(r['gamma'], r['se'])} & ({_f(r['se'])}) & "
166 + f"{r['n']:,.0f} \\\\")
167 + lines += [r"\bottomrule", r"\end{tabular}"]
168 + (TAB / "heterogeneity.tex").write_text("\n".join(lines))
169 +
170 +
171 +def t_taxshift():
172 + t = pd.read_csv(RES / "taxshift.csv")
173 + lines = [r"\begin{tabular}{lcccc}", r"\toprule",
174 + r"Price decile & $n$ & \makecell{Mean excess\\burden (\%)} & "
175 + r"\makecell{Median excess\\burden (\%)} & Std.\ error \\",
176 + r"\midrule"]
177 + for _, r in t.iterrows():
178 + lines.append(
179 + f"{int(r['decile'])} & {r['n']:,.0f} & "
180 + f"{100 * r['mean_rel']:+.1f} & {100 * r['median_rel']:+.1f} & "
181 + f"({100 * r['se']:.2f}) \\\\")
182 + lines += [r"\bottomrule", r"\end{tabular}"]
183 + (TAB / "taxshift.tex").write_text("\n".join(lines))
184 +
185 +
186 +def t_horizontal():
187 + h = pd.read_csv(RES / "horizontal.csv", index_col=0)
188 + meta = pd.read_csv(RES / "horizontal_meta.csv", index_col=0).squeeze()
189 + labels = {"age_dec": "Building age (decades)",
190 + "land_share": "Assessed land share",
191 + "is_condo": "Condominium",
192 + "is_plex": "Plex (2--5 units)",
193 + "is_cottage": "Cottage"}
194 + lines = [r"\begin{tabular}{lcc}", r"\toprule",
195 + r" & Coefficient & Std.\ error \\", r"\midrule"]
196 + for k, lab in labels.items():
197 + r = h.loc[k]
198 + lines.append(f"{lab} & {r['coef']:+.4f}{_stars(r['coef'], r['se'])} & "
199 + f"({_f(r['se'], 4)}) \\\\")
200 + lines += [r"\midrule",
201 + f"Mean of dependent variable & \\multicolumn{{2}}{{c}}"
202 + f"{{{float(meta['mean_dep']):.3f}}} \\\\",
203 + f"Observations & \\multicolumn{{2}}{{c}}{{{float(meta['n']):,.0f}}} \\\\",
204 + r"\bottomrule", r"\end{tabular}"]
205 + (TAB / "horizontal.tex").write_text("\n".join(lines))
206 +
207 +
208 +def t_robustness():
209 + r = pd.read_csv(RES / "robustness.csv")
210 + lines = [r"\begin{tabular}{lccccr}", r"\toprule",
211 + r"Sample variant & $\gamma_{\text{FE}}$ & Std.\ err. & "
212 + r"$\gamma_{\text{IV}}$ & Std.\ err. & $n$ \\",
213 + r"\midrule"]
214 + for _, x in r.iterrows():
215 + label = (str(x["variant"]).replace("<=", "$\\le$")
216 + .replace(">=", "$\\ge$").replace("$100k", "\\$100k"))
217 + lines.append(
218 + f"{label} & {x['gamma_fe']:+.3f}{_stars(x['gamma_fe'], x['se_fe'])}"
219 + f" & ({_f(x['se_fe'])}) & "
220 + f"{x['gamma_iv']:+.3f}{_stars(x['gamma_iv'], x['se_iv'])} & "
221 + f"({_f(x['se_iv'])}) & {x['n']:,.0f} \\\\")
222 + lines += [r"\bottomrule", r"\end{tabular}"]
223 + (TAB / "robustness.tex").write_text("\n".join(lines))
224 +
225 +
226 +def main() -> None:
227 + config.ensure_dirs()
228 + t_summary()
229 + t_iaao()
230 + t_cities()
231 + t_vertical()
232 + t_heterogeneity()
233 + t_taxshift()
234 + t_horizontal()
235 + t_robustness()
236 + made = sorted(p.name for p in TAB.glob("*.tex"))
237 + print(f"{len(made)} tables written:", ", ".join(made))
238 +
239 +
240 +if __name__ == "__main__":
241 + main()
added src/wp10/__init__.py +11 −0
@@ -0,0 +1,11 @@
1 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +"""WP10 — The Assessment Gap in Quebec.
3 +
4 +Vertical and horizontal inequity in municipal property valuation, estimated
5 +from 600k+ residential transactions matched to the provincial assessment roll
6 +(rôle d'évaluation foncière), 2021–2026.
7 +"""
8 +
9 +__version__ = "1.0"
10 +__author__ = "Simon-Pierre Boucher"
11 +__email__ = "contact@spboucher.ai"
added src/wp10/config.py +54 −0
@@ -0,0 +1,54 @@
1 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +"""Paths and global constants for the WP10 pipeline.
3 +
4 +All paths are relative to the repository root, so the pipeline runs on any
5 +machine after cloning. Override the root with the ``WP10_ROOT`` environment
6 +variable if the scripts are launched from elsewhere.
7 +"""
8 +import os
9 +from pathlib import Path
10 +
11 +ROOT = Path(os.environ.get("WP10_ROOT", Path(__file__).resolve().parents[2]))
12 +
13 +RAW_PARQUET = ROOT / "data" / "raw" / "transactions_700k_avec_registre_foncier.parquet"
14 +PROCESSED = ROOT / "data" / "processed"
15 +ANALYSIS_PARQUET = PROCESSED / "analysis.parquet"
16 +FIGURES = ROOT / "figures"
17 +RESULTS = ROOT / "results"
18 +REPRODUCED = RESULTS / "reproduced"
19 +TABLES = RESULTS / "tables"
20 +
21 +# ---------------------------------------------------------------- sample
22 +# Residential CUBF use codes kept in the estimation sample
23 +# 1000 Logement | 1100 Chalet ou maison de villégiature | 1211 Maison mobile
24 +# 1990 Autres immeubles résidentiels
25 +RESIDENTIAL_CUBF = {"1000", "1100", "1211", "1990"}
26 +
27 +MATCH_MAX_DIST_M = 50.0 # roll–transaction geocoding distance ceiling
28 +MATCH_MIN_SCORE = 150.0 # matcher confidence floor (max attainable: 220)
29 +
30 +PRICE_MIN = 50_000 # source feed already truncates below 50k
31 +RATIO_TRIM = (0.01, 0.99) # symmetric trim on the assessment ratio, by roll vintage
32 +
33 +CELL_MIN_OBS = 20 # minimum sales per municipality×roll×year cell (FE cells)
34 +MUNI_MIN_SALES = 100 # minimum sales for municipality-level IAAO statistics
35 +
36 +# ---------------------------------------------------------------- inference
37 +SEED_BOOT = 20260809 # bootstrap seed (IAAO confidence intervals)
38 +N_BOOT = 500 # bootstrap replications
39 +QUANTILES = [0.10, 0.25, 0.50, 0.75, 0.90] # quantile-regression grid
40 +
41 +# IAAO (2013) acceptable ranges, quoted in the paper's tables
42 +IAAO_COD_MAX_SF = 15.0 # single-family, heterogeneous areas
43 +IAAO_PRD_RANGE = (0.98, 1.03)
44 +IAAO_PRB_RANGE = (-0.05, 0.05)
45 +
46 +# Ten largest markets reported individually in the IAAO table
47 +BIG_CITIES = ["Montréal", "Québec", "Laval", "Gatineau", "Longueuil",
48 + "Lévis", "Sherbrooke", "Saguenay", "Trois-Rivières", "Terrebonne"]
49 +
50 +
51 +def ensure_dirs() -> None:
52 + """Create every output directory the pipeline writes to."""
53 + for p in (PROCESSED, FIGURES, REPRODUCED, TABLES):
54 + p.mkdir(parents=True, exist_ok=True)
added src/wp10/iaao.py +109 −0
@@ -0,0 +1,109 @@
1 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +"""IAAO ratio-study statistics.
3 +
4 +Implements the standard diagnostics of the IAAO (2013) *Standard on Ratio
5 +Studies*: the median assessment ratio, the coefficient of dispersion (COD),
6 +the price-related differential (PRD), and the coefficient of price-related
7 +bias (PRB), together with nonparametric bootstrap confidence intervals.
8 +
9 +Notation: r_i = AV_i / SP_i is the assessment ratio of sale i.
10 +"""
11 +from __future__ import annotations
12 +
13 +import numpy as np
14 +import pandas as pd
15 +
16 +from . import config
17 +
18 +
19 +# ---------------------------------------------------------------- point stats
20 +def cod(ratio: np.ndarray) -> float:
21 + """Coefficient of dispersion: 100 × mean |r − med| / med (horizontal equity)."""
22 + med = np.median(ratio)
23 + return 100.0 * np.mean(np.abs(ratio - med)) / med
24 +
25 +
26 +def prd(av: np.ndarray, sp: np.ndarray) -> float:
27 + """Price-related differential: mean ratio ÷ sale-weighted mean ratio.
28 +
29 + PRD > 1 indicates regressivity (low-priced properties assessed at higher
30 + ratios); the IAAO acceptable range is [0.98, 1.03].
31 + """
32 + r = av / sp
33 + return float(np.mean(r) / (np.sum(av) / np.sum(sp)))
34 +
35 +
36 +def prb(av: np.ndarray, sp: np.ndarray) -> tuple[float, float]:
37 + """Coefficient of price-related bias (IAAO 2013, Appendix on PRB).
38 +
39 + Regress the proportional deviation of the ratio from its median on the
40 + log (base 2) of a value proxy that blends the sale price and the
41 + median-deflated assessment:
42 +
43 + (r_i − med) / med = α + PRB · ln2( 0.5·SP_i + 0.5·AV_i/med ) + u_i
44 +
45 + Returns (PRB, robust standard error). PRB = −0.03 means ratios fall by
46 + 3% of the median with every doubling of value: regressive if PRB < 0.
47 + """
48 + r = av / sp
49 + med = np.median(r)
50 + y = (r - med) / med
51 + proxy = 0.5 * sp + 0.5 * av / med
52 + x = np.log2(proxy)
53 + X = np.column_stack([np.ones_like(x), x])
54 + beta, *_ = np.linalg.lstsq(X, y, rcond=None)
55 + resid = y - X @ beta
56 + # HC1 robust standard error of the slope
57 + XtX_inv = np.linalg.inv(X.T @ X)
58 + meat = (X * (resid ** 2)[:, None]).T @ X
59 + k = len(y) / (len(y) - 2)
60 + se = float(np.sqrt(k * (XtX_inv @ meat @ XtX_inv)[1, 1]))
61 + return float(beta[1]), se
62 +
63 +
64 +# ---------------------------------------------------------------- bootstrap
65 +def bootstrap_ci(av: np.ndarray, sp: np.ndarray,
66 + n_boot: int = config.N_BOOT,
67 + seed: int = config.SEED_BOOT) -> dict:
68 + """Percentile bootstrap 95% CIs for the median ratio, COD, PRD and PRB."""
69 + rng = np.random.default_rng(seed)
70 + n = len(av)
71 + stats = {"median": [], "cod": [], "prd": [], "prb": []}
72 + for _ in range(n_boot):
73 + idx = rng.integers(0, n, n)
74 + a, s = av[idx], sp[idx]
75 + r = a / s
76 + stats["median"].append(np.median(r))
77 + stats["cod"].append(cod(r))
78 + stats["prd"].append(prd(a, s))
79 + stats["prb"].append(prb(a, s)[0])
80 + return {k: (float(np.percentile(v, 2.5)), float(np.percentile(v, 97.5)))
81 + for k, v in stats.items()}
82 +
83 +
84 +# ---------------------------------------------------------------- group table
85 +def group_metrics(df: pd.DataFrame, by: str | list[str],
86 + min_n: int = 50, ci: bool = False) -> pd.DataFrame:
87 + """IAAO statistics computed within each group of ``by``.
88 +
89 + Returns one row per group with n, median ratio, COD, PRD, PRB (and its
90 + SE); optionally percentile-bootstrap CIs (slow — reserve for headline
91 + rows).
92 + """
93 + rows = []
94 + for key, g in df.groupby(by):
95 + if len(g) < min_n:
96 + continue
97 + av = g["role_valeur_immeuble"].to_numpy(float)
98 + sp = g["amount"].to_numpy(float)
99 + r = av / sp
100 + b, se = prb(av, sp)
101 + row = {"group": key if isinstance(key, str) else "_".join(map(str, key)),
102 + "n": len(g), "median_ratio": float(np.median(r)),
103 + "cod": cod(r), "prd": prd(av, sp), "prb": b, "prb_se": se}
104 + if ci:
105 + cis = bootstrap_ci(av, sp)
106 + for stat, (lo, hi) in cis.items():
107 + row[f"{stat}_lo"], row[f"{stat}_hi"] = lo, hi
108 + rows.append(row)
109 + return pd.DataFrame(rows)
added src/wp10/models.py +202 −0
@@ -0,0 +1,202 @@
1 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +"""Econometric estimators for WP10.
3 +
4 +Every vertical-inequity estimator ultimately measures how the assessment
5 +ratio r = AV/SP moves with market value. The workhorse is the log-log
6 +(Cheng 1974) regression
7 +
8 + ln AV_i = α_c(i) + β · ln SP_i + ε_i ,
9 +
10 +where α_c(i) is a municipality × roll vintage × sale-year fixed effect that
11 +absorbs the mechanical drift of ratios between triennial reference dates.
12 +β < 1 ⇒ regressive assessment. We report γ ≡ β − 1, the elasticity of the
13 +assessment *ratio* with respect to price (γ < 0 ⇒ regressive), estimated by
14 +
15 + - pooled OLS (no fixed effects),
16 + - absorbing least squares with cell fixed effects,
17 + - Clapp's (1990) rank-based IV, which purges the attenuation/mean-reversion
18 + bias that pushes OLS toward spurious regressivity when sale prices carry
19 + idiosyncratic noise,
20 + - quantile regression across the conditional distribution.
21 +
22 +Inference is clustered by municipality throughout.
23 +"""
24 +from __future__ import annotations
25 +
26 +import numpy as np
27 +import pandas as pd
28 +import statsmodels.api as sm
29 +from linearmodels.iv import IV2SLS
30 +from linearmodels.iv.absorbing import AbsorbingLS
31 +
32 +from . import config
33 +
34 +
35 +# ---------------------------------------------------------------- helpers
36 +def _demean(df: pd.DataFrame, cols: list[str], by: str = "cell") -> pd.DataFrame:
37 + """Within-transform ``cols`` inside groups of ``by`` (suffix ``_w``)."""
38 + out = df.copy()
39 + for c in cols:
40 + out[c + "_w"] = df[c] - df.groupby(by)[c].transform("mean")
41 + return out
42 +
43 +
44 +def _cluster_ols(y: np.ndarray, X: np.ndarray, clusters: np.ndarray,
45 + dof_adjust: int = 0):
46 + """OLS with CRVE (cluster-robust) inference via statsmodels."""
47 + model = sm.OLS(y, X)
48 + return model.fit(cov_type="cluster", cov_kwds={"groups": clusters})
49 +
50 +
51 +# ---------------------------------------------------------------- Cheng / FE
52 +def cheng_pooled(df: pd.DataFrame) -> dict:
53 + """Pooled Cheng regression: ln AV on ln SP, no fixed effects."""
54 + X = sm.add_constant(df["ln_price"].to_numpy())
55 + res = _cluster_ols(df["ln_av"].to_numpy(), X, df["muni"].to_numpy())
56 + return {"estimator": "Cheng OLS (pooled)", "beta": res.params[1],
57 + "se": res.bse[1], "gamma": res.params[1] - 1.0,
58 + "n": int(res.nobs), "r2": res.rsquared}
59 +
60 +
61 +def cheng_fe(df: pd.DataFrame, extra_controls: list[str] | None = None) -> dict:
62 + """Cheng regression absorbing municipality × roll × sale-year cells."""
63 + dep = df["ln_av"]
64 + exog_cols = ["ln_price"] + (extra_controls or [])
65 + exog = df[exog_cols]
66 + absorb = pd.DataFrame({"cell": df["cell"].astype("category")})
67 + mod = AbsorbingLS(dep, exog, absorb=absorb)
68 + res = mod.fit(cov_type="clustered", clusters=df["muni"].astype("category"))
69 + return {"estimator": "Cheng FE (cell)", "beta": float(res.params["ln_price"]),
70 + "se": float(res.std_errors["ln_price"]),
71 + "gamma": float(res.params["ln_price"]) - 1.0,
72 + "n": int(res.nobs), "r2": float(res.rsquared)}
73 +
74 +
75 +def paglin_fogarty(df: pd.DataFrame) -> dict:
76 + """Levels regression AV = a + b·SP (Paglin & Fogarty 1972).
77 +
78 + A positive intercept with b below the overall assessment level indicates
79 + regressivity in levels.
80 + """
81 + X = sm.add_constant(df["amount"].to_numpy(float))
82 + res = _cluster_ols(df["role_valeur_immeuble"].to_numpy(float), X,
83 + df["muni"].to_numpy())
84 + return {"estimator": "Paglin–Fogarty (levels)",
85 + "intercept": res.params[0], "intercept_se": res.bse[0],
86 + "slope": res.params[1], "slope_se": res.bse[1],
87 + "n": int(res.nobs), "r2": res.rsquared}
88 +
89 +
90 +# ---------------------------------------------------------------- Clapp IV
91 +def clapp_iv(df: pd.DataFrame) -> dict:
92 + """Clapp (1990) rank-based IV on within-cell demeaned data.
93 +
94 + The instrument z ∈ {−1, 0, +1} flags sales in the bottom/top third of
95 + *both* the ln AV and ln SP within-cell distributions. Because z carries
96 + only coarse rank information, it is (near-)orthogonal to the transitory
97 + component of either variable, undoing the attenuation that biases OLS
98 + toward regressivity (β̂ < β) when prices are noisy.
99 + """
100 + d = _demean(df, ["ln_av", "ln_price"])
101 + g_av = d.groupby("cell")["ln_av"].rank(pct=True)
102 + g_sp = d.groupby("cell")["ln_price"].rank(pct=True)
103 + z = np.zeros(len(d))
104 + z[(g_av <= 1 / 3) & (g_sp <= 1 / 3)] = -1.0
105 + z[(g_av > 2 / 3) & (g_sp > 2 / 3)] = 1.0
106 + d["z"] = z
107 + res = IV2SLS(d["ln_av_w"], None, d[["ln_price_w"]], d[["z"]]).fit(
108 + cov_type="clustered", clusters=d["muni"].astype("category"))
109 + beta = float(res.params["ln_price_w"])
110 + return {"estimator": "Clapp IV (rank instrument)", "beta": beta,
111 + "se": float(res.std_errors["ln_price_w"]), "gamma": beta - 1.0,
112 + "n": int(res.nobs), "r2": float(res.rsquared)}
113 +
114 +
115 +# ---------------------------------------------------------------- quantiles
116 +def quantile_betas(df: pd.DataFrame, taus: list[float] = config.QUANTILES,
117 + max_n: int = 250_000, seed: int = config.SEED_BOOT) -> pd.DataFrame:
118 + """Quantile regressions of demeaned ln AV on demeaned ln SP.
119 +
120 + Estimated on a seeded random subsample (IRLS on the full 600k sample is
121 + needlessly slow; the subsample SEs are already microscopic).
122 + """
123 + d = _demean(df, ["ln_av", "ln_price"])
124 + if len(d) > max_n:
125 + d = d.sample(max_n, random_state=seed)
126 + X = sm.add_constant(d["ln_price_w"].to_numpy())
127 + y = d["ln_av_w"].to_numpy()
128 + rows = []
129 + for tau in taus:
130 + r = sm.QuantReg(y, X).fit(q=tau, max_iter=2000)
131 + rows.append({"tau": tau, "beta": r.params[1], "se": r.bse[1],
132 + "gamma": r.params[1] - 1.0, "n": len(d)})
133 + return pd.DataFrame(rows)
134 +
135 +
136 +# ---------------------------------------------------------------- subgroups
137 +def gamma_by_group(df: pd.DataFrame, groups: dict[str, pd.Series]) -> pd.DataFrame:
138 + """Cheng-FE γ estimated separately on each labelled subsample.
139 +
140 + ``groups`` maps a label to a boolean mask over ``df``. Subsamples keep
141 + only cells that retain ≥ CELL_MIN_OBS sales after masking.
142 + """
143 + rows = []
144 + for label, mask in groups.items():
145 + sub = df[mask]
146 + counts = sub.groupby("cell")["cell"].transform("size")
147 + sub = sub[counts >= config.CELL_MIN_OBS]
148 + if len(sub) < 2_000:
149 + continue
150 + est = cheng_fe(sub)
151 + rows.append({"group": label, "gamma": est["gamma"], "se": est["se"],
152 + "n": est["n"]})
153 + return pd.DataFrame(rows)
154 +
155 +
156 +# ---------------------------------------------------------------- horizontal
157 +def horizontal_dispersion(df: pd.DataFrame) -> tuple[pd.DataFrame, dict]:
158 + """Horizontal inequity: who gets the noisiest assessments?
159 +
160 + Regresses the absolute log deviation of a sale's ratio from its cell
161 + median, |ln r_i − med_c ln r|, on property characteristics with cell
162 + fixed effects. Positive coefficients = less uniform assessment.
163 + Returns (coefficient table, fit metadata).
164 + """
165 + d = df.copy()
166 + d["abs_dev"] = (d["ln_ratio"]
167 + - d.groupby("cell")["ln_ratio"].transform("median")).abs()
168 + d["age_dec"] = d["age"] / 10.0
169 + d["is_condo"] = (d["prop_class"] == "condo").astype(float)
170 + d["is_plex"] = (d["prop_class"] == "plex").astype(float)
171 + d["is_cottage"] = (d["prop_class"] == "cottage").astype(float)
172 + d = d.dropna(subset=["abs_dev", "age_dec", "land_share"])
173 + exog_cols = ["age_dec", "land_share", "is_condo", "is_plex", "is_cottage"]
174 + mod = AbsorbingLS(d["abs_dev"], d[exog_cols],
175 + absorb=pd.DataFrame({"cell": d["cell"].astype("category")}))
176 + res = mod.fit(cov_type="clustered", clusters=d["muni"].astype("category"))
177 + tab = pd.DataFrame({"coef": res.params, "se": res.std_errors,
178 + "tstat": res.tstats})
179 + return tab, {"n": int(res.nobs), "r2": float(res.rsquared),
180 + "mean_dep": float(d["abs_dev"].mean())}
181 +
182 +
183 +# ---------------------------------------------------------------- tax shift
184 +def tax_shift(df: pd.DataFrame, n_bins: int = 10) -> pd.DataFrame:
185 + """Implied property-tax shift from differential assessment.
186 +
187 + Within a taxing cell the levy is proportional to AV, so a property whose
188 + ratio exceeds the cell median by x% pays x% more tax than under uniform
189 + assessment. We compute rel_i = r_i / med_c(r) − 1 and average it by
190 + within-cell sale-price decile.
191 + """
192 + d = df.copy()
193 + d["rel"] = d["ratio"] / d.groupby("cell")["ratio"].transform("median") - 1.0
194 + d["decile"] = (d.groupby("cell")["amount"]
195 + .rank(pct=True)
196 + .mul(n_bins).add(1 - 1e-9).astype(int).clip(1, n_bins))
197 + out = (d.groupby("decile")
198 + .agg(n=("rel", "size"), mean_rel=("rel", "mean"),
199 + median_rel=("rel", "median"),
200 + se=("rel", lambda s: s.std() / np.sqrt(len(s))))
201 + .reset_index())
202 + return out
added src/wp10/plotstyle.py +75 −0
@@ -0,0 +1,75 @@
1 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +"""Shared matplotlib style for all WP10 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/wp10/sample.py +128 −0
@@ -0,0 +1,128 @@
1 +# Author: Simon-Pierre Boucher — contact@spboucher.ai
2 +"""Estimation-sample construction for WP10.
3 +
4 +Reads the raw matched transaction–roll snapshot, restricts to residential
5 +arm's-length sales with a high-confidence roll match, builds the assessment
6 +ratio and every derived regressor, applies the trims, and writes
7 +``data/processed/analysis.parquet``.
8 +
9 +Key variable definitions
10 +------------------------
11 +ratio AV / SP where AV = ``role_valeur_immeuble`` (total assessed value
12 + on the roll in force at the sale date) and SP = ``amount``.
13 +ln_ratio log(ratio).
14 +cell municipality × roll vintage × sale year — the market-timing cell
15 + inside which assessed values share a common reference date, so
16 + the roll lag is constant and ratio comparisons are clean.
17 +lag_months months elapsed between the roll's market-condition reference
18 + date (July 1, by statute) and the sale date.
19 +land_share assessed land value / total assessed value.
20 +age sale year minus year built (from the roll where available).
21 +"""
22 +import numpy as np
23 +import pandas as pd
24 +
25 +from . import config
26 +
27 +
28 +def _property_class(df: pd.DataFrame) -> pd.Series:
29 + """Harmonised dwelling class from the listing type, CUBF code and unit count."""
30 + cls = pd.Series("other", index=df.index, dtype="object")
31 + cls[df["role_cubf"] == "1100"] = "cottage"
32 + cls[df["role_cubf"] == "1211"] = "mobile"
33 + is_dwelling = df["role_cubf"] == "1000"
34 + cls[is_dwelling & (df["propertyType"] == "condo")] = "condo"
35 + cls[is_dwelling & (df["propertyType"] == "plex")] = "plex"
36 + cls[is_dwelling & (df["propertyType"] == "unifamilial")] = "single_family"
37 + # dwellings the feed left untyped: use the roll's unit count
38 + untyped = is_dwelling & (cls == "other")
39 + cls[untyped & (df["role_nb_logements"] == 1)] = "single_family"
40 + cls[untyped & (df["role_nb_logements"].between(2, 5))] = "plex"
41 + return cls
42 +
43 +
44 +def build(raw: pd.DataFrame | None = None) -> pd.DataFrame:
45 + """Apply every sample restriction and return the analysis DataFrame."""
46 + df = raw if raw is not None else pd.read_parquet(config.RAW_PARQUET)
47 + n0 = len(df)
48 + log = [("raw snapshot", n0)]
49 +
50 + # -------------------------------------------------- residential use codes
51 + df = df[df["role_cubf"].isin(config.RESIDENTIAL_CUBF)]
52 + log.append(("residential CUBF (1000/1100/1211/1990)", len(df)))
53 +
54 + # -------------------------------------------------- match confidence
55 + df = df[(df["match_dist_m"] <= config.MATCH_MAX_DIST_M)
56 + & (df["match_score"] >= config.MATCH_MIN_SCORE)]
57 + log.append((f"match dist ≤ {config.MATCH_MAX_DIST_M:.0f} m & score ≥ "
58 + f"{config.MATCH_MIN_SCORE:.0f}", len(df)))
59 +
60 + # -------------------------------------------------- valid AV and SP
61 + df = df[(df["amount"] >= config.PRICE_MIN)
62 + & (df["role_valeur_immeuble"] > 5_000)
63 + & df["role_date_cond_marche"].notna()]
64 + log.append(("positive AV, SP ≥ 50k, dated roll", len(df)))
65 +
66 + # -------------------------------------------------- derived variables
67 + df = df.copy()
68 + df["sale_date"] = pd.to_datetime(df["date"])
69 + df["sale_year"] = df["tx_year"].astype(int)
70 + df["ratio"] = df["role_valeur_immeuble"] / df["amount"]
71 + df["ln_ratio"] = np.log(df["ratio"])
72 + df["ln_price"] = np.log(df["amount"].astype(float))
73 + df["ln_av"] = np.log(df["role_valeur_immeuble"])
74 + df["lag_months"] = ((df["sale_date"] - df["role_date_cond_marche"]).dt.days
75 + / 30.44)
76 + df["land_share"] = (df["role_valeur_terrain"]
77 + / df["role_valeur_immeuble"]).clip(0, 1)
78 + df["age"] = (df["sale_year"]
79 + - pd.to_numeric(df["role_annee_construction"], errors="coerce"))
80 + df.loc[(df["age"] < 0) | (df["age"] > 300), "age"] = np.nan
81 + df["prop_class"] = _property_class(df)
82 + df["muni"] = df["role_code_mun"]
83 + df["roll"] = df["role_anrole"]
84 + df["cell"] = (df["muni"] + "_" + df["roll"] + "_"
85 + + df["sale_year"].astype(str))
86 +
87 + # -------------------------------------------------- ratio trim (by roll vintage,
88 + # so the mechanical drift of ratios across reference dates is not trimmed away)
89 + lo, hi = config.RATIO_TRIM
90 + q = df.groupby("roll")["ratio"].quantile([lo, hi]).unstack()
91 + df = df.join(q.rename(columns={lo: "_qlo", hi: "_qhi"}), on="roll")
92 + df = df[(df["ratio"] >= df["_qlo"]) & (df["ratio"] <= df["_qhi"])]
93 + df = df.drop(columns=["_qlo", "_qhi"])
94 + log.append((f"ratio inside [{lo:.0%}, {hi:.0%}] of its roll vintage", len(df)))
95 +
96 + # -------------------------------------------------- market-timing cells
97 + counts = df.groupby("cell")["cell"].transform("size")
98 + df = df[counts >= config.CELL_MIN_OBS]
99 + log.append((f"cells (muni × roll × year) with ≥ {config.CELL_MIN_OBS} sales",
100 + len(df)))
101 +
102 + df.attrs["selection_log"] = log
103 + keep = ["id", "sale_date", "sale_year", "amount", "ln_price",
104 + "role_valeur_immeuble", "role_valeur_terrain", "role_valeur_batiment",
105 + "totalArValue", "previousValue", "ln_av", "ratio", "ln_ratio",
106 + "lag_months", "land_share", "age", "prop_class",
107 + "muni", "roll", "cell", "city", "role_municipalite",
108 + "lat", "lng", "role_superficie_terrain_m2", "role_aire_etages_m2",
109 + "role_nb_logements", "match_dist_m", "match_score",
110 + "match_valeur_exacte", "ownerType", "role_cubf"]
111 + return df[keep].reset_index(drop=True)
112 +
113 +
114 +def build_and_save() -> pd.DataFrame:
115 + """Build the sample, print the selection log, persist to parquet."""
116 + config.ensure_dirs()
117 + s = build()
118 + for step, n in s.attrs["selection_log"]:
119 + print(f" {n:>9,} after: {step}")
120 + s.to_parquet(config.ANALYSIS_PARQUET, index=False)
121 + return s
122 +
123 +
124 +def load() -> pd.DataFrame:
125 + """Load the processed analysis sample (build it first if missing)."""
126 + if not config.ANALYSIS_PARQUET.exists():
127 + return build_and_save()
128 + return pd.read_parquet(config.ANALYSIS_PARQUET)
129