SPB Git forge

spb/wp16_uqo

Public
10commits 1branches 1releases
2.3 MBsize
maindefault branch
24 min agolast push
Python 49.7% TeX 48.6% Shell 1.7%

harmonisation standard UQO WP (2026-10-17) : WP 16, page titre canonique, README anglais, copies de nœuds retirées

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 h ago (Oct 17, 2026) parent 130eb2b

69 changed files +171 −7,982

modified .gitignore +37 −3
@@ -1,5 +1,39 @@
1 −out/
2 −data/
3 −*.pyc
1 +# Raw & processed data — never committed (see data/raw/README.md for provenance)
2 +data/raw/*
3 +!data/raw/README.md
4 +data/processed/*
5 +!data/processed/.gitkeep
6 +
7 +# Python
4 8 __pycache__/
9 +*.pyc
10 +.pytest_cache/
11 +.venv/
12 +*.egg-info/
13 +
14 +# LaTeX build artifacts (paper/main.pdf IS committed)
15 +paper/*.aux
16 +paper/*.log
17 +paper/*.out
18 +paper/*.toc
19 +paper/*.lof
20 +paper/*.lot
21 +paper/*.fls
22 +paper/*.fdb_latexmk
23 +paper/*.bbl
24 +paper/*.blg
25 +paper/*.spl
26 +paper/*.synctex.gz
27 +paper/*.run.xml
28 +paper/*.bcf
29 +
30 +# Logs
31 +logs/
32 +*.log
33 +
34 +# macOS
5 35 .DS_Store
36 +
37 +# Project-specific (deviation: pipeline outputs live in out/<version>/, data snapshot in data/ — both node-side, never committed)
38 +out/
39 +data/
deleted M3U96aomparables-validation/.gitignore +0 −5
@@ -1,5 +0,0 @@
1 −out/
2 −data/
3 −*.pyc
4 −__pycache__/
5 −.DS_Store
deleted M3U96aomparables-validation/README.md +0 −33
@@ -1,33 +0,0 @@
1 −# Validation empirique de la méthode des comparables (Vrai Prix)
2 −
3 −Projet de recherche : rétro-évaluation à grande échelle de la méthode de comparaison des ventes sur les transactions
4 −résidentielles unifamiliales du Québec (Vrai Prix, 2021-01 → 2026-09). Protocole : `docs/PROTOCOLE.md`.
5 −Rapport de résultats : `docs/RAPPORT.md` / `docs/RAPPORT.pdf`.
6 −
7 −## Organisation
8 −
9 −```
10 −pipeline/ 01_dataset → 02_index → 03_models → 04_backtest → 05_analyze → 06_report
11 −docs/ PROTOCOLE.md, RAPPORT.md(.pdf), header.tex
12 −out/<version>/ sales.parquet, index_asof.npy, models_*.json, est_E*.npy, targets.parquet, tables/, fig/, results.json
13 −data/ (nœud seulement) vraiprix-20261010.db — instantané de la base de production Vrai Prix
14 −ref/ MUN.csv (répertoire des municipalités MAMH : code → MRC → région)
15 −```
16 −
17 −## Exécution
18 −
19 −Le calcul se fait sur **M2U64** (`~/comparables-validation`, venv `~/uqo-eval-terrains/env`, Python 3.14 : pandas, numpy, scipy,
20 −lightgbm, matplotlib). Le laptop garde le code, les docs et une copie des sorties.
21 −
22 −```bash
23 −rsync -a --exclude out --exclude data --exclude .git ~/Desktop/Validation-Comparables/ M2U64:comparables-validation/
24 −ssh M2U64 'cd ~/comparables-validation && CV_VERSION=2026-10-10 ./run_all.sh' # ~ 30 min
25 −./make_pdf.sh # rapatrie out/ + docs/ et compile le PDF
26 −```
27 −
28 −Variables : `CV_VERSION` (dossier de sortie), `CV_SAMPLE=3000` (échantillon de cibles pour un essai), `CV_PROCS` (processus, 20 par défaut).
29 −
30 −## Données
31 −
32 −Instantané `vraiprix.db` pris sur M3U96a (`~/apps/vrai-prix/data`) le 2026-10-10 : 766 596 transactions, 3,75 M d'unités du rôle.
33 −Jeu propre : ventes `unifamilial` jumelées au rôle, règles R0-R11 journalisées dans `out/<v>/exclusions.csv`.
deleted M3U96aomparables-validation/docs/PROTOCOLE.md +0 −101
@@ -1,101 +0,0 @@
1 −# Revisiter la méthode de comparaison des ventes : protocole de validation empirique à grande échelle
2 −
3 −**Titre de travail (article A)** — *Revisiting the Sales Comparison Approach: A Large-Scale Empirical Validation of Comparable Selection, Price Adjustments, and Value Reconciliation*
4 −
5 −**Variante** — *Does the Sales Comparison Approach Actually Work? Evidence from Large-Scale Residential Property Transactions*
6 −
7 −Données : transactions historiques **Vrai Prix** (registre foncier du Québec jumelé au rôle d'évaluation), instantané du 2026-10-10.
8 −
9 −## 1. Question de recherche
10 −
11 −La méthode des comparables, telle qu'enseignée et pratiquée par les évaluateurs agréés, produit-elle réellement les estimations les plus fiables ? Et surtout, quelles règles de **sélection**, d'**ajustement**, de **pondération** et de **réconciliation** des comparables sont empiriquement justifiées ?
12 −
13 −| Dimension | Question testée | Où dans le pipeline |
14 −|---|---|---|
15 −| Sélection | Quels comparables sont réellement les plus pertinents (proximité, similarité physique, « moins ajustés ») ? | E1 (facteur `sel`), E2 |
16 −| Nombre | Faut-il 1, 3, 5, 10, 20 ou 50 ventes ? | E1 (facteur `k`) |
17 −| Ajustements | Quels ajustements sont statistiquement justifiés (grille conventionnelle, hédonique, locale, non linéaire, ratio au rôle) ? | E2 (facteur `adj`) |
18 −| Pondération | Égalitaire, distance, récence, similarité ? | E2 (facteur `w`) |
19 −| Réconciliation | Médiane, moyenne, moyenne pondérée, combinaison optimisée (M9) ? | E2 (facteur `recon`), M9 |
20 −| Fiabilité | Dans quels marchés (densité, région, segment de prix, hétérogénéité) la méthode tient-elle ou défaille-t-elle ? | strates, H2-H5 |
21 −
22 −## 2. Protocole expérimental (rétro-évaluation « point dans le temps »)
23 −
24 −Pour chaque transaction cible :
25 −
26 −1. **Masquage** du prix de vente réel.
27 −2. **Reconstitution du marché à la date d'évaluation** : seules les ventes conclues *strictement avant* la date de la cible sont admissibles (le bien lui-même, s'il a déjà été vendu, est exclu du bassin : on évalue par comparables, pas par vente répétée). L'indice temporel, les coefficients hédoniques et les modèles d'apprentissage sont ré-estimés avec les seules ventes antérieures à la période d'évaluation (indice : ré-estimé chaque mois avec toutes les ventes des mois précédents ; modèles : estimés sur les ventes < 2024-01 pour la validation 2024 et < 2025-01 pour le test 2025-26).
28 −3. **Application** de chaque configuration (sélection × rayon × fenêtre × nombre × ajustement × pondération × réconciliation) sur exactement les mêmes cibles.
29 −4. **Comparaison** avec le prix réel : erreur absolue en pourcentage, biais, part des estimations à ±5/10/20 %, couverture (part des cibles pour lesquelles la configuration produit une valeur), dispersion des prix ajustés (intervalle de valeur).
30 −
31 −### Séparation chronologique
32 −
33 −| Période | Rôle | Ventes |
34 −|---|---|---|
35 −| 2021-01 → 2023-12 | développement (estimation des grilles d'ajustement et des modèles) | ~ 200 000 |
36 −| 2024 | validation : choix des configurations et des hyperparamètres | ~ 65 000 cibles |
37 −| 2025-01 → 2026-09 | évaluation finale hors échantillon | ~ 125 000 cibles |
38 −
39 −Les comparables d'une cible de 2025 peuvent être des ventes de 2024 ou de 2025 antérieures à sa date : c'est l'information dont disposait un évaluateur ce jour-là (nous ignorons le délai de publication au registre foncier, ce qui avantage légèrement toutes les méthodes de la même façon).
40 −
41 −## 3. Expériences
42 −
43 −### E1 — surface empirique de performance Erreur(k, d, t)
44 −
45 −- k ∈ {1, 3, 5, 7, 10, 15, 20, 30, 50} comparables ;
46 −- d ∈ {0,5 ; 1 ; 2 ; 5 ; 10 ; 25} km (rayon maximal) ;
47 −- t ∈ {3, 6, 12, 24} mois (ancienneté maximale) ;
48 −- sélection ∈ {`geo` les plus proches, `sim` les plus semblables physiquement, `mix` compromis} ;
49 −- ajustement ∈ {`none` temps seulement, `hedo` grille hédonique} ; réconciliation = médiane.
50 −
51 −1 296 configurations × toutes les cibles.
52 −
53 −### E2 — plan factoriel des règles (trois ancres (k, d, t) = (3, 2 km, 6 mois), (5, 5 km, 12 mois), (10, 10 km, 24 mois))
54 −
55 −- sélection ∈ {`geo`, `sim`, `mix`, `adj` = comparables nécessitant le moins d'ajustements} ;
56 −- ajustement ∈ {`raw` aucun, `none` temps seul, `conv` grille conventionnelle stylisée, `hedo` grille hédonique globale (within-municipalité), `hedo_reg` grille régionale rétrécie, `local` régression sur le voisinage (≤ 10 km, ≥ 40 ventes, ridge vers la grille régionale), `gbm` ajustements non linéaires (LightGBM), `role` ratio prix/rôle (comparables de la même municipalité et du même rôle)} ;
57 −- pondération ∈ {égale, 1/(0,25 + d), exp(−mois/6), 1/(0,1 + ajustement brut), produit des trois} ;
58 −- réconciliation ∈ {médiane (pondérée), moyenne (pondérée) en dollars}.
59 −
60 −960 configurations.
61 −
62 −### Compétition de modèles
63 −
64 −| Modèle | Définition opérationnelle |
65 −|---|---|
66 −| M1 | médiane des 3 ventes les plus proches (≤ 2 km, ≤ 6 mois), temps seul |
67 −| M2 | moyenne des 5 plus proches (≤ 5 km, ≤ 12 mois), temps seul |
68 −| M3 | 5 comparables choisis par similarité physique |
69 −| M4 | 5 plus proches pondérés par la distance |
70 −| M5 | 5 plus proches pondérés par la récence |
71 −| M6 | grille d'ajustement conventionnelle stylisée (½ du prix au m² pour l'aire, ½ de la valeur foncière au m² pour le terrain plafonnée à 15 %, −0,5 %/an d'âge plafonné à 20 %, ±5 % détaché/attaché) |
72 −| M7 | grille d'ajustement hédonique (coefficients within-municipalité estimés sur les ventes antérieures) |
73 −| M8a / M8b | grille locale (régression sur le voisinage) / ajustements non linéaires (GBM) |
74 −| M9 | réconciliation optimisée : combinaison convexe (NNLS) de sept estimations par comparables, poids appris en 2024 |
75 −| M10 | modèle hédonique global direct (β within + effets région, MRC, municipalité, unité de voisinage) |
76 −| M11a / M11b | gradient boosting sans / avec la valeur au rôle |
77 −| M12 | hybride : combinaison convexe de M11b et des estimations par comparables ajustées (GBM, hédonique) |
78 −| M13 | ratio au rôle : rôle de la cible × ratio prix/rôle médian des 5 plus proches du même rôle |
79 −| M0 | meilleure configuration de E1 ∪ E2 choisie sur la validation 2024, évaluée en 2025-26 |
80 −
81 −### Hypothèses
82 −
83 −- **H1** — un comparable très proche mais peu semblable peut être moins informatif qu'un comparable plus éloigné mais très semblable.
84 −- **H2** — l'ajout de comparables améliore la précision jusqu'à un seuil, au-delà duquel l'hétérogénéité domine ; le seuil dépend de la densité.
85 −- **H3** — les ajustements empiriques locaux surpassent les grilles fixes mais deviennent instables dans les petits échantillons.
86 −- **H4** — la pondération optimale dépend de la liquidité du sous-marché.
87 −- **H5** — les méthodes simples et transparentes rivalisent avec l'apprentissage automatique dans les marchés homogènes.
88 −
89 −### Inférence
90 −
91 −Différences d'erreurs appariées (mêmes cibles) avec intervalles de confiance par **bootstrap par grappes** (municipalité × trimestre), qui respecte les dépendances spatiales et temporelles.
92 −
93 −## 4. Ce que les données permettent — et ne permettent pas
94 −
95 −Caractéristiques observées pour chaque vente (registre foncier ⋈ rôle) : date, prix, coordonnées, municipalité, unité de voisinage, aire habitable (étages), superficie du terrain, année de construction, nombre d'étages, lien physique (détaché, jumelé, en rangée), genre de construction, valeur au rôle (en vigueur à la vente) et sa part terrain. **Non observés** : garage, piscine, nombre de chambres et de salles de bains, qualité et état, rénovations. Les ajustements correspondants ne peuvent donc pas être testés ici ; les erreurs résiduelles les contiennent.
96 −
97 −Nous validons la **précision prédictive** de règles professionnelles *simulées*, pas le jugement réel d'un évaluateur (il faudrait ses dossiers, ses comparables et ses ajustements). La distinction est maintenue partout dans le rapport.
98 −
99 −## 5. Article B (suite envisagée)
100 −
101 −Vers une méthode des comparables optimisée empiriquement : algorithme transparent de sélection/ajustement entraîné sur les ventes historiques et validé sur les ventes futures ; règles conditionnelles au type de bien, à la densité et à la qualité des données ; intégration dans UQO Éval pour montrer aux étudiants la comparaison entre règles traditionnelles et règles estimées.
deleted M3U96aomparables-validation/docs/header.tex +0 −28
@@ -1,28 +0,0 @@
1 −% En-tête LaTeX — Grille empirique d'ajustement des terrains (UQO Éval)
2 −\usepackage{fontspec}
3 −\setmainfont{Helvetica Neue}[Ligatures=TeX]
4 −\setsansfont{Helvetica Neue}
5 −\setmonofont{Menlo}[Scale=0.82]
6 −\usepackage{newunicodechar}
7 −\newunicodechar{→}{\ensuremath{\rightarrow}}\newunicodechar{≥}{\ensuremath{\geq}}\newunicodechar{≤}{\ensuremath{\leq}}\newunicodechar{×}{\ensuremath{\times}}\newunicodechar{−}{\ensuremath{-}}\newunicodechar{≈}{\ensuremath{\approx}}\newunicodechar{∂}{\ensuremath{\partial}}\newunicodechar{⋈}{\ensuremath{\bowtie}}\newunicodechar{Δ}{\ensuremath{\Delta}}\newunicodechar{τ}{\ensuremath{\tau}}\newunicodechar{σ}{\ensuremath{\sigma}}\newunicodechar{β}{\ensuremath{\beta}}\newunicodechar{θ}{\ensuremath{\theta}}\newunicodechar{γ}{\ensuremath{\gamma}}\newunicodechar{α}{\ensuremath{\alpha}}\newunicodechar{δ}{\ensuremath{\delta}}\newunicodechar{ε}{\ensuremath{\varepsilon}}\newunicodechar{²}{\textsuperscript{2}}\newunicodechar{⇒}{\ensuremath{\Rightarrow}}\newunicodechar{▶}{\ensuremath{\blacktriangleright}}\newunicodechar{│}{|}\newunicodechar{▼}{\ensuremath{\blacktriangledown}}\newunicodechar{─}{-}\newunicodechar{┌}{+}\newunicodechar{┐}{+}\newunicodechar{└}{+}\newunicodechar{┘}{+}\newunicodechar{├}{+}\newunicodechar{┤}{+}
8 −\usepackage{xcolor}
9 −\definecolor{uqo}{HTML}{006085}\definecolor{turq}{HTML}{2BCCAE}\definecolor{corail}{HTML}{EA6047}\definecolor{encre}{HTML}{16181B}\definecolor{papier}{HTML}{F4F5F3}
10 −\usepackage{titlesec}
11 −\titleformat{\section}{\Large\bfseries\color{uqo}}{}{0em}{}[{\color{uqo}\titlerule[0.8pt]}]
12 −\titleformat{\subsection}{\large\bfseries\color{uqo}}{}{0em}{}
13 −\titlespacing*{\section}{0pt}{18pt plus 4pt}{8pt}
14 −\usepackage{etoolbox}
15 −\AtBeginEnvironment{longtable}{\scriptsize\renewcommand{\arraystretch}{1.18}}
16 −\usepackage{colortbl}\arrayrulecolor{uqo!35}
17 −\usepackage{tcolorbox}
18 −\renewenvironment{quote}{\begin{tcolorbox}[colback=papier,colframe=turq,boxrule=0pt,leftrule=3pt,arc=1pt,left=6pt,right=6pt,top=4pt,bottom=4pt]}{\end{tcolorbox}}
19 −\usepackage{fancyhdr}
20 −\pagestyle{fancy}\fancyhf{}
21 −\fancyhead[L]{\footnotesize\color{gray}Grille empirique d'ajustement des terrains — UQO Éval}\fancyhead[R]{\footnotesize\color{gray}IMM1003 · IMM1033}
22 −\fancyfoot[C]{\footnotesize\color{gray}\thepage}\renewcommand{\headrulewidth}{0.3pt}
23 −\usepackage{caption}\captionsetup{font=small,labelfont={bf,color=uqo}}
24 −\usepackage{float}\floatplacement{figure}{H}
25 −\setlength{\emergencystretch}{3em}\hyphenpenalty=300\tolerance=1500
26 −\usepackage{microtype}
27 −\makeatletter\renewcommand{\maketitle}{\begin{center}{\color{uqo}\Huge\bfseries\@title\par}\vspace{8pt}{\normalsize\color{gray}\@author\par\@date\par}\end{center}\vspace{10pt}}\makeatother
28 −\usepackage{polyglossia}\setdefaultlanguage[frenchpart=false]{french}
deleted M3U96aomparables-validation/make_pdf.sh +0 −13
@@ -1,13 +0,0 @@
1 −#!/bin/zsh
2 −# Rapatrie les résultats depuis M2U64 et produit docs/RAPPORT.pdf (pandoc → XeLaTeX)
3 −set -e
4 −cd "$(dirname "$0")"
5 −V=${CV_VERSION:-2026-10-10}
6 −mkdir -p out/$V
7 −rsync -az --exclude 'est_E*.npy' --exclude 'sd_E2.npy' --exclude '*_pred_*.npy' --exclude 'sales.parquet' M2U64:comparables-validation/out/$V/ out/$V/
8 −rsync -az M2U64:comparables-validation/docs/ docs/ 2>/dev/null || true
9 −cd docs
10 −pandoc RAPPORT.md -o RAPPORT.pdf --pdf-engine=xelatex -H header.tex --toc --toc-depth=2 \
11 − -V geometry:"a4paper,margin=17mm,top=20mm,bottom=20mm" -V fontsize=10pt -V linkcolor=uqo -V urlcolor=uqo -V toccolor=uqo -V colorlinks=true \
12 − -V toc-title="Table des matières" --resource-path=.:../out/$V/fig 2>&1 | grep -v "Missing character\|Overfull\|Underfull" | tail -8 || true
13 −echo "PDF : $(pwd)/RAPPORT.pdf ($(du -h RAPPORT.pdf | cut -f1))"
deleted M3U96aomparables-validation/pipeline/01_dataset.py +0 −97
@@ -1,97 +0,0 @@
1 −# -*- coding: utf-8 -*-
2 −"""01 — Jeu de données : transactions unifamiliales Vrai Prix ⋈ rôle, nettoyage journalisé,
3 −reventes, géographie, périodes. Sorties : sales.parquet, exclusions.csv, dataset_stats.json."""
4 −from common import *
5 −
6 −con = connect()
7 −log(f"lecture {DB}")
8 −sql = """
9 −select t.id, t.date, t.amount, t.lat, t.lng, t.city, t.property_type, t.year_built, t.floor_area,
10 − t.building_type, t.id_provinc, t.valeur_role as role_vente, t.land_area,
11 − u.code_mun, u.municipalite, u.type_prop as u_type, u.annee_construction, u.aire_etages_m2,
12 − u.superficie_terrain_m2, u.front_terrain_m, u.nb_etages, u.nb_logements, u.lien_physique,
13 − u.genre_construction, u.unite_voisinage, u.dat_cond_marche, u.valeur_terrain, u.valeur_batiment,
14 − u.valeur_role as role_actuel, u.valeur_anterieure as role_anterieur, u.cubf
15 −from transactions t left join units u on u.id_provinc = t.id_provinc
16 −where t.property_type = 'unifamilial'
17 −"""
18 −df = pd.read_sql(sql, con)
19 −log(f"{len(df):,} transactions unifamiliales brutes")
20 −n0 = len(df)
21 −excl = [] # (règle, n)
22 −def drop(mask, rule):
23 − global df
24 − n = int(mask.sum()); excl.append((rule, n)); df = df[~mask].copy(); log(f" {rule}: -{n:,} → {len(df):,}")
25 −
26 −df["date"] = pd.to_datetime(df["date"])
27 −df = df.sort_values(["id_provinc", "date"]).reset_index(drop=True)
28 −drop(df["id_provinc"].isna() | df["code_mun"].isna(), "R0 sans jumelage au rôle (id_provinc / code_mun)")
29 −drop(df.duplicated(["id_provinc", "date"]), "R1 doublons (même unité, même date)")
30 −drop(~df["u_type"].isin(["unifamilial"]) | (df["nb_logements"].fillna(1) > 1), "R2 unité du rôle non unifamiliale (chalet, plex, terrain…) ou > 1 logement")
31 −drop(df["lat"].isna() | df["lng"].isna() | (df["lat"] < 44.5) | (df["lat"] > 63) | (df["lng"] > -56) | (df["lng"] < -80), "R3 coordonnées absentes ou hors Québec")
32 −fa = df["floor_area"].fillna(df["aire_etages_m2"])
33 −df["floor_area"] = fa
34 −drop(fa.isna() | (fa < 40) | (fa > 600), "R4 aire habitable absente ou hors [40, 600] m²")
35 −la = df["land_area"].fillna(df["superficie_terrain_m2"])
36 −df["land_area"] = la
37 −drop(la.isna() | (la < 80) | (la > 20000), "R5 superficie de terrain absente ou hors [80, 20 000] m²")
38 −yb = df["year_built"].fillna(df["annee_construction"])
39 −df["year_built"] = yb
40 −drop(yb.isna() | (yb < 1850) | (yb > df["date"].dt.year), "R6 année de construction absente, < 1850 ou postérieure à la vente (vente de terrain)")
41 −drop((df["year_built"] == df["date"].dt.year), "R6b vente l'année même de la construction (prix du terrain ou d'une construction inachevée : médiane 346 k$ et ratio au rôle 1,15 contre 450-480 k$ et 1,3 aux âges 1-4)")
42 −drop((df["amount"] < 50000) | (df["amount"] > 3_000_000), "R7 prix hors [50 k$, 3 M$]")
43 −ratio = df["amount"] / df["role_vente"]
44 −drop(df["role_vente"].isna() | (df["role_vente"] <= 0) | (ratio < 0.33) | (ratio > 3.0), "R8 rôle absent ou ratio prix/rôle hors [0,33 ; 3] (transactions présumées non représentatives)")
45 −# reventes rapides avec forte variation (flips / rénovations : les caractéristiques enregistrées ne décrivent plus le bien)
46 −df = df.sort_values(["id_provinc", "date"]).reset_index(drop=True)
47 −g = df.groupby("id_provinc")
48 −prev_date = g["date"].shift(1); prev_amt = g["amount"].shift(1)
49 −dd = (df["date"] - prev_date).dt.days
50 −chg = df["amount"] / prev_amt - 1
51 −flip = (dd <= 180) & (chg.abs() > 0.40)
52 −drop(flip.fillna(False), "R9 revente ≤ 180 jours avec variation > 40 % (flip)")
53 −drop(df["lien_physique"].isna() | df["genre_construction"].isna(), "R10 lien physique ou genre de construction absent au rôle")
54 −
55 −# ----------------------------------------------------------------- variables dérivées
56 −rep = load_repertoire()
57 −df = df.merge(rep, on="code_mun", how="left")
58 −drop(df["region"].isna(), "R11 municipalité absente du répertoire MAMH")
59 −df["sale_year"] = df["date"].dt.year
60 −df["month"] = month_index(df["date"])
61 −df["day"] = (df["date"] - pd.Timestamp("2021-01-01")).dt.days.astype(int)
62 −df["age"] = (df["sale_year"] - df["year_built"]).clip(lower=0)
63 −df["ln_price"] = np.log(df["amount"])
64 −df["ln_floor"] = np.log(df["floor_area"])
65 −df["ln_land"] = np.log(df["land_area"])
66 −df["lien"] = df["lien_physique"].map(LIEN).fillna(0).astype(int)
67 −df["genre"] = df["genre_construction"].map(GENRE).fillna(0).astype(int)
68 −df["btype"] = df["building_type"].map(BTYPE).fillna(-1).astype(int)
69 −df["etages"] = df["nb_etages"].fillna(1).clip(1, 3)
70 −df["x_km"], df["y_km"] = to_xy_km(df["lat"], df["lng"])
71 −# millésime du rôle au moment de la vente (rôle courant / antérieur / autre)
72 −df["role_vintage"] = np.where(np.isclose(df["role_vente"], df["role_actuel"].fillna(-1)), 2,
73 − np.where(np.isclose(df["role_vente"], df["role_anterieur"].fillna(-1)), 1, 0))
74 −df["ratio_role"] = df["amount"] / df["role_vente"]
75 −df["part_terrain"] = (df["valeur_terrain"] / df["role_actuel"]).clip(0.02, 0.95).fillna(0.2)
76 −df["neuf"] = (df["age"] <= 3).astype(int)
77 −df["period"] = np.select([df["date"] <= TRAIN_END, df["date"] <= VAL_END], ["train", "val"], "test")
78 −df = df.sort_values("date").reset_index(drop=True)
79 −df["idx"] = np.arange(len(df), dtype=np.int32)
80 −
81 −keep = ["idx", "id", "id_provinc", "date", "day", "month", "sale_year", "period", "amount", "ln_price",
82 − "lat", "lng", "x_km", "y_km", "code_mun", "mun_nom", "mrc", "mrc_nom", "region", "region_nom",
83 − "unite_voisinage", "floor_area", "ln_floor", "land_area", "ln_land", "year_built", "age", "neuf",
84 − "lien", "genre", "btype", "etages", "role_vente", "role_vintage", "ratio_role", "part_terrain"]
85 −df = df[keep]
86 −save_parquet(df, "sales.parquet")
87 −pd.DataFrame(excl, columns=["regle", "n"]).to_csv(OUT / "exclusions.csv", index=False)
88 −stats = {
89 − "brut": n0, "propre": len(df), "exclusions": excl,
90 − "par_periode": df["period"].value_counts().to_dict(),
91 − "par_annee": df["sale_year"].value_counts().sort_index().to_dict(),
92 − "par_region": df.groupby("region_nom").size().sort_values(ascending=False).to_dict(),
93 − "prix": {k: float(v) for k, v in df["amount"].describe().items()},
94 − "dates": [str(df["date"].min().date()), str(df["date"].max().date())],
95 −}
96 −save_json(stats, "dataset_stats.json")
97 −log(f"jeu propre : {len(df):,} ventes ({stats['par_periode']})")
deleted M3U96aomparables-validation/pipeline/02_index.py +0 −78
@@ -1,78 +0,0 @@
1 −# -*- coding: utf-8 -*-
2 −"""02 — Indice temporel régional « point dans le temps ».
3 −Pour chaque mois d'évaluation m, on ré-estime un indice hédonique mensuel par région administrative
4 −avec toutes les ventes antérieures au mois m (fenêtre expansive) (jamais le mois m lui-même). Niveau de référence = m-1,
5 −dernier mois entièrement connu. Sortie : index_asof.npy [m_eval, région, mois] (log), regions.json,
6 −et un indice « plein échantillon » descriptif (index_full.csv)."""
7 −from common import *
8 −
9 −df = load_parquet("sales.parquet")
10 −regions = sorted(df["region"].unique().tolist())
11 −rid = {r: i for i, r in enumerate(regions)}
12 −df["rid"] = df["region"].map(rid).astype(int)
13 −M = int(df["month"].max()) + 1
14 −M_EVAL0 = int(month_index(pd.Series([VAL_START]))[0])
15 −WINDOW = 999 # fenêtre « expansive » : toutes les ventes antérieures au mois m
16 −SHRINK = 40.0 # n/(n+SHRINK) : rétrécissement de l'effet mensuel régional vers l'effet provincial
17 −
18 −X_cols = ["ln_floor", "ln_land", "age"]
19 −def design(d):
20 − X = np.column_stack([np.ones(len(d)), d["ln_floor"], d["ln_land"], d["age"], d["age"] ** 2 / 100,
21 − (d["lien"] == 1), (d["lien"] >= 2), (d["genre"] == 1), (d["genre"] == 2), (d["genre"] == 3),
22 − d["etages"], d["neuf"]]).astype(float)
23 − return X
24 −
25 −def fit_month_effects(d, months):
26 − """OLS à effets fixes de municipalité (estimateur within) : ln P ~ X + effets mensuels (base = dernier mois),
27 − toutes les variables étant centrées par municipalité pour que l'indice ne soit pas contaminé par les
28 − déplacements du mélange des ventes entre municipalités chères et bon marché. Renvoie effets et n par mois."""
29 − X = design(d)[:, 1:]
30 − mm = d["month"].to_numpy()
31 − base = months[-1]
32 − dums = np.column_stack([(mm == m).astype(float) for m in months if m != base])
33 − A = np.hstack([X, dums]); y = d["ln_price"].to_numpy(float)
34 − codes, inv = np.unique(d["code_mun"].to_numpy(), return_inverse=True)
35 − cnt = np.bincount(inv).astype(float)
36 − mA = np.zeros((len(codes), A.shape[1])); np.add.at(mA, inv, A); A = A - mA[inv] / cnt[inv, None]
37 − my = np.bincount(inv, y) / cnt; y = y - my[inv]
38 − beta, *_ = np.linalg.lstsq(A, y, rcond=None)
39 − eff = {base: 0.0}
40 − k = X.shape[1]
41 − for j, m in enumerate([m for m in months if m != base]):
42 − eff[m] = float(beta[k + j])
43 − n = {m: int((mm == m).sum()) for m in months}
44 − return eff, n
45 −
46 −idx = np.full((M - M_EVAL0, len(regions), M), np.nan, dtype=np.float32)
47 −log(f"indice point-dans-le-temps : mois d'évaluation {M_EVAL0}..{M-1}, {len(regions)} régions")
48 −for m in range(M_EVAL0, M):
49 − months = list(range(max(0, m - WINDOW), m))
50 − win = df[(df["month"] >= months[0]) & (df["month"] < m)]
51 − prov_eff, _ = fit_month_effects(win, months)
52 − for r in regions:
53 − dr = win[win["region"] == r]
54 − if len(dr) < 200:
55 − for mo in months: idx[m - M_EVAL0, rid[r], mo] = prov_eff[mo]
56 − continue
57 − eff, n = fit_month_effects(dr, months)
58 − for mo in months:
59 − w = n[mo] / (n[mo] + SHRINK)
60 − idx[m - M_EVAL0, rid[r], mo] = w * eff[mo] + (1 - w) * prov_eff[mo]
61 − if m % 6 == 0: log(f" mois {m} ({len(win):,} ventes dans la fenêtre)")
62 −np.save(OUT / "index_asof.npy", idx)
63 −save_json({"regions": regions, "M_EVAL0": M_EVAL0, "M": M, "window": WINDOW, "shrink": SHRINK}, "index_meta.json")
64 −
65 −# indice descriptif plein échantillon (pour le rapport seulement — pas utilisé dans la rétro-évaluation)
66 −rows = []
67 −months = list(range(0, M))
68 −eff_p, n_p = fit_month_effects(df, months)
69 −for mo in months:
70 − rows.append({"month": mo, "ym": f"{2021 + mo // 12}-{mo % 12 + 1:02d}", "region": "QC", "log_idx": eff_p[mo] - eff_p[months[0]], "n": n_p[mo]})
71 −for r in regions:
72 − dr = df[df["region"] == r]
73 − if len(dr) < 2000: continue
74 − eff, n = fit_month_effects(dr, months)
75 − for mo in months:
76 − rows.append({"month": mo, "ym": f"{2021 + mo // 12}-{mo % 12 + 1:02d}", "region": r, "log_idx": eff[mo] - eff[months[0]], "n": n[mo]})
77 −pd.DataFrame(rows).to_csv(OUT / "index_full.csv", index=False)
78 −log("indice terminé")
deleted M3U96aomparables-validation/pipeline/03_models.py +0 −119
@@ -1,119 +0,0 @@
1 −# -*- coding: utf-8 -*-
2 −"""03 — Modèles d'ajustement et modèles de référence, ré-estimés chaque trimestre avec les seules ventes
3 −antérieures au trimestre (as-of 2024-01, 2024-04, …, 2026-07) — comme le ferait un AVM ré-entraîné régulièrement :
4 − • grille hédonique globale (coefficients) et régionale rétrécie → ajustements B / C
5 − • LightGBM (objectif L1 = médiane) sur le prix ajusté dans le temps (avec et sans rôle) → ajustements C-GBM, M11
6 − • hédonique globale directe avec effets de municipalité → M10
7 −Sorties : models_<période>.json, gbm_pred_<période>.npy (prédiction f pour TOUTES les ventes)."""
8 −import pickle
9 −from common import *
10 −import lightgbm as lgb
11 −
12 −df = load_parquet("sales.parquet")
13 −meta = load_json("index_meta.json"); regions = meta["regions"]; M_EVAL0 = meta["M_EVAL0"]
14 −rid = {r: i for i, r in enumerate(regions)}
15 −df["rid"] = df["region"].map(rid).astype(int)
16 −idx = np.load(OUT / "index_asof.npy")
17 −
18 −FEATS = ["ln_floor", "ln_land", "age", "age2", "lien1", "lien2", "genre1", "genre2", "genre3", "etages", "neuf"]
19 −def feats(d):
20 − return pd.DataFrame({
21 − "ln_floor": d["ln_floor"], "ln_land": d["ln_land"], "age": d["age"], "age2": d["age"] ** 2 / 100,
22 − "lien1": (d["lien"] == 1).astype(float), "lien2": (d["lien"] >= 2).astype(float),
23 − "genre1": (d["genre"] == 1).astype(float), "genre2": (d["genre"] == 2).astype(float), "genre3": (d["genre"] == 3).astype(float),
24 − "etages": d["etages"].astype(float), "neuf": d["neuf"].astype(float)}, index=d.index)
25 −
26 −def time_adjusted(d, m_asof):
27 − """ln P ramené au niveau du mois m_asof-1 avec l'indice connu au mois m_asof."""
28 − lvl = idx[m_asof - M_EVAL0]
29 − return d["ln_price"].to_numpy() - lvl[d["rid"].to_numpy(), d["month"].to_numpy()] + lvl[d["rid"].to_numpy(), m_asof - 1]
30 −
31 −def ols(X, y):
32 − A = np.column_stack([np.ones(len(X)), X])
33 − b, *_ = np.linalg.lstsq(A, y, rcond=None)
34 − return b
35 −
36 −ASOF = list(range(M_EVAL0, int(df["month"].max()) + 1, 3)) # ré-estimation trimestrielle (2024-01, 2024-04, …)
37 −save_json({"asof": ASOF}, "asof.json")
38 −TODO = [int(a) for a in os.environ["CV_ASOF"].split(",")] if os.environ.get("CV_ASOF") else ASOF # sous-ensemble (répartition sur plusieurs nœuds)
39 −for m_asof in TODO:
40 − period = str(m_asof); start = f"{2021 + m_asof // 12}-{m_asof % 12 + 1:02d}-01"
41 − tr = df[df["month"] < m_asof].copy()
42 − y = time_adjusted(tr, m_asof)
43 − F = feats(tr)
44 − log(f"[{period}] entraînement sur {len(tr):,} ventes < {start} (indice connu au mois {m_asof})")
45 − # --- grille hédonique globale : estimateur « within » (effets fixes de municipalité), pour que les β
46 − # mesurent des différences entre biens voisins et non entre marchés
47 − def within(frame, cols, by):
48 − return frame[cols] - frame.groupby(by)[cols].transform("mean")
49 − # effets mensuels résiduels (base = dernier mois connu m_asof-1) : l'indice régional ne retire pas toute la
50 − # tendance commune ; sans eux, les modèles globaux sous-estiment les ventes récentes
51 − months_tr = sorted(tr["month"].unique()); MCOLS = [f"m{m}" for m in months_tr if m != m_asof - 1]
52 − Fm = pd.concat([F] + [pd.Series((tr["month"] == m).astype(float), index=tr.index, name=f"m{m}") for m in months_tr if m != m_asof - 1], axis=1)
53 − Fw = within(pd.concat([Fm, tr[["code_mun"]]], axis=1), FEATS + MCOLS, "code_mun")
54 − yw = y - pd.Series(y, index=tr.index).groupby(tr["code_mun"]).transform("mean").to_numpy()
55 − bw_all, *_ = np.linalg.lstsq(Fw.to_numpy(), yw, rcond=None)
56 − bw = bw_all[:len(FEATS)]; bm = bw_all[len(FEATS):]
57 − beta_global = dict(zip(FEATS, bw.tolist()))
58 − xb = F.to_numpy() @ bw + Fm[MCOLS].to_numpy() @ bm
59 − resid = y - xb
60 − r2w = 1 - np.var(yw - Fw.to_numpy() @ bw_all) / np.var(yw)
61 − Fw = Fw[FEATS]
62 − log(f" hédonique within-municipalité : R² within = {r2w:.3f} ; β = { {k: round(v, 3) for k, v in beta_global.items()} }")
63 − # --- grille régionale (within-municipalité) rétrécie vers la globale (n/(n+300))
64 − beta_region = {}
65 − for r in regions:
66 − sel = (tr["region"] == r).to_numpy()
67 − if sel.sum() < 100: beta_region[r] = beta_global; continue
68 − b, *_ = np.linalg.lstsq(Fw.to_numpy()[sel], yw[sel], rcond=None)
69 − w = sel.sum() / (sel.sum() + 300.0)
70 − beta_region[r] = {k: float(w * b[j] + (1 - w) * beta_global[k]) for j, k in enumerate(FEATS)}
71 − # --- M10 : hédonique directe = Xβ + effet de région + MRC + municipalité + unité de voisinage (rétrécis en cascade)
72 − reg_eff = pd.Series(resid, index=tr.index).groupby(tr["region"]).mean().to_dict()
73 − tr["res_g"] = resid - tr["region"].map(reg_eff).to_numpy()
74 − mrc_eff = tr.groupby("mrc")["res_g"].agg(["sum", "count"])
75 − mrc_eff = (mrc_eff["sum"] / (mrc_eff["count"] + 30)).to_dict()
76 − tr["res_m"] = tr["res_g"] - tr["mrc"].map(mrc_eff).fillna(0)
77 − mun_eff = tr.groupby("code_mun")["res_m"].agg(["sum", "count"])
78 − mun_eff = (mun_eff["sum"] / (mun_eff["count"] + 20)).to_dict()
79 − tr["res_u"] = tr["res_m"] - tr["code_mun"].map(mun_eff).fillna(0)
80 − # correction médiane : la MCO vise la moyenne du log ; la distribution est asymétrique (queue basse), on recentre sur la médiane
81 − uv_tmp = tr.groupby(["code_mun", "unite_voisinage"])["res_u"].transform(lambda v: v.sum() / (len(v) + 10))
82 − median_shift = float(np.median(tr["res_u"] - uv_tmp))
83 − uv = tr.groupby(["code_mun", "unite_voisinage"])["res_u"].agg(["sum", "count"])
84 − uv_eff = {f"{a}|{b}": v for (a, b), v in (uv["sum"] / (uv["count"] + 10)).items()}
85 − # --- LightGBM (prix ajusté dans le temps) avec rôle et sans rôle
86 − def gbm_frame(d):
87 − G = feats(d)
88 − G["lat"] = d["lat"]; G["lng"] = d["lng"]; G["x_km"] = d["x_km"]; G["y_km"] = d["y_km"]
89 − G["rid"] = d["rid"]; G["btype"] = d["btype"]
90 − G["ln_role"] = np.log(d["role_vente"]); G["part_terrain"] = d["part_terrain"]; G["role_vintage"] = d["role_vintage"]
91 − G["month"] = np.minimum(d["month"], m_asof - 1).astype(float) # tendance résiduelle ; hors échantillon = dernier mois connu
92 − return G
93 − G = gbm_frame(tr)
94 − params = dict(objective="regression_l1", learning_rate=0.05, num_leaves=127, min_data_in_leaf=40, feature_fraction=0.8,
95 − bagging_fraction=0.8, bagging_freq=1, lambda_l2=1.0, verbose=-1, num_threads=int(os.environ.get("CV_THREADS", 20)))
96 − models = {}
97 − for name, cols in [("gbm", [c for c in G.columns if c not in ("ln_role", "part_terrain", "role_vintage")]),
98 − ("gbm_role", list(G.columns))]:
99 − ds = lgb.Dataset(G[cols], y, categorical_feature=["rid", "btype"] if "btype" in cols else ["rid"])
100 − # validation interne : derniers 10 % chronologiques pour fixer le nombre d'arbres
101 − n = len(G); cut = int(n * 0.9)
102 − dtr = lgb.Dataset(G[cols].iloc[:cut], y[:cut], categorical_feature=["rid", "btype"])
103 − dva = lgb.Dataset(G[cols].iloc[cut:], y[cut:], reference=dtr)
104 − bst = lgb.train(params, dtr, num_boost_round=3000, valid_sets=[dva], callbacks=[lgb.early_stopping(100, verbose=False)])
105 − best = max(bst.best_iteration, 300) # plancher : l'arrêt précoce sur la tranche finale (mois partiels) peut être trop court
106 − bst = lgb.train(params, ds, num_boost_round=best)
107 − models[name] = bst
108 − Gall = gbm_frame(df); Gall["month"] = float(m_asof - 1) # f évalué au niveau du dernier mois connu pour toutes les ventes
109 − pred_all = bst.predict(Gall[cols])
110 − np.save(OUT / f"{name}_pred_{period}.npy", pred_all.astype(np.float32))
111 − rr = y - bst.predict(G[cols]); r2 = 1 - np.var(rr) / np.var(y)
112 − log(f" {name}: {best} arbres, R² (in-sample) = {r2:.3f}, résidu médian = {np.median(rr) * 100:.2f} %")
113 − bst.save_model(str(OUT / f"{name}_{period}.txt"))
114 − save_json({"period": period, "m_asof": m_asof, "start": start, "n_train": len(tr), "feats": FEATS,
115 − "beta_global": beta_global, "intercept_global": 0.0, "region_fe": reg_eff,
116 − "beta_region": beta_region, "mrc_eff": mrc_eff, "mun_eff": mun_eff, "uv_eff": uv_eff,
117 − "r2_within": float(r2w), "sd_resid_u": float(tr["res_u"].std()), "median_shift": median_shift},
118 − f"models_{period}.json")
119 −log("modèles terminés")
deleted M3U96aomparables-validation/pipeline/04_backtest.py +0 −193
@@ -1,193 +0,0 @@
1 −# -*- coding: utf-8 -*-
2 −"""04 — Moteur de rétro-évaluation.
3 −Pour chaque vente cible (2024 = validation, 2025-2026 = test), le prix est masqué ; seules les ventes
4 −conclues STRICTEMENT AVANT la date cible (et l'indice/les modèles connus à cette date) sont utilisées.
5 −On évalue d'un coup toutes les configurations (sélection × rayon × fenêtre × nombre × ajustement ×
6 −pondération × réconciliation) sur exactement les mêmes cibles.
7 −Sorties : targets.parquet, configs_E1.csv/est_E1.npy (surface k,d,T), configs_E2.csv/est_E2.npy/sd_E2.npy
8 −(factoriel sélection × ajustement × pondération × réconciliation), n_avail.npy."""
9 −import os
10 −for _v in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS", "VECLIB_MAXIMUM_THREADS"):
11 − os.environ[_v] = "1" # un seul fil BLAS par processus (fork + BLAS multi-fils = interblocage sur macOS)
12 −import multiprocessing as mp, time
13 −from scipy.spatial import cKDTree
14 −from common import *
15 −
16 −df = load_parquet("sales.parquet")
17 −meta = load_json("index_meta.json"); regions = meta["regions"]; M_EVAL0 = meta["M_EVAL0"]
18 −rid_map = {r: i for i, r in enumerate(regions)}
19 −IDX = np.load(OUT / "index_asof.npy")
20 −ASOF = load_json("asof.json")["asof"]
21 −MODELS = {a: load_json(f"models_{a}.json") for a in ASOF}
22 −FEATS = MODELS[ASOF[0]]["feats"]
23 −GBM = {a: np.load(OUT / f"gbm_pred_{a}.npy") for a in ASOF}
24 −
25 −# ----------------------------------------------------------------- tableaux globaux (partagés par fork)
26 −N = len(df)
27 −DAY = df["day"].to_numpy(np.int32); MONTH = df["month"].to_numpy(np.int32)
28 −RID = df["region"].map(rid_map).to_numpy(np.int32)
29 −X = df["x_km"].to_numpy(np.float64); Y = df["y_km"].to_numpy(np.float64)
30 −LNP = df["ln_price"].to_numpy(np.float64); P = df["amount"].to_numpy(np.float64)
31 −FLOOR = df["floor_area"].to_numpy(np.float64); LAND = df["land_area"].to_numpy(np.float64)
32 −AGE = df["age"].to_numpy(np.float64); LIEN = df["lien"].to_numpy(np.int8); ETAGES = df["etages"].to_numpy(np.float64)
33 −GENRE = df["genre"].to_numpy(np.int8)
34 −PROV = pd.factorize(df["id_provinc"])[0].astype(np.int32)
35 −MUN = pd.factorize(df["code_mun"])[0].astype(np.int32)
36 −VINT = df["role_vintage"].to_numpy(np.int8); LNROLE = np.log(df["role_vente"].to_numpy(np.float64))
37 −PART = df["part_terrain"].to_numpy(np.float64)
38 −NEUF = df["neuf"].to_numpy(np.float64)
39 −# matrice des caractéristiques hédoniques (même ordre que FEATS)
40 −XF = np.column_stack([np.log(FLOOR), np.log(LAND), AGE, AGE ** 2 / 100, (LIEN == 1), (LIEN >= 2),
41 − (GENRE == 1), (GENRE == 2), (GENRE == 3), ETAGES, NEUF]).astype(np.float64)
42 −BETA_G = {a: np.array([MODELS[a]["beta_global"][f] for f in FEATS]) for a in ASOF}
43 −BETA_R = {a: np.array([[MODELS[a]["beta_region"][r][f] for f in FEATS] for r in regions]) for a in ASOF}
44 −ASOF_ARR = np.array(ASOF)
45 −TREE = cKDTree(np.column_stack([X, Y]))
46 −
47 −D_MAX, T_MAX_DAYS = 25.0, 731
48 −KS = [1, 3, 5, 7, 10, 15, 20, 30, 50]
49 −DS = [0.5, 1, 2, 5, 10, 25]
50 −TS = [3, 6, 12, 24]
51 −SELS = ["geo", "sim", "mix", "adj"]
52 −ADJS = ["raw", "none", "conv", "hedo", "hedo_reg", "local", "gbm", "role"]
53 −WRS = [("equal", "median"), ("equal", "mean"), ("dist", "median"), ("dist", "mean"), ("rec", "median"), ("rec", "mean"),
54 − ("sim", "median"), ("sim", "mean"), ("all", "median"), ("all", "mean")]
55 −ANCHORS = [(3, 2, 6), (5, 5, 12), (10, 10, 24)]
56 −E1 = [dict(sel=s, d=d, T=T, k=k, adj=a, w="equal", recon="median")
57 − for s in ("geo", "sim", "mix") for d in DS for T in TS for k in KS for a in ("none", "hedo")]
58 −E2 = [dict(sel=s, d=d, T=T, k=k, adj=a, w=w, recon=r)
59 − for (k, d, T) in ANCHORS for s in SELS for a in ADJS for (w, r) in WRS]
60 −LOCAL_COLS = [0, 1, 2, 4, 5, 9] # ln_floor, ln_land, age, lien1, lien2, etages
61 −LAMBDA_LOCAL = 50.0
62 −
63 −def wmedian(v, w):
64 − o = np.argsort(v); v = v[o]; w = w[o]
65 − c = np.cumsum(w); return v[np.searchsorted(c, 0.5 * c[-1])]
66 −
67 −def recon(vals, w, how):
68 − ok = ~np.isnan(vals)
69 − if not ok.any(): return np.nan, np.nan
70 − v = vals[ok]; ww = w[ok]
71 − sd = v.std() if len(v) > 1 else np.nan
72 − if how == "median":
73 − return (np.median(v) if ww.min() == ww.max() else wmedian(v, ww)), sd
74 − return np.log(np.sum(ww * np.exp(v)) / ww.sum()), sd
75 −
76 −def evaluate(i):
77 − """Toutes les configurations pour la cible i. Renvoie (est_E1, est_E2, sd_E2, n_avail, info)."""
78 − period = int(ASOF_ARR[ASOF_ARR <= MONTH[i]].max()) # dernier trimestre de ré-estimation ≤ mois de la cible
79 − e1 = np.full(len(E1), np.nan, np.float32); e2 = np.full(len(E2), np.nan, np.float32)
80 − s2 = np.full(len(E2), np.nan, np.float32); nav = np.zeros(len(DS) * len(TS), np.int32)
81 − cand = np.asarray(TREE.query_ball_point((X[i], Y[i]), D_MAX), dtype=np.int64)
82 − keep = (DAY[cand] < DAY[i]) & (DAY[cand] >= DAY[i] - T_MAX_DAYS) & (PROV[cand] != PROV[i])
83 − cand = cand[keep]
84 − info = dict(idx=int(i), asof=period, n_cand=int(len(cand)))
85 − if len(cand) == 0: return e1, e2, s2, nav, info
86 − dist = np.hypot(X[cand] - X[i], Y[cand] - Y[i])
87 − mago = (DAY[i] - DAY[cand]) / 30.4375
88 − m_eval = MONTH[i]; lvl = IDX[m_eval - M_EVAL0]
89 − tadj = np.where(MONTH[cand] == m_eval, 0.0, lvl[RID[cand], m_eval - 1] - lvl[RID[cand], MONTH[cand]])
90 − tadj = np.nan_to_num(tadj, nan=0.0)
91 − lnT = LNP[cand] + tadj
92 − dX = XF[i] - XF[cand] # cible − comparable
93 − # --- ajustements
94 − bg = BETA_G[period]; br = BETA_R[period][RID[i]]
95 − adj = {}
96 − adj["raw"] = LNP[cand]
97 − adj["none"] = lnT
98 − Pt = np.exp(lnT)
99 − d_floor = (FLOOR[i] - FLOOR[cand]) * 0.5 * Pt / FLOOR[cand]
100 − d_land = np.clip((LAND[i] - LAND[cand]) * 0.5 * Pt * PART[cand] / LAND[cand], -0.15 * Pt, 0.15 * Pt)
101 − d_age = np.clip(-0.005 * Pt * (AGE[i] - AGE[cand]), -0.2 * Pt, 0.2 * Pt)
102 − d_lien = np.where((LIEN[i] == 0) & (LIEN[cand] > 0), 0.05 * Pt, np.where((LIEN[i] > 0) & (LIEN[cand] == 0), -0.05 * Pt, 0.0))
103 − tot = np.maximum(d_floor + d_land + d_age + d_lien, -0.6 * Pt)
104 − adj["conv"] = np.log(Pt + tot)
105 − gross_conv = (np.abs(d_floor) + np.abs(d_land) + np.abs(d_age) + np.abs(d_lien)) / Pt
106 − adj["hedo"] = lnT + dX @ bg
107 − gross_hedo = np.abs(dX * bg).sum(axis=1)
108 − adj["hedo_reg"] = lnT + dX @ br
109 − loc = dist <= 10.0
110 − if loc.sum() >= 40:
111 − Xl = XF[cand][loc][:, LOCAL_COLS]; yl = lnT[loc]
112 − b0 = br[LOCAL_COLS]
113 − Xc = Xl - Xl.mean(axis=0); yc = yl - yl.mean()
114 − A = Xc.T @ Xc + LAMBDA_LOCAL * np.eye(len(LOCAL_COLS))
115 − bl = np.linalg.solve(A, Xc.T @ yc + LAMBDA_LOCAL * b0)
116 − adj["local"] = lnT + dX[:, LOCAL_COLS] @ bl
117 − else:
118 − adj["local"] = adj["hedo_reg"]
119 − f = GBM[period]
120 − adj["gbm"] = lnT + (f[i] - f[cand])
121 − same = (MUN[cand] == MUN[i]) & (VINT[cand] == VINT[i])
122 − adj["role"] = np.where(same, LNROLE[i] + (lnT - LNROLE[cand]), np.nan)
123 − # --- scores de sélection
124 − dissim = np.sqrt((dX[:, 0] / 0.25) ** 2 + (dX[:, 1] / 0.6) ** 2 + (dX[:, 2] / 15) ** 2
125 − + (LIEN[cand] != LIEN[i]) * 1.0 + (ETAGES[cand] != ETAGES[i]) * 0.5)
126 − score = {"geo": dist, "sim": dissim + dist / 25.0, "mix": dissim + dist / 2.0, "adj": gross_hedo + dist / 25.0}
127 − # --- pondérations
128 − wts = {"equal": np.ones(len(cand)), "dist": 1.0 / (0.25 + dist), "rec": np.exp(-mago / 6.0), "sim": 1.0 / (0.1 + gross_hedo)}
129 − wts["all"] = wts["dist"] * wts["rec"] * wts["sim"]
130 − # --- masques (d, T) et classements
131 − top = {}
132 − for di, d in enumerate(DS):
133 − for ti, T in enumerate(TS):
134 − m = (dist <= d) & (mago <= T)
135 − nav[di * len(TS) + ti] = int(m.sum())
136 − if not m.any(): continue
137 − ids = np.flatnonzero(m)
138 − for s in SELS:
139 − sc = score[s][ids]
140 − if len(ids) > 50:
141 − part = np.argpartition(sc, 50)[:50]; part = part[np.argsort(sc[part])]
142 − else:
143 − part = np.argsort(sc)
144 − top[(s, d, T)] = ids[part]
145 − # --- E1
146 − for c, cfg in enumerate(E1):
147 − t = top.get((cfg["sel"], cfg["d"], cfg["T"]))
148 − if t is None: continue
149 − sub = t[:cfg["k"]]
150 − e1[c], _ = recon(adj[cfg["adj"]][sub], wts["equal"][sub], "median")
151 − # --- E2
152 − for c, cfg in enumerate(E2):
153 − t = top.get((cfg["sel"], cfg["d"], cfg["T"]))
154 − if t is None: continue
155 − sub = t[:cfg["k"]]
156 − e2[c], s2[c] = recon(adj[cfg["adj"]][sub], wts[cfg["w"]][sub], cfg["recon"])
157 − return e1, e2, s2, nav, info
158 −
159 −def worker(chunk):
160 − return [evaluate(i) for i in chunk]
161 −
162 −if __name__ == "__main__":
163 − targets = df.index[df["period"].isin(["val", "test"])].to_numpy()
164 − if os.environ.get("CV_SAMPLE"):
165 − rng = np.random.default_rng(1); targets = np.sort(rng.choice(targets, int(os.environ["CV_SAMPLE"]), replace=False))
166 − SUF = ""
167 − if os.environ.get("CV_SHARD"): # répartition sur plusieurs nœuds : CV_SHARD=i/N → cibles i, i+N, i+2N, …
168 − i_sh, n_sh = map(int, os.environ["CV_SHARD"].split("/")); targets = targets[i_sh::n_sh]; SUF = f".shard{i_sh}of{n_sh}"
169 − log(f"{len(targets):,} cibles ; {len(E1)} configurations E1, {len(E2)} E2 ; {N:,} ventes dans le bassin")
170 − pd.DataFrame(E1).to_csv(OUT / "configs_E1.csv", index_label="cfg")
171 − pd.DataFrame(E2).to_csv(OUT / "configs_E2.csv", index_label="cfg")
172 − est1 = np.full((len(targets), len(E1)), np.nan, np.float32)
173 − est2 = np.full((len(targets), len(E2)), np.nan, np.float32)
174 − sd2 = np.full((len(targets), len(E2)), np.nan, np.float32)
175 − nav = np.zeros((len(targets), len(DS) * len(TS)), np.int32)
176 − infos = []
177 − chunks = [targets[j:j + 400] for j in range(0, len(targets), 400)]
178 − t0 = time.time(); done = 0
179 − with mp.get_context(os.environ.get("CV_MP", "fork")).Pool(int(os.environ.get("CV_PROCS", 20))) as pool:
180 − for res in pool.imap(worker, chunks):
181 − for (e1, e2, s2, nv, info) in res:
182 − est1[done] = e1; est2[done] = e2; sd2[done] = s2; nav[done] = nv; infos.append(info); done += 1
183 − if done % 20000 < 400:
184 − log(f" {done:,}/{len(targets):,} cibles ({time.time() - t0:.0f} s)")
185 − np.save(OUT / f"est_E1{SUF}.npy", est1); np.save(OUT / f"est_E2{SUF}.npy", est2); np.save(OUT / f"sd_E2{SUF}.npy", sd2); np.save(OUT / f"n_avail{SUF}.npy", nav)
186 − T = df.loc[targets, ["idx", "id", "date", "period", "amount", "ln_price", "region", "region_nom", "mrc", "mrc_nom", "code_mun",
187 − "mun_nom", "unite_voisinage", "floor_area", "land_area", "age", "lien", "etages", "role_vente", "month", "lat", "lng"]].reset_index(drop=True)
188 − T["n_cand"] = [x["n_cand"] for x in infos]; T["asof"] = [x["asof"] for x in infos]
189 − T["n_2km_12m"] = nav[:, DS.index(2) * len(TS) + TS.index(12)]
190 − T["n_5km_12m"] = nav[:, DS.index(5) * len(TS) + TS.index(12)]
191 − save_parquet(T, f"targets{SUF}.parquet")
192 − pd.DataFrame({"DS": [DS], "TS": [TS], "KS": [KS]}).to_json(OUT / "grid_meta.json")
193 − log(f"rétro-évaluation terminée en {time.time() - t0:.0f} s")
deleted M3U96aomparables-validation/pipeline/04b_gather.py +0 −15
@@ -1,15 +0,0 @@
1 −# -*- coding: utf-8 -*-
2 −"""04b — Rassemble les fragments (shards) de la rétro-évaluation calculés sur plusieurs nœuds, dans l'ordre des cibles."""
3 −import glob
4 −from common import *
5 −sh = sorted(glob.glob(str(OUT / "targets.shard*.parquet")))
6 −log(f"{len(sh)} fragments")
7 −Ts = [pd.read_parquet(p) for p in sh]
8 −parts = {}
9 −for name in ["est_E1", "est_E2", "sd_E2", "n_avail"]:
10 − parts[name] = [np.load(p.replace("targets.", f"{name}.").replace(".parquet", ".npy")) for p in sh]
11 −T = pd.concat(Ts, ignore_index=True); order = np.argsort(T["idx"].to_numpy(), kind="stable")
12 −T = T.iloc[order].reset_index(drop=True); save_parquet(T, "targets.parquet")
13 −for name, arrs in parts.items():
14 − A = np.concatenate(arrs, axis=0)[order]; np.save(OUT / f"{name}.npy", A); log(f" {name}: {A.shape}")
15 −log("rassemblement terminé")
deleted M3U96aomparables-validation/pipeline/05_analyze.py +0 −358
@@ -1,358 +0,0 @@
1 −# -*- coding: utf-8 -*-
2 −"""05 — Analyse : métriques par configuration (validation 2024 → choix ; test 2025-26 → verdict),
3 −surface Erreur(k, d, t), effets des facteurs (sélection, ajustement, pondération, réconciliation),
4 −compétition de modèles M1-M13, hypothèses H1-H5, strates (région, densité, prix), intervalles,
5 −règles conditionnelles. Sorties : tables/*.csv, results.json, fig/*.pdf"""
6 −from scipy.optimize import nnls
7 −import matplotlib; matplotlib.use("Agg")
8 −import matplotlib.pyplot as plt
9 −from matplotlib import rcParams
10 −from common import *
11 −
12 −# ---------------------------------------------------------------- style (palette de référence, marques fines)
13 −PAL = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#008300", "#6250d6", "#e34948"]
14 −SEQ = ["#cde2fb", "#9ec5f4", "#6da7ec", "#3987e5", "#256abf", "#184f95", "#0d366b"]
15 −rcParams.update({"font.family": "Helvetica Neue", "font.size": 9, "axes.edgecolor": "#c3c2b7", "axes.linewidth": 0.6,
16 − "axes.grid": True, "grid.color": "#e8e7e3", "grid.linewidth": 0.5, "axes.spines.top": False,
17 − "axes.spines.right": False, "legend.frameon": False, "figure.dpi": 150, "savefig.bbox": "tight",
18 − "axes.titleweight": "bold", "axes.titlesize": 10, "axes.labelcolor": "#52514e", "xtick.color": "#52514e",
19 − "ytick.color": "#52514e", "figure.facecolor": "#fcfcfb", "axes.facecolor": "#fcfcfb"})
20 −from matplotlib.colors import LinearSegmentedColormap
21 −CMAP = LinearSegmentedColormap.from_list("seqblue", SEQ)
22 −TAB = OUT / "tables"; TAB.mkdir(exist_ok=True); FIG = OUT / "fig"
23 −def savefig(fig, name): fig.savefig(FIG / f"{name}.pdf"); fig.savefig(FIG / f"{name}.png"); plt.close(fig)
24 −
25 −# ---------------------------------------------------------------- chargement
26 −T = load_parquet("targets.parquet"); S = load_parquet("sales.parquet").set_index("idx")
27 −E1 = pd.read_csv(OUT / "configs_E1.csv"); E2 = pd.read_csv(OUT / "configs_E2.csv")
28 −est1 = np.load(OUT / "est_E1.npy"); est2 = np.load(OUT / "est_E2.npy"); sd2 = np.load(OUT / "sd_E2.npy"); nav = np.load(OUT / "n_avail.npy")
29 −meta = load_json("index_meta.json"); regions = meta["regions"]; M_EVAL0 = meta["M_EVAL0"]; IDX = np.load(OUT / "index_asof.npy")
30 −rid = {r: i for i, r in enumerate(regions)}
31 −lnP = T["ln_price"].to_numpy(np.float32)
32 −VAL = (T["period"] == "val").to_numpy(); TEST = (T["period"] == "test").to_numpy()
33 −log(f"{len(T):,} cibles (val {VAL.sum():,}, test {TEST.sum():,}) ; E1 {est1.shape[1]} ; E2 {est2.shape[1]}")
34 −
35 −def err_of(est): return (np.exp(est - lnP[:, None]) - 1).astype(np.float32)
36 −def metrics(err, sel):
37 − e = err[sel]; a = np.abs(e)
38 − ok = ~np.isnan(a); n = ok.sum(axis=0)
39 − with np.errstate(all="ignore"):
40 − return pd.DataFrame({"mdape": np.nanmedian(a, axis=0) * 100, "mape": np.nanmean(a, axis=0) * 100,
41 − "biais": np.nanmedian(e, axis=0) * 100, "biais_moy": np.nanmean(e, axis=0) * 100,
42 − "p5": np.nanmean(a <= 0.05, axis=0) * 100, "p10": np.nanmean(a <= 0.10, axis=0) * 100,
43 − "p20": np.nanmean(a <= 0.20, axis=0) * 100, "couverture": n / sel.sum() * 100, "n": n})
44 −err1 = err_of(est1); err2 = err_of(est2)
45 −del est1
46 −res = {}
47 −
48 −# ---------------------------------------------------------------- densité & strates
49 −T["dens"] = pd.cut(T["n_2km_12m"], [-1, 4, 19, 49, 199, 10 ** 9], labels=["< 5", "5-19", "20-49", "50-199", "≥ 200"])
50 −T["prix_q"] = pd.qcut(T["amount"], 5, labels=["Q1 (bas)", "Q2", "Q3", "Q4", "Q5 (haut)"])
51 −T["trimestre"] = pd.to_datetime(T["date"]).dt.to_period("Q").astype(str)
52 −T["age_cl"] = pd.cut(T["age"], [-1, 3, 15, 40, 70, 1000], labels=["≤ 3 ans", "4-15", "16-40", "41-70", "> 70"])
53 −T["cluster"] = pd.factorize(T["code_mun"].astype(str) + "|" + T["trimestre"])[0]
54 −
55 −# ---------------------------------------------------------------- E1 : surface Erreur(k, d, t)
56 −mv = metrics(err1, VAL).add_suffix("_val"); mt = metrics(err1, TEST).add_suffix("_test")
57 −surf = pd.concat([E1, mv, mt], axis=1)
58 −surf.to_csv(TAB / "surface_E1.csv", index=False)
59 −ok = surf["couverture_val"] >= 97
60 −best_e1 = surf[ok].sort_values("mdape_val").iloc[0]
61 −res["E1_best_val"] = best_e1.to_dict()
62 −log(f"E1 meilleure (val, couverture ≥ 97 %) : {best_e1[['sel','d','T','k','adj']].to_dict()} → val {best_e1.mdape_val:.2f} %, test {best_e1.mdape_test:.2f} %")
63 −for (s, a), g in surf[ok].groupby(["sel", "adj"]):
64 − b = g.sort_values("mdape_val").iloc[0]
65 − res.setdefault("E1_best_by_sel_adj", {})[f"{s}|{a}"] = dict(k=int(b.k), d=float(b.d), T=int(b["T"]), mdape_val=float(b.mdape_val), mdape_test=float(b.mdape_test), couverture_test=float(b.couverture_test))
66 −
67 −# figure : MdAPE vs k selon d (T = 12, geo, hedo, test)
68 −fig, axes = plt.subplots(1, 2, figsize=(9, 3.4), sharey=True)
69 −for ax, a in zip(axes, ["none", "hedo"]):
70 − for j, d in enumerate([0.5, 1, 2, 5, 10, 25]):
71 − g = surf[(surf.sel == "geo") & (surf["T"] == 12) & (surf.adj == a) & (surf.d == d)].sort_values("k")
72 − ax.plot(g.k, g.mdape_test, color=PAL[j], lw=2, marker="o", ms=4, label=f"d ≤ {d} km")
73 − ax.set_xscale("log"); ax.set_xticks([1, 3, 5, 10, 20, 50]); ax.set_xticklabels([1, 3, 5, 10, 20, 50])
74 − ax.set_xlabel("nombre de comparables k"); ax.set_title({"none": "sans ajustement (temps seulement)", "hedo": "grille hédonique"}[a])
75 −axes[0].set_ylabel("MdAPE, test 2025-26 (%)"); axes[1].legend(ncol=2, fontsize=8)
76 −savefig(fig, "f1_mdape_vs_k")
77 −# figure : carte d × T au meilleur k (geo, hedo)
78 −fig, axes = plt.subplots(1, 2, figsize=(8, 3.2))
79 −for ax, a in zip(axes, ["none", "hedo"]):
80 − g = surf[(surf.sel == "geo") & (surf.adj == a)].groupby(["d", "T"])["mdape_test"].min().unstack("T")
81 − im = ax.imshow(g.to_numpy(), cmap=CMAP, aspect="auto")
82 − ax.set_xticks(range(len(g.columns))); ax.set_xticklabels([f"{t} mois" for t in g.columns]); ax.set_yticks(range(len(g.index))); ax.set_yticklabels([f"{d} km" for d in g.index])
83 − for i in range(g.shape[0]):
84 − for j in range(g.shape[1]):
85 − v = g.iloc[i, j]; ax.text(j, i, f"{v:.1f}", ha="center", va="center", fontsize=7.5, color="white" if v < np.nanpercentile(g.to_numpy(), 40) else "#0b0b0b")
86 − ax.grid(False); ax.set_title({"none": "sans ajustement", "hedo": "grille hédonique"}[a] + " — MdAPE au meilleur k")
87 −savefig(fig, "f2_surface_d_T")
88 −
89 −# ---------------------------------------------------------------- E2 : facteurs
90 −mv2 = metrics(err2, VAL).add_suffix("_val"); mt2 = metrics(err2, TEST).add_suffix("_test")
91 −fac = pd.concat([E2, mv2, mt2], axis=1); fac.to_csv(TAB / "facteurs_E2.csv", index=False)
92 −anchor = (fac.k == 5) & (fac.d == 5) & (fac["T"] == 12)
93 −base = anchor & (fac.sel == "geo") & (fac.w == "equal") & (fac.recon == "median")
94 −res["E2_adjust_anchor"] = fac[base].set_index("adj")[["mdape_val", "mdape_test", "mape_test", "biais_test", "p10_test", "couverture_test"]].to_dict("index")
95 −res["E2_sel_anchor"] = fac[anchor & (fac.adj == "hedo") & (fac.w == "equal") & (fac.recon == "median")].set_index("sel")[["mdape_val", "mdape_test", "p10_test"]].to_dict("index")
96 −res["E2_wr_anchor"] = {f"{r.w}|{r.recon}": dict(mdape_val=r.mdape_val, mdape_test=r.mdape_test, p10_test=r.p10_test)
97 − for r in fac[anchor & (fac.adj == "hedo") & (fac.sel == "geo")].itertuples()}
98 −# effets marginaux moyens (moyenne de MdAPE test sur toutes les autres modalités)
99 −marg = {}
100 −for f in ["sel", "adj", "w", "recon"]:
101 − marg[f] = fac.groupby(f)["mdape_test"].mean().round(2).to_dict()
102 −res["E2_marginal"] = marg
103 −anc_tab = fac.groupby(["k", "d", "T"])["mdape_test"].min().round(2).to_dict()
104 −res["E2_anchor_best"] = {f"{k}|{d}|{t}": v for (k, d, t), v in anc_tab.items()}
105 −# figure : ajustements à l'ancre (val et test)
106 −fig, ax = plt.subplots(figsize=(7, 3.2))
107 −g = fac[base].set_index("adj").loc[["raw", "none", "conv", "hedo", "hedo_reg", "local", "gbm", "role"]]
108 −lab = {"raw": "aucun", "none": "temps seul", "conv": "grille conv.", "hedo": "hédonique", "hedo_reg": "hédo. régionale", "local": "locale", "gbm": "GBM", "role": "ratio au rôle"}
109 −x = np.arange(len(g)); ax.bar(x - 0.2, g.mdape_val, 0.38, color=PAL[0], label="validation 2024"); ax.bar(x + 0.2, g.mdape_test, 0.38, color=PAL[1], label="test 2025-26")
110 −for i, (v1, v2) in enumerate(zip(g.mdape_val, g.mdape_test)):
111 − ax.text(i - 0.2, v1 + 0.2, f"{v1:.1f}", ha="center", fontsize=7); ax.text(i + 0.2, v2 + 0.2, f"{v2:.1f}", ha="center", fontsize=7)
112 −ax.set_xticks(x); ax.set_xticklabels([lab[a] for a in g.index]); ax.set_ylabel("MdAPE (%)"); ax.legend(); ax.set_title("Type d'ajustement — 5 comparables les plus proches, ≤ 5 km, ≤ 12 mois, médiane")
113 −savefig(fig, "f3_ajustements")
114 −
115 −# ---------------------------------------------------------------- modèles de référence (M10, M11) et compétition
116 −def level_shift(m_asof):
117 − mt_ = T["month"].to_numpy(); r = T["region"].map(rid).to_numpy()
118 − return IDX[mt_ - M_EVAL0, r, mt_ - 1] - IDX[mt_ - M_EVAL0, r, m_asof - 1]
119 −Sx = S.loc[T["idx"]]
120 −XF = np.column_stack([Sx.ln_floor, Sx.ln_land, Sx.age, Sx.age ** 2 / 100, Sx.lien == 1, Sx.lien >= 2, Sx.genre == 1, Sx.genre == 2, Sx.genre == 3, Sx.etages, Sx.neuf]).astype(float)
121 −M10 = np.full(len(T), np.nan); M11 = np.full(len(T), np.nan); M11b = np.full(len(T), np.nan)
122 −for a in load_json("asof.json")["asof"]:
123 − mask = (T["asof"] == a).to_numpy(); period = str(a)
124 − if not mask.any(): continue
125 − mod = load_json(f"models_{period}.json"); beta = np.array([mod["beta_global"][f] for f in mod["feats"]])
126 − shift = level_shift(mod["m_asof"])
127 − fe = (T["region"].map(mod["region_fe"]).fillna(0) + T["mrc"].map(mod["mrc_eff"]).fillna(0) + T["code_mun"].map(mod["mun_eff"]).fillna(0)
128 − + (T["code_mun"].astype(str) + "|" + T["unite_voisinage"].astype(str)).map(mod["uv_eff"]).fillna(0)).to_numpy()
129 − M10[mask] = (XF @ beta + fe + shift + mod["median_shift"])[mask]
130 − M11[mask] = (np.load(OUT / f"gbm_role_pred_{period}.npy")[T["idx"].to_numpy()] + shift)[mask]
131 − M11b[mask] = (np.load(OUT / f"gbm_pred_{period}.npy")[T["idx"].to_numpy()] + shift)[mask]
132 −
133 −def e2col(sel, k, d, Tm, adj, w, recon):
134 − m = (E2.sel == sel) & (E2.k == k) & (E2.d == d) & (E2["T"] == Tm) & (E2.adj == adj) & (E2.w == w) & (E2.recon == recon)
135 − return int(E2.index[m][0])
136 −def e1col(sel, k, d, Tm, adj):
137 − m = (E1.sel == sel) & (E1.k == k) & (E1.d == d) & (E1["T"] == Tm) & (E1.adj == adj)
138 − return int(E1.index[m][0])
139 −MODELS = {
140 − "M1 médiane des 3 plus proches (temps seul)": ("E2", e2col("geo", 3, 2, 6, "none", "equal", "median")),
141 − "M2 moyenne des 5 plus proches (temps seul)": ("E2", e2col("geo", 5, 5, 12, "none", "equal", "mean")),
142 − "M3 sélection par similarité physique": ("E2", e2col("sim", 5, 5, 12, "none", "equal", "median")),
143 − "M4 pondération par distance": ("E2", e2col("geo", 5, 5, 12, "none", "dist", "mean")),
144 − "M5 pondération par récence": ("E2", e2col("geo", 5, 5, 12, "none", "rec", "mean")),
145 − "M6 grille d'ajustement conventionnelle": ("E2", e2col("geo", 5, 5, 12, "conv", "equal", "median")),
146 − "M7 grille d'ajustement hédonique": ("E2", e2col("geo", 5, 5, 12, "hedo", "equal", "median")),
147 − "M8a grille locale (régression sur le voisinage)": ("E2", e2col("geo", 5, 5, 12, "local", "equal", "median")),
148 − "M8b ajustements non linéaires (GBM)": ("E2", e2col("geo", 5, 5, 12, "gbm", "equal", "median")),
149 − "M13 ratio au rôle (5 plus proches, même rôle)": ("E2", e2col("geo", 5, 5, 12, "role", "equal", "median")),
150 − "M10 hédonique globale directe": ("X", M10),
151 − "M11a gradient boosting (sans rôle)": ("X", M11b),
152 − "M11b gradient boosting (avec rôle)": ("X", M11),
153 −}
154 −def est_of(spec):
155 − kind, c = spec
156 − return est2[:, c].astype(float) if kind == "E2" else c
157 −# M0 : meilleure configuration empirique choisie sur la validation (E1 ∪ E2, couverture ≥ 90 %)
158 −cand_all = pd.concat([surf.assign(src="E1"), fac.assign(src="E2")], ignore_index=True)
159 −cand_ok = cand_all[cand_all.couverture_val >= 97].sort_values("mdape_val")
160 −b0 = cand_ok.iloc[0]
161 −M0_est = (est2[:, int(b0.cfg)] if b0.src == "E2" else np.load(OUT / "est_E1.npy", mmap_mode="r")[:, int(b0.cfg)]).astype(float)
162 −res["M0"] = b0[["src", "sel", "d", "T", "k", "adj", "w", "recon", "mdape_val", "mdape_test", "couverture_test"]].to_dict()
163 −res["M0_sans_contrainte"] = cand_all[cand_all.couverture_val >= 50].sort_values("mdape_val").iloc[0][["src", "sel", "d", "T", "k", "adj", "w", "recon", "mdape_val", "mdape_test", "couverture_val", "couverture_test"]].to_dict()
164 −MODELS["M0 meilleure configuration empirique (choisie en 2024, couverture ≥ 97 %)"] = ("X", M0_est)
165 −# M9 : réconciliation optimisée = combinaison convexe (NNLS sur la validation) d'estimations de comparables
166 −stack_cols = {a: e2col("geo", 5, 5, 12, a, "equal", "median") for a in ["none", "conv", "hedo", "hedo_reg", "local", "gbm", "role"]}
167 −def stack(names, mats, fit_mask):
168 − """Combinaison convexe apprise par NNLS sur la validation ; si une composante manque pour une cible,
169 − les poids des composantes disponibles sont renormalisés."""
170 − A = np.column_stack(mats); okr = ~np.isnan(A).any(axis=1)
171 − w, _ = nnls(A[okr & fit_mask], lnP[okr & fit_mask].astype(float)); w = w / w.sum()
172 − W = np.where(np.isnan(A), 0.0, w[None, :]); den = W.sum(axis=1)
173 − out = np.nansum(A * W, axis=1) / np.where(den > 0, den, np.nan)
174 − return out, dict(zip(names, w.round(3)))
175 −M9_est, w9 = stack(list(stack_cols), [est2[:, c].astype(float) for c in stack_cols.values()], VAL)
176 −res["M9_weights"] = w9; MODELS["M9 réconciliation optimisée (poids appris en 2024)"] = ("X", M9_est)
177 −M12_est, w12 = stack(["gbm_role", "comps_gbm", "comps_hedo"], [M11, est2[:, stack_cols["gbm"]].astype(float), est2[:, stack_cols["hedo"]].astype(float)], VAL)
178 −res["M12_weights"] = w12; MODELS["M12 hybride comparables + GBM (poids appris en 2024)"] = ("X", M12_est)
179 −
180 −rows = []
181 −ERR = {}
182 −for name, spec in MODELS.items():
183 − e = (np.exp(est_of(spec) - lnP) - 1).astype(np.float32); ERR[name] = e
184 − for per, mask in [("val", VAL), ("test", TEST)]:
185 − m = metrics(e[:, None], mask).iloc[0].to_dict(); m.update(modele=name, periode=per); rows.append(m)
186 −comp = pd.DataFrame(rows); comp.to_csv(TAB / "competition_modeles.csv", index=False)
187 −pd.DataFrame({n.split(" ")[0]: est_of(s_) for n, s_ in MODELS.items()}).assign(idx=T["idx"].to_numpy()).to_parquet(OUT / "models_est.parquet", index=False)
188 −res["competition"] = comp[comp.periode == "test"].set_index("modele")[["mdape", "mape", "biais", "p5", "p10", "p20", "couverture"]].round(2).to_dict("index")
189 −res["competition_val"] = comp[comp.periode == "val"].set_index("modele")[["mdape", "p10", "couverture"]].round(2).to_dict("index")
190 −log("compétition (test) :\n" + comp[comp.periode == "test"][["modele", "mdape", "mape", "biais", "p10", "couverture"]].round(2).to_string(index=False))
191 −fig, ax = plt.subplots(figsize=(7.5, 5))
192 −g = comp[comp.periode == "test"].sort_values("mdape", ascending=False)
193 −cols = [PAL[0] if ("M1" in n and "M1 " not in n) or n.startswith(("M10", "M11", "M12")) else PAL[2] if n.startswith(("M0", "M9")) else PAL[1] for n in g.modele]
194 −cols = [PAL[0] if n.startswith(("M10", "M11", "M12")) else (PAL[2] if n.startswith(("M0", "M9")) else PAL[1]) for n in g.modele]
195 −ax.barh(g.modele, g.mdape, color=cols, height=0.65)
196 −for i, v in enumerate(g.mdape): ax.text(v + 0.15, i, f"{v:.1f} %", va="center", fontsize=8)
197 −ax.set_xlabel("MdAPE, test 2025-26 (%)"); ax.set_title("Compétition de modèles (orange : comparables ; vert : optimisés ; bleu : références externes)")
198 −savefig(fig, "f4_competition")
199 −
200 −# ---------------------------------------------------------------- bootstrap par grappes (municipalité × trimestre) des différences appariées
201 −rng = np.random.default_rng(7)
202 −def paired(eA, eB, mask, B=400):
203 − a = np.abs(eA[mask]); b = np.abs(eB[mask]); cl = T["cluster"].to_numpy()[mask]
204 − okp = ~np.isnan(a) & ~np.isnan(b); a, b, cl = a[okp], b[okp], cl[okp]
205 − d = a - b
206 − cl_ids, inv = np.unique(cl, return_inverse=True); nc = len(cl_ids)
207 − sums = np.bincount(inv, d, nc); cnts = np.bincount(inv, minlength=nc)
208 − boots_mean = []; boots_med = []
209 − order = np.argsort(inv); d_sorted = d[order]; starts = np.searchsorted(inv[order], np.arange(nc)); ends = np.append(starts[1:], len(d))
210 − for _ in range(B):
211 − pick = rng.integers(0, nc, nc)
212 − boots_mean.append(sums[pick].sum() / cnts[pick].sum())
213 − if _ < 150:
214 − idxs = np.concatenate([np.arange(starts[p], ends[p]) for p in pick])
215 − boots_med.append(np.median(a[order][idxs]) - np.median(b[order][idxs]))
216 − return dict(n=int(len(d)), diff_mape=float(d.mean() * 100), ci_mape=[float(np.percentile(boots_mean, 2.5) * 100), float(np.percentile(boots_mean, 97.5) * 100)],
217 − diff_mdape=float((np.median(a) - np.median(b)) * 100), ci_mdape=[float(np.percentile(boots_med, 2.5) * 100), float(np.percentile(boots_med, 97.5) * 100)],
218 − part_A_meilleur=float((a < b).mean() * 100))
219 −PAIRS = [
220 − ("M7 grille d'ajustement hédonique", "M2 moyenne des 5 plus proches (temps seul)"),
221 − ("M7 grille d'ajustement hédonique", "M6 grille d'ajustement conventionnelle"),
222 − ("M8a grille locale (régression sur le voisinage)", "M7 grille d'ajustement hédonique"),
223 − ("M8b ajustements non linéaires (GBM)", "M7 grille d'ajustement hédonique"),
224 − ("M11b gradient boosting (avec rôle)", "M0 meilleure configuration empirique (choisie en 2024, couverture ≥ 97 %)"),
225 − ("M11a gradient boosting (sans rôle)", "M0 meilleure configuration empirique (choisie en 2024, couverture ≥ 97 %)"),
226 − ("M12 hybride comparables + GBM (poids appris en 2024)", "M11b gradient boosting (avec rôle)"),
227 − ("M13 ratio au rôle (5 plus proches, même rôle)", "M7 grille d'ajustement hédonique"),
228 − ("M3 sélection par similarité physique", "M2 moyenne des 5 plus proches (temps seul)"),
229 − ("M9 réconciliation optimisée (poids appris en 2024)", "M7 grille d'ajustement hédonique"),
230 − ("M1 médiane des 3 plus proches (temps seul)", "M2 moyenne des 5 plus proches (temps seul)"),
231 −]
232 −res["paires"] = {f"{a} vs {b}": paired(ERR[a], ERR[b], TEST) for a, b in PAIRS}
233 −pd.DataFrame([dict(A=a, B=b, **res["paires"][f"{a} vs {b}"]) for a, b in PAIRS]).to_csv(TAB / "paires_bootstrap.csv", index=False)
234 −
235 −# ---------------------------------------------------------------- strates
236 −def strata(err, by):
237 − out = []
238 − for lev, g in T[TEST].groupby(by, observed=True):
239 − m = np.zeros(len(T), bool); m[g.index] = True
240 − r = metrics(err[:, None], m).iloc[0].to_dict(); r[by] = str(lev); out.append(r)
241 − return pd.DataFrame(out)
242 −KEY = ["M2 moyenne des 5 plus proches (temps seul)", "M7 grille d'ajustement hédonique", "M8a grille locale (régression sur le voisinage)",
243 − "M13 ratio au rôle (5 plus proches, même rôle)", "M0 meilleure configuration empirique (choisie en 2024, couverture ≥ 97 %)", "M10 hédonique globale directe", "M11b gradient boosting (avec rôle)", "M12 hybride comparables + GBM (poids appris en 2024)"]
244 −for by in ["region_nom", "dens", "prix_q", "trimestre", "age_cl", "lien"]:
245 − tabs = []
246 − for name in KEY:
247 − s = strata(ERR[name], by); s["modele"] = name.split(" ")[0]; tabs.append(s)
248 − st = pd.concat(tabs); st.to_csv(TAB / f"strates_{by}.csv", index=False)
249 − res[f"strates_{by}"] = st.pivot(index=by, columns="modele", values="mdape").round(2).to_dict("index")
250 − res[f"strates_{by}_n"] = st[st.modele == "M7"].set_index(by)["n"].to_dict()
251 −# figure : densité
252 −fig, ax = plt.subplots(figsize=(6.5, 3.4))
253 −st = pd.read_csv(TAB / "strates_dens.csv")
254 −order = ["< 5", "5-19", "20-49", "50-199", "≥ 200"]
255 −for j, (name, lab_) in enumerate([("M2", "M2 5 plus proches"), ("M7", "M7 grille hédonique"), ("M0", "M0 meilleure config."), ("M11b", "M11b GBM"), ("M12", "M12 hybride")]):
256 − g = st[st.modele == name].set_index("dens").loc[order]
257 − ax.plot(order, g.mdape, color=PAL[j], lw=2, marker="o", ms=4, label=lab_)
258 −ax.set_xlabel("ventes disponibles dans un rayon de 2 km au cours des 12 mois précédents"); ax.set_ylabel("MdAPE, test (%)"); ax.legend(fontsize=8); ax.set_title("Précision selon la liquidité du sous-marché")
259 −savefig(fig, "f5_densite")
260 −
261 −# ---------------------------------------------------------------- hypothèses
262 −H = {}
263 −# H1 : proche mais dissemblable vs loin mais semblable (k = 5, T = 12, sans ajustement)
264 −H["H1"] = {"geo_d1": surf.loc[e1col("geo", 5, 1, 12, "none"), ["mdape_test", "couverture_test"]].to_dict(),
265 − "sim_d10": surf.loc[e1col("sim", 5, 10, 12, "none"), ["mdape_test", "couverture_test"]].to_dict(),
266 − "geo_d10": surf.loc[e1col("geo", 5, 10, 12, "none"), ["mdape_test", "couverture_test"]].to_dict(),
267 − "mix_d10": surf.loc[e1col("mix", 5, 10, 12, "none"), ["mdape_test", "couverture_test"]].to_dict(),
268 − "sim_d10_hedo": surf.loc[e1col("sim", 5, 10, 12, "hedo"), ["mdape_test", "couverture_test"]].to_dict(),
269 − "geo_d10_hedo": surf.loc[e1col("geo", 5, 10, 12, "hedo"), ["mdape_test", "couverture_test"]].to_dict(),
270 − "mix_d10_hedo": surf.loc[e1col("mix", 5, 10, 12, "hedo"), ["mdape_test", "couverture_test"]].to_dict()}
271 −# H2 : k optimal selon la densité (geo, d = 10, T = 12, hedo et none)
272 −h2 = {}
273 −for a in ["none", "hedo"]:
274 − for lev in order:
275 − m = TEST & (T["dens"] == lev).to_numpy()
276 − cols_ = [e1col("geo", k, 10, 12, a) for k in [1, 3, 5, 7, 10, 15, 20, 30, 50]]
277 − md = np.nanmedian(np.abs(err1[m][:, cols_]), axis=0) * 100
278 − h2[f"{a}|{lev}"] = dict(zip([1, 3, 5, 7, 10, 15, 20, 30, 50], md.round(2)))
279 −H["H2"] = h2
280 −fig, axes = plt.subplots(1, 2, figsize=(9, 3.3), sharey=True)
281 −for ax, a in zip(axes, ["none", "hedo"]):
282 − for j, lev in enumerate(order):
283 − v = h2[f"{a}|{lev}"]; ax.plot(list(v), list(v.values()), color=PAL[j], lw=2, marker="o", ms=3.5, label=f"densité {lev}")
284 − ax.set_xscale("log"); ax.set_xticks([1, 3, 5, 10, 20, 50]); ax.set_xticklabels([1, 3, 5, 10, 20, 50]); ax.set_xlabel("k"); ax.set_title({"none": "temps seul", "hedo": "grille hédonique"}[a] + " — d ≤ 10 km, 12 mois")
285 −axes[0].set_ylabel("MdAPE, test (%)"); axes[1].legend(fontsize=8)
286 −savefig(fig, "f6_k_par_densite")
287 −# H3 : local vs hédonique fixe selon la densité, aux trois ancres
288 −h3 = {}
289 −for (k, d, Tm) in [(3, 2, 6), (5, 5, 12), (10, 10, 24)]:
290 − for lev in order:
291 − m = TEST & (T["dens"] == lev).to_numpy()
292 − h3[f"{k}|{d}|{Tm}|{lev}"] = {a: float(np.nanmedian(np.abs(err2[m][:, e2col("geo", k, d, Tm, a, "equal", "median")])) * 100) for a in ["conv", "hedo", "hedo_reg", "local", "gbm"]}
293 −H["H3"] = h3
294 −# H4 : pondération selon la densité (ancre 5/5/12, hedo)
295 −h4 = {}
296 −for lev in order:
297 − m = TEST & (T["dens"] == lev).to_numpy()
298 − h4[lev] = {f"{w}|{r}": float(np.nanmedian(np.abs(err2[m][:, e2col("geo", 5, 5, 12, "hedo", w, r)])) * 100) for (w, r) in [("equal", "median"), ("equal", "mean"), ("dist", "mean"), ("rec", "mean"), ("sim", "mean"), ("all", "mean"), ("all", "median")]}
299 −H["H4"] = h4
300 −# H5 : méthodes simples vs ML selon l'homogénéité (dispersion des prix ajustés des 10 voisins à 10 km / 24 mois)
301 −het = sd2[:, e2col("geo", 10, 10, 24, "hedo", "equal", "median")].astype(float)
302 −T["heterog"] = pd.qcut(pd.Series(np.where(np.isnan(het), np.nanmax(het), het)), 3, labels=["homogène", "moyen", "hétérogène"])
303 −h5 = {}
304 −for lev in ["homogène", "moyen", "hétérogène"]:
305 − m = TEST & (T["heterog"] == lev).to_numpy()
306 − h5[lev] = {n.split(" ")[0]: float(np.nanmedian(np.abs(ERR[n][m])) * 100) for n in KEY}
307 − h5[lev]["sd_median"] = float(np.nanmedian(het[m]))
308 −H["H5"] = h5
309 −res["hypotheses"] = H
310 −
311 −# ---------------------------------------------------------------- intervalles d'incertitude (couverture)
312 −intervals = {}
313 −for a in ["none", "hedo", "gbm"]:
314 − for (k, d, Tm) in [(5, 5, 12), (10, 10, 24)]:
315 − c = e2col("geo", k, d, Tm, a, "equal", "median"); e = est2[:, c].astype(float); s = sd2[:, c].astype(float)
316 − okv = VAL & ~np.isnan(e) & ~np.isnan(s) & (s > 0)
317 − z_needed = np.abs(lnP[okv] - e[okv]) / s[okv]; z80 = float(np.percentile(z_needed, 80))
318 − okt = TEST & ~np.isnan(e) & ~np.isnan(s) & (s > 0)
319 − cov_nominal = float(np.mean(np.abs(lnP[okt] - e[okt]) <= 1.2816 * s[okt]) * 100)
320 − cov_cal = float(np.mean(np.abs(lnP[okt] - e[okt]) <= z80 * s[okt]) * 100)
321 − width = float(np.median(2 * z80 * s[okt]) * 100)
322 − intervals[f"{a}|{k}|{d}|{Tm}"] = dict(z80_val=z80, couverture_nominale_80=cov_nominal, couverture_calibree=cov_cal, largeur_mediane_pct=width, n_test=int(okt.sum()))
323 −res["intervalles"] = intervals
324 −
325 −# ---------------------------------------------------------------- règles conditionnelles (système de recommandation)
326 −cond = {}; est_cond = np.full(len(T), np.nan)
327 −for lev in order:
328 − mv_ = VAL & (T["dens"] == lev).to_numpy(); mt_ = TEST & (T["dens"] == lev).to_numpy()
329 − m1 = metrics(err1, mv_); m2 = metrics(err2, mv_)
330 − allm = pd.concat([pd.concat([E1, m1], axis=1).assign(src="E1"), pd.concat([E2, m2], axis=1).assign(src="E2")], ignore_index=True)
331 − b = allm[allm.couverture >= 95].sort_values("mdape").iloc[0]
332 − e = (err1 if b.src == "E1" else err2)[:, int(b.cfg)]
333 − est_cond[mt_] = e[mt_]
334 − cond[lev] = dict(b[["src", "sel", "d", "T", "k", "adj", "w", "recon", "mdape", "couverture"]].to_dict(), mdape_test=float(np.nanmedian(np.abs(e[mt_])) * 100), n_test=int(mt_.sum()))
335 −res["regles_conditionnelles"] = cond
336 −res["regles_conditionnelles_global_test"] = dict(mdape=float(np.nanmedian(np.abs(est_cond[TEST])) * 100), p10=float(np.nanmean(np.abs(est_cond[TEST]) <= 0.1) * 100), couverture=float(np.mean(~np.isnan(est_cond[TEST])) * 100))
337 −pd.DataFrame(cond).T.to_csv(TAB / "regles_conditionnelles.csv")
338 −
339 −# ---------------------------------------------------------------- stabilité temporelle, indice, divers
340 −tq = []
341 −for q, g in T[TEST].groupby("trimestre"):
342 − m = np.zeros(len(T), bool); m[g.index] = True
343 − tq.append(dict(trimestre=q, n=int(m.sum()), **{n.split(" ")[0]: float(np.nanmedian(np.abs(ERR[n][m])) * 100) for n in KEY},
344 − **{"biais_" + n.split(" ")[0]: float(np.nanmedian(ERR[n][m]) * 100) for n in KEY}))
345 −pd.DataFrame(tq).to_csv(TAB / "stabilite_trimestres.csv", index=False); res["stabilite"] = tq
346 −fig, ax = plt.subplots(figsize=(7, 3.2))
347 −idxf = pd.read_csv(OUT / "index_full.csv")
348 −for j, (r, lab_) in enumerate([("QC", "Québec"), ("06", "Montréal"), ("03", "Capitale-Nationale"), ("07", "Outaouais"), ("05", "Estrie")]):
349 − g = idxf[idxf.region.astype(str).str.zfill(2) == r] if r != "QC" else idxf[idxf.region == "QC"]
350 − if len(g): ax.plot(pd.to_datetime(g.ym), np.exp(g.log_idx) * 100, color=PAL[j], lw=2 if r == "QC" else 1.4, label=lab_)
351 −ax.set_ylabel("indice hédonique (2021-01 = 100)"); ax.legend(fontsize=8); ax.set_title("Indice de prix mensuel (plein échantillon, descriptif)")
352 −savefig(fig, "f7_indice")
353 −res["dataset"] = load_json("dataset_stats.json")
354 −res["n_targets"] = dict(val=int(VAL.sum()), test=int(TEST.sum()))
355 −res["densite_distribution"] = T[TEST]["dens"].value_counts().sort_index().to_dict()
356 −res["n_cand_median"] = dict(val=float(np.median(T[VAL].n_cand)), test=float(np.median(T[TEST].n_cand)))
357 −save_json(res, "results.json")
358 −log("analyse terminée")
deleted M3U96aomparables-validation/pipeline/06_report.py +0 −313
@@ -1,313 +0,0 @@
1 −# -*- coding: utf-8 -*-
2 −"""06 — Rapport : assemble docs/RAPPORT.md à partir de results.json et des tables (texte + tableaux + figures).
3 −Les phrases d'interprétation sont calculées à partir des résultats (jamais de chiffre écrit à la main)."""
4 −from common import *
5 −
6 −R = load_json("results.json"); TAB = OUT / "tables"
7 −DOCS = ROOT / "docs"; DOCS.mkdir(exist_ok=True)
8 −D = R["dataset"]; comp = R["competition"]; compv = R["competition_val"]
9 −def f1(x): return f"{x:.1f}".replace(".", ",")
10 −def f2(x): return f"{x:.2f}".replace(".", ",")
11 −def n_(x): return f"{int(round(x)):,}".replace(",", " ")
12 −def pct(x): return f"{f1(x)} %"
13 −def md_table(df, cols=None, fmt=None, index=False):
14 − if cols: df = df[cols]
15 − if index: df = df.reset_index()
16 − fmt = fmt or {}
17 − head = "| " + " | ".join(str(c) for c in df.columns) + " |\n|" + "|".join("---" for _ in df.columns) + "|\n"
18 − rows = []
19 − for _, r in df.iterrows():
20 − cells = []
21 − for c in df.columns:
22 − v = r[c]
23 − if c in fmt: cells.append(fmt[c](v))
24 − elif isinstance(v, float): cells.append(f1(v))
25 − else: cells.append(str(v))
26 − rows.append("| " + " | ".join(cells) + " |")
27 − return head + "\n".join(rows) + "\n"
28 −short = lambda name: name.split(" ")[0]
29 −LAB = {"raw": "aucun ajustement", "none": "temps seulement", "conv": "grille conventionnelle", "hedo": "grille hédonique", "hedo_reg": "grille hédonique régionale",
30 − "local": "régression locale", "gbm": "ajustements GBM", "role": "ratio au rôle", "geo": "les plus proches (distance)", "sim": "les plus semblables (physique)",
31 − "mix": "compromis distance / similarité", "adj": "les moins ajustés", "equal": "égale", "dist": "1/distance", "rec": "récence", "all": "distance × récence × similarité",
32 − "median": "médiane", "mean": "moyenne"}
33 −
34 −# ------------------------------------------------------------------ tableaux
35 −excl = pd.DataFrame(D["exclusions"], columns=["Règle", "n"]); excl["n"] = excl["n"].map(n_)
36 −cm = pd.DataFrame(comp).T; cm.index.name = "Modèle"; cm = cm.reset_index()
37 −cm["val"] = cm["Modèle"].map(lambda m: compv[m]["mdape"])
38 −cm = cm.sort_values("mdape")
39 −t_comp = md_table(cm.rename(columns={"mdape": "MdAPE test", "val": "MdAPE val.", "mape": "MAPE", "biais": "Biais (médian)", "p5": "±5 %", "p10": "±10 %", "p20": "±20 %", "couverture": "Couverture"}),
40 − ["Modèle", "MdAPE val.", "MdAPE test", "MAPE", "Biais (médian)", "±5 %", "±10 %", "±20 %", "Couverture"])
41 −surf = pd.read_csv(TAB / "surface_E1.csv")
42 −def surf_tab(sel, adj, T_):
43 − g = surf[(surf.sel == sel) & (surf.adj == adj) & (surf["T"] == T_)].pivot(index="d", columns="k", values="mdape_test")
44 − g.index = [f"≤ {d} km" for d in g.index]; g.columns = [f"k = {k}" for k in g.columns]; g.index.name = "rayon"
45 − return md_table(g.round(1), index=True)
46 −kb = surf[(surf.sel == "geo") & (surf.adj == "hedo") & (surf.d == 10) & (surf["T"] == 12)].sort_values("k")
47 −fac = pd.read_csv(TAB / "facteurs_E2.csv")
48 −anchor = fac[(fac.k == 5) & (fac.d == 5) & (fac["T"] == 12)]
49 −t_adj = anchor[(anchor.sel == "geo") & (anchor.w == "equal") & (anchor.recon == "median")].set_index("adj").loc[["raw", "none", "conv", "hedo", "hedo_reg", "local", "gbm", "role"]]
50 −t_adj = t_adj.reset_index(); t_adj["Ajustement"] = t_adj["adj"].map(LAB)
51 −t_adj_md = md_table(t_adj.rename(columns={"mdape_val": "MdAPE val.", "mdape_test": "MdAPE test", "mape_test": "MAPE test", "biais_test": "Biais", "p10_test": "±10 %", "couverture_test": "Couverture"}),
52 − ["Ajustement", "MdAPE val.", "MdAPE test", "MAPE test", "Biais", "±10 %", "Couverture"])
53 −t_sel = anchor[(anchor.adj == "hedo") & (anchor.w == "equal") & (anchor.recon == "median")].copy(); t_sel["Sélection"] = t_sel["sel"].map(LAB)
54 −t_sel_md = md_table(t_sel.rename(columns={"mdape_val": "MdAPE val.", "mdape_test": "MdAPE test", "p10_test": "±10 %", "biais_test": "Biais"}), ["Sélection", "MdAPE val.", "MdAPE test", "Biais", "±10 %"])
55 −t_wr = anchor[(anchor.adj == "hedo") & (anchor.sel == "geo")].copy(); t_wr["Pondération"] = t_wr["w"].map(LAB); t_wr["Réconciliation"] = t_wr["recon"].map(LAB)
56 −t_wr_md = md_table(t_wr.rename(columns={"mdape_val": "MdAPE val.", "mdape_test": "MdAPE test", "p10_test": "±10 %", "biais_test": "Biais"}), ["Pondération", "Réconciliation", "MdAPE val.", "MdAPE test", "Biais", "±10 %"])
57 −pairs = pd.read_csv(TAB / "paires_bootstrap.csv")
58 −pairs["Comparaison"] = pairs.apply(lambda r: f"{short(r.A)} − {short(r.B)}", axis=1)
59 −pairs["Δ MdAPE (pts) [IC 95 %]"] = pairs.apply(lambda r: f"{f2(r.diff_mdape)} [{f2(eval(r.ci_mdape)[0])} ; {f2(eval(r.ci_mdape)[1])}]", axis=1)
60 −pairs["Δ MAPE (pts) [IC 95 %]"] = pairs.apply(lambda r: f"{f2(r.diff_mape)} [{f2(eval(r.ci_mape)[0])} ; {f2(eval(r.ci_mape)[1])}]", axis=1)
61 −pairs["A plus précis (% des cibles)"] = pairs["part_A_meilleur"].map(f1)
62 −t_pairs = md_table(pairs, ["Comparaison", "Δ MdAPE (pts) [IC 95 %]", "Δ MAPE (pts) [IC 95 %]", "A plus précis (% des cibles)", "n"], fmt={"n": n_})
63 −def strata_md(by, label):
64 − st = pd.read_csv(TAB / f"strates_{by}.csv")
65 − g = st.pivot(index=by, columns="modele", values="mdape").round(1)
66 − g["n"] = st[st.modele == "M7"].set_index(by)["n"]
67 − g = g[["n", "M2", "M7", "M8a", "M13", "M0", "M10", "M11b", "M12"]]
68 − if by == "dens": g = g.loc[["< 5", "5-19", "20-49", "50-199", "≥ 200"]]
69 − if by == "region_nom": g = g.sort_values("n", ascending=False)
70 − g.index.name = label
71 − return md_table(g, index=True, fmt={"n": n_})
72 −H = R["hypotheses"]
73 −h2 = pd.DataFrame({k.split("|")[1]: v for k, v in H["H2"].items() if k.startswith("hedo")}).T; h2.index.name = "densité"; h2.columns = [f"k = {c}" for c in h2.columns]
74 −h2_md = md_table(h2.loc[["< 5", "5-19", "20-49", "50-199", "≥ 200"]], index=True)
75 −h2n = pd.DataFrame({k.split("|")[1]: v for k, v in H["H2"].items() if k.startswith("none")}).T; h2n.index.name = "densité"; h2n.columns = [f"k = {c}" for c in h2n.columns]
76 −h2n_md = md_table(h2n.loc[["< 5", "5-19", "20-49", "50-199", "≥ 200"]], index=True)
77 −h3 = pd.DataFrame({k.split("|")[3]: v for k, v in H["H3"].items() if k.startswith("5|5|12")}).T.loc[["< 5", "5-19", "20-49", "50-199", "≥ 200"]]
78 −h3.columns = [LAB[c] for c in h3.columns]; h3.index.name = "densité"; h3_md = md_table(h3, index=True)
79 −h3s = pd.DataFrame({k.split("|")[3]: v for k, v in H["H3"].items() if k.startswith("3|2|6")}).T.loc[["< 5", "5-19", "20-49", "50-199", "≥ 200"]]
80 −h3s.columns = [LAB[c] for c in h3s.columns]; h3s.index.name = "densité"; h3s_md = md_table(h3s, index=True)
81 −h4 = pd.DataFrame(H["H4"]).T.loc[["< 5", "5-19", "20-49", "50-199", "≥ 200"]]; h4.columns = [f"{LAB[c.split('|')[0]]} / {LAB[c.split('|')[1]]}" for c in h4.columns]; h4.index.name = "densité"; h4_md = md_table(h4, index=True)
82 −h5 = pd.DataFrame(H["H5"]).T; h5.index.name = "sous-marché"; h5 = h5.rename(columns={"sd_median": "σ médian (log)"}); h5_md = md_table(h5[["σ médian (log)", "M2", "M7", "M8a", "M13", "M0", "M10", "M11b", "M12"]], index=True, fmt={"σ médian (log)": lambda v: f"{v:.3f}".replace(".", ",")})
83 −iv = pd.DataFrame(R["intervalles"]).T; iv.index = [f"{LAB[i.split('|')[0]]}, k = {i.split('|')[1]}, ≤ {i.split('|')[2]} km, {i.split('|')[3]} mois" for i in iv.index]; iv.index.name = "configuration"
84 −iv_md = md_table(iv.rename(columns={"z80_val": "z calibré (val.)", "couverture_nominale_80": "couverture à z = 1,28", "couverture_calibree": "couverture calibrée", "largeur_mediane_pct": "largeur médiane (%)", "n_test": "n"}), index=True, fmt={"n": n_, "z calibré (val.)": f2})
85 −rc = pd.DataFrame(R["regles_conditionnelles"]).T.loc[["< 5", "5-19", "20-49", "50-199", "≥ 200"]]
86 −rc["règle"] = rc.apply(lambda r: f"{LAB[r.sel]}, k = {int(r.k)}, ≤ {r.d} km, {int(r['T'])} mois, {LAB[r.adj]}, pond. {LAB[r.w]}, {LAB[r.recon]}", axis=1)
87 −rc.index.name = "densité"; rc_md = md_table(rc.rename(columns={"mdape": "MdAPE val.", "mdape_test": "MdAPE test", "couverture": "couverture val.", "n_test": "n test"}), ["règle", "MdAPE val.", "MdAPE test", "couverture val.", "n test"], index=True, fmt={"n test": n_})
88 −stab = pd.DataFrame(R["stabilite"]); stab_md = md_table(stab[["trimestre", "n", "M2", "M7", "M0", "M11b", "M12", "biais_M7", "biais_M11b"]].rename(columns={"biais_M7": "biais M7", "biais_M11b": "biais M11b"}), fmt={"n": n_})
89 −
90 −# ------------------------------------------------------------------ chiffres clés pour le texte
91 −best = min(comp, key=lambda m: comp[m]["mdape"]); best_comps = min([m for m in comp if short(m) in ("M1", "M2", "M3", "M4", "M5", "M6", "M7", "M8a", "M8b", "M13", "M0", "M9")], key=lambda m: comp[m]["mdape"])
92 −m0 = R["M0"]; m0u = R["M0_sans_contrainte"]
93 −gain_adj = comp["M2 moyenne des 5 plus proches (temps seul)"]["mdape"] - comp["M7 grille d'ajustement hédonique"]["mdape"]
94 −gain_sel = comp["M2 moyenne des 5 plus proches (temps seul)"]["mdape"] - comp["M3 sélection par similarité physique"]["mdape"]
95 −k_curve = {int(r.k): r.mdape_test for r in kb.itertuples()}
96 −k_best = min(k_curve, key=k_curve.get)
97 −e1b = R["E1_best_val"]
98 −pair = R["paires"]
99 −def pr(a, b): return pair[f"{a} vs {b}"]
100 −M7 = "M7 grille d'ajustement hédonique"; M2 = "M2 moyenne des 5 plus proches (temps seul)"; M6 = "M6 grille d'ajustement conventionnelle"
101 −M8a = "M8a grille locale (régression sur le voisinage)"; M8b = "M8b ajustements non linéaires (GBM)"; M11b = "M11b gradient boosting (avec rôle)"; M11a = "M11a gradient boosting (sans rôle)"
102 −M0 = "M0 meilleure configuration empirique (choisie en 2024, couverture ≥ 97 %)"; M12 = "M12 hybride comparables + GBM (poids appris en 2024)"; M13 = "M13 ratio au rôle (5 plus proches, même rôle)"; M9 = "M9 réconciliation optimisée (poids appris en 2024)"; M3 = "M3 sélection par similarité physique"; M1 = "M1 médiane des 3 plus proches (temps seul)"; M10 = "M10 hédonique globale directe"
103 −dens = R["strates_dens"]; densn = R["strates_dens_n"]
104 −h1 = H["H1"]
105 −marg = R["E2_marginal"]
106 −cond_g = R["regles_conditionnelles_global_test"]
107 −nT = R["n_targets"]
108 −
109 −txt = f"""---
110 −title: "La méthode des comparables à l'épreuve des données : validation empirique à grande échelle sur {n_(D['propre'])} ventes résidentielles québécoises"
111 −subtitle: "Sélection, nombre, ajustements, pondération et réconciliation des ventes comparables — rétro-évaluation point-dans-le-temps sur les transactions Vrai Prix 2021-2026"
112 −author: "Simon-Pierre Boucher — version {VERSION}"
113 −date: "{time.strftime('%d/%m/%Y')}"
114 −lang: fr-CA
115 −---
116 −
117 −# Résumé
118 −
119 −**Question.** La méthode de comparaison des ventes, telle qu'enseignée et pratiquée en évaluation immobilière, produit-elle des estimations fiables, et lesquelles de ses règles (choix des comparables, nombre, rayon, ancienneté, ajustements, pondération, réconciliation) sont empiriquement justifiées ?
120 −
121 −**Données et protocole.** {n_(D['propre'])} ventes de résidences unifamiliales du Québec (registre foncier jumelé au rôle d'évaluation, Vrai Prix, {D['dates'][0]} → {D['dates'][1]}), après un nettoyage journalisé de {n_(D['brut'])} transactions brutes. Pour chacune des **{n_(nT['val'] + nT['test'])} ventes cibles** de 2024 (validation, {n_(nT['val'])}) et de 2025-2026 (test hors échantillon, {n_(nT['test'])}), le prix est masqué et la valeur est estimée avec les seules ventes conclues **avant** la date de la cible, un indice de prix ré-estimé chaque mois avec les seules ventes antérieures, et des grilles d'ajustement et modèles ré-estimés chaque trimestre. **2 256 configurations** de la méthode des comparables et treize modèles de référence sont appliqués aux mêmes cibles ; les différences sont testées par bootstrap par grappes (municipalité × trimestre).
122 −
123 −**Résultats principaux (test 2025-2026).**
124 −
125 −1. **Les ajustements sont le facteur dominant.** Prendre la moyenne des cinq ventes les plus proches, ajustées seulement pour le temps (M2), donne une erreur absolue médiane (MdAPE) de **{pct(comp[M2]['mdape'])}** ; une grille d'ajustement hédonique (M7) la ramène à **{pct(comp[M7]['mdape'])}** (−{f1(gain_adj)} points, IC 95 % de la différence de MdAPE [{f2(pr(M7, M2)['ci_mdape'][0])} ; {f2(pr(M7, M2)['ci_mdape'][1])}]). Une grille conventionnelle stylisée (M6) fait presque aussi bien ({pct(comp[M6]['mdape'])}) : ce qui compte, c'est d'ajuster, plus que la finesse de la grille.
126 −2. **Choisir des comparables semblables vaut autant qu'ajuster.** Sans aucun ajustement physique, sélectionner les cinq ventes les plus **semblables** (aire, terrain, âge, lien physique, étages) plutôt que les cinq plus **proches** fait passer la MdAPE de {pct(comp[M2]['mdape'])} à **{pct(comp[M3]['mdape'])}** (H1 confirmée : un comparable un peu plus éloigné mais semblable vaut mieux qu'un voisin dissemblable).
127 −3. **Le nombre optimal de comparables est bien supérieur à trois.** Avec la grille hédonique, la MdAPE décroît de {pct(k_curve[1])} (k = 1) à {pct(k_curve[3])} (k = 3), {pct(k_curve[5])} (k = 5), {pct(k_curve[10])} (k = 10) et atteint son minimum à **k = {k_best}** ({pct(k_curve[k_best])}) avant de remonter ({pct(k_curve[50])} à k = 50) ; l'optimum dépend de la densité du marché (H2 confirmée).
128 −4. **Le rayon et la fenêtre temporelle doivent être larges.** La meilleure configuration de la surface Erreur(k, d, t) choisie en 2024 est « {LAB[e1b['sel']]}, k = {int(e1b['k'])}, ≤ {e1b['d']} km, {int(e1b['T'])} mois, {LAB[e1b['adj']]} » ({pct(e1b['mdape_val'])} en validation → **{pct(e1b['mdape_test'])}** en test). Les rayons de 0,5-1 km et les fenêtres de 3 mois, privilégiés par la pratique, sont dominés dès que les comparables sont ajustés.
129 −5. **La pondération et le mode de réconciliation comptent peu** (effets marginaux moyens de {f1(min(marg['w'].values()))} à {f1(max(marg['w'].values()))} points selon la pondération, médiane {f1(marg['recon']['median'])} contre moyenne {f1(marg['recon']['mean'])}), bien moins que la sélection ({f1(min(marg['sel'].values()))}-{f1(max(marg['sel'].values()))}) et l'ajustement ({f1(min(marg['adj'].values()))}-{f1(max(marg['adj'].values()))}).
130 −6. **La meilleure méthode des comparables, entièrement transparente et choisie en 2024, atteint {pct(comp[M0]['mdape'])} en 2025-2026** (M0 : {LAB[m0['sel']]}, k = {int(m0['k'])}, ≤ {m0['d']} km, {int(m0['T'])} mois, {LAB[m0['adj']]}, pondération {LAB[m0['w']]}, {LAB[m0['recon']]}), contre **{pct(comp[M11b]['mdape'])}** pour un gradient boosting ré-entraîné chaque trimestre avec la valeur au rôle (M11b) et {pct(comp[M11a]['mdape'])} sans le rôle (M11a). L'hybride comparables + GBM (M12) atteint **{pct(comp[M12]['mdape'])}**. Le coût de la transparence est donc de {f1(comp[M0]['mdape'] - comp[M11b]['mdape'])} point{'s' if abs(comp[M0]['mdape'] - comp[M11b]['mdape']) >= 2 else ''} de MdAPE face au meilleur modèle opaque, et il s'annule dans les sous-marchés homogènes (H5).
131 −7. **Le ratio au rôle est un comparable redoutable** : rôle de la cible × ratio prix/rôle médian des cinq plus proches ventes du même rôle (M13) donne {pct(comp[M13]['mdape'])}, mieux que toute grille d'ajustement physique, mais seulement pour {pct(comp[M13]['couverture'])} des cibles.
132 −8. **La fiabilité dépend d'abord de la liquidité du sous-marché** : la MdAPE de M7 passe de {pct(dens['≥ 200']['M7'])} là où plus de 200 ventes ont eu lieu à moins de 2 km dans l'année à {pct(dens['< 5']['M7'])} là où il y en a eu moins de 5 ; des règles conditionnelles à la densité (système de recommandation) obtiennent {pct(cond_g['mdape'])} globalement avec {pct(cond_g['couverture'])} de couverture.
133 −
134 −**Portée.** Nous validons la précision prédictive de règles professionnelles *simulées* sur des caractéristiques observables (aire, terrain, âge, étages, lien physique, rôle) ; les ajustements non observables (garage, piscine, chambres, qualité, rénovations) restent dans l'erreur résiduelle, et le jugement réel d'un évaluateur n'est pas reproduit.
135 −
136 −# 1. Données
137 −
138 −## 1.1 Source et nettoyage
139 −
140 −Instantané de la base de production Vrai Prix pris le 2026-10-10 : {n_(766596)} transactions résidentielles et 3,75 M d'unités d'évaluation. Nous retenons les ventes de type « unifamilial » jumelées à une unité du rôle, et appliquons des règles de nettoyage explicites et journalisées — rien n'est éliminé silencieusement.
141 −
142 −{md_table(excl)}
143 −Jeu propre : **{n_(D['propre'])} ventes** ({D['dates'][0]} → {D['dates'][1]}) ; prix médian {n_(D['prix']['50%'])} $, moyenne {n_(D['prix']['mean'])} $.
144 −
145 −Deux règles méritent un commentaire. R8 écarte les ratios prix/rôle extrêmes (< 0,33 ou > 3) : faute d'indicateur de lien de dépendance entre les parties, c'est notre proxy de « transaction non représentative » — un évaluateur les écarterait aussi. R6b écarte les ventes conclues l'année même de la construction : leur prix médian ({n_(346108)} $) et leur ratio au rôle (1,15) sont ceux de terrains ou de constructions inachevées, non de maisons achevées (450-480 k$ et 1,3 aux âges 1-4 ans).
146 −
147 −## 1.2 Répartition
148 −
149 −| Période | Rôle | Ventes |
150 −|---|---|---|
151 −| 2021-01 → 2023-12 | développement | {n_(D['par_periode']['train'])} |
152 −| 2024 | validation (choix des configurations) | {n_(D['par_periode']['val'])} |
153 −| 2025-01 → 2026-09 | test hors échantillon | {n_(D['par_periode']['test'])} |
154 −
155 −Régions les plus représentées : {', '.join(f"{k} ({n_(v)})" for k, v in list(D['par_region'].items())[:6])}.
156 −
157 −![Indice de prix mensuel par région (descriptif, plein échantillon).](f7_indice.pdf){{ width=85% }}
158 −
159 −## 1.3 Caractéristiques disponibles et limites
160 −
161 −Observées pour chaque vente : date, prix, coordonnées, municipalité, unité de voisinage, aire habitable, superficie du terrain, année de construction, nombre d'étages, lien physique (détaché, jumelé, en rangée), genre de construction, valeur au rôle en vigueur à la vente et sa part terrain. Non observées : garage, piscine, nombre de chambres et de salles de bains, qualité, état, rénovations. Les grilles d'ajustement testées ne portent donc que sur les premières ; les secondes sont une source d'erreur commune à toutes les méthodes.
162 −
163 −# 2. Protocole
164 −
165 −## 2.1 Rétro-évaluation point-dans-le-temps
166 −
167 −Pour chaque cible : (1) le prix est masqué ; (2) le bassin de comparables est constitué des ventes conclues strictement avant la date de la cible (la cible elle-même, si elle a déjà été vendue, est exclue : on évalue par comparables, non par vente répétée), dans un rayon de 25 km et une fenêtre de 24 mois ; (3) l'indice temporel régional utilisé pour ramener chaque comparable au niveau du dernier mois connu est ré-estimé chaque mois avec les seules ventes antérieures (régression hédonique à effets fixes de municipalité et effets mensuels, par région administrative, rétrécie vers l'indice provincial quand les ventes sont rares) ; (4) les coefficients d'ajustement hédoniques, les grilles régionales et les modèles d'apprentissage sont ré-estimés chaque trimestre avec les seules ventes antérieures au trimestre ; (5) chaque configuration produit une valeur et, le cas échéant, une dispersion des prix ajustés.
168 −
169 −Le niveau de prix du mois de la cible n'est jamais connu : toutes les méthodes travaillent au niveau du mois précédent, ce qui reproduit la situation réelle d'un évaluateur.
170 −
171 −## 2.2 Configurations testées
172 −
173 −**E1 — surface Erreur(k, d, t)** : k ∈ {{1, 3, 5, 7, 10, 15, 20, 30, 50}}, d ∈ {{0,5 ; 1 ; 2 ; 5 ; 10 ; 25 km}}, t ∈ {{3, 6, 12, 24 mois}}, sélection ∈ {{plus proches, plus semblables, compromis}}, ajustement ∈ {{temps seul, grille hédonique}}, médiane. 1 296 configurations.
174 −
175 −**E2 — plan factoriel des règles** aux ancres (k, d, t) = (3, 2 km, 6 mois), (5, 5 km, 12 mois), (10, 10 km, 24 mois) : sélection (4) × ajustement (8 : aucun, temps seul, grille conventionnelle, hédonique globale, hédonique régionale, régression locale, GBM, ratio au rôle) × pondération (5) × réconciliation (2). 960 configurations.
176 −
177 −*Grille conventionnelle stylisée* (M6) : aire habitable ajustée à la moitié du prix au m² du comparable ; terrain à la moitié de la valeur foncière implicite au m² (plafond ±15 %) ; âge à −0,5 % par année d'écart (plafond ±20 %) ; ±5 % entre détaché et attaché. *Grille hédonique* (M7) : coefficients d'une régression du log du prix sur les caractéristiques, à effets fixes de municipalité (within), ré-estimée chaque trimestre. *Régression locale* (M8a) : mêmes coefficients ré-estimés sur les ventes à moins de 10 km des 24 derniers mois (≥ 40 ventes, ridge vers la grille régionale). *GBM* (M8b) : l'ajustement est la différence des prédictions d'un gradient boosting entre la cible et le comparable. *Ratio au rôle* (M13) : valeur au rôle de la cible × ratio prix/rôle du comparable, pour les comparables de la même municipalité et du même rôle triennal.
178 −
179 −## 2.3 Mesures
180 −
181 −Erreur absolue en pourcentage APE = |P̂ − P| / P ; MdAPE (médiane), MAPE (moyenne), biais médian signé, parts à ±5, ±10 et ±20 %, couverture (part des cibles pour lesquelles la configuration produit une valeur — une configuration trop restrictive ne « rate » pas, elle s'abstient, et cela doit se voir). Les différences entre méthodes sont des différences d'erreurs appariées sur les mêmes cibles, avec intervalles de confiance à 95 % par bootstrap par grappes municipalité × trimestre (400 réplications).
182 −
183 −# 3. Résultats
184 −
185 −## 3.1 Combien de comparables ? À quelle distance ? De quelle ancienneté ?
186 −
187 −![MdAPE (test 2025-2026) selon le nombre de comparables, par rayon, fenêtre de 12 mois, sélection par distance.](f1_mdape_vs_k.pdf){{ width=100% }}
188 −
189 −![MdAPE au meilleur k pour chaque couple rayon × fenêtre.](f2_surface_d_T.pdf){{ width=90% }}
190 −
191 −**Sélection par distance, grille hédonique, fenêtre 12 mois — MdAPE test (%)**
192 −
193 −{surf_tab('geo', 'hedo', 12)}
194 −**Sélection par distance, temps seulement, fenêtre 12 mois — MdAPE test (%)**
195 −
196 −{surf_tab('geo', 'none', 12)}
197 −**Sélection par similarité physique, grille hédonique, fenêtre 12 mois — MdAPE test (%)**
198 −
199 −{surf_tab('sim', 'hedo', 12)}
200 −Lecture. (i) Sans ajustement, l'erreur décroît avec k et avec le rayon tant que l'on reste sous quelques kilomètres, puis se stabilise ; (ii) avec la grille hédonique, elle décroît nettement plus et l'optimum se déplace vers des k et des rayons plus grands ; (iii) la fenêtre de 3 mois est systématiquement dominée par 12-24 mois dès que l'on ajuste pour le temps. Meilleures configurations de E1 par sélection × ajustement (choisies en 2024, évaluées en 2025-2026) :
201 −
202 −{md_table(pd.DataFrame(R['E1_best_by_sel_adj']).T.reset_index().rename(columns={'index': 'sélection | ajustement', 'mdape_val': 'MdAPE val.', 'mdape_test': 'MdAPE test', 'couverture_test': 'couverture test'}), ['sélection | ajustement', 'k', 'd', 'T', 'MdAPE val.', 'MdAPE test', 'couverture test'], fmt={'k': lambda v: str(int(v)), 'T': lambda v: str(int(v)), 'd': lambda v: f1(v)})}
203 −### k optimal selon la densité (H2)
204 −
205 −{h2_md}
206 −*(sélection par distance, ≤ 10 km, 12 mois, grille hédonique ; MdAPE test en %, par nombre de ventes disponibles à moins de 2 km au cours des 12 mois précédents)*
207 −
208 −Sans ajustement :
209 −
210 −{h2n_md}
211 −![k optimal par strate de densité.](f6_k_par_densite.pdf){{ width=100% }}
212 −
213 −## 3.2 Quels ajustements sont justifiés ?
214 −
215 −![Type d'ajustement, cinq comparables les plus proches, ≤ 5 km, ≤ 12 mois, médiane.](f3_ajustements.pdf){{ width=90% }}
216 −
217 −{t_adj_md}
218 −Trois enseignements. **L'ajustement temporel seul** vaut {f1(t_adj.set_index('adj').loc['raw', 'mdape_test'] - t_adj.set_index('adj').loc['none', 'mdape_test'])} point ; **n'importe quelle grille physique** vaut {f1(t_adj.set_index('adj').loc['none', 'mdape_test'] - t_adj.set_index('adj').loc['conv', 'mdape_test'])} à {f1(t_adj.set_index('adj').loc['none', 'mdape_test'] - t_adj.set_index('adj').loc['gbm', 'mdape_test'])} points de plus ; **les raffinements** (grille régionale, régression locale, ajustements non linéaires) apportent peu face à la grille hédonique globale, et la régression locale est même {('moins bonne' if t_adj.set_index('adj').loc['local', 'mdape_test'] > t_adj.set_index('adj').loc['hedo', 'mdape_test'] else 'meilleure')} ({pct(t_adj.set_index('adj').loc['local', 'mdape_test'])} contre {pct(t_adj.set_index('adj').loc['hedo', 'mdape_test'])}).
219 −
220 −### Grilles locales et petits échantillons (H3)
221 −
222 −MdAPE test (%) par type d'ajustement et densité, ancre (5, 5 km, 12 mois) :
223 −
224 −{h3_md}
225 −Ancre (3, 2 km, 6 mois) :
226 −
227 −{h3s_md}
228 −## 3.3 Comment choisir les comparables ? (H1)
229 −
230 −Ancre (5, 5 km, 12 mois), grille hédonique, médiane :
231 −
232 −{t_sel_md}
233 −Test direct de H1 (k = 5, 12 mois, temps seulement) : les cinq voisins à moins de 1 km donnent une MdAPE de {pct(h1['geo_d1']['mdape_test'])} (couverture {pct(h1['geo_d1']['couverture_test'])}) ; les cinq ventes les plus semblables à moins de 10 km, {pct(h1['sim_d10']['mdape_test'])} (couverture {pct(h1['sim_d10']['couverture_test'])}) ; les cinq plus proches à moins de 10 km, {pct(h1['geo_d10']['mdape_test'])}. Avec la grille hédonique : plus proches {pct(h1['geo_d10_hedo']['mdape_test'])}, plus semblables {pct(h1['sim_d10_hedo']['mdape_test'])}, compromis {pct(h1['mix_d10_hedo']['mdape_test'])}.
234 −
235 −## 3.4 Pondération et réconciliation (H4)
236 −
237 −Ancre (5, 5 km, 12 mois), sélection par distance, grille hédonique :
238 −
239 −{t_wr_md}
240 −Selon la densité (MdAPE test, %) :
241 −
242 −{h4_md}
243 −Effets marginaux moyens (MdAPE test moyenne sur toutes les autres modalités de E2) : sélection {', '.join(f"{LAB[k]} {f1(v)}" for k, v in marg['sel'].items())} ; ajustement {', '.join(f"{LAB[k]} {f1(v)}" for k, v in marg['adj'].items())} ; pondération {', '.join(f"{LAB[k]} {f1(v)}" for k, v in marg['w'].items())} ; réconciliation {', '.join(f"{LAB[k]} {f1(v)}" for k, v in marg['recon'].items())}.
244 −
245 −## 3.5 Compétition de modèles
246 −
247 −![Compétition de modèles, test 2025-2026.](f4_competition.pdf){{ width=95% }}
248 −
249 −{t_comp}
250 −M0 : {LAB[m0['sel']]}, k = {int(m0['k'])}, ≤ {m0['d']} km, {int(m0['T'])} mois, {LAB[m0['adj']]}, pondération {LAB[m0['w']]}, {LAB[m0['recon']]} (meilleure des 2 256 configurations en 2024 parmi celles qui couvrent ≥ 97 % des cibles ; sans cette contrainte, la meilleure est « {LAB[m0u['sel']]}, k = {int(m0u['k'])}, ≤ {m0u['d']} km, {int(m0u['T'])} mois, {LAB[m0u['adj']]} » à {pct(m0u['mdape_test'])} mais pour {pct(m0u['couverture_test'])} des cibles seulement). Poids appris de M9 : {', '.join(f"{LAB[k]} {f2(v)}" for k, v in R['M9_weights'].items() if v > 0)}. Poids de M12 : {', '.join(f"{k} {f2(v)}" for k, v in R['M12_weights'].items() if v > 0)}.
251 −
252 −### Différences appariées (test ; bootstrap par grappes municipalité × trimestre)
253 −
254 −{t_pairs}
255 −Un Δ négatif signifie que A est plus précis que B ; l'intervalle qui exclut zéro signale une différence robuste aux dépendances spatiales et temporelles.
256 −
257 −## 3.6 Où la méthode tient-elle, où défaille-t-elle ?
258 −
259 −![Précision selon la liquidité du sous-marché.](f5_densite.pdf){{ width=85% }}
260 −
261 −**Par densité** (ventes à moins de 2 km dans les 12 mois précédents) — MdAPE test (%) :
262 −
263 −{strata_md('dens', 'densité')}
264 −**Par région administrative** :
265 −
266 −{strata_md('region_nom', 'région')}
267 −**Par quintile de prix** :
268 −
269 −{strata_md('prix_q', 'quintile de prix')}
270 −**Par âge du bâtiment** :
271 −
272 −{strata_md('age_cl', 'âge')}
273 −**Par lien physique** (0 détaché, 1 jumelé, 2 en rangée, 3 intégré) :
274 −
275 −{strata_md('lien', 'lien physique')}
276 −### Marchés homogènes et hétérogènes (H5)
277 −
278 −Terciles de la dispersion (écart-type du log des prix ajustés) des dix ventes les plus proches à ≤ 10 km / 24 mois — MdAPE test (%) :
279 −
280 −{h5_md}
281 −### Stabilité dans le temps
282 −
283 −{stab_md}
284 −Le biais trimestriel commun à toutes les méthodes reflète le fait que le niveau de prix du mois courant est inconnu (toutes travaillent au niveau du mois précédent) et la saisonnalité des prix.
285 −
286 −## 3.7 Intervalles de valeur
287 −
288 −La dispersion des prix ajustés des comparables retenus fournit un intervalle de valeur. Avec z = 1,28 (couverture nominale de 80 % si les erreurs étaient normales), puis avec un z calibré sur la validation 2024 :
289 −
290 −{iv_md}
291 −## 3.8 Vers un système de recommandation des comparables
292 −
293 −Règle choisie en 2024 pour chaque strate de densité (parmi les 2 256 configurations couvrant ≥ 95 % des cibles de la strate), évaluée en 2025-2026 :
294 −
295 −{rc_md}
296 −Globalement, ces règles conditionnelles atteignent une MdAPE de **{pct(cond_g['mdape'])}** ({pct(cond_g['p10'])} des estimations à ±10 %, couverture {pct(cond_g['couverture'])}), contre {pct(comp[M0]['mdape'])} pour la meilleure règle unique.
297 −
298 −# 4. Discussion
299 −
300 −**Ce que la méthode des comparables fait bien.** Correctement paramétrée — comparables semblables, nombre d'une dizaine, rayon et fenêtre larges, ajustements hédoniques ou même conventionnels — elle atteint une précision ({pct(comp[M0]['mdape'])}) que ne dépasse le meilleur modèle d'apprentissage ré-entraîné chaque trimestre que de {f1(comp[M0]['mdape'] - comp[M11b]['mdape'])} point{'s' if abs(comp[M0]['mdape'] - comp[M11b]['mdape']) >= 2 else ''}, tout en restant intégralement explicable : chaque valeur est la médiane de quelques ventes identifiées, ajustées par des coefficients lisibles.
301 −
302 −**Ce qui contredit la pratique.** Trois comparables, un rayon de quelques centaines de mètres et une fenêtre de trois mois — les conventions les plus répandues — sont dominés sur toute la surface Erreur(k, d, t) : {pct(surf[(surf.sel == 'geo') & (surf.adj == 'hedo') & (surf.k == 3) & (surf.d == 1) & (surf['T'] == 3)].mdape_test.iloc[0])} de MdAPE (k = 3, ≤ 1 km, 3 mois, grille hédonique, couverture {pct(surf[(surf.sel == 'geo') & (surf.adj == 'hedo') & (surf.k == 3) & (surf.d == 1) & (surf['T'] == 3)].couverture_test.iloc[0])}) contre {pct(e1b['mdape_test'])} pour la meilleure configuration. La rareté des comparables « parfaits » est un argument pour élargir la recherche et ajuster, non pour se restreindre.
303 −
304 −**Le rôle d'évaluation comme information.** Le ratio au rôle (M13) et le GBM avec rôle (M11b) dominent leurs équivalents sans rôle : la valeur au rôle encapsule des caractéristiques non observées ici (qualité, dépendances, état) et une appréciation locale que les modèles globaux ne captent pas. Les modèles globaux sans rôle (M10, M11a) présentent d'ailleurs un biais négatif en 2025-2026 : l'appréciation est spatialement hétérogène et un indice régional, même ré-estimé chaque trimestre, la sous-estime là où les ventes se concentrent.
305 −
306 −**Limites.** (1) Règles professionnelles simulées, non dossiers d'évaluateurs. (2) Caractéristiques limitées à celles du rôle et du registre. (3) Délai de publication des ventes ignoré. (4) Un seul type de bien (unifamilial) ; condos et plex restent à traiter. (5) Les configurations ont été choisies sur 2024 puis évaluées sur 2025-2026 : les écarts entre validation et test (tableaux ci-dessus) mesurent la stabilité des règles, et ils sont faibles.
307 −
308 −# 5. Reproductibilité
309 −
310 −Code : `pipeline/01_dataset.py` → `06_report.py` (Python 3.14, pandas, numpy, scipy, lightgbm, matplotlib) ; sorties versionnées `out/{VERSION}/` (configurations `configs_E1.csv`, `configs_E2.csv` ; estimations `est_E1.npy`, `est_E2.npy` ; cibles `targets.parquet` ; tables `tables/*.csv` ; `results.json`). Exécution : `./run_all.sh` sur le nœud de calcul (M2U64), `./make_pdf.sh` sur le poste de travail.
311 −"""
312 −(DOCS / "RAPPORT.md").write_text(txt, encoding="utf-8")
313 −log(f"rapport écrit : {DOCS / 'RAPPORT.md'} ({len(txt.split()):,} mots)")
deleted M3U96aomparables-validation/pipeline/common.py +0 −85
@@ -1,85 +0,0 @@
1 −# -*- coding: utf-8 -*-
2 −"""Validation empirique de la méthode des comparables — fonctions communes.
3 −Données : instantané Vrai Prix (transactions ⋈ units) du 2026-10-10.
4 −Toutes les sorties sont versionnées sous out/<version>/.
5 −"""
6 −from __future__ import annotations
7 −import json, math, os, sqlite3, time
8 −from pathlib import Path
9 −import numpy as np
10 −import pandas as pd
11 −
12 −ROOT = Path(os.environ.get("CV_ROOT", Path(__file__).resolve().parents[1]))
13 −DB = Path(os.environ.get("CV_DB", ROOT / "data" / "vraiprix-20261010.db"))
14 −REF = ROOT / "ref"
15 −VERSION = os.environ.get("CV_VERSION", time.strftime("%Y-%m-%d"))
16 −OUT = ROOT / "out" / VERSION
17 −OUT.mkdir(parents=True, exist_ok=True)
18 −(OUT / "fig").mkdir(exist_ok=True)
19 −
20 −# Périodes (séparation chronologique)
21 −TRAIN_END = "2023-12-31" # développement
22 −VAL_START, VAL_END = "2024-01-01", "2024-12-31" # choix des configurations
23 −TEST_START, TEST_END = "2025-01-01", "2026-09-29" # évaluation finale hors échantillon
24 −MONTH0 = "2021-01" # premier mois des données
25 −
26 −def log(msg: str) -> None:
27 − print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
28 −
29 −def connect(path: Path = DB) -> sqlite3.Connection:
30 − return sqlite3.connect(f"file:{path}?mode=ro", uri=True, timeout=60)
31 −
32 −def _json_default(o):
33 − if isinstance(o, np.integer): return int(o)
34 − if isinstance(o, np.floating): return None if np.isnan(o) else float(o)
35 − if isinstance(o, np.bool_): return bool(o)
36 − if isinstance(o, np.ndarray): return o.tolist()
37 − if isinstance(o, pd.Timestamp): return o.strftime("%Y-%m-%d")
38 − if isinstance(o, float) and math.isnan(o): return None
39 − return str(o)
40 −
41 −def save_json(obj, name: str) -> Path:
42 − p = OUT / name; p.parent.mkdir(parents=True, exist_ok=True)
43 − with open(p, "w", encoding="utf-8") as f:
44 − json.dump(obj, f, ensure_ascii=False, indent=2, default=_json_default)
45 − return p
46 −
47 −def load_json(name: str):
48 − with open(OUT / name, encoding="utf-8") as f:
49 − return json.load(f)
50 −
51 −def save_parquet(df: pd.DataFrame, name: str) -> Path:
52 − p = OUT / name; df.to_parquet(p, index=False); return p
53 −
54 −def load_parquet(name: str) -> pd.DataFrame:
55 − return pd.read_parquet(OUT / name)
56 −
57 −def month_index(dates) -> np.ndarray:
58 − """Mois entier depuis MONTH0 (2021-01 = 0)."""
59 − d = pd.to_datetime(dates)
60 − y0, m0 = map(int, MONTH0.split("-"))
61 − return ((d.dt.year - y0) * 12 + (d.dt.month - m0)).to_numpy()
62 −
63 −def to_xy_km(lat, lng, lat0=46.8, lng0=-71.2):
64 − """Projection équirectangulaire locale (km) : erreur < 1 % pour des distances < 50 km."""
65 − x = (np.asarray(lng, float) - lng0) * 111.32 * np.cos(np.radians(np.asarray(lat, float)))
66 − y = (np.asarray(lat, float) - lat0) * 110.57
67 − return x, y
68 −
69 −def load_repertoire() -> pd.DataFrame:
70 − mun = pd.read_csv(REF / "MUN.csv", dtype=str, encoding="utf-8-sig")
71 − mun = mun.rename(columns=str.lower)
72 − out = pd.DataFrame({
73 − "code_mun": mun["mcode"].str.zfill(5),
74 − "mun_nom": mun["munnom"],
75 − "region": mun["regadm"].str.extract(r"\((\d+)\)")[0],
76 − "region_nom": mun["regadm"].str.replace(r"\s*\(\d+\)", "", regex=True),
77 − "mrc": mun["mrc"].str.extract(r"\((\d+)\)")[0],
78 − "mrc_nom": mun["mrc"].str.replace(r"\s*\(\d+\)", "", regex=True),
79 − })
80 − return out.drop_duplicates("code_mun")
81 −
82 −# Codages catégoriels (ordre stable)
83 −LIEN = {"Détaché": 0, "Jumelé": 1, "En rangée 1 côté": 2, "En rangée plus de 1 côté": 2, "Intégré": 3}
84 −GENRE = {"De plain-pied": 0, "Unimodulaire": 0, "À étages entiers": 1, "À niveaux décalés": 2, "À étage mansardé": 3}
85 −BTYPE = {"single-story": 0, "full-story": 1, "mansard": 2, "split-level": 3, "single-wide": 4}
deleted M3U96aomparables-validation/run_all.sh +0 −14
@@ -1,14 +0,0 @@
1 −#!/bin/zsh
2 −# Chaîne complète sur le nœud de calcul (M2U64). Usage : CV_VERSION=2026-10-10 ./run_all.sh [étape de départ]
3 −set -e
4 −cd "$(dirname "$0")"
5 −PY=${PY:-$HOME/uqo-eval-terrains/env/bin/python}
6 −export CV_VERSION=${CV_VERSION:-$(date +%F)}
7 −START=${1:-1}
8 −for s in 01_dataset 02_index 03_models 04_backtest 05_analyze 06_report; do
9 − n=${s%%_*}
10 − [ "$((10#$n))" -lt "$START" ] && continue
11 − echo "=== $s ($(date +%T)) ==="
12 − (cd pipeline && $PY $s.py)
13 −done
14 −echo "=== terminé $(date +%T) ==="
deleted M3U96bomparables-validation/.gitignore +0 −5
@@ -1,5 +0,0 @@
1 −out/
2 −data/
3 −*.pyc
4 −__pycache__/
5 −.DS_Store
deleted M3U96bomparables-validation/README.md +0 −33
@@ -1,33 +0,0 @@
1 −# Validation empirique de la méthode des comparables (Vrai Prix)
2 −
3 −Projet de recherche : rétro-évaluation à grande échelle de la méthode de comparaison des ventes sur les transactions
4 −résidentielles unifamiliales du Québec (Vrai Prix, 2021-01 → 2026-09). Protocole : `docs/PROTOCOLE.md`.
5 −Rapport de résultats : `docs/RAPPORT.md` / `docs/RAPPORT.pdf`.
6 −
7 −## Organisation
8 −
9 −```
10 −pipeline/ 01_dataset → 02_index → 03_models → 04_backtest → 05_analyze → 06_report
11 −docs/ PROTOCOLE.md, RAPPORT.md(.pdf), header.tex
12 −out/<version>/ sales.parquet, index_asof.npy, models_*.json, est_E*.npy, targets.parquet, tables/, fig/, results.json
13 −data/ (nœud seulement) vraiprix-20261010.db — instantané de la base de production Vrai Prix
14 −ref/ MUN.csv (répertoire des municipalités MAMH : code → MRC → région)
15 −```
16 −
17 −## Exécution
18 −
19 −Le calcul se fait sur **M2U64** (`~/comparables-validation`, venv `~/uqo-eval-terrains/env`, Python 3.14 : pandas, numpy, scipy,
20 −lightgbm, matplotlib). Le laptop garde le code, les docs et une copie des sorties.
21 −
22 −```bash
23 −rsync -a --exclude out --exclude data --exclude .git ~/Desktop/Validation-Comparables/ M2U64:comparables-validation/
24 −ssh M2U64 'cd ~/comparables-validation && CV_VERSION=2026-10-10 ./run_all.sh' # ~ 30 min
25 −./make_pdf.sh # rapatrie out/ + docs/ et compile le PDF
26 −```
27 −
28 −Variables : `CV_VERSION` (dossier de sortie), `CV_SAMPLE=3000` (échantillon de cibles pour un essai), `CV_PROCS` (processus, 20 par défaut).
29 −
30 −## Données
31 −
32 −Instantané `vraiprix.db` pris sur M3U96a (`~/apps/vrai-prix/data`) le 2026-10-10 : 766 596 transactions, 3,75 M d'unités du rôle.
33 −Jeu propre : ventes `unifamilial` jumelées au rôle, règles R0-R11 journalisées dans `out/<v>/exclusions.csv`.
deleted M3U96bomparables-validation/docs/PROTOCOLE.md +0 −101
@@ -1,101 +0,0 @@
1 −# Revisiter la méthode de comparaison des ventes : protocole de validation empirique à grande échelle
2 −
3 −**Titre de travail (article A)** — *Revisiting the Sales Comparison Approach: A Large-Scale Empirical Validation of Comparable Selection, Price Adjustments, and Value Reconciliation*
4 −
5 −**Variante** — *Does the Sales Comparison Approach Actually Work? Evidence from Large-Scale Residential Property Transactions*
6 −
7 −Données : transactions historiques **Vrai Prix** (registre foncier du Québec jumelé au rôle d'évaluation), instantané du 2026-10-10.
8 −
9 −## 1. Question de recherche
10 −
11 −La méthode des comparables, telle qu'enseignée et pratiquée par les évaluateurs agréés, produit-elle réellement les estimations les plus fiables ? Et surtout, quelles règles de **sélection**, d'**ajustement**, de **pondération** et de **réconciliation** des comparables sont empiriquement justifiées ?
12 −
13 −| Dimension | Question testée | Où dans le pipeline |
14 −|---|---|---|
15 −| Sélection | Quels comparables sont réellement les plus pertinents (proximité, similarité physique, « moins ajustés ») ? | E1 (facteur `sel`), E2 |
16 −| Nombre | Faut-il 1, 3, 5, 10, 20 ou 50 ventes ? | E1 (facteur `k`) |
17 −| Ajustements | Quels ajustements sont statistiquement justifiés (grille conventionnelle, hédonique, locale, non linéaire, ratio au rôle) ? | E2 (facteur `adj`) |
18 −| Pondération | Égalitaire, distance, récence, similarité ? | E2 (facteur `w`) |
19 −| Réconciliation | Médiane, moyenne, moyenne pondérée, combinaison optimisée (M9) ? | E2 (facteur `recon`), M9 |
20 −| Fiabilité | Dans quels marchés (densité, région, segment de prix, hétérogénéité) la méthode tient-elle ou défaille-t-elle ? | strates, H2-H5 |
21 −
22 −## 2. Protocole expérimental (rétro-évaluation « point dans le temps »)
23 −
24 −Pour chaque transaction cible :
25 −
26 −1. **Masquage** du prix de vente réel.
27 −2. **Reconstitution du marché à la date d'évaluation** : seules les ventes conclues *strictement avant* la date de la cible sont admissibles (le bien lui-même, s'il a déjà été vendu, est exclu du bassin : on évalue par comparables, pas par vente répétée). L'indice temporel, les coefficients hédoniques et les modèles d'apprentissage sont ré-estimés avec les seules ventes antérieures à la période d'évaluation (indice : ré-estimé chaque mois avec toutes les ventes des mois précédents ; modèles : estimés sur les ventes < 2024-01 pour la validation 2024 et < 2025-01 pour le test 2025-26).
28 −3. **Application** de chaque configuration (sélection × rayon × fenêtre × nombre × ajustement × pondération × réconciliation) sur exactement les mêmes cibles.
29 −4. **Comparaison** avec le prix réel : erreur absolue en pourcentage, biais, part des estimations à ±5/10/20 %, couverture (part des cibles pour lesquelles la configuration produit une valeur), dispersion des prix ajustés (intervalle de valeur).
30 −
31 −### Séparation chronologique
32 −
33 −| Période | Rôle | Ventes |
34 −|---|---|---|
35 −| 2021-01 → 2023-12 | développement (estimation des grilles d'ajustement et des modèles) | ~ 200 000 |
36 −| 2024 | validation : choix des configurations et des hyperparamètres | ~ 65 000 cibles |
37 −| 2025-01 → 2026-09 | évaluation finale hors échantillon | ~ 125 000 cibles |
38 −
39 −Les comparables d'une cible de 2025 peuvent être des ventes de 2024 ou de 2025 antérieures à sa date : c'est l'information dont disposait un évaluateur ce jour-là (nous ignorons le délai de publication au registre foncier, ce qui avantage légèrement toutes les méthodes de la même façon).
40 −
41 −## 3. Expériences
42 −
43 −### E1 — surface empirique de performance Erreur(k, d, t)
44 −
45 −- k ∈ {1, 3, 5, 7, 10, 15, 20, 30, 50} comparables ;
46 −- d ∈ {0,5 ; 1 ; 2 ; 5 ; 10 ; 25} km (rayon maximal) ;
47 −- t ∈ {3, 6, 12, 24} mois (ancienneté maximale) ;
48 −- sélection ∈ {`geo` les plus proches, `sim` les plus semblables physiquement, `mix` compromis} ;
49 −- ajustement ∈ {`none` temps seulement, `hedo` grille hédonique} ; réconciliation = médiane.
50 −
51 −1 296 configurations × toutes les cibles.
52 −
53 −### E2 — plan factoriel des règles (trois ancres (k, d, t) = (3, 2 km, 6 mois), (5, 5 km, 12 mois), (10, 10 km, 24 mois))
54 −
55 −- sélection ∈ {`geo`, `sim`, `mix`, `adj` = comparables nécessitant le moins d'ajustements} ;
56 −- ajustement ∈ {`raw` aucun, `none` temps seul, `conv` grille conventionnelle stylisée, `hedo` grille hédonique globale (within-municipalité), `hedo_reg` grille régionale rétrécie, `local` régression sur le voisinage (≤ 10 km, ≥ 40 ventes, ridge vers la grille régionale), `gbm` ajustements non linéaires (LightGBM), `role` ratio prix/rôle (comparables de la même municipalité et du même rôle)} ;
57 −- pondération ∈ {égale, 1/(0,25 + d), exp(−mois/6), 1/(0,1 + ajustement brut), produit des trois} ;
58 −- réconciliation ∈ {médiane (pondérée), moyenne (pondérée) en dollars}.
59 −
60 −960 configurations.
61 −
62 −### Compétition de modèles
63 −
64 −| Modèle | Définition opérationnelle |
65 −|---|---|
66 −| M1 | médiane des 3 ventes les plus proches (≤ 2 km, ≤ 6 mois), temps seul |
67 −| M2 | moyenne des 5 plus proches (≤ 5 km, ≤ 12 mois), temps seul |
68 −| M3 | 5 comparables choisis par similarité physique |
69 −| M4 | 5 plus proches pondérés par la distance |
70 −| M5 | 5 plus proches pondérés par la récence |
71 −| M6 | grille d'ajustement conventionnelle stylisée (½ du prix au m² pour l'aire, ½ de la valeur foncière au m² pour le terrain plafonnée à 15 %, −0,5 %/an d'âge plafonné à 20 %, ±5 % détaché/attaché) |
72 −| M7 | grille d'ajustement hédonique (coefficients within-municipalité estimés sur les ventes antérieures) |
73 −| M8a / M8b | grille locale (régression sur le voisinage) / ajustements non linéaires (GBM) |
74 −| M9 | réconciliation optimisée : combinaison convexe (NNLS) de sept estimations par comparables, poids appris en 2024 |
75 −| M10 | modèle hédonique global direct (β within + effets région, MRC, municipalité, unité de voisinage) |
76 −| M11a / M11b | gradient boosting sans / avec la valeur au rôle |
77 −| M12 | hybride : combinaison convexe de M11b et des estimations par comparables ajustées (GBM, hédonique) |
78 −| M13 | ratio au rôle : rôle de la cible × ratio prix/rôle médian des 5 plus proches du même rôle |
79 −| M0 | meilleure configuration de E1 ∪ E2 choisie sur la validation 2024, évaluée en 2025-26 |
80 −
81 −### Hypothèses
82 −
83 −- **H1** — un comparable très proche mais peu semblable peut être moins informatif qu'un comparable plus éloigné mais très semblable.
84 −- **H2** — l'ajout de comparables améliore la précision jusqu'à un seuil, au-delà duquel l'hétérogénéité domine ; le seuil dépend de la densité.
85 −- **H3** — les ajustements empiriques locaux surpassent les grilles fixes mais deviennent instables dans les petits échantillons.
86 −- **H4** — la pondération optimale dépend de la liquidité du sous-marché.
87 −- **H5** — les méthodes simples et transparentes rivalisent avec l'apprentissage automatique dans les marchés homogènes.
88 −
89 −### Inférence
90 −
91 −Différences d'erreurs appariées (mêmes cibles) avec intervalles de confiance par **bootstrap par grappes** (municipalité × trimestre), qui respecte les dépendances spatiales et temporelles.
92 −
93 −## 4. Ce que les données permettent — et ne permettent pas
94 −
95 −Caractéristiques observées pour chaque vente (registre foncier ⋈ rôle) : date, prix, coordonnées, municipalité, unité de voisinage, aire habitable (étages), superficie du terrain, année de construction, nombre d'étages, lien physique (détaché, jumelé, en rangée), genre de construction, valeur au rôle (en vigueur à la vente) et sa part terrain. **Non observés** : garage, piscine, nombre de chambres et de salles de bains, qualité et état, rénovations. Les ajustements correspondants ne peuvent donc pas être testés ici ; les erreurs résiduelles les contiennent.
96 −
97 −Nous validons la **précision prédictive** de règles professionnelles *simulées*, pas le jugement réel d'un évaluateur (il faudrait ses dossiers, ses comparables et ses ajustements). La distinction est maintenue partout dans le rapport.
98 −
99 −## 5. Article B (suite envisagée)
100 −
101 −Vers une méthode des comparables optimisée empiriquement : algorithme transparent de sélection/ajustement entraîné sur les ventes historiques et validé sur les ventes futures ; règles conditionnelles au type de bien, à la densité et à la qualité des données ; intégration dans UQO Éval pour montrer aux étudiants la comparaison entre règles traditionnelles et règles estimées.
deleted M3U96bomparables-validation/docs/header.tex +0 −28
@@ -1,28 +0,0 @@
1 −% En-tête LaTeX — Grille empirique d'ajustement des terrains (UQO Éval)
2 −\usepackage{fontspec}
3 −\setmainfont{Helvetica Neue}[Ligatures=TeX]
4 −\setsansfont{Helvetica Neue}
5 −\setmonofont{Menlo}[Scale=0.82]
6 −\usepackage{newunicodechar}
7 −\newunicodechar{→}{\ensuremath{\rightarrow}}\newunicodechar{≥}{\ensuremath{\geq}}\newunicodechar{≤}{\ensuremath{\leq}}\newunicodechar{×}{\ensuremath{\times}}\newunicodechar{−}{\ensuremath{-}}\newunicodechar{≈}{\ensuremath{\approx}}\newunicodechar{∂}{\ensuremath{\partial}}\newunicodechar{⋈}{\ensuremath{\bowtie}}\newunicodechar{Δ}{\ensuremath{\Delta}}\newunicodechar{τ}{\ensuremath{\tau}}\newunicodechar{σ}{\ensuremath{\sigma}}\newunicodechar{β}{\ensuremath{\beta}}\newunicodechar{θ}{\ensuremath{\theta}}\newunicodechar{γ}{\ensuremath{\gamma}}\newunicodechar{α}{\ensuremath{\alpha}}\newunicodechar{δ}{\ensuremath{\delta}}\newunicodechar{ε}{\ensuremath{\varepsilon}}\newunicodechar{²}{\textsuperscript{2}}\newunicodechar{⇒}{\ensuremath{\Rightarrow}}\newunicodechar{▶}{\ensuremath{\blacktriangleright}}\newunicodechar{│}{|}\newunicodechar{▼}{\ensuremath{\blacktriangledown}}\newunicodechar{─}{-}\newunicodechar{┌}{+}\newunicodechar{┐}{+}\newunicodechar{└}{+}\newunicodechar{┘}{+}\newunicodechar{├}{+}\newunicodechar{┤}{+}
8 −\usepackage{xcolor}
9 −\definecolor{uqo}{HTML}{006085}\definecolor{turq}{HTML}{2BCCAE}\definecolor{corail}{HTML}{EA6047}\definecolor{encre}{HTML}{16181B}\definecolor{papier}{HTML}{F4F5F3}
10 −\usepackage{titlesec}
11 −\titleformat{\section}{\Large\bfseries\color{uqo}}{}{0em}{}[{\color{uqo}\titlerule[0.8pt]}]
12 −\titleformat{\subsection}{\large\bfseries\color{uqo}}{}{0em}{}
13 −\titlespacing*{\section}{0pt}{18pt plus 4pt}{8pt}
14 −\usepackage{etoolbox}
15 −\AtBeginEnvironment{longtable}{\scriptsize\renewcommand{\arraystretch}{1.18}}
16 −\usepackage{colortbl}\arrayrulecolor{uqo!35}
17 −\usepackage{tcolorbox}
18 −\renewenvironment{quote}{\begin{tcolorbox}[colback=papier,colframe=turq,boxrule=0pt,leftrule=3pt,arc=1pt,left=6pt,right=6pt,top=4pt,bottom=4pt]}{\end{tcolorbox}}
19 −\usepackage{fancyhdr}
20 −\pagestyle{fancy}\fancyhf{}
21 −\fancyhead[L]{\footnotesize\color{gray}Grille empirique d'ajustement des terrains — UQO Éval}\fancyhead[R]{\footnotesize\color{gray}IMM1003 · IMM1033}
22 −\fancyfoot[C]{\footnotesize\color{gray}\thepage}\renewcommand{\headrulewidth}{0.3pt}
23 −\usepackage{caption}\captionsetup{font=small,labelfont={bf,color=uqo}}
24 −\usepackage{float}\floatplacement{figure}{H}
25 −\setlength{\emergencystretch}{3em}\hyphenpenalty=300\tolerance=1500
26 −\usepackage{microtype}
27 −\makeatletter\renewcommand{\maketitle}{\begin{center}{\color{uqo}\Huge\bfseries\@title\par}\vspace{8pt}{\normalsize\color{gray}\@author\par\@date\par}\end{center}\vspace{10pt}}\makeatother
28 −\usepackage{polyglossia}\setdefaultlanguage[frenchpart=false]{french}
deleted M3U96bomparables-validation/make_pdf.sh +0 −13
@@ -1,13 +0,0 @@
1 −#!/bin/zsh
2 −# Rapatrie les résultats depuis M2U64 et produit docs/RAPPORT.pdf (pandoc → XeLaTeX)
3 −set -e
4 −cd "$(dirname "$0")"
5 −V=${CV_VERSION:-2026-10-10}
6 −mkdir -p out/$V
7 −rsync -az --exclude 'est_E*.npy' --exclude 'sd_E2.npy' --exclude '*_pred_*.npy' --exclude 'sales.parquet' M2U64:comparables-validation/out/$V/ out/$V/
8 −rsync -az M2U64:comparables-validation/docs/ docs/ 2>/dev/null || true
9 −cd docs
10 −pandoc RAPPORT.md -o RAPPORT.pdf --pdf-engine=xelatex -H header.tex --toc --toc-depth=2 \
11 − -V geometry:"a4paper,margin=17mm,top=20mm,bottom=20mm" -V fontsize=10pt -V linkcolor=uqo -V urlcolor=uqo -V toccolor=uqo -V colorlinks=true \
12 − -V toc-title="Table des matières" --resource-path=.:../out/$V/fig 2>&1 | grep -v "Missing character\|Overfull\|Underfull" | tail -8 || true
13 −echo "PDF : $(pwd)/RAPPORT.pdf ($(du -h RAPPORT.pdf | cut -f1))"
deleted M3U96bomparables-validation/pipeline/01_dataset.py +0 −97
@@ -1,97 +0,0 @@
1 −# -*- coding: utf-8 -*-
2 −"""01 — Jeu de données : transactions unifamiliales Vrai Prix ⋈ rôle, nettoyage journalisé,
3 −reventes, géographie, périodes. Sorties : sales.parquet, exclusions.csv, dataset_stats.json."""
4 −from common import *
5 −
6 −con = connect()
7 −log(f"lecture {DB}")
8 −sql = """
9 −select t.id, t.date, t.amount, t.lat, t.lng, t.city, t.property_type, t.year_built, t.floor_area,
10 − t.building_type, t.id_provinc, t.valeur_role as role_vente, t.land_area,
11 − u.code_mun, u.municipalite, u.type_prop as u_type, u.annee_construction, u.aire_etages_m2,
12 − u.superficie_terrain_m2, u.front_terrain_m, u.nb_etages, u.nb_logements, u.lien_physique,
13 − u.genre_construction, u.unite_voisinage, u.dat_cond_marche, u.valeur_terrain, u.valeur_batiment,
14 − u.valeur_role as role_actuel, u.valeur_anterieure as role_anterieur, u.cubf
15 −from transactions t left join units u on u.id_provinc = t.id_provinc
16 −where t.property_type = 'unifamilial'
17 −"""
18 −df = pd.read_sql(sql, con)
19 −log(f"{len(df):,} transactions unifamiliales brutes")
20 −n0 = len(df)
21 −excl = [] # (règle, n)
22 −def drop(mask, rule):
23 − global df
24 − n = int(mask.sum()); excl.append((rule, n)); df = df[~mask].copy(); log(f" {rule}: -{n:,} → {len(df):,}")
25 −
26 −df["date"] = pd.to_datetime(df["date"])
27 −df = df.sort_values(["id_provinc", "date"]).reset_index(drop=True)
28 −drop(df["id_provinc"].isna() | df["code_mun"].isna(), "R0 sans jumelage au rôle (id_provinc / code_mun)")
29 −drop(df.duplicated(["id_provinc", "date"]), "R1 doublons (même unité, même date)")
30 −drop(~df["u_type"].isin(["unifamilial"]) | (df["nb_logements"].fillna(1) > 1), "R2 unité du rôle non unifamiliale (chalet, plex, terrain…) ou > 1 logement")
31 −drop(df["lat"].isna() | df["lng"].isna() | (df["lat"] < 44.5) | (df["lat"] > 63) | (df["lng"] > -56) | (df["lng"] < -80), "R3 coordonnées absentes ou hors Québec")
32 −fa = df["floor_area"].fillna(df["aire_etages_m2"])
33 −df["floor_area"] = fa
34 −drop(fa.isna() | (fa < 40) | (fa > 600), "R4 aire habitable absente ou hors [40, 600] m²")
35 −la = df["land_area"].fillna(df["superficie_terrain_m2"])
36 −df["land_area"] = la
37 −drop(la.isna() | (la < 80) | (la > 20000), "R5 superficie de terrain absente ou hors [80, 20 000] m²")
38 −yb = df["year_built"].fillna(df["annee_construction"])
39 −df["year_built"] = yb
40 −drop(yb.isna() | (yb < 1850) | (yb > df["date"].dt.year), "R6 année de construction absente, < 1850 ou postérieure à la vente (vente de terrain)")
41 −drop((df["year_built"] == df["date"].dt.year), "R6b vente l'année même de la construction (prix du terrain ou d'une construction inachevée : médiane 346 k$ et ratio au rôle 1,15 contre 450-480 k$ et 1,3 aux âges 1-4)")
42 −drop((df["amount"] < 50000) | (df["amount"] > 3_000_000), "R7 prix hors [50 k$, 3 M$]")
43 −ratio = df["amount"] / df["role_vente"]
44 −drop(df["role_vente"].isna() | (df["role_vente"] <= 0) | (ratio < 0.33) | (ratio > 3.0), "R8 rôle absent ou ratio prix/rôle hors [0,33 ; 3] (transactions présumées non représentatives)")
45 −# reventes rapides avec forte variation (flips / rénovations : les caractéristiques enregistrées ne décrivent plus le bien)
46 −df = df.sort_values(["id_provinc", "date"]).reset_index(drop=True)
47 −g = df.groupby("id_provinc")
48 −prev_date = g["date"].shift(1); prev_amt = g["amount"].shift(1)
49 −dd = (df["date"] - prev_date).dt.days
50 −chg = df["amount"] / prev_amt - 1
51 −flip = (dd <= 180) & (chg.abs() > 0.40)
52 −drop(flip.fillna(False), "R9 revente ≤ 180 jours avec variation > 40 % (flip)")
53 −drop(df["lien_physique"].isna() | df["genre_construction"].isna(), "R10 lien physique ou genre de construction absent au rôle")
54 −
55 −# ----------------------------------------------------------------- variables dérivées
56 −rep = load_repertoire()
57 −df = df.merge(rep, on="code_mun", how="left")
58 −drop(df["region"].isna(), "R11 municipalité absente du répertoire MAMH")
59 −df["sale_year"] = df["date"].dt.year
60 −df["month"] = month_index(df["date"])
61 −df["day"] = (df["date"] - pd.Timestamp("2021-01-01")).dt.days.astype(int)
62 −df["age"] = (df["sale_year"] - df["year_built"]).clip(lower=0)
63 −df["ln_price"] = np.log(df["amount"])
64 −df["ln_floor"] = np.log(df["floor_area"])
65 −df["ln_land"] = np.log(df["land_area"])
66 −df["lien"] = df["lien_physique"].map(LIEN).fillna(0).astype(int)
67 −df["genre"] = df["genre_construction"].map(GENRE).fillna(0).astype(int)
68 −df["btype"] = df["building_type"].map(BTYPE).fillna(-1).astype(int)
69 −df["etages"] = df["nb_etages"].fillna(1).clip(1, 3)
70 −df["x_km"], df["y_km"] = to_xy_km(df["lat"], df["lng"])
71 −# millésime du rôle au moment de la vente (rôle courant / antérieur / autre)
72 −df["role_vintage"] = np.where(np.isclose(df["role_vente"], df["role_actuel"].fillna(-1)), 2,
73 − np.where(np.isclose(df["role_vente"], df["role_anterieur"].fillna(-1)), 1, 0))
74 −df["ratio_role"] = df["amount"] / df["role_vente"]
75 −df["part_terrain"] = (df["valeur_terrain"] / df["role_actuel"]).clip(0.02, 0.95).fillna(0.2)
76 −df["neuf"] = (df["age"] <= 3).astype(int)
77 −df["period"] = np.select([df["date"] <= TRAIN_END, df["date"] <= VAL_END], ["train", "val"], "test")
78 −df = df.sort_values("date").reset_index(drop=True)
79 −df["idx"] = np.arange(len(df), dtype=np.int32)
80 −
81 −keep = ["idx", "id", "id_provinc", "date", "day", "month", "sale_year", "period", "amount", "ln_price",
82 − "lat", "lng", "x_km", "y_km", "code_mun", "mun_nom", "mrc", "mrc_nom", "region", "region_nom",
83 − "unite_voisinage", "floor_area", "ln_floor", "land_area", "ln_land", "year_built", "age", "neuf",
84 − "lien", "genre", "btype", "etages", "role_vente", "role_vintage", "ratio_role", "part_terrain"]
85 −df = df[keep]
86 −save_parquet(df, "sales.parquet")
87 −pd.DataFrame(excl, columns=["regle", "n"]).to_csv(OUT / "exclusions.csv", index=False)
88 −stats = {
89 − "brut": n0, "propre": len(df), "exclusions": excl,
90 − "par_periode": df["period"].value_counts().to_dict(),
91 − "par_annee": df["sale_year"].value_counts().sort_index().to_dict(),
92 − "par_region": df.groupby("region_nom").size().sort_values(ascending=False).to_dict(),
93 − "prix": {k: float(v) for k, v in df["amount"].describe().items()},
94 − "dates": [str(df["date"].min().date()), str(df["date"].max().date())],
95 −}
96 −save_json(stats, "dataset_stats.json")
97 −log(f"jeu propre : {len(df):,} ventes ({stats['par_periode']})")
deleted M3U96bomparables-validation/pipeline/02_index.py +0 −78
@@ -1,78 +0,0 @@
1 −# -*- coding: utf-8 -*-
2 −"""02 — Indice temporel régional « point dans le temps ».
3 −Pour chaque mois d'évaluation m, on ré-estime un indice hédonique mensuel par région administrative
4 −avec toutes les ventes antérieures au mois m (fenêtre expansive) (jamais le mois m lui-même). Niveau de référence = m-1,
5 −dernier mois entièrement connu. Sortie : index_asof.npy [m_eval, région, mois] (log), regions.json,
6 −et un indice « plein échantillon » descriptif (index_full.csv)."""
7 −from common import *
8 −
9 −df = load_parquet("sales.parquet")
10 −regions = sorted(df["region"].unique().tolist())
11 −rid = {r: i for i, r in enumerate(regions)}
12 −df["rid"] = df["region"].map(rid).astype(int)
13 −M = int(df["month"].max()) + 1
14 −M_EVAL0 = int(month_index(pd.Series([VAL_START]))[0])
15 −WINDOW = 999 # fenêtre « expansive » : toutes les ventes antérieures au mois m
16 −SHRINK = 40.0 # n/(n+SHRINK) : rétrécissement de l'effet mensuel régional vers l'effet provincial
17 −
18 −X_cols = ["ln_floor", "ln_land", "age"]
19 −def design(d):
20 − X = np.column_stack([np.ones(len(d)), d["ln_floor"], d["ln_land"], d["age"], d["age"] ** 2 / 100,
21 − (d["lien"] == 1), (d["lien"] >= 2), (d["genre"] == 1), (d["genre"] == 2), (d["genre"] == 3),
22 − d["etages"], d["neuf"]]).astype(float)
23 − return X
24 −
25 −def fit_month_effects(d, months):
26 − """OLS à effets fixes de municipalité (estimateur within) : ln P ~ X + effets mensuels (base = dernier mois),
27 − toutes les variables étant centrées par municipalité pour que l'indice ne soit pas contaminé par les
28 − déplacements du mélange des ventes entre municipalités chères et bon marché. Renvoie effets et n par mois."""
29 − X = design(d)[:, 1:]
30 − mm = d["month"].to_numpy()
31 − base = months[-1]
32 − dums = np.column_stack([(mm == m).astype(float) for m in months if m != base])
33 − A = np.hstack([X, dums]); y = d["ln_price"].to_numpy(float)
34 − codes, inv = np.unique(d["code_mun"].to_numpy(), return_inverse=True)
35 − cnt = np.bincount(inv).astype(float)
36 − mA = np.zeros((len(codes), A.shape[1])); np.add.at(mA, inv, A); A = A - mA[inv] / cnt[inv, None]
37 − my = np.bincount(inv, y) / cnt; y = y - my[inv]
38 − beta, *_ = np.linalg.lstsq(A, y, rcond=None)
39 − eff = {base: 0.0}
40 − k = X.shape[1]
41 − for j, m in enumerate([m for m in months if m != base]):
42 − eff[m] = float(beta[k + j])
43 − n = {m: int((mm == m).sum()) for m in months}
44 − return eff, n
45 −
46 −idx = np.full((M - M_EVAL0, len(regions), M), np.nan, dtype=np.float32)
47 −log(f"indice point-dans-le-temps : mois d'évaluation {M_EVAL0}..{M-1}, {len(regions)} régions")
48 −for m in range(M_EVAL0, M):
49 − months = list(range(max(0, m - WINDOW), m))
50 − win = df[(df["month"] >= months[0]) & (df["month"] < m)]
51 − prov_eff, _ = fit_month_effects(win, months)
52 − for r in regions:
53 − dr = win[win["region"] == r]
54 − if len(dr) < 200:
55 − for mo in months: idx[m - M_EVAL0, rid[r], mo] = prov_eff[mo]
56 − continue
57 − eff, n = fit_month_effects(dr, months)
58 − for mo in months:
59 − w = n[mo] / (n[mo] + SHRINK)
60 − idx[m - M_EVAL0, rid[r], mo] = w * eff[mo] + (1 - w) * prov_eff[mo]
61 − if m % 6 == 0: log(f" mois {m} ({len(win):,} ventes dans la fenêtre)")
62 −np.save(OUT / "index_asof.npy", idx)
63 −save_json({"regions": regions, "M_EVAL0": M_EVAL0, "M": M, "window": WINDOW, "shrink": SHRINK}, "index_meta.json")
64 −
65 −# indice descriptif plein échantillon (pour le rapport seulement — pas utilisé dans la rétro-évaluation)
66 −rows = []
67 −months = list(range(0, M))
68 −eff_p, n_p = fit_month_effects(df, months)
69 −for mo in months:
70 − rows.append({"month": mo, "ym": f"{2021 + mo // 12}-{mo % 12 + 1:02d}", "region": "QC", "log_idx": eff_p[mo] - eff_p[months[0]], "n": n_p[mo]})
71 −for r in regions:
72 − dr = df[df["region"] == r]
73 − if len(dr) < 2000: continue
74 − eff, n = fit_month_effects(dr, months)
75 − for mo in months:
76 − rows.append({"month": mo, "ym": f"{2021 + mo // 12}-{mo % 12 + 1:02d}", "region": r, "log_idx": eff[mo] - eff[months[0]], "n": n[mo]})
77 −pd.DataFrame(rows).to_csv(OUT / "index_full.csv", index=False)
78 −log("indice terminé")
deleted M3U96bomparables-validation/pipeline/03_models.py +0 −119
@@ -1,119 +0,0 @@
1 −# -*- coding: utf-8 -*-
2 −"""03 — Modèles d'ajustement et modèles de référence, ré-estimés chaque trimestre avec les seules ventes
3 −antérieures au trimestre (as-of 2024-01, 2024-04, …, 2026-07) — comme le ferait un AVM ré-entraîné régulièrement :
4 − • grille hédonique globale (coefficients) et régionale rétrécie → ajustements B / C
5 − • LightGBM (objectif L1 = médiane) sur le prix ajusté dans le temps (avec et sans rôle) → ajustements C-GBM, M11
6 − • hédonique globale directe avec effets de municipalité → M10
7 −Sorties : models_<période>.json, gbm_pred_<période>.npy (prédiction f pour TOUTES les ventes)."""
8 −import pickle
9 −from common import *
10 −import lightgbm as lgb
11 −
12 −df = load_parquet("sales.parquet")
13 −meta = load_json("index_meta.json"); regions = meta["regions"]; M_EVAL0 = meta["M_EVAL0"]
14 −rid = {r: i for i, r in enumerate(regions)}
15 −df["rid"] = df["region"].map(rid).astype(int)
16 −idx = np.load(OUT / "index_asof.npy")
17 −
18 −FEATS = ["ln_floor", "ln_land", "age", "age2", "lien1", "lien2", "genre1", "genre2", "genre3", "etages", "neuf"]
19 −def feats(d):
20 − return pd.DataFrame({
21 − "ln_floor": d["ln_floor"], "ln_land": d["ln_land"], "age": d["age"], "age2": d["age"] ** 2 / 100,
22 − "lien1": (d["lien"] == 1).astype(float), "lien2": (d["lien"] >= 2).astype(float),
23 − "genre1": (d["genre"] == 1).astype(float), "genre2": (d["genre"] == 2).astype(float), "genre3": (d["genre"] == 3).astype(float),
24 − "etages": d["etages"].astype(float), "neuf": d["neuf"].astype(float)}, index=d.index)
25 −
26 −def time_adjusted(d, m_asof):
27 − """ln P ramené au niveau du mois m_asof-1 avec l'indice connu au mois m_asof."""
28 − lvl = idx[m_asof - M_EVAL0]
29 − return d["ln_price"].to_numpy() - lvl[d["rid"].to_numpy(), d["month"].to_numpy()] + lvl[d["rid"].to_numpy(), m_asof - 1]
30 −
31 −def ols(X, y):
32 − A = np.column_stack([np.ones(len(X)), X])
33 − b, *_ = np.linalg.lstsq(A, y, rcond=None)
34 − return b
35 −
36 −ASOF = list(range(M_EVAL0, int(df["month"].max()) + 1, 3)) # ré-estimation trimestrielle (2024-01, 2024-04, …)
37 −save_json({"asof": ASOF}, "asof.json")
38 −TODO = [int(a) for a in os.environ["CV_ASOF"].split(",")] if os.environ.get("CV_ASOF") else ASOF # sous-ensemble (répartition sur plusieurs nœuds)
39 −for m_asof in TODO:
40 − period = str(m_asof); start = f"{2021 + m_asof // 12}-{m_asof % 12 + 1:02d}-01"
41 − tr = df[df["month"] < m_asof].copy()
42 − y = time_adjusted(tr, m_asof)
43 − F = feats(tr)
44 − log(f"[{period}] entraînement sur {len(tr):,} ventes < {start} (indice connu au mois {m_asof})")
45 − # --- grille hédonique globale : estimateur « within » (effets fixes de municipalité), pour que les β
46 − # mesurent des différences entre biens voisins et non entre marchés
47 − def within(frame, cols, by):
48 − return frame[cols] - frame.groupby(by)[cols].transform("mean")
49 − # effets mensuels résiduels (base = dernier mois connu m_asof-1) : l'indice régional ne retire pas toute la
50 − # tendance commune ; sans eux, les modèles globaux sous-estiment les ventes récentes
51 − months_tr = sorted(tr["month"].unique()); MCOLS = [f"m{m}" for m in months_tr if m != m_asof - 1]
52 − Fm = pd.concat([F] + [pd.Series((tr["month"] == m).astype(float), index=tr.index, name=f"m{m}") for m in months_tr if m != m_asof - 1], axis=1)
53 − Fw = within(pd.concat([Fm, tr[["code_mun"]]], axis=1), FEATS + MCOLS, "code_mun")
54 − yw = y - pd.Series(y, index=tr.index).groupby(tr["code_mun"]).transform("mean").to_numpy()
55 − bw_all, *_ = np.linalg.lstsq(Fw.to_numpy(), yw, rcond=None)
56 − bw = bw_all[:len(FEATS)]; bm = bw_all[len(FEATS):]
57 − beta_global = dict(zip(FEATS, bw.tolist()))
58 − xb = F.to_numpy() @ bw + Fm[MCOLS].to_numpy() @ bm
59 − resid = y - xb
60 − r2w = 1 - np.var(yw - Fw.to_numpy() @ bw_all) / np.var(yw)
61 − Fw = Fw[FEATS]
62 − log(f" hédonique within-municipalité : R² within = {r2w:.3f} ; β = { {k: round(v, 3) for k, v in beta_global.items()} }")
63 − # --- grille régionale (within-municipalité) rétrécie vers la globale (n/(n+300))
64 − beta_region = {}
65 − for r in regions:
66 − sel = (tr["region"] == r).to_numpy()
67 − if sel.sum() < 100: beta_region[r] = beta_global; continue
68 − b, *_ = np.linalg.lstsq(Fw.to_numpy()[sel], yw[sel], rcond=None)
69 − w = sel.sum() / (sel.sum() + 300.0)
70 − beta_region[r] = {k: float(w * b[j] + (1 - w) * beta_global[k]) for j, k in enumerate(FEATS)}
71 − # --- M10 : hédonique directe = Xβ + effet de région + MRC + municipalité + unité de voisinage (rétrécis en cascade)
72 − reg_eff = pd.Series(resid, index=tr.index).groupby(tr["region"]).mean().to_dict()
73 − tr["res_g"] = resid - tr["region"].map(reg_eff).to_numpy()
74 − mrc_eff = tr.groupby("mrc")["res_g"].agg(["sum", "count"])
75 − mrc_eff = (mrc_eff["sum"] / (mrc_eff["count"] + 30)).to_dict()
76 − tr["res_m"] = tr["res_g"] - tr["mrc"].map(mrc_eff).fillna(0)
77 − mun_eff = tr.groupby("code_mun")["res_m"].agg(["sum", "count"])
78 − mun_eff = (mun_eff["sum"] / (mun_eff["count"] + 20)).to_dict()
79 − tr["res_u"] = tr["res_m"] - tr["code_mun"].map(mun_eff).fillna(0)
80 − # correction médiane : la MCO vise la moyenne du log ; la distribution est asymétrique (queue basse), on recentre sur la médiane
81 − uv_tmp = tr.groupby(["code_mun", "unite_voisinage"])["res_u"].transform(lambda v: v.sum() / (len(v) + 10))
82 − median_shift = float(np.median(tr["res_u"] - uv_tmp))
83 − uv = tr.groupby(["code_mun", "unite_voisinage"])["res_u"].agg(["sum", "count"])
84 − uv_eff = {f"{a}|{b}": v for (a, b), v in (uv["sum"] / (uv["count"] + 10)).items()}
85 − # --- LightGBM (prix ajusté dans le temps) avec rôle et sans rôle
86 − def gbm_frame(d):
87 − G = feats(d)
88 − G["lat"] = d["lat"]; G["lng"] = d["lng"]; G["x_km"] = d["x_km"]; G["y_km"] = d["y_km"]
89 − G["rid"] = d["rid"]; G["btype"] = d["btype"]
90 − G["ln_role"] = np.log(d["role_vente"]); G["part_terrain"] = d["part_terrain"]; G["role_vintage"] = d["role_vintage"]
91 − G["month"] = np.minimum(d["month"], m_asof - 1).astype(float) # tendance résiduelle ; hors échantillon = dernier mois connu
92 − return G
93 − G = gbm_frame(tr)
94 − params = dict(objective="regression_l1", learning_rate=0.05, num_leaves=127, min_data_in_leaf=40, feature_fraction=0.8,
95 − bagging_fraction=0.8, bagging_freq=1, lambda_l2=1.0, verbose=-1, num_threads=int(os.environ.get("CV_THREADS", 20)))
96 − models = {}
97 − for name, cols in [("gbm", [c for c in G.columns if c not in ("ln_role", "part_terrain", "role_vintage")]),
98 − ("gbm_role", list(G.columns))]:
99 − ds = lgb.Dataset(G[cols], y, categorical_feature=["rid", "btype"] if "btype" in cols else ["rid"])
100 − # validation interne : derniers 10 % chronologiques pour fixer le nombre d'arbres
101 − n = len(G); cut = int(n * 0.9)
102 − dtr = lgb.Dataset(G[cols].iloc[:cut], y[:cut], categorical_feature=["rid", "btype"])
103 − dva = lgb.Dataset(G[cols].iloc[cut:], y[cut:], reference=dtr)
104 − bst = lgb.train(params, dtr, num_boost_round=3000, valid_sets=[dva], callbacks=[lgb.early_stopping(100, verbose=False)])
105 − best = max(bst.best_iteration, 300) # plancher : l'arrêt précoce sur la tranche finale (mois partiels) peut être trop court
106 − bst = lgb.train(params, ds, num_boost_round=best)
107 − models[name] = bst
108 − Gall = gbm_frame(df); Gall["month"] = float(m_asof - 1) # f évalué au niveau du dernier mois connu pour toutes les ventes
109 − pred_all = bst.predict(Gall[cols])
110 − np.save(OUT / f"{name}_pred_{period}.npy", pred_all.astype(np.float32))
111 − rr = y - bst.predict(G[cols]); r2 = 1 - np.var(rr) / np.var(y)
112 − log(f" {name}: {best} arbres, R² (in-sample) = {r2:.3f}, résidu médian = {np.median(rr) * 100:.2f} %")
113 − bst.save_model(str(OUT / f"{name}_{period}.txt"))
114 − save_json({"period": period, "m_asof": m_asof, "start": start, "n_train": len(tr), "feats": FEATS,
115 − "beta_global": beta_global, "intercept_global": 0.0, "region_fe": reg_eff,
116 − "beta_region": beta_region, "mrc_eff": mrc_eff, "mun_eff": mun_eff, "uv_eff": uv_eff,
117 − "r2_within": float(r2w), "sd_resid_u": float(tr["res_u"].std()), "median_shift": median_shift},
118 − f"models_{period}.json")
119 −log("modèles terminés")
deleted M3U96bomparables-validation/pipeline/04_backtest.py +0 −193
@@ -1,193 +0,0 @@
1 −# -*- coding: utf-8 -*-
2 −"""04 — Moteur de rétro-évaluation.
3 −Pour chaque vente cible (2024 = validation, 2025-2026 = test), le prix est masqué ; seules les ventes
4 −conclues STRICTEMENT AVANT la date cible (et l'indice/les modèles connus à cette date) sont utilisées.
5 −On évalue d'un coup toutes les configurations (sélection × rayon × fenêtre × nombre × ajustement ×
6 −pondération × réconciliation) sur exactement les mêmes cibles.
7 −Sorties : targets.parquet, configs_E1.csv/est_E1.npy (surface k,d,T), configs_E2.csv/est_E2.npy/sd_E2.npy
8 −(factoriel sélection × ajustement × pondération × réconciliation), n_avail.npy."""
9 −import os
10 −for _v in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS", "VECLIB_MAXIMUM_THREADS"):
11 − os.environ[_v] = "1" # un seul fil BLAS par processus (fork + BLAS multi-fils = interblocage sur macOS)
12 −import multiprocessing as mp, time
13 −from scipy.spatial import cKDTree
14 −from common import *
15 −
16 −df = load_parquet("sales.parquet")
17 −meta = load_json("index_meta.json"); regions = meta["regions"]; M_EVAL0 = meta["M_EVAL0"]
18 −rid_map = {r: i for i, r in enumerate(regions)}
19 −IDX = np.load(OUT / "index_asof.npy")
20 −ASOF = load_json("asof.json")["asof"]
21 −MODELS = {a: load_json(f"models_{a}.json") for a in ASOF}
22 −FEATS = MODELS[ASOF[0]]["feats"]
23 −GBM = {a: np.load(OUT / f"gbm_pred_{a}.npy") for a in ASOF}
24 −
25 −# ----------------------------------------------------------------- tableaux globaux (partagés par fork)
26 −N = len(df)
27 −DAY = df["day"].to_numpy(np.int32); MONTH = df["month"].to_numpy(np.int32)
28 −RID = df["region"].map(rid_map).to_numpy(np.int32)
29 −X = df["x_km"].to_numpy(np.float64); Y = df["y_km"].to_numpy(np.float64)
30 −LNP = df["ln_price"].to_numpy(np.float64); P = df["amount"].to_numpy(np.float64)
31 −FLOOR = df["floor_area"].to_numpy(np.float64); LAND = df["land_area"].to_numpy(np.float64)
32 −AGE = df["age"].to_numpy(np.float64); LIEN = df["lien"].to_numpy(np.int8); ETAGES = df["etages"].to_numpy(np.float64)
33 −GENRE = df["genre"].to_numpy(np.int8)
34 −PROV = pd.factorize(df["id_provinc"])[0].astype(np.int32)
35 −MUN = pd.factorize(df["code_mun"])[0].astype(np.int32)
36 −VINT = df["role_vintage"].to_numpy(np.int8); LNROLE = np.log(df["role_vente"].to_numpy(np.float64))
37 −PART = df["part_terrain"].to_numpy(np.float64)
38 −NEUF = df["neuf"].to_numpy(np.float64)
39 −# matrice des caractéristiques hédoniques (même ordre que FEATS)
40 −XF = np.column_stack([np.log(FLOOR), np.log(LAND), AGE, AGE ** 2 / 100, (LIEN == 1), (LIEN >= 2),
41 − (GENRE == 1), (GENRE == 2), (GENRE == 3), ETAGES, NEUF]).astype(np.float64)
42 −BETA_G = {a: np.array([MODELS[a]["beta_global"][f] for f in FEATS]) for a in ASOF}
43 −BETA_R = {a: np.array([[MODELS[a]["beta_region"][r][f] for f in FEATS] for r in regions]) for a in ASOF}
44 −ASOF_ARR = np.array(ASOF)
45 −TREE = cKDTree(np.column_stack([X, Y]))
46 −
47 −D_MAX, T_MAX_DAYS = 25.0, 731
48 −KS = [1, 3, 5, 7, 10, 15, 20, 30, 50]
49 −DS = [0.5, 1, 2, 5, 10, 25]
50 −TS = [3, 6, 12, 24]
51 −SELS = ["geo", "sim", "mix", "adj"]
52 −ADJS = ["raw", "none", "conv", "hedo", "hedo_reg", "local", "gbm", "role"]
53 −WRS = [("equal", "median"), ("equal", "mean"), ("dist", "median"), ("dist", "mean"), ("rec", "median"), ("rec", "mean"),
54 − ("sim", "median"), ("sim", "mean"), ("all", "median"), ("all", "mean")]
55 −ANCHORS = [(3, 2, 6), (5, 5, 12), (10, 10, 24)]
56 −E1 = [dict(sel=s, d=d, T=T, k=k, adj=a, w="equal", recon="median")
57 − for s in ("geo", "sim", "mix") for d in DS for T in TS for k in KS for a in ("none", "hedo")]
58 −E2 = [dict(sel=s, d=d, T=T, k=k, adj=a, w=w, recon=r)
59 − for (k, d, T) in ANCHORS for s in SELS for a in ADJS for (w, r) in WRS]
60 −LOCAL_COLS = [0, 1, 2, 4, 5, 9] # ln_floor, ln_land, age, lien1, lien2, etages
61 −LAMBDA_LOCAL = 50.0
62 −
63 −def wmedian(v, w):
64 − o = np.argsort(v); v = v[o]; w = w[o]
65 − c = np.cumsum(w); return v[np.searchsorted(c, 0.5 * c[-1])]
66 −
67 −def recon(vals, w, how):
68 − ok = ~np.isnan(vals)
69 − if not ok.any(): return np.nan, np.nan
70 − v = vals[ok]; ww = w[ok]
71 − sd = v.std() if len(v) > 1 else np.nan
72 − if how == "median":
73 − return (np.median(v) if ww.min() == ww.max() else wmedian(v, ww)), sd
74 − return np.log(np.sum(ww * np.exp(v)) / ww.sum()), sd
75 −
76 −def evaluate(i):
77 − """Toutes les configurations pour la cible i. Renvoie (est_E1, est_E2, sd_E2, n_avail, info)."""
78 − period = int(ASOF_ARR[ASOF_ARR <= MONTH[i]].max()) # dernier trimestre de ré-estimation ≤ mois de la cible
79 − e1 = np.full(len(E1), np.nan, np.float32); e2 = np.full(len(E2), np.nan, np.float32)
80 − s2 = np.full(len(E2), np.nan, np.float32); nav = np.zeros(len(DS) * len(TS), np.int32)
81 − cand = np.asarray(TREE.query_ball_point((X[i], Y[i]), D_MAX), dtype=np.int64)
82 − keep = (DAY[cand] < DAY[i]) & (DAY[cand] >= DAY[i] - T_MAX_DAYS) & (PROV[cand] != PROV[i])
83 − cand = cand[keep]
84 − info = dict(idx=int(i), asof=period, n_cand=int(len(cand)))
85 − if len(cand) == 0: return e1, e2, s2, nav, info
86 − dist = np.hypot(X[cand] - X[i], Y[cand] - Y[i])
87 − mago = (DAY[i] - DAY[cand]) / 30.4375
88 − m_eval = MONTH[i]; lvl = IDX[m_eval - M_EVAL0]
89 − tadj = np.where(MONTH[cand] == m_eval, 0.0, lvl[RID[cand], m_eval - 1] - lvl[RID[cand], MONTH[cand]])
90 − tadj = np.nan_to_num(tadj, nan=0.0)
91 − lnT = LNP[cand] + tadj
92 − dX = XF[i] - XF[cand] # cible − comparable
93 − # --- ajustements
94 − bg = BETA_G[period]; br = BETA_R[period][RID[i]]
95 − adj = {}
96 − adj["raw"] = LNP[cand]
97 − adj["none"] = lnT
98 − Pt = np.exp(lnT)
99 − d_floor = (FLOOR[i] - FLOOR[cand]) * 0.5 * Pt / FLOOR[cand]
100 − d_land = np.clip((LAND[i] - LAND[cand]) * 0.5 * Pt * PART[cand] / LAND[cand], -0.15 * Pt, 0.15 * Pt)
101 − d_age = np.clip(-0.005 * Pt * (AGE[i] - AGE[cand]), -0.2 * Pt, 0.2 * Pt)
102 − d_lien = np.where((LIEN[i] == 0) & (LIEN[cand] > 0), 0.05 * Pt, np.where((LIEN[i] > 0) & (LIEN[cand] == 0), -0.05 * Pt, 0.0))
103 − tot = np.maximum(d_floor + d_land + d_age + d_lien, -0.6 * Pt)
104 − adj["conv"] = np.log(Pt + tot)
105 − gross_conv = (np.abs(d_floor) + np.abs(d_land) + np.abs(d_age) + np.abs(d_lien)) / Pt
106 − adj["hedo"] = lnT + dX @ bg
107 − gross_hedo = np.abs(dX * bg).sum(axis=1)
108 − adj["hedo_reg"] = lnT + dX @ br
109 − loc = dist <= 10.0
110 − if loc.sum() >= 40:
111 − Xl = XF[cand][loc][:, LOCAL_COLS]; yl = lnT[loc]
112 − b0 = br[LOCAL_COLS]
113 − Xc = Xl - Xl.mean(axis=0); yc = yl - yl.mean()
114 − A = Xc.T @ Xc + LAMBDA_LOCAL * np.eye(len(LOCAL_COLS))
115 − bl = np.linalg.solve(A, Xc.T @ yc + LAMBDA_LOCAL * b0)
116 − adj["local"] = lnT + dX[:, LOCAL_COLS] @ bl
117 − else:
118 − adj["local"] = adj["hedo_reg"]
119 − f = GBM[period]
120 − adj["gbm"] = lnT + (f[i] - f[cand])
121 − same = (MUN[cand] == MUN[i]) & (VINT[cand] == VINT[i])
122 − adj["role"] = np.where(same, LNROLE[i] + (lnT - LNROLE[cand]), np.nan)
123 − # --- scores de sélection
124 − dissim = np.sqrt((dX[:, 0] / 0.25) ** 2 + (dX[:, 1] / 0.6) ** 2 + (dX[:, 2] / 15) ** 2
125 − + (LIEN[cand] != LIEN[i]) * 1.0 + (ETAGES[cand] != ETAGES[i]) * 0.5)
126 − score = {"geo": dist, "sim": dissim + dist / 25.0, "mix": dissim + dist / 2.0, "adj": gross_hedo + dist / 25.0}
127 − # --- pondérations
128 − wts = {"equal": np.ones(len(cand)), "dist": 1.0 / (0.25 + dist), "rec": np.exp(-mago / 6.0), "sim": 1.0 / (0.1 + gross_hedo)}
129 − wts["all"] = wts["dist"] * wts["rec"] * wts["sim"]
130 − # --- masques (d, T) et classements
131 − top = {}
132 − for di, d in enumerate(DS):
133 − for ti, T in enumerate(TS):
134 − m = (dist <= d) & (mago <= T)
135 − nav[di * len(TS) + ti] = int(m.sum())
136 − if not m.any(): continue
137 − ids = np.flatnonzero(m)
138 − for s in SELS:
139 − sc = score[s][ids]
140 − if len(ids) > 50:
141 − part = np.argpartition(sc, 50)[:50]; part = part[np.argsort(sc[part])]
142 − else:
143 − part = np.argsort(sc)
144 − top[(s, d, T)] = ids[part]
145 − # --- E1
146 − for c, cfg in enumerate(E1):
147 − t = top.get((cfg["sel"], cfg["d"], cfg["T"]))
148 − if t is None: continue
149 − sub = t[:cfg["k"]]
150 − e1[c], _ = recon(adj[cfg["adj"]][sub], wts["equal"][sub], "median")
151 − # --- E2
152 − for c, cfg in enumerate(E2):
153 − t = top.get((cfg["sel"], cfg["d"], cfg["T"]))
154 − if t is None: continue
155 − sub = t[:cfg["k"]]
156 − e2[c], s2[c] = recon(adj[cfg["adj"]][sub], wts[cfg["w"]][sub], cfg["recon"])
157 − return e1, e2, s2, nav, info
158 −
159 −def worker(chunk):
160 − return [evaluate(i) for i in chunk]
161 −
162 −if __name__ == "__main__":
163 − targets = df.index[df["period"].isin(["val", "test"])].to_numpy()
164 − if os.environ.get("CV_SAMPLE"):
165 − rng = np.random.default_rng(1); targets = np.sort(rng.choice(targets, int(os.environ["CV_SAMPLE"]), replace=False))
166 − SUF = ""
167 − if os.environ.get("CV_SHARD"): # répartition sur plusieurs nœuds : CV_SHARD=i/N → cibles i, i+N, i+2N, …
168 − i_sh, n_sh = map(int, os.environ["CV_SHARD"].split("/")); targets = targets[i_sh::n_sh]; SUF = f".shard{i_sh}of{n_sh}"
169 − log(f"{len(targets):,} cibles ; {len(E1)} configurations E1, {len(E2)} E2 ; {N:,} ventes dans le bassin")
170 − pd.DataFrame(E1).to_csv(OUT / "configs_E1.csv", index_label="cfg")
171 − pd.DataFrame(E2).to_csv(OUT / "configs_E2.csv", index_label="cfg")
172 − est1 = np.full((len(targets), len(E1)), np.nan, np.float32)
173 − est2 = np.full((len(targets), len(E2)), np.nan, np.float32)
174 − sd2 = np.full((len(targets), len(E2)), np.nan, np.float32)
175 − nav = np.zeros((len(targets), len(DS) * len(TS)), np.int32)
176 − infos = []
177 − chunks = [targets[j:j + 400] for j in range(0, len(targets), 400)]
178 − t0 = time.time(); done = 0
179 − with mp.get_context(os.environ.get("CV_MP", "fork")).Pool(int(os.environ.get("CV_PROCS", 20))) as pool:
180 − for res in pool.imap(worker, chunks):
181 − for (e1, e2, s2, nv, info) in res:
182 − est1[done] = e1; est2[done] = e2; sd2[done] = s2; nav[done] = nv; infos.append(info); done += 1
183 − if done % 20000 < 400:
184 − log(f" {done:,}/{len(targets):,} cibles ({time.time() - t0:.0f} s)")
185 − np.save(OUT / f"est_E1{SUF}.npy", est1); np.save(OUT / f"est_E2{SUF}.npy", est2); np.save(OUT / f"sd_E2{SUF}.npy", sd2); np.save(OUT / f"n_avail{SUF}.npy", nav)
186 − T = df.loc[targets, ["idx", "id", "date", "period", "amount", "ln_price", "region", "region_nom", "mrc", "mrc_nom", "code_mun",
187 − "mun_nom", "unite_voisinage", "floor_area", "land_area", "age", "lien", "etages", "role_vente", "month", "lat", "lng"]].reset_index(drop=True)
188 − T["n_cand"] = [x["n_cand"] for x in infos]; T["asof"] = [x["asof"] for x in infos]
189 − T["n_2km_12m"] = nav[:, DS.index(2) * len(TS) + TS.index(12)]
190 − T["n_5km_12m"] = nav[:, DS.index(5) * len(TS) + TS.index(12)]
191 − save_parquet(T, f"targets{SUF}.parquet")
192 − pd.DataFrame({"DS": [DS], "TS": [TS], "KS": [KS]}).to_json(OUT / "grid_meta.json")
193 − log(f"rétro-évaluation terminée en {time.time() - t0:.0f} s")
deleted M3U96bomparables-validation/pipeline/04b_gather.py +0 −15
@@ -1,15 +0,0 @@
1 −# -*- coding: utf-8 -*-
2 −"""04b — Rassemble les fragments (shards) de la rétro-évaluation calculés sur plusieurs nœuds, dans l'ordre des cibles."""
3 −import glob
4 −from common import *
5 −sh = sorted(glob.glob(str(OUT / "targets.shard*.parquet")))
6 −log(f"{len(sh)} fragments")
7 −Ts = [pd.read_parquet(p) for p in sh]
8 −parts = {}
9 −for name in ["est_E1", "est_E2", "sd_E2", "n_avail"]:
10 − parts[name] = [np.load(p.replace("targets.", f"{name}.").replace(".parquet", ".npy")) for p in sh]
11 −T = pd.concat(Ts, ignore_index=True); order = np.argsort(T["idx"].to_numpy(), kind="stable")
12 −T = T.iloc[order].reset_index(drop=True); save_parquet(T, "targets.parquet")
13 −for name, arrs in parts.items():
14 − A = np.concatenate(arrs, axis=0)[order]; np.save(OUT / f"{name}.npy", A); log(f" {name}: {A.shape}")
15 −log("rassemblement terminé")
deleted M3U96bomparables-validation/pipeline/05_analyze.py +0 −358
@@ -1,358 +0,0 @@
1 −# -*- coding: utf-8 -*-
2 −"""05 — Analyse : métriques par configuration (validation 2024 → choix ; test 2025-26 → verdict),
3 −surface Erreur(k, d, t), effets des facteurs (sélection, ajustement, pondération, réconciliation),
4 −compétition de modèles M1-M13, hypothèses H1-H5, strates (région, densité, prix), intervalles,
5 −règles conditionnelles. Sorties : tables/*.csv, results.json, fig/*.pdf"""
6 −from scipy.optimize import nnls
7 −import matplotlib; matplotlib.use("Agg")
8 −import matplotlib.pyplot as plt
9 −from matplotlib import rcParams
10 −from common import *
11 −
12 −# ---------------------------------------------------------------- style (palette de référence, marques fines)
13 −PAL = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#008300", "#6250d6", "#e34948"]
14 −SEQ = ["#cde2fb", "#9ec5f4", "#6da7ec", "#3987e5", "#256abf", "#184f95", "#0d366b"]
15 −rcParams.update({"font.family": "Helvetica Neue", "font.size": 9, "axes.edgecolor": "#c3c2b7", "axes.linewidth": 0.6,
16 − "axes.grid": True, "grid.color": "#e8e7e3", "grid.linewidth": 0.5, "axes.spines.top": False,
17 − "axes.spines.right": False, "legend.frameon": False, "figure.dpi": 150, "savefig.bbox": "tight",
18 − "axes.titleweight": "bold", "axes.titlesize": 10, "axes.labelcolor": "#52514e", "xtick.color": "#52514e",
19 − "ytick.color": "#52514e", "figure.facecolor": "#fcfcfb", "axes.facecolor": "#fcfcfb"})
20 −from matplotlib.colors import LinearSegmentedColormap
21 −CMAP = LinearSegmentedColormap.from_list("seqblue", SEQ)
22 −TAB = OUT / "tables"; TAB.mkdir(exist_ok=True); FIG = OUT / "fig"
23 −def savefig(fig, name): fig.savefig(FIG / f"{name}.pdf"); fig.savefig(FIG / f"{name}.png"); plt.close(fig)
24 −
25 −# ---------------------------------------------------------------- chargement
26 −T = load_parquet("targets.parquet"); S = load_parquet("sales.parquet").set_index("idx")
27 −E1 = pd.read_csv(OUT / "configs_E1.csv"); E2 = pd.read_csv(OUT / "configs_E2.csv")
28 −est1 = np.load(OUT / "est_E1.npy"); est2 = np.load(OUT / "est_E2.npy"); sd2 = np.load(OUT / "sd_E2.npy"); nav = np.load(OUT / "n_avail.npy")
29 −meta = load_json("index_meta.json"); regions = meta["regions"]; M_EVAL0 = meta["M_EVAL0"]; IDX = np.load(OUT / "index_asof.npy")
30 −rid = {r: i for i, r in enumerate(regions)}
31 −lnP = T["ln_price"].to_numpy(np.float32)
32 −VAL = (T["period"] == "val").to_numpy(); TEST = (T["period"] == "test").to_numpy()
33 −log(f"{len(T):,} cibles (val {VAL.sum():,}, test {TEST.sum():,}) ; E1 {est1.shape[1]} ; E2 {est2.shape[1]}")
34 −
35 −def err_of(est): return (np.exp(est - lnP[:, None]) - 1).astype(np.float32)
36 −def metrics(err, sel):
37 − e = err[sel]; a = np.abs(e)
38 − ok = ~np.isnan(a); n = ok.sum(axis=0)
39 − with np.errstate(all="ignore"):
40 − return pd.DataFrame({"mdape": np.nanmedian(a, axis=0) * 100, "mape": np.nanmean(a, axis=0) * 100,
41 − "biais": np.nanmedian(e, axis=0) * 100, "biais_moy": np.nanmean(e, axis=0) * 100,
42 − "p5": np.nanmean(a <= 0.05, axis=0) * 100, "p10": np.nanmean(a <= 0.10, axis=0) * 100,
43 − "p20": np.nanmean(a <= 0.20, axis=0) * 100, "couverture": n / sel.sum() * 100, "n": n})
44 −err1 = err_of(est1); err2 = err_of(est2)
45 −del est1
46 −res = {}
47 −
48 −# ---------------------------------------------------------------- densité & strates
49 −T["dens"] = pd.cut(T["n_2km_12m"], [-1, 4, 19, 49, 199, 10 ** 9], labels=["< 5", "5-19", "20-49", "50-199", "≥ 200"])
50 −T["prix_q"] = pd.qcut(T["amount"], 5, labels=["Q1 (bas)", "Q2", "Q3", "Q4", "Q5 (haut)"])
51 −T["trimestre"] = pd.to_datetime(T["date"]).dt.to_period("Q").astype(str)
52 −T["age_cl"] = pd.cut(T["age"], [-1, 3, 15, 40, 70, 1000], labels=["≤ 3 ans", "4-15", "16-40", "41-70", "> 70"])
53 −T["cluster"] = pd.factorize(T["code_mun"].astype(str) + "|" + T["trimestre"])[0]
54 −
55 −# ---------------------------------------------------------------- E1 : surface Erreur(k, d, t)
56 −mv = metrics(err1, VAL).add_suffix("_val"); mt = metrics(err1, TEST).add_suffix("_test")
57 −surf = pd.concat([E1, mv, mt], axis=1)
58 −surf.to_csv(TAB / "surface_E1.csv", index=False)
59 −ok = surf["couverture_val"] >= 97
60 −best_e1 = surf[ok].sort_values("mdape_val").iloc[0]
61 −res["E1_best_val"] = best_e1.to_dict()
62 −log(f"E1 meilleure (val, couverture ≥ 97 %) : {best_e1[['sel','d','T','k','adj']].to_dict()} → val {best_e1.mdape_val:.2f} %, test {best_e1.mdape_test:.2f} %")
63 −for (s, a), g in surf[ok].groupby(["sel", "adj"]):
64 − b = g.sort_values("mdape_val").iloc[0]
65 − res.setdefault("E1_best_by_sel_adj", {})[f"{s}|{a}"] = dict(k=int(b.k), d=float(b.d), T=int(b["T"]), mdape_val=float(b.mdape_val), mdape_test=float(b.mdape_test), couverture_test=float(b.couverture_test))
66 −
67 −# figure : MdAPE vs k selon d (T = 12, geo, hedo, test)
68 −fig, axes = plt.subplots(1, 2, figsize=(9, 3.4), sharey=True)
69 −for ax, a in zip(axes, ["none", "hedo"]):
70 − for j, d in enumerate([0.5, 1, 2, 5, 10, 25]):
71 − g = surf[(surf.sel == "geo") & (surf["T"] == 12) & (surf.adj == a) & (surf.d == d)].sort_values("k")
72 − ax.plot(g.k, g.mdape_test, color=PAL[j], lw=2, marker="o", ms=4, label=f"d ≤ {d} km")
73 − ax.set_xscale("log"); ax.set_xticks([1, 3, 5, 10, 20, 50]); ax.set_xticklabels([1, 3, 5, 10, 20, 50])
74 − ax.set_xlabel("nombre de comparables k"); ax.set_title({"none": "sans ajustement (temps seulement)", "hedo": "grille hédonique"}[a])
75 −axes[0].set_ylabel("MdAPE, test 2025-26 (%)"); axes[1].legend(ncol=2, fontsize=8)
76 −savefig(fig, "f1_mdape_vs_k")
77 −# figure : carte d × T au meilleur k (geo, hedo)
78 −fig, axes = plt.subplots(1, 2, figsize=(8, 3.2))
79 −for ax, a in zip(axes, ["none", "hedo"]):
80 − g = surf[(surf.sel == "geo") & (surf.adj == a)].groupby(["d", "T"])["mdape_test"].min().unstack("T")
81 − im = ax.imshow(g.to_numpy(), cmap=CMAP, aspect="auto")
82 − ax.set_xticks(range(len(g.columns))); ax.set_xticklabels([f"{t} mois" for t in g.columns]); ax.set_yticks(range(len(g.index))); ax.set_yticklabels([f"{d} km" for d in g.index])
83 − for i in range(g.shape[0]):
84 − for j in range(g.shape[1]):
85 − v = g.iloc[i, j]; ax.text(j, i, f"{v:.1f}", ha="center", va="center", fontsize=7.5, color="white" if v < np.nanpercentile(g.to_numpy(), 40) else "#0b0b0b")
86 − ax.grid(False); ax.set_title({"none": "sans ajustement", "hedo": "grille hédonique"}[a] + " — MdAPE au meilleur k")
87 −savefig(fig, "f2_surface_d_T")
88 −
89 −# ---------------------------------------------------------------- E2 : facteurs
90 −mv2 = metrics(err2, VAL).add_suffix("_val"); mt2 = metrics(err2, TEST).add_suffix("_test")
91 −fac = pd.concat([E2, mv2, mt2], axis=1); fac.to_csv(TAB / "facteurs_E2.csv", index=False)
92 −anchor = (fac.k == 5) & (fac.d == 5) & (fac["T"] == 12)
93 −base = anchor & (fac.sel == "geo") & (fac.w == "equal") & (fac.recon == "median")
94 −res["E2_adjust_anchor"] = fac[base].set_index("adj")[["mdape_val", "mdape_test", "mape_test", "biais_test", "p10_test", "couverture_test"]].to_dict("index")
95 −res["E2_sel_anchor"] = fac[anchor & (fac.adj == "hedo") & (fac.w == "equal") & (fac.recon == "median")].set_index("sel")[["mdape_val", "mdape_test", "p10_test"]].to_dict("index")
96 −res["E2_wr_anchor"] = {f"{r.w}|{r.recon}": dict(mdape_val=r.mdape_val, mdape_test=r.mdape_test, p10_test=r.p10_test)
97 − for r in fac[anchor & (fac.adj == "hedo") & (fac.sel == "geo")].itertuples()}
98 −# effets marginaux moyens (moyenne de MdAPE test sur toutes les autres modalités)
99 −marg = {}
100 −for f in ["sel", "adj", "w", "recon"]:
101 − marg[f] = fac.groupby(f)["mdape_test"].mean().round(2).to_dict()
102 −res["E2_marginal"] = marg
103 −anc_tab = fac.groupby(["k", "d", "T"])["mdape_test"].min().round(2).to_dict()
104 −res["E2_anchor_best"] = {f"{k}|{d}|{t}": v for (k, d, t), v in anc_tab.items()}
105 −# figure : ajustements à l'ancre (val et test)
106 −fig, ax = plt.subplots(figsize=(7, 3.2))
107 −g = fac[base].set_index("adj").loc[["raw", "none", "conv", "hedo", "hedo_reg", "local", "gbm", "role"]]
108 −lab = {"raw": "aucun", "none": "temps seul", "conv": "grille conv.", "hedo": "hédonique", "hedo_reg": "hédo. régionale", "local": "locale", "gbm": "GBM", "role": "ratio au rôle"}
109 −x = np.arange(len(g)); ax.bar(x - 0.2, g.mdape_val, 0.38, color=PAL[0], label="validation 2024"); ax.bar(x + 0.2, g.mdape_test, 0.38, color=PAL[1], label="test 2025-26")
110 −for i, (v1, v2) in enumerate(zip(g.mdape_val, g.mdape_test)):
111 − ax.text(i - 0.2, v1 + 0.2, f"{v1:.1f}", ha="center", fontsize=7); ax.text(i + 0.2, v2 + 0.2, f"{v2:.1f}", ha="center", fontsize=7)
112 −ax.set_xticks(x); ax.set_xticklabels([lab[a] for a in g.index]); ax.set_ylabel("MdAPE (%)"); ax.legend(); ax.set_title("Type d'ajustement — 5 comparables les plus proches, ≤ 5 km, ≤ 12 mois, médiane")
113 −savefig(fig, "f3_ajustements")
114 −
115 −# ---------------------------------------------------------------- modèles de référence (M10, M11) et compétition
116 −def level_shift(m_asof):
117 − mt_ = T["month"].to_numpy(); r = T["region"].map(rid).to_numpy()
118 − return IDX[mt_ - M_EVAL0, r, mt_ - 1] - IDX[mt_ - M_EVAL0, r, m_asof - 1]
119 −Sx = S.loc[T["idx"]]
120 −XF = np.column_stack([Sx.ln_floor, Sx.ln_land, Sx.age, Sx.age ** 2 / 100, Sx.lien == 1, Sx.lien >= 2, Sx.genre == 1, Sx.genre == 2, Sx.genre == 3, Sx.etages, Sx.neuf]).astype(float)
121 −M10 = np.full(len(T), np.nan); M11 = np.full(len(T), np.nan); M11b = np.full(len(T), np.nan)
122 −for a in load_json("asof.json")["asof"]:
123 − mask = (T["asof"] == a).to_numpy(); period = str(a)
124 − if not mask.any(): continue
125 − mod = load_json(f"models_{period}.json"); beta = np.array([mod["beta_global"][f] for f in mod["feats"]])
126 − shift = level_shift(mod["m_asof"])
127 − fe = (T["region"].map(mod["region_fe"]).fillna(0) + T["mrc"].map(mod["mrc_eff"]).fillna(0) + T["code_mun"].map(mod["mun_eff"]).fillna(0)
128 − + (T["code_mun"].astype(str) + "|" + T["unite_voisinage"].astype(str)).map(mod["uv_eff"]).fillna(0)).to_numpy()
129 − M10[mask] = (XF @ beta + fe + shift + mod["median_shift"])[mask]
130 − M11[mask] = (np.load(OUT / f"gbm_role_pred_{period}.npy")[T["idx"].to_numpy()] + shift)[mask]
131 − M11b[mask] = (np.load(OUT / f"gbm_pred_{period}.npy")[T["idx"].to_numpy()] + shift)[mask]
132 −
133 −def e2col(sel, k, d, Tm, adj, w, recon):
134 − m = (E2.sel == sel) & (E2.k == k) & (E2.d == d) & (E2["T"] == Tm) & (E2.adj == adj) & (E2.w == w) & (E2.recon == recon)
135 − return int(E2.index[m][0])
136 −def e1col(sel, k, d, Tm, adj):
137 − m = (E1.sel == sel) & (E1.k == k) & (E1.d == d) & (E1["T"] == Tm) & (E1.adj == adj)
138 − return int(E1.index[m][0])
139 −MODELS = {
140 − "M1 médiane des 3 plus proches (temps seul)": ("E2", e2col("geo", 3, 2, 6, "none", "equal", "median")),
141 − "M2 moyenne des 5 plus proches (temps seul)": ("E2", e2col("geo", 5, 5, 12, "none", "equal", "mean")),
142 − "M3 sélection par similarité physique": ("E2", e2col("sim", 5, 5, 12, "none", "equal", "median")),
143 − "M4 pondération par distance": ("E2", e2col("geo", 5, 5, 12, "none", "dist", "mean")),
144 − "M5 pondération par récence": ("E2", e2col("geo", 5, 5, 12, "none", "rec", "mean")),
145 − "M6 grille d'ajustement conventionnelle": ("E2", e2col("geo", 5, 5, 12, "conv", "equal", "median")),
146 − "M7 grille d'ajustement hédonique": ("E2", e2col("geo", 5, 5, 12, "hedo", "equal", "median")),
147 − "M8a grille locale (régression sur le voisinage)": ("E2", e2col("geo", 5, 5, 12, "local", "equal", "median")),
148 − "M8b ajustements non linéaires (GBM)": ("E2", e2col("geo", 5, 5, 12, "gbm", "equal", "median")),
149 − "M13 ratio au rôle (5 plus proches, même rôle)": ("E2", e2col("geo", 5, 5, 12, "role", "equal", "median")),
150 − "M10 hédonique globale directe": ("X", M10),
151 − "M11a gradient boosting (sans rôle)": ("X", M11b),
152 − "M11b gradient boosting (avec rôle)": ("X", M11),
153 −}
154 −def est_of(spec):
155 − kind, c = spec
156 − return est2[:, c].astype(float) if kind == "E2" else c
157 −# M0 : meilleure configuration empirique choisie sur la validation (E1 ∪ E2, couverture ≥ 90 %)
158 −cand_all = pd.concat([surf.assign(src="E1"), fac.assign(src="E2")], ignore_index=True)
159 −cand_ok = cand_all[cand_all.couverture_val >= 97].sort_values("mdape_val")
160 −b0 = cand_ok.iloc[0]
161 −M0_est = (est2[:, int(b0.cfg)] if b0.src == "E2" else np.load(OUT / "est_E1.npy", mmap_mode="r")[:, int(b0.cfg)]).astype(float)
162 −res["M0"] = b0[["src", "sel", "d", "T", "k", "adj", "w", "recon", "mdape_val", "mdape_test", "couverture_test"]].to_dict()
163 −res["M0_sans_contrainte"] = cand_all[cand_all.couverture_val >= 50].sort_values("mdape_val").iloc[0][["src", "sel", "d", "T", "k", "adj", "w", "recon", "mdape_val", "mdape_test", "couverture_val", "couverture_test"]].to_dict()
164 −MODELS["M0 meilleure configuration empirique (choisie en 2024, couverture ≥ 97 %)"] = ("X", M0_est)
165 −# M9 : réconciliation optimisée = combinaison convexe (NNLS sur la validation) d'estimations de comparables
166 −stack_cols = {a: e2col("geo", 5, 5, 12, a, "equal", "median") for a in ["none", "conv", "hedo", "hedo_reg", "local", "gbm", "role"]}
167 −def stack(names, mats, fit_mask):
168 − """Combinaison convexe apprise par NNLS sur la validation ; si une composante manque pour une cible,
169 − les poids des composantes disponibles sont renormalisés."""
170 − A = np.column_stack(mats); okr = ~np.isnan(A).any(axis=1)
171 − w, _ = nnls(A[okr & fit_mask], lnP[okr & fit_mask].astype(float)); w = w / w.sum()
172 − W = np.where(np.isnan(A), 0.0, w[None, :]); den = W.sum(axis=1)
173 − out = np.nansum(A * W, axis=1) / np.where(den > 0, den, np.nan)
174 − return out, dict(zip(names, w.round(3)))
175 −M9_est, w9 = stack(list(stack_cols), [est2[:, c].astype(float) for c in stack_cols.values()], VAL)
176 −res["M9_weights"] = w9; MODELS["M9 réconciliation optimisée (poids appris en 2024)"] = ("X", M9_est)
177 −M12_est, w12 = stack(["gbm_role", "comps_gbm", "comps_hedo"], [M11, est2[:, stack_cols["gbm"]].astype(float), est2[:, stack_cols["hedo"]].astype(float)], VAL)
178 −res["M12_weights"] = w12; MODELS["M12 hybride comparables + GBM (poids appris en 2024)"] = ("X", M12_est)
179 −
180 −rows = []
181 −ERR = {}
182 −for name, spec in MODELS.items():
183 − e = (np.exp(est_of(spec) - lnP) - 1).astype(np.float32); ERR[name] = e
184 − for per, mask in [("val", VAL), ("test", TEST)]:
185 − m = metrics(e[:, None], mask).iloc[0].to_dict(); m.update(modele=name, periode=per); rows.append(m)
186 −comp = pd.DataFrame(rows); comp.to_csv(TAB / "competition_modeles.csv", index=False)
187 −pd.DataFrame({n.split(" ")[0]: est_of(s_) for n, s_ in MODELS.items()}).assign(idx=T["idx"].to_numpy()).to_parquet(OUT / "models_est.parquet", index=False)
188 −res["competition"] = comp[comp.periode == "test"].set_index("modele")[["mdape", "mape", "biais", "p5", "p10", "p20", "couverture"]].round(2).to_dict("index")
189 −res["competition_val"] = comp[comp.periode == "val"].set_index("modele")[["mdape", "p10", "couverture"]].round(2).to_dict("index")
190 −log("compétition (test) :\n" + comp[comp.periode == "test"][["modele", "mdape", "mape", "biais", "p10", "couverture"]].round(2).to_string(index=False))
191 −fig, ax = plt.subplots(figsize=(7.5, 5))
192 −g = comp[comp.periode == "test"].sort_values("mdape", ascending=False)
193 −cols = [PAL[0] if ("M1" in n and "M1 " not in n) or n.startswith(("M10", "M11", "M12")) else PAL[2] if n.startswith(("M0", "M9")) else PAL[1] for n in g.modele]
194 −cols = [PAL[0] if n.startswith(("M10", "M11", "M12")) else (PAL[2] if n.startswith(("M0", "M9")) else PAL[1]) for n in g.modele]
195 −ax.barh(g.modele, g.mdape, color=cols, height=0.65)
196 −for i, v in enumerate(g.mdape): ax.text(v + 0.15, i, f"{v:.1f} %", va="center", fontsize=8)
197 −ax.set_xlabel("MdAPE, test 2025-26 (%)"); ax.set_title("Compétition de modèles (orange : comparables ; vert : optimisés ; bleu : références externes)")
198 −savefig(fig, "f4_competition")
199 −
200 −# ---------------------------------------------------------------- bootstrap par grappes (municipalité × trimestre) des différences appariées
201 −rng = np.random.default_rng(7)
202 −def paired(eA, eB, mask, B=400):
203 − a = np.abs(eA[mask]); b = np.abs(eB[mask]); cl = T["cluster"].to_numpy()[mask]
204 − okp = ~np.isnan(a) & ~np.isnan(b); a, b, cl = a[okp], b[okp], cl[okp]
205 − d = a - b
206 − cl_ids, inv = np.unique(cl, return_inverse=True); nc = len(cl_ids)
207 − sums = np.bincount(inv, d, nc); cnts = np.bincount(inv, minlength=nc)
208 − boots_mean = []; boots_med = []
209 − order = np.argsort(inv); d_sorted = d[order]; starts = np.searchsorted(inv[order], np.arange(nc)); ends = np.append(starts[1:], len(d))
210 − for _ in range(B):
211 − pick = rng.integers(0, nc, nc)
212 − boots_mean.append(sums[pick].sum() / cnts[pick].sum())
213 − if _ < 150:
214 − idxs = np.concatenate([np.arange(starts[p], ends[p]) for p in pick])
215 − boots_med.append(np.median(a[order][idxs]) - np.median(b[order][idxs]))
216 − return dict(n=int(len(d)), diff_mape=float(d.mean() * 100), ci_mape=[float(np.percentile(boots_mean, 2.5) * 100), float(np.percentile(boots_mean, 97.5) * 100)],
217 − diff_mdape=float((np.median(a) - np.median(b)) * 100), ci_mdape=[float(np.percentile(boots_med, 2.5) * 100), float(np.percentile(boots_med, 97.5) * 100)],
218 − part_A_meilleur=float((a < b).mean() * 100))
219 −PAIRS = [
220 − ("M7 grille d'ajustement hédonique", "M2 moyenne des 5 plus proches (temps seul)"),
221 − ("M7 grille d'ajustement hédonique", "M6 grille d'ajustement conventionnelle"),
222 − ("M8a grille locale (régression sur le voisinage)", "M7 grille d'ajustement hédonique"),
223 − ("M8b ajustements non linéaires (GBM)", "M7 grille d'ajustement hédonique"),
224 − ("M11b gradient boosting (avec rôle)", "M0 meilleure configuration empirique (choisie en 2024, couverture ≥ 97 %)"),
225 − ("M11a gradient boosting (sans rôle)", "M0 meilleure configuration empirique (choisie en 2024, couverture ≥ 97 %)"),
226 − ("M12 hybride comparables + GBM (poids appris en 2024)", "M11b gradient boosting (avec rôle)"),
227 − ("M13 ratio au rôle (5 plus proches, même rôle)", "M7 grille d'ajustement hédonique"),
228 − ("M3 sélection par similarité physique", "M2 moyenne des 5 plus proches (temps seul)"),
229 − ("M9 réconciliation optimisée (poids appris en 2024)", "M7 grille d'ajustement hédonique"),
230 − ("M1 médiane des 3 plus proches (temps seul)", "M2 moyenne des 5 plus proches (temps seul)"),
231 −]
232 −res["paires"] = {f"{a} vs {b}": paired(ERR[a], ERR[b], TEST) for a, b in PAIRS}
233 −pd.DataFrame([dict(A=a, B=b, **res["paires"][f"{a} vs {b}"]) for a, b in PAIRS]).to_csv(TAB / "paires_bootstrap.csv", index=False)
234 −
235 −# ---------------------------------------------------------------- strates
236 −def strata(err, by):
237 − out = []
238 − for lev, g in T[TEST].groupby(by, observed=True):
239 − m = np.zeros(len(T), bool); m[g.index] = True
240 − r = metrics(err[:, None], m).iloc[0].to_dict(); r[by] = str(lev); out.append(r)
241 − return pd.DataFrame(out)
242 −KEY = ["M2 moyenne des 5 plus proches (temps seul)", "M7 grille d'ajustement hédonique", "M8a grille locale (régression sur le voisinage)",
243 − "M13 ratio au rôle (5 plus proches, même rôle)", "M0 meilleure configuration empirique (choisie en 2024, couverture ≥ 97 %)", "M10 hédonique globale directe", "M11b gradient boosting (avec rôle)", "M12 hybride comparables + GBM (poids appris en 2024)"]
244 −for by in ["region_nom", "dens", "prix_q", "trimestre", "age_cl", "lien"]:
245 − tabs = []
246 − for name in KEY:
247 − s = strata(ERR[name], by); s["modele"] = name.split(" ")[0]; tabs.append(s)
248 − st = pd.concat(tabs); st.to_csv(TAB / f"strates_{by}.csv", index=False)
249 − res[f"strates_{by}"] = st.pivot(index=by, columns="modele", values="mdape").round(2).to_dict("index")
250 − res[f"strates_{by}_n"] = st[st.modele == "M7"].set_index(by)["n"].to_dict()
251 −# figure : densité
252 −fig, ax = plt.subplots(figsize=(6.5, 3.4))
253 −st = pd.read_csv(TAB / "strates_dens.csv")
254 −order = ["< 5", "5-19", "20-49", "50-199", "≥ 200"]
255 −for j, (name, lab_) in enumerate([("M2", "M2 5 plus proches"), ("M7", "M7 grille hédonique"), ("M0", "M0 meilleure config."), ("M11b", "M11b GBM"), ("M12", "M12 hybride")]):
256 − g = st[st.modele == name].set_index("dens").loc[order]
257 − ax.plot(order, g.mdape, color=PAL[j], lw=2, marker="o", ms=4, label=lab_)
258 −ax.set_xlabel("ventes disponibles dans un rayon de 2 km au cours des 12 mois précédents"); ax.set_ylabel("MdAPE, test (%)"); ax.legend(fontsize=8); ax.set_title("Précision selon la liquidité du sous-marché")
259 −savefig(fig, "f5_densite")
260 −
261 −# ---------------------------------------------------------------- hypothèses
262 −H = {}
263 −# H1 : proche mais dissemblable vs loin mais semblable (k = 5, T = 12, sans ajustement)
264 −H["H1"] = {"geo_d1": surf.loc[e1col("geo", 5, 1, 12, "none"), ["mdape_test", "couverture_test"]].to_dict(),
265 − "sim_d10": surf.loc[e1col("sim", 5, 10, 12, "none"), ["mdape_test", "couverture_test"]].to_dict(),
266 − "geo_d10": surf.loc[e1col("geo", 5, 10, 12, "none"), ["mdape_test", "couverture_test"]].to_dict(),
267 − "mix_d10": surf.loc[e1col("mix", 5, 10, 12, "none"), ["mdape_test", "couverture_test"]].to_dict(),
268 − "sim_d10_hedo": surf.loc[e1col("sim", 5, 10, 12, "hedo"), ["mdape_test", "couverture_test"]].to_dict(),
269 − "geo_d10_hedo": surf.loc[e1col("geo", 5, 10, 12, "hedo"), ["mdape_test", "couverture_test"]].to_dict(),
270 − "mix_d10_hedo": surf.loc[e1col("mix", 5, 10, 12, "hedo"), ["mdape_test", "couverture_test"]].to_dict()}
271 −# H2 : k optimal selon la densité (geo, d = 10, T = 12, hedo et none)
272 −h2 = {}
273 −for a in ["none", "hedo"]:
274 − for lev in order:
275 − m = TEST & (T["dens"] == lev).to_numpy()
276 − cols_ = [e1col("geo", k, 10, 12, a) for k in [1, 3, 5, 7, 10, 15, 20, 30, 50]]
277 − md = np.nanmedian(np.abs(err1[m][:, cols_]), axis=0) * 100
278 − h2[f"{a}|{lev}"] = dict(zip([1, 3, 5, 7, 10, 15, 20, 30, 50], md.round(2)))
279 −H["H2"] = h2
280 −fig, axes = plt.subplots(1, 2, figsize=(9, 3.3), sharey=True)
281 −for ax, a in zip(axes, ["none", "hedo"]):
282 − for j, lev in enumerate(order):
283 − v = h2[f"{a}|{lev}"]; ax.plot(list(v), list(v.values()), color=PAL[j], lw=2, marker="o", ms=3.5, label=f"densité {lev}")
284 − ax.set_xscale("log"); ax.set_xticks([1, 3, 5, 10, 20, 50]); ax.set_xticklabels([1, 3, 5, 10, 20, 50]); ax.set_xlabel("k"); ax.set_title({"none": "temps seul", "hedo": "grille hédonique"}[a] + " — d ≤ 10 km, 12 mois")
285 −axes[0].set_ylabel("MdAPE, test (%)"); axes[1].legend(fontsize=8)
286 −savefig(fig, "f6_k_par_densite")
287 −# H3 : local vs hédonique fixe selon la densité, aux trois ancres
288 −h3 = {}
289 −for (k, d, Tm) in [(3, 2, 6), (5, 5, 12), (10, 10, 24)]:
290 − for lev in order:
291 − m = TEST & (T["dens"] == lev).to_numpy()
292 − h3[f"{k}|{d}|{Tm}|{lev}"] = {a: float(np.nanmedian(np.abs(err2[m][:, e2col("geo", k, d, Tm, a, "equal", "median")])) * 100) for a in ["conv", "hedo", "hedo_reg", "local", "gbm"]}
293 −H["H3"] = h3
294 −# H4 : pondération selon la densité (ancre 5/5/12, hedo)
295 −h4 = {}
296 −for lev in order:
297 − m = TEST & (T["dens"] == lev).to_numpy()
298 − h4[lev] = {f"{w}|{r}": float(np.nanmedian(np.abs(err2[m][:, e2col("geo", 5, 5, 12, "hedo", w, r)])) * 100) for (w, r) in [("equal", "median"), ("equal", "mean"), ("dist", "mean"), ("rec", "mean"), ("sim", "mean"), ("all", "mean"), ("all", "median")]}
299 −H["H4"] = h4
300 −# H5 : méthodes simples vs ML selon l'homogénéité (dispersion des prix ajustés des 10 voisins à 10 km / 24 mois)
301 −het = sd2[:, e2col("geo", 10, 10, 24, "hedo", "equal", "median")].astype(float)
302 −T["heterog"] = pd.qcut(pd.Series(np.where(np.isnan(het), np.nanmax(het), het)), 3, labels=["homogène", "moyen", "hétérogène"])
303 −h5 = {}
304 −for lev in ["homogène", "moyen", "hétérogène"]:
305 − m = TEST & (T["heterog"] == lev).to_numpy()
306 − h5[lev] = {n.split(" ")[0]: float(np.nanmedian(np.abs(ERR[n][m])) * 100) for n in KEY}
307 − h5[lev]["sd_median"] = float(np.nanmedian(het[m]))
308 −H["H5"] = h5
309 −res["hypotheses"] = H
310 −
311 −# ---------------------------------------------------------------- intervalles d'incertitude (couverture)
312 −intervals = {}
313 −for a in ["none", "hedo", "gbm"]:
314 − for (k, d, Tm) in [(5, 5, 12), (10, 10, 24)]:
315 − c = e2col("geo", k, d, Tm, a, "equal", "median"); e = est2[:, c].astype(float); s = sd2[:, c].astype(float)
316 − okv = VAL & ~np.isnan(e) & ~np.isnan(s) & (s > 0)
317 − z_needed = np.abs(lnP[okv] - e[okv]) / s[okv]; z80 = float(np.percentile(z_needed, 80))
318 − okt = TEST & ~np.isnan(e) & ~np.isnan(s) & (s > 0)
319 − cov_nominal = float(np.mean(np.abs(lnP[okt] - e[okt]) <= 1.2816 * s[okt]) * 100)
320 − cov_cal = float(np.mean(np.abs(lnP[okt] - e[okt]) <= z80 * s[okt]) * 100)
321 − width = float(np.median(2 * z80 * s[okt]) * 100)
322 − intervals[f"{a}|{k}|{d}|{Tm}"] = dict(z80_val=z80, couverture_nominale_80=cov_nominal, couverture_calibree=cov_cal, largeur_mediane_pct=width, n_test=int(okt.sum()))
323 −res["intervalles"] = intervals
324 −
325 −# ---------------------------------------------------------------- règles conditionnelles (système de recommandation)
326 −cond = {}; est_cond = np.full(len(T), np.nan)
327 −for lev in order:
328 − mv_ = VAL & (T["dens"] == lev).to_numpy(); mt_ = TEST & (T["dens"] == lev).to_numpy()
329 − m1 = metrics(err1, mv_); m2 = metrics(err2, mv_)
330 − allm = pd.concat([pd.concat([E1, m1], axis=1).assign(src="E1"), pd.concat([E2, m2], axis=1).assign(src="E2")], ignore_index=True)
331 − b = allm[allm.couverture >= 95].sort_values("mdape").iloc[0]
332 − e = (err1 if b.src == "E1" else err2)[:, int(b.cfg)]
333 − est_cond[mt_] = e[mt_]
334 − cond[lev] = dict(b[["src", "sel", "d", "T", "k", "adj", "w", "recon", "mdape", "couverture"]].to_dict(), mdape_test=float(np.nanmedian(np.abs(e[mt_])) * 100), n_test=int(mt_.sum()))
335 −res["regles_conditionnelles"] = cond
336 −res["regles_conditionnelles_global_test"] = dict(mdape=float(np.nanmedian(np.abs(est_cond[TEST])) * 100), p10=float(np.nanmean(np.abs(est_cond[TEST]) <= 0.1) * 100), couverture=float(np.mean(~np.isnan(est_cond[TEST])) * 100))
337 −pd.DataFrame(cond).T.to_csv(TAB / "regles_conditionnelles.csv")
338 −
339 −# ---------------------------------------------------------------- stabilité temporelle, indice, divers
340 −tq = []
341 −for q, g in T[TEST].groupby("trimestre"):
342 − m = np.zeros(len(T), bool); m[g.index] = True
343 − tq.append(dict(trimestre=q, n=int(m.sum()), **{n.split(" ")[0]: float(np.nanmedian(np.abs(ERR[n][m])) * 100) for n in KEY},
344 − **{"biais_" + n.split(" ")[0]: float(np.nanmedian(ERR[n][m]) * 100) for n in KEY}))
345 −pd.DataFrame(tq).to_csv(TAB / "stabilite_trimestres.csv", index=False); res["stabilite"] = tq
346 −fig, ax = plt.subplots(figsize=(7, 3.2))
347 −idxf = pd.read_csv(OUT / "index_full.csv")
348 −for j, (r, lab_) in enumerate([("QC", "Québec"), ("06", "Montréal"), ("03", "Capitale-Nationale"), ("07", "Outaouais"), ("05", "Estrie")]):
349 − g = idxf[idxf.region.astype(str).str.zfill(2) == r] if r != "QC" else idxf[idxf.region == "QC"]
350 − if len(g): ax.plot(pd.to_datetime(g.ym), np.exp(g.log_idx) * 100, color=PAL[j], lw=2 if r == "QC" else 1.4, label=lab_)
351 −ax.set_ylabel("indice hédonique (2021-01 = 100)"); ax.legend(fontsize=8); ax.set_title("Indice de prix mensuel (plein échantillon, descriptif)")
352 −savefig(fig, "f7_indice")
353 −res["dataset"] = load_json("dataset_stats.json")
354 −res["n_targets"] = dict(val=int(VAL.sum()), test=int(TEST.sum()))
355 −res["densite_distribution"] = T[TEST]["dens"].value_counts().sort_index().to_dict()
356 −res["n_cand_median"] = dict(val=float(np.median(T[VAL].n_cand)), test=float(np.median(T[TEST].n_cand)))
357 −save_json(res, "results.json")
358 −log("analyse terminée")
deleted M3U96bomparables-validation/pipeline/06_report.py +0 −313
@@ -1,313 +0,0 @@
1 −# -*- coding: utf-8 -*-
2 −"""06 — Rapport : assemble docs/RAPPORT.md à partir de results.json et des tables (texte + tableaux + figures).
3 −Les phrases d'interprétation sont calculées à partir des résultats (jamais de chiffre écrit à la main)."""
4 −from common import *
5 −
6 −R = load_json("results.json"); TAB = OUT / "tables"
7 −DOCS = ROOT / "docs"; DOCS.mkdir(exist_ok=True)
8 −D = R["dataset"]; comp = R["competition"]; compv = R["competition_val"]
9 −def f1(x): return f"{x:.1f}".replace(".", ",")
10 −def f2(x): return f"{x:.2f}".replace(".", ",")
11 −def n_(x): return f"{int(round(x)):,}".replace(",", " ")
12 −def pct(x): return f"{f1(x)} %"
13 −def md_table(df, cols=None, fmt=None, index=False):
14 − if cols: df = df[cols]
15 − if index: df = df.reset_index()
16 − fmt = fmt or {}
17 − head = "| " + " | ".join(str(c) for c in df.columns) + " |\n|" + "|".join("---" for _ in df.columns) + "|\n"
18 − rows = []
19 − for _, r in df.iterrows():
20 − cells = []
21 − for c in df.columns:
22 − v = r[c]
23 − if c in fmt: cells.append(fmt[c](v))
24 − elif isinstance(v, float): cells.append(f1(v))
25 − else: cells.append(str(v))
26 − rows.append("| " + " | ".join(cells) + " |")
27 − return head + "\n".join(rows) + "\n"
28 −short = lambda name: name.split(" ")[0]
29 −LAB = {"raw": "aucun ajustement", "none": "temps seulement", "conv": "grille conventionnelle", "hedo": "grille hédonique", "hedo_reg": "grille hédonique régionale",
30 − "local": "régression locale", "gbm": "ajustements GBM", "role": "ratio au rôle", "geo": "les plus proches (distance)", "sim": "les plus semblables (physique)",
31 − "mix": "compromis distance / similarité", "adj": "les moins ajustés", "equal": "égale", "dist": "1/distance", "rec": "récence", "all": "distance × récence × similarité",
32 − "median": "médiane", "mean": "moyenne"}
33 −
34 −# ------------------------------------------------------------------ tableaux
35 −excl = pd.DataFrame(D["exclusions"], columns=["Règle", "n"]); excl["n"] = excl["n"].map(n_)
36 −cm = pd.DataFrame(comp).T; cm.index.name = "Modèle"; cm = cm.reset_index()
37 −cm["val"] = cm["Modèle"].map(lambda m: compv[m]["mdape"])
38 −cm = cm.sort_values("mdape")
39 −t_comp = md_table(cm.rename(columns={"mdape": "MdAPE test", "val": "MdAPE val.", "mape": "MAPE", "biais": "Biais (médian)", "p5": "±5 %", "p10": "±10 %", "p20": "±20 %", "couverture": "Couverture"}),
40 − ["Modèle", "MdAPE val.", "MdAPE test", "MAPE", "Biais (médian)", "±5 %", "±10 %", "±20 %", "Couverture"])
41 −surf = pd.read_csv(TAB / "surface_E1.csv")
42 −def surf_tab(sel, adj, T_):
43 − g = surf[(surf.sel == sel) & (surf.adj == adj) & (surf["T"] == T_)].pivot(index="d", columns="k", values="mdape_test")
44 − g.index = [f"≤ {d} km" for d in g.index]; g.columns = [f"k = {k}" for k in g.columns]; g.index.name = "rayon"
45 − return md_table(g.round(1), index=True)
46 −kb = surf[(surf.sel == "geo") & (surf.adj == "hedo") & (surf.d == 10) & (surf["T"] == 12)].sort_values("k")
47 −fac = pd.read_csv(TAB / "facteurs_E2.csv")
48 −anchor = fac[(fac.k == 5) & (fac.d == 5) & (fac["T"] == 12)]
49 −t_adj = anchor[(anchor.sel == "geo") & (anchor.w == "equal") & (anchor.recon == "median")].set_index("adj").loc[["raw", "none", "conv", "hedo", "hedo_reg", "local", "gbm", "role"]]
50 −t_adj = t_adj.reset_index(); t_adj["Ajustement"] = t_adj["adj"].map(LAB)
51 −t_adj_md = md_table(t_adj.rename(columns={"mdape_val": "MdAPE val.", "mdape_test": "MdAPE test", "mape_test": "MAPE test", "biais_test": "Biais", "p10_test": "±10 %", "couverture_test": "Couverture"}),
52 − ["Ajustement", "MdAPE val.", "MdAPE test", "MAPE test", "Biais", "±10 %", "Couverture"])
53 −t_sel = anchor[(anchor.adj == "hedo") & (anchor.w == "equal") & (anchor.recon == "median")].copy(); t_sel["Sélection"] = t_sel["sel"].map(LAB)
54 −t_sel_md = md_table(t_sel.rename(columns={"mdape_val": "MdAPE val.", "mdape_test": "MdAPE test", "p10_test": "±10 %", "biais_test": "Biais"}), ["Sélection", "MdAPE val.", "MdAPE test", "Biais", "±10 %"])
55 −t_wr = anchor[(anchor.adj == "hedo") & (anchor.sel == "geo")].copy(); t_wr["Pondération"] = t_wr["w"].map(LAB); t_wr["Réconciliation"] = t_wr["recon"].map(LAB)
56 −t_wr_md = md_table(t_wr.rename(columns={"mdape_val": "MdAPE val.", "mdape_test": "MdAPE test", "p10_test": "±10 %", "biais_test": "Biais"}), ["Pondération", "Réconciliation", "MdAPE val.", "MdAPE test", "Biais", "±10 %"])
57 −pairs = pd.read_csv(TAB / "paires_bootstrap.csv")
58 −pairs["Comparaison"] = pairs.apply(lambda r: f"{short(r.A)} − {short(r.B)}", axis=1)
59 −pairs["Δ MdAPE (pts) [IC 95 %]"] = pairs.apply(lambda r: f"{f2(r.diff_mdape)} [{f2(eval(r.ci_mdape)[0])} ; {f2(eval(r.ci_mdape)[1])}]", axis=1)
60 −pairs["Δ MAPE (pts) [IC 95 %]"] = pairs.apply(lambda r: f"{f2(r.diff_mape)} [{f2(eval(r.ci_mape)[0])} ; {f2(eval(r.ci_mape)[1])}]", axis=1)
61 −pairs["A plus précis (% des cibles)"] = pairs["part_A_meilleur"].map(f1)
62 −t_pairs = md_table(pairs, ["Comparaison", "Δ MdAPE (pts) [IC 95 %]", "Δ MAPE (pts) [IC 95 %]", "A plus précis (% des cibles)", "n"], fmt={"n": n_})
63 −def strata_md(by, label):
64 − st = pd.read_csv(TAB / f"strates_{by}.csv")
65 − g = st.pivot(index=by, columns="modele", values="mdape").round(1)
66 − g["n"] = st[st.modele == "M7"].set_index(by)["n"]
67 − g = g[["n", "M2", "M7", "M8a", "M13", "M0", "M10", "M11b", "M12"]]
68 − if by == "dens": g = g.loc[["< 5", "5-19", "20-49", "50-199", "≥ 200"]]
69 − if by == "region_nom": g = g.sort_values("n", ascending=False)
70 − g.index.name = label
71 − return md_table(g, index=True, fmt={"n": n_})
72 −H = R["hypotheses"]
73 −h2 = pd.DataFrame({k.split("|")[1]: v for k, v in H["H2"].items() if k.startswith("hedo")}).T; h2.index.name = "densité"; h2.columns = [f"k = {c}" for c in h2.columns]
74 −h2_md = md_table(h2.loc[["< 5", "5-19", "20-49", "50-199", "≥ 200"]], index=True)
75 −h2n = pd.DataFrame({k.split("|")[1]: v for k, v in H["H2"].items() if k.startswith("none")}).T; h2n.index.name = "densité"; h2n.columns = [f"k = {c}" for c in h2n.columns]
76 −h2n_md = md_table(h2n.loc[["< 5", "5-19", "20-49", "50-199", "≥ 200"]], index=True)
77 −h3 = pd.DataFrame({k.split("|")[3]: v for k, v in H["H3"].items() if k.startswith("5|5|12")}).T.loc[["< 5", "5-19", "20-49", "50-199", "≥ 200"]]
78 −h3.columns = [LAB[c] for c in h3.columns]; h3.index.name = "densité"; h3_md = md_table(h3, index=True)
79 −h3s = pd.DataFrame({k.split("|")[3]: v for k, v in H["H3"].items() if k.startswith("3|2|6")}).T.loc[["< 5", "5-19", "20-49", "50-199", "≥ 200"]]
80 −h3s.columns = [LAB[c] for c in h3s.columns]; h3s.index.name = "densité"; h3s_md = md_table(h3s, index=True)
81 −h4 = pd.DataFrame(H["H4"]).T.loc[["< 5", "5-19", "20-49", "50-199", "≥ 200"]]; h4.columns = [f"{LAB[c.split('|')[0]]} / {LAB[c.split('|')[1]]}" for c in h4.columns]; h4.index.name = "densité"; h4_md = md_table(h4, index=True)
82 −h5 = pd.DataFrame(H["H5"]).T; h5.index.name = "sous-marché"; h5 = h5.rename(columns={"sd_median": "σ médian (log)"}); h5_md = md_table(h5[["σ médian (log)", "M2", "M7", "M8a", "M13", "M0", "M10", "M11b", "M12"]], index=True, fmt={"σ médian (log)": lambda v: f"{v:.3f}".replace(".", ",")})
83 −iv = pd.DataFrame(R["intervalles"]).T; iv.index = [f"{LAB[i.split('|')[0]]}, k = {i.split('|')[1]}, ≤ {i.split('|')[2]} km, {i.split('|')[3]} mois" for i in iv.index]; iv.index.name = "configuration"
84 −iv_md = md_table(iv.rename(columns={"z80_val": "z calibré (val.)", "couverture_nominale_80": "couverture à z = 1,28", "couverture_calibree": "couverture calibrée", "largeur_mediane_pct": "largeur médiane (%)", "n_test": "n"}), index=True, fmt={"n": n_, "z calibré (val.)": f2})
85 −rc = pd.DataFrame(R["regles_conditionnelles"]).T.loc[["< 5", "5-19", "20-49", "50-199", "≥ 200"]]
86 −rc["règle"] = rc.apply(lambda r: f"{LAB[r.sel]}, k = {int(r.k)}, ≤ {r.d} km, {int(r['T'])} mois, {LAB[r.adj]}, pond. {LAB[r.w]}, {LAB[r.recon]}", axis=1)
87 −rc.index.name = "densité"; rc_md = md_table(rc.rename(columns={"mdape": "MdAPE val.", "mdape_test": "MdAPE test", "couverture": "couverture val.", "n_test": "n test"}), ["règle", "MdAPE val.", "MdAPE test", "couverture val.", "n test"], index=True, fmt={"n test": n_})
88 −stab = pd.DataFrame(R["stabilite"]); stab_md = md_table(stab[["trimestre", "n", "M2", "M7", "M0", "M11b", "M12", "biais_M7", "biais_M11b"]].rename(columns={"biais_M7": "biais M7", "biais_M11b": "biais M11b"}), fmt={"n": n_})
89 −
90 −# ------------------------------------------------------------------ chiffres clés pour le texte
91 −best = min(comp, key=lambda m: comp[m]["mdape"]); best_comps = min([m for m in comp if short(m) in ("M1", "M2", "M3", "M4", "M5", "M6", "M7", "M8a", "M8b", "M13", "M0", "M9")], key=lambda m: comp[m]["mdape"])
92 −m0 = R["M0"]; m0u = R["M0_sans_contrainte"]
93 −gain_adj = comp["M2 moyenne des 5 plus proches (temps seul)"]["mdape"] - comp["M7 grille d'ajustement hédonique"]["mdape"]
94 −gain_sel = comp["M2 moyenne des 5 plus proches (temps seul)"]["mdape"] - comp["M3 sélection par similarité physique"]["mdape"]
95 −k_curve = {int(r.k): r.mdape_test for r in kb.itertuples()}
96 −k_best = min(k_curve, key=k_curve.get)
97 −e1b = R["E1_best_val"]
98 −pair = R["paires"]
99 −def pr(a, b): return pair[f"{a} vs {b}"]
100 −M7 = "M7 grille d'ajustement hédonique"; M2 = "M2 moyenne des 5 plus proches (temps seul)"; M6 = "M6 grille d'ajustement conventionnelle"
101 −M8a = "M8a grille locale (régression sur le voisinage)"; M8b = "M8b ajustements non linéaires (GBM)"; M11b = "M11b gradient boosting (avec rôle)"; M11a = "M11a gradient boosting (sans rôle)"
102 −M0 = "M0 meilleure configuration empirique (choisie en 2024, couverture ≥ 97 %)"; M12 = "M12 hybride comparables + GBM (poids appris en 2024)"; M13 = "M13 ratio au rôle (5 plus proches, même rôle)"; M9 = "M9 réconciliation optimisée (poids appris en 2024)"; M3 = "M3 sélection par similarité physique"; M1 = "M1 médiane des 3 plus proches (temps seul)"; M10 = "M10 hédonique globale directe"
103 −dens = R["strates_dens"]; densn = R["strates_dens_n"]
104 −h1 = H["H1"]
105 −marg = R["E2_marginal"]
106 −cond_g = R["regles_conditionnelles_global_test"]
107 −nT = R["n_targets"]
108 −
109 −txt = f"""---
110 −title: "La méthode des comparables à l'épreuve des données : validation empirique à grande échelle sur {n_(D['propre'])} ventes résidentielles québécoises"
111 −subtitle: "Sélection, nombre, ajustements, pondération et réconciliation des ventes comparables — rétro-évaluation point-dans-le-temps sur les transactions Vrai Prix 2021-2026"
112 −author: "Simon-Pierre Boucher — version {VERSION}"
113 −date: "{time.strftime('%d/%m/%Y')}"
114 −lang: fr-CA
115 −---
116 −
117 −# Résumé
118 −
119 −**Question.** La méthode de comparaison des ventes, telle qu'enseignée et pratiquée en évaluation immobilière, produit-elle des estimations fiables, et lesquelles de ses règles (choix des comparables, nombre, rayon, ancienneté, ajustements, pondération, réconciliation) sont empiriquement justifiées ?
120 −
121 −**Données et protocole.** {n_(D['propre'])} ventes de résidences unifamiliales du Québec (registre foncier jumelé au rôle d'évaluation, Vrai Prix, {D['dates'][0]} → {D['dates'][1]}), après un nettoyage journalisé de {n_(D['brut'])} transactions brutes. Pour chacune des **{n_(nT['val'] + nT['test'])} ventes cibles** de 2024 (validation, {n_(nT['val'])}) et de 2025-2026 (test hors échantillon, {n_(nT['test'])}), le prix est masqué et la valeur est estimée avec les seules ventes conclues **avant** la date de la cible, un indice de prix ré-estimé chaque mois avec les seules ventes antérieures, et des grilles d'ajustement et modèles ré-estimés chaque trimestre. **2 256 configurations** de la méthode des comparables et treize modèles de référence sont appliqués aux mêmes cibles ; les différences sont testées par bootstrap par grappes (municipalité × trimestre).
122 −
123 −**Résultats principaux (test 2025-2026).**
124 −
125 −1. **Les ajustements sont le facteur dominant.** Prendre la moyenne des cinq ventes les plus proches, ajustées seulement pour le temps (M2), donne une erreur absolue médiane (MdAPE) de **{pct(comp[M2]['mdape'])}** ; une grille d'ajustement hédonique (M7) la ramène à **{pct(comp[M7]['mdape'])}** (−{f1(gain_adj)} points, IC 95 % de la différence de MdAPE [{f2(pr(M7, M2)['ci_mdape'][0])} ; {f2(pr(M7, M2)['ci_mdape'][1])}]). Une grille conventionnelle stylisée (M6) fait presque aussi bien ({pct(comp[M6]['mdape'])}) : ce qui compte, c'est d'ajuster, plus que la finesse de la grille.
126 −2. **Choisir des comparables semblables vaut autant qu'ajuster.** Sans aucun ajustement physique, sélectionner les cinq ventes les plus **semblables** (aire, terrain, âge, lien physique, étages) plutôt que les cinq plus **proches** fait passer la MdAPE de {pct(comp[M2]['mdape'])} à **{pct(comp[M3]['mdape'])}** (H1 confirmée : un comparable un peu plus éloigné mais semblable vaut mieux qu'un voisin dissemblable).
127 −3. **Le nombre optimal de comparables est bien supérieur à trois.** Avec la grille hédonique, la MdAPE décroît de {pct(k_curve[1])} (k = 1) à {pct(k_curve[3])} (k = 3), {pct(k_curve[5])} (k = 5), {pct(k_curve[10])} (k = 10) et atteint son minimum à **k = {k_best}** ({pct(k_curve[k_best])}) avant de remonter ({pct(k_curve[50])} à k = 50) ; l'optimum dépend de la densité du marché (H2 confirmée).
128 −4. **Le rayon et la fenêtre temporelle doivent être larges.** La meilleure configuration de la surface Erreur(k, d, t) choisie en 2024 est « {LAB[e1b['sel']]}, k = {int(e1b['k'])}, ≤ {e1b['d']} km, {int(e1b['T'])} mois, {LAB[e1b['adj']]} » ({pct(e1b['mdape_val'])} en validation → **{pct(e1b['mdape_test'])}** en test). Les rayons de 0,5-1 km et les fenêtres de 3 mois, privilégiés par la pratique, sont dominés dès que les comparables sont ajustés.
129 −5. **La pondération et le mode de réconciliation comptent peu** (effets marginaux moyens de {f1(min(marg['w'].values()))} à {f1(max(marg['w'].values()))} points selon la pondération, médiane {f1(marg['recon']['median'])} contre moyenne {f1(marg['recon']['mean'])}), bien moins que la sélection ({f1(min(marg['sel'].values()))}-{f1(max(marg['sel'].values()))}) et l'ajustement ({f1(min(marg['adj'].values()))}-{f1(max(marg['adj'].values()))}).
130 −6. **La meilleure méthode des comparables, entièrement transparente et choisie en 2024, atteint {pct(comp[M0]['mdape'])} en 2025-2026** (M0 : {LAB[m0['sel']]}, k = {int(m0['k'])}, ≤ {m0['d']} km, {int(m0['T'])} mois, {LAB[m0['adj']]}, pondération {LAB[m0['w']]}, {LAB[m0['recon']]}), contre **{pct(comp[M11b]['mdape'])}** pour un gradient boosting ré-entraîné chaque trimestre avec la valeur au rôle (M11b) et {pct(comp[M11a]['mdape'])} sans le rôle (M11a). L'hybride comparables + GBM (M12) atteint **{pct(comp[M12]['mdape'])}**. Le coût de la transparence est donc de {f1(comp[M0]['mdape'] - comp[M11b]['mdape'])} point{'s' if abs(comp[M0]['mdape'] - comp[M11b]['mdape']) >= 2 else ''} de MdAPE face au meilleur modèle opaque, et il s'annule dans les sous-marchés homogènes (H5).
131 −7. **Le ratio au rôle est un comparable redoutable** : rôle de la cible × ratio prix/rôle médian des cinq plus proches ventes du même rôle (M13) donne {pct(comp[M13]['mdape'])}, mieux que toute grille d'ajustement physique, mais seulement pour {pct(comp[M13]['couverture'])} des cibles.
132 −8. **La fiabilité dépend d'abord de la liquidité du sous-marché** : la MdAPE de M7 passe de {pct(dens['≥ 200']['M7'])} là où plus de 200 ventes ont eu lieu à moins de 2 km dans l'année à {pct(dens['< 5']['M7'])} là où il y en a eu moins de 5 ; des règles conditionnelles à la densité (système de recommandation) obtiennent {pct(cond_g['mdape'])} globalement avec {pct(cond_g['couverture'])} de couverture.
133 −
134 −**Portée.** Nous validons la précision prédictive de règles professionnelles *simulées* sur des caractéristiques observables (aire, terrain, âge, étages, lien physique, rôle) ; les ajustements non observables (garage, piscine, chambres, qualité, rénovations) restent dans l'erreur résiduelle, et le jugement réel d'un évaluateur n'est pas reproduit.
135 −
136 −# 1. Données
137 −
138 −## 1.1 Source et nettoyage
139 −
140 −Instantané de la base de production Vrai Prix pris le 2026-10-10 : {n_(766596)} transactions résidentielles et 3,75 M d'unités d'évaluation. Nous retenons les ventes de type « unifamilial » jumelées à une unité du rôle, et appliquons des règles de nettoyage explicites et journalisées — rien n'est éliminé silencieusement.
141 −
142 −{md_table(excl)}
143 −Jeu propre : **{n_(D['propre'])} ventes** ({D['dates'][0]} → {D['dates'][1]}) ; prix médian {n_(D['prix']['50%'])} $, moyenne {n_(D['prix']['mean'])} $.
144 −
145 −Deux règles méritent un commentaire. R8 écarte les ratios prix/rôle extrêmes (< 0,33 ou > 3) : faute d'indicateur de lien de dépendance entre les parties, c'est notre proxy de « transaction non représentative » — un évaluateur les écarterait aussi. R6b écarte les ventes conclues l'année même de la construction : leur prix médian ({n_(346108)} $) et leur ratio au rôle (1,15) sont ceux de terrains ou de constructions inachevées, non de maisons achevées (450-480 k$ et 1,3 aux âges 1-4 ans).
146 −
147 −## 1.2 Répartition
148 −
149 −| Période | Rôle | Ventes |
150 −|---|---|---|
151 −| 2021-01 → 2023-12 | développement | {n_(D['par_periode']['train'])} |
152 −| 2024 | validation (choix des configurations) | {n_(D['par_periode']['val'])} |
153 −| 2025-01 → 2026-09 | test hors échantillon | {n_(D['par_periode']['test'])} |
154 −
155 −Régions les plus représentées : {', '.join(f"{k} ({n_(v)})" for k, v in list(D['par_region'].items())[:6])}.
156 −
157 −![Indice de prix mensuel par région (descriptif, plein échantillon).](f7_indice.pdf){{ width=85% }}
158 −
159 −## 1.3 Caractéristiques disponibles et limites
160 −
161 −Observées pour chaque vente : date, prix, coordonnées, municipalité, unité de voisinage, aire habitable, superficie du terrain, année de construction, nombre d'étages, lien physique (détaché, jumelé, en rangée), genre de construction, valeur au rôle en vigueur à la vente et sa part terrain. Non observées : garage, piscine, nombre de chambres et de salles de bains, qualité, état, rénovations. Les grilles d'ajustement testées ne portent donc que sur les premières ; les secondes sont une source d'erreur commune à toutes les méthodes.
162 −
163 −# 2. Protocole
164 −
165 −## 2.1 Rétro-évaluation point-dans-le-temps
166 −
167 −Pour chaque cible : (1) le prix est masqué ; (2) le bassin de comparables est constitué des ventes conclues strictement avant la date de la cible (la cible elle-même, si elle a déjà été vendue, est exclue : on évalue par comparables, non par vente répétée), dans un rayon de 25 km et une fenêtre de 24 mois ; (3) l'indice temporel régional utilisé pour ramener chaque comparable au niveau du dernier mois connu est ré-estimé chaque mois avec les seules ventes antérieures (régression hédonique à effets fixes de municipalité et effets mensuels, par région administrative, rétrécie vers l'indice provincial quand les ventes sont rares) ; (4) les coefficients d'ajustement hédoniques, les grilles régionales et les modèles d'apprentissage sont ré-estimés chaque trimestre avec les seules ventes antérieures au trimestre ; (5) chaque configuration produit une valeur et, le cas échéant, une dispersion des prix ajustés.
168 −
169 −Le niveau de prix du mois de la cible n'est jamais connu : toutes les méthodes travaillent au niveau du mois précédent, ce qui reproduit la situation réelle d'un évaluateur.
170 −
171 −## 2.2 Configurations testées
172 −
173 −**E1 — surface Erreur(k, d, t)** : k ∈ {{1, 3, 5, 7, 10, 15, 20, 30, 50}}, d ∈ {{0,5 ; 1 ; 2 ; 5 ; 10 ; 25 km}}, t ∈ {{3, 6, 12, 24 mois}}, sélection ∈ {{plus proches, plus semblables, compromis}}, ajustement ∈ {{temps seul, grille hédonique}}, médiane. 1 296 configurations.
174 −
175 −**E2 — plan factoriel des règles** aux ancres (k, d, t) = (3, 2 km, 6 mois), (5, 5 km, 12 mois), (10, 10 km, 24 mois) : sélection (4) × ajustement (8 : aucun, temps seul, grille conventionnelle, hédonique globale, hédonique régionale, régression locale, GBM, ratio au rôle) × pondération (5) × réconciliation (2). 960 configurations.
176 −
177 −*Grille conventionnelle stylisée* (M6) : aire habitable ajustée à la moitié du prix au m² du comparable ; terrain à la moitié de la valeur foncière implicite au m² (plafond ±15 %) ; âge à −0,5 % par année d'écart (plafond ±20 %) ; ±5 % entre détaché et attaché. *Grille hédonique* (M7) : coefficients d'une régression du log du prix sur les caractéristiques, à effets fixes de municipalité (within), ré-estimée chaque trimestre. *Régression locale* (M8a) : mêmes coefficients ré-estimés sur les ventes à moins de 10 km des 24 derniers mois (≥ 40 ventes, ridge vers la grille régionale). *GBM* (M8b) : l'ajustement est la différence des prédictions d'un gradient boosting entre la cible et le comparable. *Ratio au rôle* (M13) : valeur au rôle de la cible × ratio prix/rôle du comparable, pour les comparables de la même municipalité et du même rôle triennal.
178 −
179 −## 2.3 Mesures
180 −
181 −Erreur absolue en pourcentage APE = |P̂ − P| / P ; MdAPE (médiane), MAPE (moyenne), biais médian signé, parts à ±5, ±10 et ±20 %, couverture (part des cibles pour lesquelles la configuration produit une valeur — une configuration trop restrictive ne « rate » pas, elle s'abstient, et cela doit se voir). Les différences entre méthodes sont des différences d'erreurs appariées sur les mêmes cibles, avec intervalles de confiance à 95 % par bootstrap par grappes municipalité × trimestre (400 réplications).
182 −
183 −# 3. Résultats
184 −
185 −## 3.1 Combien de comparables ? À quelle distance ? De quelle ancienneté ?
186 −
187 −![MdAPE (test 2025-2026) selon le nombre de comparables, par rayon, fenêtre de 12 mois, sélection par distance.](f1_mdape_vs_k.pdf){{ width=100% }}
188 −
189 −![MdAPE au meilleur k pour chaque couple rayon × fenêtre.](f2_surface_d_T.pdf){{ width=90% }}
190 −
191 −**Sélection par distance, grille hédonique, fenêtre 12 mois — MdAPE test (%)**
192 −
193 −{surf_tab('geo', 'hedo', 12)}
194 −**Sélection par distance, temps seulement, fenêtre 12 mois — MdAPE test (%)**
195 −
196 −{surf_tab('geo', 'none', 12)}
197 −**Sélection par similarité physique, grille hédonique, fenêtre 12 mois — MdAPE test (%)**
198 −
199 −{surf_tab('sim', 'hedo', 12)}
200 −Lecture. (i) Sans ajustement, l'erreur décroît avec k et avec le rayon tant que l'on reste sous quelques kilomètres, puis se stabilise ; (ii) avec la grille hédonique, elle décroît nettement plus et l'optimum se déplace vers des k et des rayons plus grands ; (iii) la fenêtre de 3 mois est systématiquement dominée par 12-24 mois dès que l'on ajuste pour le temps. Meilleures configurations de E1 par sélection × ajustement (choisies en 2024, évaluées en 2025-2026) :
201 −
202 −{md_table(pd.DataFrame(R['E1_best_by_sel_adj']).T.reset_index().rename(columns={'index': 'sélection | ajustement', 'mdape_val': 'MdAPE val.', 'mdape_test': 'MdAPE test', 'couverture_test': 'couverture test'}), ['sélection | ajustement', 'k', 'd', 'T', 'MdAPE val.', 'MdAPE test', 'couverture test'], fmt={'k': lambda v: str(int(v)), 'T': lambda v: str(int(v)), 'd': lambda v: f1(v)})}
203 −### k optimal selon la densité (H2)
204 −
205 −{h2_md}
206 −*(sélection par distance, ≤ 10 km, 12 mois, grille hédonique ; MdAPE test en %, par nombre de ventes disponibles à moins de 2 km au cours des 12 mois précédents)*
207 −
208 −Sans ajustement :
209 −
210 −{h2n_md}
211 −![k optimal par strate de densité.](f6_k_par_densite.pdf){{ width=100% }}
212 −
213 −## 3.2 Quels ajustements sont justifiés ?
214 −
215 −![Type d'ajustement, cinq comparables les plus proches, ≤ 5 km, ≤ 12 mois, médiane.](f3_ajustements.pdf){{ width=90% }}
216 −
217 −{t_adj_md}
218 −Trois enseignements. **L'ajustement temporel seul** vaut {f1(t_adj.set_index('adj').loc['raw', 'mdape_test'] - t_adj.set_index('adj').loc['none', 'mdape_test'])} point ; **n'importe quelle grille physique** vaut {f1(t_adj.set_index('adj').loc['none', 'mdape_test'] - t_adj.set_index('adj').loc['conv', 'mdape_test'])} à {f1(t_adj.set_index('adj').loc['none', 'mdape_test'] - t_adj.set_index('adj').loc['gbm', 'mdape_test'])} points de plus ; **les raffinements** (grille régionale, régression locale, ajustements non linéaires) apportent peu face à la grille hédonique globale, et la régression locale est même {('moins bonne' if t_adj.set_index('adj').loc['local', 'mdape_test'] > t_adj.set_index('adj').loc['hedo', 'mdape_test'] else 'meilleure')} ({pct(t_adj.set_index('adj').loc['local', 'mdape_test'])} contre {pct(t_adj.set_index('adj').loc['hedo', 'mdape_test'])}).
219 −
220 −### Grilles locales et petits échantillons (H3)
221 −
222 −MdAPE test (%) par type d'ajustement et densité, ancre (5, 5 km, 12 mois) :
223 −
224 −{h3_md}
225 −Ancre (3, 2 km, 6 mois) :
226 −
227 −{h3s_md}
228 −## 3.3 Comment choisir les comparables ? (H1)
229 −
230 −Ancre (5, 5 km, 12 mois), grille hédonique, médiane :
231 −
232 −{t_sel_md}
233 −Test direct de H1 (k = 5, 12 mois, temps seulement) : les cinq voisins à moins de 1 km donnent une MdAPE de {pct(h1['geo_d1']['mdape_test'])} (couverture {pct(h1['geo_d1']['couverture_test'])}) ; les cinq ventes les plus semblables à moins de 10 km, {pct(h1['sim_d10']['mdape_test'])} (couverture {pct(h1['sim_d10']['couverture_test'])}) ; les cinq plus proches à moins de 10 km, {pct(h1['geo_d10']['mdape_test'])}. Avec la grille hédonique : plus proches {pct(h1['geo_d10_hedo']['mdape_test'])}, plus semblables {pct(h1['sim_d10_hedo']['mdape_test'])}, compromis {pct(h1['mix_d10_hedo']['mdape_test'])}.
234 −
235 −## 3.4 Pondération et réconciliation (H4)
236 −
237 −Ancre (5, 5 km, 12 mois), sélection par distance, grille hédonique :
238 −
239 −{t_wr_md}
240 −Selon la densité (MdAPE test, %) :
241 −
242 −{h4_md}
243 −Effets marginaux moyens (MdAPE test moyenne sur toutes les autres modalités de E2) : sélection {', '.join(f"{LAB[k]} {f1(v)}" for k, v in marg['sel'].items())} ; ajustement {', '.join(f"{LAB[k]} {f1(v)}" for k, v in marg['adj'].items())} ; pondération {', '.join(f"{LAB[k]} {f1(v)}" for k, v in marg['w'].items())} ; réconciliation {', '.join(f"{LAB[k]} {f1(v)}" for k, v in marg['recon'].items())}.
244 −
245 −## 3.5 Compétition de modèles
246 −
247 −![Compétition de modèles, test 2025-2026.](f4_competition.pdf){{ width=95% }}
248 −
249 −{t_comp}
250 −M0 : {LAB[m0['sel']]}, k = {int(m0['k'])}, ≤ {m0['d']} km, {int(m0['T'])} mois, {LAB[m0['adj']]}, pondération {LAB[m0['w']]}, {LAB[m0['recon']]} (meilleure des 2 256 configurations en 2024 parmi celles qui couvrent ≥ 97 % des cibles ; sans cette contrainte, la meilleure est « {LAB[m0u['sel']]}, k = {int(m0u['k'])}, ≤ {m0u['d']} km, {int(m0u['T'])} mois, {LAB[m0u['adj']]} » à {pct(m0u['mdape_test'])} mais pour {pct(m0u['couverture_test'])} des cibles seulement). Poids appris de M9 : {', '.join(f"{LAB[k]} {f2(v)}" for k, v in R['M9_weights'].items() if v > 0)}. Poids de M12 : {', '.join(f"{k} {f2(v)}" for k, v in R['M12_weights'].items() if v > 0)}.
251 −
252 −### Différences appariées (test ; bootstrap par grappes municipalité × trimestre)
253 −
254 −{t_pairs}
255 −Un Δ négatif signifie que A est plus précis que B ; l'intervalle qui exclut zéro signale une différence robuste aux dépendances spatiales et temporelles.
256 −
257 −## 3.6 Où la méthode tient-elle, où défaille-t-elle ?
258 −
259 −![Précision selon la liquidité du sous-marché.](f5_densite.pdf){{ width=85% }}
260 −
261 −**Par densité** (ventes à moins de 2 km dans les 12 mois précédents) — MdAPE test (%) :
262 −
263 −{strata_md('dens', 'densité')}
264 −**Par région administrative** :
265 −
266 −{strata_md('region_nom', 'région')}
267 −**Par quintile de prix** :
268 −
269 −{strata_md('prix_q', 'quintile de prix')}
270 −**Par âge du bâtiment** :
271 −
272 −{strata_md('age_cl', 'âge')}
273 −**Par lien physique** (0 détaché, 1 jumelé, 2 en rangée, 3 intégré) :
274 −
275 −{strata_md('lien', 'lien physique')}
276 −### Marchés homogènes et hétérogènes (H5)
277 −
278 −Terciles de la dispersion (écart-type du log des prix ajustés) des dix ventes les plus proches à ≤ 10 km / 24 mois — MdAPE test (%) :
279 −
280 −{h5_md}
281 −### Stabilité dans le temps
282 −
283 −{stab_md}
284 −Le biais trimestriel commun à toutes les méthodes reflète le fait que le niveau de prix du mois courant est inconnu (toutes travaillent au niveau du mois précédent) et la saisonnalité des prix.
285 −
286 −## 3.7 Intervalles de valeur
287 −
288 −La dispersion des prix ajustés des comparables retenus fournit un intervalle de valeur. Avec z = 1,28 (couverture nominale de 80 % si les erreurs étaient normales), puis avec un z calibré sur la validation 2024 :
289 −
290 −{iv_md}
291 −## 3.8 Vers un système de recommandation des comparables
292 −
293 −Règle choisie en 2024 pour chaque strate de densité (parmi les 2 256 configurations couvrant ≥ 95 % des cibles de la strate), évaluée en 2025-2026 :
294 −
295 −{rc_md}
296 −Globalement, ces règles conditionnelles atteignent une MdAPE de **{pct(cond_g['mdape'])}** ({pct(cond_g['p10'])} des estimations à ±10 %, couverture {pct(cond_g['couverture'])}), contre {pct(comp[M0]['mdape'])} pour la meilleure règle unique.
297 −
298 −# 4. Discussion
299 −
300 −**Ce que la méthode des comparables fait bien.** Correctement paramétrée — comparables semblables, nombre d'une dizaine, rayon et fenêtre larges, ajustements hédoniques ou même conventionnels — elle atteint une précision ({pct(comp[M0]['mdape'])}) que ne dépasse le meilleur modèle d'apprentissage ré-entraîné chaque trimestre que de {f1(comp[M0]['mdape'] - comp[M11b]['mdape'])} point{'s' if abs(comp[M0]['mdape'] - comp[M11b]['mdape']) >= 2 else ''}, tout en restant intégralement explicable : chaque valeur est la médiane de quelques ventes identifiées, ajustées par des coefficients lisibles.
301 −
302 −**Ce qui contredit la pratique.** Trois comparables, un rayon de quelques centaines de mètres et une fenêtre de trois mois — les conventions les plus répandues — sont dominés sur toute la surface Erreur(k, d, t) : {pct(surf[(surf.sel == 'geo') & (surf.adj == 'hedo') & (surf.k == 3) & (surf.d == 1) & (surf['T'] == 3)].mdape_test.iloc[0])} de MdAPE (k = 3, ≤ 1 km, 3 mois, grille hédonique, couverture {pct(surf[(surf.sel == 'geo') & (surf.adj == 'hedo') & (surf.k == 3) & (surf.d == 1) & (surf['T'] == 3)].couverture_test.iloc[0])}) contre {pct(e1b['mdape_test'])} pour la meilleure configuration. La rareté des comparables « parfaits » est un argument pour élargir la recherche et ajuster, non pour se restreindre.
303 −
304 −**Le rôle d'évaluation comme information.** Le ratio au rôle (M13) et le GBM avec rôle (M11b) dominent leurs équivalents sans rôle : la valeur au rôle encapsule des caractéristiques non observées ici (qualité, dépendances, état) et une appréciation locale que les modèles globaux ne captent pas. Les modèles globaux sans rôle (M10, M11a) présentent d'ailleurs un biais négatif en 2025-2026 : l'appréciation est spatialement hétérogène et un indice régional, même ré-estimé chaque trimestre, la sous-estime là où les ventes se concentrent.
305 −
306 −**Limites.** (1) Règles professionnelles simulées, non dossiers d'évaluateurs. (2) Caractéristiques limitées à celles du rôle et du registre. (3) Délai de publication des ventes ignoré. (4) Un seul type de bien (unifamilial) ; condos et plex restent à traiter. (5) Les configurations ont été choisies sur 2024 puis évaluées sur 2025-2026 : les écarts entre validation et test (tableaux ci-dessus) mesurent la stabilité des règles, et ils sont faibles.
307 −
308 −# 5. Reproductibilité
309 −
310 −Code : `pipeline/01_dataset.py` → `06_report.py` (Python 3.14, pandas, numpy, scipy, lightgbm, matplotlib) ; sorties versionnées `out/{VERSION}/` (configurations `configs_E1.csv`, `configs_E2.csv` ; estimations `est_E1.npy`, `est_E2.npy` ; cibles `targets.parquet` ; tables `tables/*.csv` ; `results.json`). Exécution : `./run_all.sh` sur le nœud de calcul (M2U64), `./make_pdf.sh` sur le poste de travail.
311 −"""
312 −(DOCS / "RAPPORT.md").write_text(txt, encoding="utf-8")
313 −log(f"rapport écrit : {DOCS / 'RAPPORT.md'} ({len(txt.split()):,} mots)")
deleted M3U96bomparables-validation/pipeline/common.py +0 −85
@@ -1,85 +0,0 @@
1 −# -*- coding: utf-8 -*-
2 −"""Validation empirique de la méthode des comparables — fonctions communes.
3 −Données : instantané Vrai Prix (transactions ⋈ units) du 2026-10-10.
4 −Toutes les sorties sont versionnées sous out/<version>/.
5 −"""
6 −from __future__ import annotations
7 −import json, math, os, sqlite3, time
8 −from pathlib import Path
9 −import numpy as np
10 −import pandas as pd
11 −
12 −ROOT = Path(os.environ.get("CV_ROOT", Path(__file__).resolve().parents[1]))
13 −DB = Path(os.environ.get("CV_DB", ROOT / "data" / "vraiprix-20261010.db"))
14 −REF = ROOT / "ref"
15 −VERSION = os.environ.get("CV_VERSION", time.strftime("%Y-%m-%d"))
16 −OUT = ROOT / "out" / VERSION
17 −OUT.mkdir(parents=True, exist_ok=True)
18 −(OUT / "fig").mkdir(exist_ok=True)
19 −
20 −# Périodes (séparation chronologique)
21 −TRAIN_END = "2023-12-31" # développement
22 −VAL_START, VAL_END = "2024-01-01", "2024-12-31" # choix des configurations
23 −TEST_START, TEST_END = "2025-01-01", "2026-09-29" # évaluation finale hors échantillon
24 −MONTH0 = "2021-01" # premier mois des données
25 −
26 −def log(msg: str) -> None:
27 − print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
28 −
29 −def connect(path: Path = DB) -> sqlite3.Connection:
30 − return sqlite3.connect(f"file:{path}?mode=ro", uri=True, timeout=60)
31 −
32 −def _json_default(o):
33 − if isinstance(o, np.integer): return int(o)
34 − if isinstance(o, np.floating): return None if np.isnan(o) else float(o)
35 − if isinstance(o, np.bool_): return bool(o)
36 − if isinstance(o, np.ndarray): return o.tolist()
37 − if isinstance(o, pd.Timestamp): return o.strftime("%Y-%m-%d")
38 − if isinstance(o, float) and math.isnan(o): return None
39 − return str(o)
40 −
41 −def save_json(obj, name: str) -> Path:
42 − p = OUT / name; p.parent.mkdir(parents=True, exist_ok=True)
43 − with open(p, "w", encoding="utf-8") as f:
44 − json.dump(obj, f, ensure_ascii=False, indent=2, default=_json_default)
45 − return p
46 −
47 −def load_json(name: str):
48 − with open(OUT / name, encoding="utf-8") as f:
49 − return json.load(f)
50 −
51 −def save_parquet(df: pd.DataFrame, name: str) -> Path:
52 − p = OUT / name; df.to_parquet(p, index=False); return p
53 −
54 −def load_parquet(name: str) -> pd.DataFrame:
55 − return pd.read_parquet(OUT / name)
56 −
57 −def month_index(dates) -> np.ndarray:
58 − """Mois entier depuis MONTH0 (2021-01 = 0)."""
59 − d = pd.to_datetime(dates)
60 − y0, m0 = map(int, MONTH0.split("-"))
61 − return ((d.dt.year - y0) * 12 + (d.dt.month - m0)).to_numpy()
62 −
63 −def to_xy_km(lat, lng, lat0=46.8, lng0=-71.2):
64 − """Projection équirectangulaire locale (km) : erreur < 1 % pour des distances < 50 km."""
65 − x = (np.asarray(lng, float) - lng0) * 111.32 * np.cos(np.radians(np.asarray(lat, float)))
66 − y = (np.asarray(lat, float) - lat0) * 110.57
67 − return x, y
68 −
69 −def load_repertoire() -> pd.DataFrame:
70 − mun = pd.read_csv(REF / "MUN.csv", dtype=str, encoding="utf-8-sig")
71 − mun = mun.rename(columns=str.lower)
72 − out = pd.DataFrame({
73 − "code_mun": mun["mcode"].str.zfill(5),
74 − "mun_nom": mun["munnom"],
75 − "region": mun["regadm"].str.extract(r"\((\d+)\)")[0],
76 − "region_nom": mun["regadm"].str.replace(r"\s*\(\d+\)", "", regex=True),
77 − "mrc": mun["mrc"].str.extract(r"\((\d+)\)")[0],
78 − "mrc_nom": mun["mrc"].str.replace(r"\s*\(\d+\)", "", regex=True),
79 − })
80 − return out.drop_duplicates("code_mun")
81 −
82 −# Codages catégoriels (ordre stable)
83 −LIEN = {"Détaché": 0, "Jumelé": 1, "En rangée 1 côté": 2, "En rangée plus de 1 côté": 2, "Intégré": 3}
84 −GENRE = {"De plain-pied": 0, "Unimodulaire": 0, "À étages entiers": 1, "À niveaux décalés": 2, "À étage mansardé": 3}
85 −BTYPE = {"single-story": 0, "full-story": 1, "mansard": 2, "split-level": 3, "single-wide": 4}
deleted M3U96bomparables-validation/run_all.sh +0 −14
@@ -1,14 +0,0 @@
1 −#!/bin/zsh
2 −# Chaîne complète sur le nœud de calcul (M2U64). Usage : CV_VERSION=2026-10-10 ./run_all.sh [étape de départ]
3 −set -e
4 −cd "$(dirname "$0")"
5 −PY=${PY:-$HOME/uqo-eval-terrains/env/bin/python}
6 −export CV_VERSION=${CV_VERSION:-$(date +%F)}
7 −START=${1:-1}
8 −for s in 01_dataset 02_index 03_models 04_backtest 05_analyze 06_report; do
9 − n=${s%%_*}
10 − [ "$((10#$n))" -lt "$START" ] && continue
11 − echo "=== $s ($(date +%T)) ==="
12 − (cd pipeline && $PY $s.py)
13 −done
14 −echo "=== terminé $(date +%T) ==="
deleted M4M64aomparables-validation/.gitignore +0 −5
@@ -1,5 +0,0 @@
1 −out/
2 −data/
3 −*.pyc
4 −__pycache__/
5 −.DS_Store
deleted M4M64aomparables-validation/README.md +0 −33
@@ -1,33 +0,0 @@
1 −# Validation empirique de la méthode des comparables (Vrai Prix)
2 −
3 −Projet de recherche : rétro-évaluation à grande échelle de la méthode de comparaison des ventes sur les transactions
4 −résidentielles unifamiliales du Québec (Vrai Prix, 2021-01 → 2026-09). Protocole : `docs/PROTOCOLE.md`.
5 −Rapport de résultats : `docs/RAPPORT.md` / `docs/RAPPORT.pdf`.
6 −
7 −## Organisation
8 −
9 −```
10 −pipeline/ 01_dataset → 02_index → 03_models → 04_backtest → 05_analyze → 06_report
11 −docs/ PROTOCOLE.md, RAPPORT.md(.pdf), header.tex
12 −out/<version>/ sales.parquet, index_asof.npy, models_*.json, est_E*.npy, targets.parquet, tables/, fig/, results.json
13 −data/ (nœud seulement) vraiprix-20261010.db — instantané de la base de production Vrai Prix
14 −ref/ MUN.csv (répertoire des municipalités MAMH : code → MRC → région)
15 −```
16 −
17 −## Exécution
18 −
19 −Le calcul se fait sur **M2U64** (`~/comparables-validation`, venv `~/uqo-eval-terrains/env`, Python 3.14 : pandas, numpy, scipy,
20 −lightgbm, matplotlib). Le laptop garde le code, les docs et une copie des sorties.
21 −
22 −```bash
23 −rsync -a --exclude out --exclude data --exclude .git ~/Desktop/Validation-Comparables/ M2U64:comparables-validation/
24 −ssh M2U64 'cd ~/comparables-validation && CV_VERSION=2026-10-10 ./run_all.sh' # ~ 30 min
25 −./make_pdf.sh # rapatrie out/ + docs/ et compile le PDF
26 −```
27 −
28 −Variables : `CV_VERSION` (dossier de sortie), `CV_SAMPLE=3000` (échantillon de cibles pour un essai), `CV_PROCS` (processus, 20 par défaut).
29 −
30 −## Données
31 −
32 −Instantané `vraiprix.db` pris sur M3U96a (`~/apps/vrai-prix/data`) le 2026-10-10 : 766 596 transactions, 3,75 M d'unités du rôle.
33 −Jeu propre : ventes `unifamilial` jumelées au rôle, règles R0-R11 journalisées dans `out/<v>/exclusions.csv`.
deleted M4M64aomparables-validation/docs/PROTOCOLE.md +0 −101
@@ -1,101 +0,0 @@
1 −# Revisiter la méthode de comparaison des ventes : protocole de validation empirique à grande échelle
2 −
3 −**Titre de travail (article A)** — *Revisiting the Sales Comparison Approach: A Large-Scale Empirical Validation of Comparable Selection, Price Adjustments, and Value Reconciliation*
4 −
5 −**Variante** — *Does the Sales Comparison Approach Actually Work? Evidence from Large-Scale Residential Property Transactions*
6 −
7 −Données : transactions historiques **Vrai Prix** (registre foncier du Québec jumelé au rôle d'évaluation), instantané du 2026-10-10.
8 −
9 −## 1. Question de recherche
10 −
11 −La méthode des comparables, telle qu'enseignée et pratiquée par les évaluateurs agréés, produit-elle réellement les estimations les plus fiables ? Et surtout, quelles règles de **sélection**, d'**ajustement**, de **pondération** et de **réconciliation** des comparables sont empiriquement justifiées ?
12 −
13 −| Dimension | Question testée | Où dans le pipeline |
14 −|---|---|---|
15 −| Sélection | Quels comparables sont réellement les plus pertinents (proximité, similarité physique, « moins ajustés ») ? | E1 (facteur `sel`), E2 |
16 −| Nombre | Faut-il 1, 3, 5, 10, 20 ou 50 ventes ? | E1 (facteur `k`) |
17 −| Ajustements | Quels ajustements sont statistiquement justifiés (grille conventionnelle, hédonique, locale, non linéaire, ratio au rôle) ? | E2 (facteur `adj`) |
18 −| Pondération | Égalitaire, distance, récence, similarité ? | E2 (facteur `w`) |
19 −| Réconciliation | Médiane, moyenne, moyenne pondérée, combinaison optimisée (M9) ? | E2 (facteur `recon`), M9 |
20 −| Fiabilité | Dans quels marchés (densité, région, segment de prix, hétérogénéité) la méthode tient-elle ou défaille-t-elle ? | strates, H2-H5 |
21 −
22 −## 2. Protocole expérimental (rétro-évaluation « point dans le temps »)
23 −
24 −Pour chaque transaction cible :
25 −
26 −1. **Masquage** du prix de vente réel.
27 −2. **Reconstitution du marché à la date d'évaluation** : seules les ventes conclues *strictement avant* la date de la cible sont admissibles (le bien lui-même, s'il a déjà été vendu, est exclu du bassin : on évalue par comparables, pas par vente répétée). L'indice temporel, les coefficients hédoniques et les modèles d'apprentissage sont ré-estimés avec les seules ventes antérieures à la période d'évaluation (indice : ré-estimé chaque mois avec toutes les ventes des mois précédents ; modèles : estimés sur les ventes < 2024-01 pour la validation 2024 et < 2025-01 pour le test 2025-26).
28 −3. **Application** de chaque configuration (sélection × rayon × fenêtre × nombre × ajustement × pondération × réconciliation) sur exactement les mêmes cibles.
29 −4. **Comparaison** avec le prix réel : erreur absolue en pourcentage, biais, part des estimations à ±5/10/20 %, couverture (part des cibles pour lesquelles la configuration produit une valeur), dispersion des prix ajustés (intervalle de valeur).
30 −
31 −### Séparation chronologique
32 −
33 −| Période | Rôle | Ventes |
34 −|---|---|---|
35 −| 2021-01 → 2023-12 | développement (estimation des grilles d'ajustement et des modèles) | ~ 200 000 |
36 −| 2024 | validation : choix des configurations et des hyperparamètres | ~ 65 000 cibles |
37 −| 2025-01 → 2026-09 | évaluation finale hors échantillon | ~ 125 000 cibles |
38 −
39 −Les comparables d'une cible de 2025 peuvent être des ventes de 2024 ou de 2025 antérieures à sa date : c'est l'information dont disposait un évaluateur ce jour-là (nous ignorons le délai de publication au registre foncier, ce qui avantage légèrement toutes les méthodes de la même façon).
40 −
41 −## 3. Expériences
42 −
43 −### E1 — surface empirique de performance Erreur(k, d, t)
44 −
45 −- k ∈ {1, 3, 5, 7, 10, 15, 20, 30, 50} comparables ;
46 −- d ∈ {0,5 ; 1 ; 2 ; 5 ; 10 ; 25} km (rayon maximal) ;
47 −- t ∈ {3, 6, 12, 24} mois (ancienneté maximale) ;
48 −- sélection ∈ {`geo` les plus proches, `sim` les plus semblables physiquement, `mix` compromis} ;
49 −- ajustement ∈ {`none` temps seulement, `hedo` grille hédonique} ; réconciliation = médiane.
50 −
51 −1 296 configurations × toutes les cibles.
52 −
53 −### E2 — plan factoriel des règles (trois ancres (k, d, t) = (3, 2 km, 6 mois), (5, 5 km, 12 mois), (10, 10 km, 24 mois))
54 −
55 −- sélection ∈ {`geo`, `sim`, `mix`, `adj` = comparables nécessitant le moins d'ajustements} ;
56 −- ajustement ∈ {`raw` aucun, `none` temps seul, `conv` grille conventionnelle stylisée, `hedo` grille hédonique globale (within-municipalité), `hedo_reg` grille régionale rétrécie, `local` régression sur le voisinage (≤ 10 km, ≥ 40 ventes, ridge vers la grille régionale), `gbm` ajustements non linéaires (LightGBM), `role` ratio prix/rôle (comparables de la même municipalité et du même rôle)} ;
57 −- pondération ∈ {égale, 1/(0,25 + d), exp(−mois/6), 1/(0,1 + ajustement brut), produit des trois} ;
58 −- réconciliation ∈ {médiane (pondérée), moyenne (pondérée) en dollars}.
59 −
60 −960 configurations.
61 −
62 −### Compétition de modèles
63 −
64 −| Modèle | Définition opérationnelle |
65 −|---|---|
66 −| M1 | médiane des 3 ventes les plus proches (≤ 2 km, ≤ 6 mois), temps seul |
67 −| M2 | moyenne des 5 plus proches (≤ 5 km, ≤ 12 mois), temps seul |
68 −| M3 | 5 comparables choisis par similarité physique |
69 −| M4 | 5 plus proches pondérés par la distance |
70 −| M5 | 5 plus proches pondérés par la récence |
71 −| M6 | grille d'ajustement conventionnelle stylisée (½ du prix au m² pour l'aire, ½ de la valeur foncière au m² pour le terrain plafonnée à 15 %, −0,5 %/an d'âge plafonné à 20 %, ±5 % détaché/attaché) |
72 −| M7 | grille d'ajustement hédonique (coefficients within-municipalité estimés sur les ventes antérieures) |
73 −| M8a / M8b | grille locale (régression sur le voisinage) / ajustements non linéaires (GBM) |
74 −| M9 | réconciliation optimisée : combinaison convexe (NNLS) de sept estimations par comparables, poids appris en 2024 |
75 −| M10 | modèle hédonique global direct (β within + effets région, MRC, municipalité, unité de voisinage) |
76 −| M11a / M11b | gradient boosting sans / avec la valeur au rôle |
77 −| M12 | hybride : combinaison convexe de M11b et des estimations par comparables ajustées (GBM, hédonique) |
78 −| M13 | ratio au rôle : rôle de la cible × ratio prix/rôle médian des 5 plus proches du même rôle |
79 −| M0 | meilleure configuration de E1 ∪ E2 choisie sur la validation 2024, évaluée en 2025-26 |
80 −
81 −### Hypothèses
82 −
83 −- **H1** — un comparable très proche mais peu semblable peut être moins informatif qu'un comparable plus éloigné mais très semblable.
84 −- **H2** — l'ajout de comparables améliore la précision jusqu'à un seuil, au-delà duquel l'hétérogénéité domine ; le seuil dépend de la densité.
85 −- **H3** — les ajustements empiriques locaux surpassent les grilles fixes mais deviennent instables dans les petits échantillons.
86 −- **H4** — la pondération optimale dépend de la liquidité du sous-marché.
87 −- **H5** — les méthodes simples et transparentes rivalisent avec l'apprentissage automatique dans les marchés homogènes.
88 −
89 −### Inférence
90 −
91 −Différences d'erreurs appariées (mêmes cibles) avec intervalles de confiance par **bootstrap par grappes** (municipalité × trimestre), qui respecte les dépendances spatiales et temporelles.
92 −
93 −## 4. Ce que les données permettent — et ne permettent pas
94 −
95 −Caractéristiques observées pour chaque vente (registre foncier ⋈ rôle) : date, prix, coordonnées, municipalité, unité de voisinage, aire habitable (étages), superficie du terrain, année de construction, nombre d'étages, lien physique (détaché, jumelé, en rangée), genre de construction, valeur au rôle (en vigueur à la vente) et sa part terrain. **Non observés** : garage, piscine, nombre de chambres et de salles de bains, qualité et état, rénovations. Les ajustements correspondants ne peuvent donc pas être testés ici ; les erreurs résiduelles les contiennent.
96 −
97 −Nous validons la **précision prédictive** de règles professionnelles *simulées*, pas le jugement réel d'un évaluateur (il faudrait ses dossiers, ses comparables et ses ajustements). La distinction est maintenue partout dans le rapport.
98 −
99 −## 5. Article B (suite envisagée)
100 −
101 −Vers une méthode des comparables optimisée empiriquement : algorithme transparent de sélection/ajustement entraîné sur les ventes historiques et validé sur les ventes futures ; règles conditionnelles au type de bien, à la densité et à la qualité des données ; intégration dans UQO Éval pour montrer aux étudiants la comparaison entre règles traditionnelles et règles estimées.
deleted M4M64aomparables-validation/docs/header.tex +0 −28
@@ -1,28 +0,0 @@
1 −% En-tête LaTeX — Grille empirique d'ajustement des terrains (UQO Éval)
2 −\usepackage{fontspec}
3 −\setmainfont{Helvetica Neue}[Ligatures=TeX]
4 −\setsansfont{Helvetica Neue}
5 −\setmonofont{Menlo}[Scale=0.82]
6 −\usepackage{newunicodechar}
7 −\newunicodechar{→}{\ensuremath{\rightarrow}}\newunicodechar{≥}{\ensuremath{\geq}}\newunicodechar{≤}{\ensuremath{\leq}}\newunicodechar{×}{\ensuremath{\times}}\newunicodechar{−}{\ensuremath{-}}\newunicodechar{≈}{\ensuremath{\approx}}\newunicodechar{∂}{\ensuremath{\partial}}\newunicodechar{⋈}{\ensuremath{\bowtie}}\newunicodechar{Δ}{\ensuremath{\Delta}}\newunicodechar{τ}{\ensuremath{\tau}}\newunicodechar{σ}{\ensuremath{\sigma}}\newunicodechar{β}{\ensuremath{\beta}}\newunicodechar{θ}{\ensuremath{\theta}}\newunicodechar{γ}{\ensuremath{\gamma}}\newunicodechar{α}{\ensuremath{\alpha}}\newunicodechar{δ}{\ensuremath{\delta}}\newunicodechar{ε}{\ensuremath{\varepsilon}}\newunicodechar{²}{\textsuperscript{2}}\newunicodechar{⇒}{\ensuremath{\Rightarrow}}\newunicodechar{▶}{\ensuremath{\blacktriangleright}}\newunicodechar{│}{|}\newunicodechar{▼}{\ensuremath{\blacktriangledown}}\newunicodechar{─}{-}\newunicodechar{┌}{+}\newunicodechar{┐}{+}\newunicodechar{└}{+}\newunicodechar{┘}{+}\newunicodechar{├}{+}\newunicodechar{┤}{+}
8 −\usepackage{xcolor}
9 −\definecolor{uqo}{HTML}{006085}\definecolor{turq}{HTML}{2BCCAE}\definecolor{corail}{HTML}{EA6047}\definecolor{encre}{HTML}{16181B}\definecolor{papier}{HTML}{F4F5F3}
10 −\usepackage{titlesec}
11 −\titleformat{\section}{\Large\bfseries\color{uqo}}{}{0em}{}[{\color{uqo}\titlerule[0.8pt]}]
12 −\titleformat{\subsection}{\large\bfseries\color{uqo}}{}{0em}{}
13 −\titlespacing*{\section}{0pt}{18pt plus 4pt}{8pt}
14 −\usepackage{etoolbox}
15 −\AtBeginEnvironment{longtable}{\scriptsize\renewcommand{\arraystretch}{1.18}}
16 −\usepackage{colortbl}\arrayrulecolor{uqo!35}
17 −\usepackage{tcolorbox}
18 −\renewenvironment{quote}{\begin{tcolorbox}[colback=papier,colframe=turq,boxrule=0pt,leftrule=3pt,arc=1pt,left=6pt,right=6pt,top=4pt,bottom=4pt]}{\end{tcolorbox}}
19 −\usepackage{fancyhdr}
20 −\pagestyle{fancy}\fancyhf{}
21 −\fancyhead[L]{\footnotesize\color{gray}Grille empirique d'ajustement des terrains — UQO Éval}\fancyhead[R]{\footnotesize\color{gray}IMM1003 · IMM1033}
22 −\fancyfoot[C]{\footnotesize\color{gray}\thepage}\renewcommand{\headrulewidth}{0.3pt}
23 −\usepackage{caption}\captionsetup{font=small,labelfont={bf,color=uqo}}
24 −\usepackage{float}\floatplacement{figure}{H}
25 −\setlength{\emergencystretch}{3em}\hyphenpenalty=300\tolerance=1500
26 −\usepackage{microtype}
27 −\makeatletter\renewcommand{\maketitle}{\begin{center}{\color{uqo}\Huge\bfseries\@title\par}\vspace{8pt}{\normalsize\color{gray}\@author\par\@date\par}\end{center}\vspace{10pt}}\makeatother
28 −\usepackage{polyglossia}\setdefaultlanguage[frenchpart=false]{french}
deleted M4M64aomparables-validation/make_pdf.sh +0 −13
@@ -1,13 +0,0 @@
1 −#!/bin/zsh
2 −# Rapatrie les résultats depuis M2U64 et produit docs/RAPPORT.pdf (pandoc → XeLaTeX)
3 −set -e
4 −cd "$(dirname "$0")"
5 −V=${CV_VERSION:-2026-10-10}
6 −mkdir -p out/$V
7 −rsync -az --exclude 'est_E*.npy' --exclude 'sd_E2.npy' --exclude '*_pred_*.npy' --exclude 'sales.parquet' M2U64:comparables-validation/out/$V/ out/$V/
8 −rsync -az M2U64:comparables-validation/docs/ docs/ 2>/dev/null || true
9 −cd docs
10 −pandoc RAPPORT.md -o RAPPORT.pdf --pdf-engine=xelatex -H header.tex --toc --toc-depth=2 \
11 − -V geometry:"a4paper,margin=17mm,top=20mm,bottom=20mm" -V fontsize=10pt -V linkcolor=uqo -V urlcolor=uqo -V toccolor=uqo -V colorlinks=true \
12 − -V toc-title="Table des matières" --resource-path=.:../out/$V/fig 2>&1 | grep -v "Missing character\|Overfull\|Underfull" | tail -8 || true
13 −echo "PDF : $(pwd)/RAPPORT.pdf ($(du -h RAPPORT.pdf | cut -f1))"
deleted M4M64aomparables-validation/pipeline/01_dataset.py +0 −97
@@ -1,97 +0,0 @@
1 −# -*- coding: utf-8 -*-
2 −"""01 — Jeu de données : transactions unifamiliales Vrai Prix ⋈ rôle, nettoyage journalisé,
3 −reventes, géographie, périodes. Sorties : sales.parquet, exclusions.csv, dataset_stats.json."""
4 −from common import *
5 −
6 −con = connect()
7 −log(f"lecture {DB}")
8 −sql = """
9 −select t.id, t.date, t.amount, t.lat, t.lng, t.city, t.property_type, t.year_built, t.floor_area,
10 − t.building_type, t.id_provinc, t.valeur_role as role_vente, t.land_area,
11 − u.code_mun, u.municipalite, u.type_prop as u_type, u.annee_construction, u.aire_etages_m2,
12 − u.superficie_terrain_m2, u.front_terrain_m, u.nb_etages, u.nb_logements, u.lien_physique,
13 − u.genre_construction, u.unite_voisinage, u.dat_cond_marche, u.valeur_terrain, u.valeur_batiment,
14 − u.valeur_role as role_actuel, u.valeur_anterieure as role_anterieur, u.cubf
15 −from transactions t left join units u on u.id_provinc = t.id_provinc
16 −where t.property_type = 'unifamilial'
17 −"""
18 −df = pd.read_sql(sql, con)
19 −log(f"{len(df):,} transactions unifamiliales brutes")
20 −n0 = len(df)
21 −excl = [] # (règle, n)
22 −def drop(mask, rule):
23 − global df
24 − n = int(mask.sum()); excl.append((rule, n)); df = df[~mask].copy(); log(f" {rule}: -{n:,} → {len(df):,}")
25 −
26 −df["date"] = pd.to_datetime(df["date"])
27 −df = df.sort_values(["id_provinc", "date"]).reset_index(drop=True)
28 −drop(df["id_provinc"].isna() | df["code_mun"].isna(), "R0 sans jumelage au rôle (id_provinc / code_mun)")
29 −drop(df.duplicated(["id_provinc", "date"]), "R1 doublons (même unité, même date)")
30 −drop(~df["u_type"].isin(["unifamilial"]) | (df["nb_logements"].fillna(1) > 1), "R2 unité du rôle non unifamiliale (chalet, plex, terrain…) ou > 1 logement")
31 −drop(df["lat"].isna() | df["lng"].isna() | (df["lat"] < 44.5) | (df["lat"] > 63) | (df["lng"] > -56) | (df["lng"] < -80), "R3 coordonnées absentes ou hors Québec")
32 −fa = df["floor_area"].fillna(df["aire_etages_m2"])
33 −df["floor_area"] = fa
34 −drop(fa.isna() | (fa < 40) | (fa > 600), "R4 aire habitable absente ou hors [40, 600] m²")
35 −la = df["land_area"].fillna(df["superficie_terrain_m2"])
36 −df["land_area"] = la
37 −drop(la.isna() | (la < 80) | (la > 20000), "R5 superficie de terrain absente ou hors [80, 20 000] m²")
38 −yb = df["year_built"].fillna(df["annee_construction"])
39 −df["year_built"] = yb
40 −drop(yb.isna() | (yb < 1850) | (yb > df["date"].dt.year), "R6 année de construction absente, < 1850 ou postérieure à la vente (vente de terrain)")
41 −drop((df["year_built"] == df["date"].dt.year), "R6b vente l'année même de la construction (prix du terrain ou d'une construction inachevée : médiane 346 k$ et ratio au rôle 1,15 contre 450-480 k$ et 1,3 aux âges 1-4)")
42 −drop((df["amount"] < 50000) | (df["amount"] > 3_000_000), "R7 prix hors [50 k$, 3 M$]")
43 −ratio = df["amount"] / df["role_vente"]
44 −drop(df["role_vente"].isna() | (df["role_vente"] <= 0) | (ratio < 0.33) | (ratio > 3.0), "R8 rôle absent ou ratio prix/rôle hors [0,33 ; 3] (transactions présumées non représentatives)")
45 −# reventes rapides avec forte variation (flips / rénovations : les caractéristiques enregistrées ne décrivent plus le bien)
46 −df = df.sort_values(["id_provinc", "date"]).reset_index(drop=True)
47 −g = df.groupby("id_provinc")
48 −prev_date = g["date"].shift(1); prev_amt = g["amount"].shift(1)
49 −dd = (df["date"] - prev_date).dt.days
50 −chg = df["amount"] / prev_amt - 1
51 −flip = (dd <= 180) & (chg.abs() > 0.40)
52 −drop(flip.fillna(False), "R9 revente ≤ 180 jours avec variation > 40 % (flip)")
53 −drop(df["lien_physique"].isna() | df["genre_construction"].isna(), "R10 lien physique ou genre de construction absent au rôle")
54 −
55 −# ----------------------------------------------------------------- variables dérivées
56 −rep = load_repertoire()
57 −df = df.merge(rep, on="code_mun", how="left")
58 −drop(df["region"].isna(), "R11 municipalité absente du répertoire MAMH")
59 −df["sale_year"] = df["date"].dt.year
60 −df["month"] = month_index(df["date"])
61 −df["day"] = (df["date"] - pd.Timestamp("2021-01-01")).dt.days.astype(int)
62 −df["age"] = (df["sale_year"] - df["year_built"]).clip(lower=0)
63 −df["ln_price"] = np.log(df["amount"])
64 −df["ln_floor"] = np.log(df["floor_area"])
65 −df["ln_land"] = np.log(df["land_area"])
66 −df["lien"] = df["lien_physique"].map(LIEN).fillna(0).astype(int)
67 −df["genre"] = df["genre_construction"].map(GENRE).fillna(0).astype(int)
68 −df["btype"] = df["building_type"].map(BTYPE).fillna(-1).astype(int)
69 −df["etages"] = df["nb_etages"].fillna(1).clip(1, 3)
70 −df["x_km"], df["y_km"] = to_xy_km(df["lat"], df["lng"])
71 −# millésime du rôle au moment de la vente (rôle courant / antérieur / autre)
72 −df["role_vintage"] = np.where(np.isclose(df["role_vente"], df["role_actuel"].fillna(-1)), 2,
73 − np.where(np.isclose(df["role_vente"], df["role_anterieur"].fillna(-1)), 1, 0))
74 −df["ratio_role"] = df["amount"] / df["role_vente"]
75 −df["part_terrain"] = (df["valeur_terrain"] / df["role_actuel"]).clip(0.02, 0.95).fillna(0.2)
76 −df["neuf"] = (df["age"] <= 3).astype(int)
77 −df["period"] = np.select([df["date"] <= TRAIN_END, df["date"] <= VAL_END], ["train", "val"], "test")
78 −df = df.sort_values("date").reset_index(drop=True)
79 −df["idx"] = np.arange(len(df), dtype=np.int32)
80 −
81 −keep = ["idx", "id", "id_provinc", "date", "day", "month", "sale_year", "period", "amount", "ln_price",
82 − "lat", "lng", "x_km", "y_km", "code_mun", "mun_nom", "mrc", "mrc_nom", "region", "region_nom",
83 − "unite_voisinage", "floor_area", "ln_floor", "land_area", "ln_land", "year_built", "age", "neuf",
84 − "lien", "genre", "btype", "etages", "role_vente", "role_vintage", "ratio_role", "part_terrain"]
85 −df = df[keep]
86 −save_parquet(df, "sales.parquet")
87 −pd.DataFrame(excl, columns=["regle", "n"]).to_csv(OUT / "exclusions.csv", index=False)
88 −stats = {
89 − "brut": n0, "propre": len(df), "exclusions": excl,
90 − "par_periode": df["period"].value_counts().to_dict(),
91 − "par_annee": df["sale_year"].value_counts().sort_index().to_dict(),
92 − "par_region": df.groupby("region_nom").size().sort_values(ascending=False).to_dict(),
93 − "prix": {k: float(v) for k, v in df["amount"].describe().items()},
94 − "dates": [str(df["date"].min().date()), str(df["date"].max().date())],
95 −}
96 −save_json(stats, "dataset_stats.json")
97 −log(f"jeu propre : {len(df):,} ventes ({stats['par_periode']})")
deleted M4M64aomparables-validation/pipeline/02_index.py +0 −78
@@ -1,78 +0,0 @@
1 −# -*- coding: utf-8 -*-
2 −"""02 — Indice temporel régional « point dans le temps ».
3 −Pour chaque mois d'évaluation m, on ré-estime un indice hédonique mensuel par région administrative
4 −avec toutes les ventes antérieures au mois m (fenêtre expansive) (jamais le mois m lui-même). Niveau de référence = m-1,
5 −dernier mois entièrement connu. Sortie : index_asof.npy [m_eval, région, mois] (log), regions.json,
6 −et un indice « plein échantillon » descriptif (index_full.csv)."""
7 −from common import *
8 −
9 −df = load_parquet("sales.parquet")
10 −regions = sorted(df["region"].unique().tolist())
11 −rid = {r: i for i, r in enumerate(regions)}
12 −df["rid"] = df["region"].map(rid).astype(int)
13 −M = int(df["month"].max()) + 1
14 −M_EVAL0 = int(month_index(pd.Series([VAL_START]))[0])
15 −WINDOW = 999 # fenêtre « expansive » : toutes les ventes antérieures au mois m
16 −SHRINK = 40.0 # n/(n+SHRINK) : rétrécissement de l'effet mensuel régional vers l'effet provincial
17 −
18 −X_cols = ["ln_floor", "ln_land", "age"]
19 −def design(d):
20 − X = np.column_stack([np.ones(len(d)), d["ln_floor"], d["ln_land"], d["age"], d["age"] ** 2 / 100,
21 − (d["lien"] == 1), (d["lien"] >= 2), (d["genre"] == 1), (d["genre"] == 2), (d["genre"] == 3),
22 − d["etages"], d["neuf"]]).astype(float)
23 − return X
24 −
25 −def fit_month_effects(d, months):
26 − """OLS à effets fixes de municipalité (estimateur within) : ln P ~ X + effets mensuels (base = dernier mois),
27 − toutes les variables étant centrées par municipalité pour que l'indice ne soit pas contaminé par les
28 − déplacements du mélange des ventes entre municipalités chères et bon marché. Renvoie effets et n par mois."""
29 − X = design(d)[:, 1:]
30 − mm = d["month"].to_numpy()
31 − base = months[-1]
32 − dums = np.column_stack([(mm == m).astype(float) for m in months if m != base])
33 − A = np.hstack([X, dums]); y = d["ln_price"].to_numpy(float)
34 − codes, inv = np.unique(d["code_mun"].to_numpy(), return_inverse=True)
35 − cnt = np.bincount(inv).astype(float)
36 − mA = np.zeros((len(codes), A.shape[1])); np.add.at(mA, inv, A); A = A - mA[inv] / cnt[inv, None]
37 − my = np.bincount(inv, y) / cnt; y = y - my[inv]
38 − beta, *_ = np.linalg.lstsq(A, y, rcond=None)
39 − eff = {base: 0.0}
40 − k = X.shape[1]
41 − for j, m in enumerate([m for m in months if m != base]):
42 − eff[m] = float(beta[k + j])
43 − n = {m: int((mm == m).sum()) for m in months}
44 − return eff, n
45 −
46 −idx = np.full((M - M_EVAL0, len(regions), M), np.nan, dtype=np.float32)
47 −log(f"indice point-dans-le-temps : mois d'évaluation {M_EVAL0}..{M-1}, {len(regions)} régions")
48 −for m in range(M_EVAL0, M):
49 − months = list(range(max(0, m - WINDOW), m))
50 − win = df[(df["month"] >= months[0]) & (df["month"] < m)]
51 − prov_eff, _ = fit_month_effects(win, months)
52 − for r in regions:
53 − dr = win[win["region"] == r]
54 − if len(dr) < 200:
55 − for mo in months: idx[m - M_EVAL0, rid[r], mo] = prov_eff[mo]
56 − continue
57 − eff, n = fit_month_effects(dr, months)
58 − for mo in months:
59 − w = n[mo] / (n[mo] + SHRINK)
60 − idx[m - M_EVAL0, rid[r], mo] = w * eff[mo] + (1 - w) * prov_eff[mo]
61 − if m % 6 == 0: log(f" mois {m} ({len(win):,} ventes dans la fenêtre)")
62 −np.save(OUT / "index_asof.npy", idx)
63 −save_json({"regions": regions, "M_EVAL0": M_EVAL0, "M": M, "window": WINDOW, "shrink": SHRINK}, "index_meta.json")
64 −
65 −# indice descriptif plein échantillon (pour le rapport seulement — pas utilisé dans la rétro-évaluation)
66 −rows = []
67 −months = list(range(0, M))
68 −eff_p, n_p = fit_month_effects(df, months)
69 −for mo in months:
70 − rows.append({"month": mo, "ym": f"{2021 + mo // 12}-{mo % 12 + 1:02d}", "region": "QC", "log_idx": eff_p[mo] - eff_p[months[0]], "n": n_p[mo]})
71 −for r in regions:
72 − dr = df[df["region"] == r]
73 − if len(dr) < 2000: continue
74 − eff, n = fit_month_effects(dr, months)
75 − for mo in months:
76 − rows.append({"month": mo, "ym": f"{2021 + mo // 12}-{mo % 12 + 1:02d}", "region": r, "log_idx": eff[mo] - eff[months[0]], "n": n[mo]})
77 −pd.DataFrame(rows).to_csv(OUT / "index_full.csv", index=False)
78 −log("indice terminé")
deleted M4M64aomparables-validation/pipeline/03_models.py +0 −119
@@ -1,119 +0,0 @@
1 −# -*- coding: utf-8 -*-
2 −"""03 — Modèles d'ajustement et modèles de référence, ré-estimés chaque trimestre avec les seules ventes
3 −antérieures au trimestre (as-of 2024-01, 2024-04, …, 2026-07) — comme le ferait un AVM ré-entraîné régulièrement :
4 − • grille hédonique globale (coefficients) et régionale rétrécie → ajustements B / C
5 − • LightGBM (objectif L1 = médiane) sur le prix ajusté dans le temps (avec et sans rôle) → ajustements C-GBM, M11
6 − • hédonique globale directe avec effets de municipalité → M10
7 −Sorties : models_<période>.json, gbm_pred_<période>.npy (prédiction f pour TOUTES les ventes)."""
8 −import pickle
9 −from common import *
10 −import lightgbm as lgb
11 −
12 −df = load_parquet("sales.parquet")
13 −meta = load_json("index_meta.json"); regions = meta["regions"]; M_EVAL0 = meta["M_EVAL0"]
14 −rid = {r: i for i, r in enumerate(regions)}
15 −df["rid"] = df["region"].map(rid).astype(int)
16 −idx = np.load(OUT / "index_asof.npy")
17 −
18 −FEATS = ["ln_floor", "ln_land", "age", "age2", "lien1", "lien2", "genre1", "genre2", "genre3", "etages", "neuf"]
19 −def feats(d):
20 − return pd.DataFrame({
21 − "ln_floor": d["ln_floor"], "ln_land": d["ln_land"], "age": d["age"], "age2": d["age"] ** 2 / 100,
22 − "lien1": (d["lien"] == 1).astype(float), "lien2": (d["lien"] >= 2).astype(float),
23 − "genre1": (d["genre"] == 1).astype(float), "genre2": (d["genre"] == 2).astype(float), "genre3": (d["genre"] == 3).astype(float),
24 − "etages": d["etages"].astype(float), "neuf": d["neuf"].astype(float)}, index=d.index)
25 −
26 −def time_adjusted(d, m_asof):
27 − """ln P ramené au niveau du mois m_asof-1 avec l'indice connu au mois m_asof."""
28 − lvl = idx[m_asof - M_EVAL0]
29 − return d["ln_price"].to_numpy() - lvl[d["rid"].to_numpy(), d["month"].to_numpy()] + lvl[d["rid"].to_numpy(), m_asof - 1]
30 −
31 −def ols(X, y):
32 − A = np.column_stack([np.ones(len(X)), X])
33 − b, *_ = np.linalg.lstsq(A, y, rcond=None)
34 − return b
35 −
36 −ASOF = list(range(M_EVAL0, int(df["month"].max()) + 1, 3)) # ré-estimation trimestrielle (2024-01, 2024-04, …)
37 −save_json({"asof": ASOF}, "asof.json")
38 −TODO = [int(a) for a in os.environ["CV_ASOF"].split(",")] if os.environ.get("CV_ASOF") else ASOF # sous-ensemble (répartition sur plusieurs nœuds)
39 −for m_asof in TODO:
40 − period = str(m_asof); start = f"{2021 + m_asof // 12}-{m_asof % 12 + 1:02d}-01"
41 − tr = df[df["month"] < m_asof].copy()
42 − y = time_adjusted(tr, m_asof)
43 − F = feats(tr)
44 − log(f"[{period}] entraînement sur {len(tr):,} ventes < {start} (indice connu au mois {m_asof})")
45 − # --- grille hédonique globale : estimateur « within » (effets fixes de municipalité), pour que les β
46 − # mesurent des différences entre biens voisins et non entre marchés
47 − def within(frame, cols, by):
48 − return frame[cols] - frame.groupby(by)[cols].transform("mean")
49 − # effets mensuels résiduels (base = dernier mois connu m_asof-1) : l'indice régional ne retire pas toute la
50 − # tendance commune ; sans eux, les modèles globaux sous-estiment les ventes récentes
51 − months_tr = sorted(tr["month"].unique()); MCOLS = [f"m{m}" for m in months_tr if m != m_asof - 1]
52 − Fm = pd.concat([F] + [pd.Series((tr["month"] == m).astype(float), index=tr.index, name=f"m{m}") for m in months_tr if m != m_asof - 1], axis=1)
53 − Fw = within(pd.concat([Fm, tr[["code_mun"]]], axis=1), FEATS + MCOLS, "code_mun")
54 − yw = y - pd.Series(y, index=tr.index).groupby(tr["code_mun"]).transform("mean").to_numpy()
55 − bw_all, *_ = np.linalg.lstsq(Fw.to_numpy(), yw, rcond=None)
56 − bw = bw_all[:len(FEATS)]; bm = bw_all[len(FEATS):]
57 − beta_global = dict(zip(FEATS, bw.tolist()))
58 − xb = F.to_numpy() @ bw + Fm[MCOLS].to_numpy() @ bm
59 − resid = y - xb
60 − r2w = 1 - np.var(yw - Fw.to_numpy() @ bw_all) / np.var(yw)
61 − Fw = Fw[FEATS]
62 − log(f" hédonique within-municipalité : R² within = {r2w:.3f} ; β = { {k: round(v, 3) for k, v in beta_global.items()} }")
63 − # --- grille régionale (within-municipalité) rétrécie vers la globale (n/(n+300))
64 − beta_region = {}
65 − for r in regions:
66 − sel = (tr["region"] == r).to_numpy()
67 − if sel.sum() < 100: beta_region[r] = beta_global; continue
68 − b, *_ = np.linalg.lstsq(Fw.to_numpy()[sel], yw[sel], rcond=None)
69 − w = sel.sum() / (sel.sum() + 300.0)
70 − beta_region[r] = {k: float(w * b[j] + (1 - w) * beta_global[k]) for j, k in enumerate(FEATS)}
71 − # --- M10 : hédonique directe = Xβ + effet de région + MRC + municipalité + unité de voisinage (rétrécis en cascade)
72 − reg_eff = pd.Series(resid, index=tr.index).groupby(tr["region"]).mean().to_dict()
73 − tr["res_g"] = resid - tr["region"].map(reg_eff).to_numpy()
74 − mrc_eff = tr.groupby("mrc")["res_g"].agg(["sum", "count"])
75 − mrc_eff = (mrc_eff["sum"] / (mrc_eff["count"] + 30)).to_dict()
76 − tr["res_m"] = tr["res_g"] - tr["mrc"].map(mrc_eff).fillna(0)
77 − mun_eff = tr.groupby("code_mun")["res_m"].agg(["sum", "count"])
78 − mun_eff = (mun_eff["sum"] / (mun_eff["count"] + 20)).to_dict()
79 − tr["res_u"] = tr["res_m"] - tr["code_mun"].map(mun_eff).fillna(0)
80 − # correction médiane : la MCO vise la moyenne du log ; la distribution est asymétrique (queue basse), on recentre sur la médiane
81 − uv_tmp = tr.groupby(["code_mun", "unite_voisinage"])["res_u"].transform(lambda v: v.sum() / (len(v) + 10))
82 − median_shift = float(np.median(tr["res_u"] - uv_tmp))
83 − uv = tr.groupby(["code_mun", "unite_voisinage"])["res_u"].agg(["sum", "count"])
84 − uv_eff = {f"{a}|{b}": v for (a, b), v in (uv["sum"] / (uv["count"] + 10)).items()}
85 − # --- LightGBM (prix ajusté dans le temps) avec rôle et sans rôle
86 − def gbm_frame(d):
87 − G = feats(d)
88 − G["lat"] = d["lat"]; G["lng"] = d["lng"]; G["x_km"] = d["x_km"]; G["y_km"] = d["y_km"]
89 − G["rid"] = d["rid"]; G["btype"] = d["btype"]
90 − G["ln_role"] = np.log(d["role_vente"]); G["part_terrain"] = d["part_terrain"]; G["role_vintage"] = d["role_vintage"]
91 − G["month"] = np.minimum(d["month"], m_asof - 1).astype(float) # tendance résiduelle ; hors échantillon = dernier mois connu
92 − return G
93 − G = gbm_frame(tr)
94 − params = dict(objective="regression_l1", learning_rate=0.05, num_leaves=127, min_data_in_leaf=40, feature_fraction=0.8,
95 − bagging_fraction=0.8, bagging_freq=1, lambda_l2=1.0, verbose=-1, num_threads=int(os.environ.get("CV_THREADS", 20)))
96 − models = {}
97 − for name, cols in [("gbm", [c for c in G.columns if c not in ("ln_role", "part_terrain", "role_vintage")]),
98 − ("gbm_role", list(G.columns))]:
99 − ds = lgb.Dataset(G[cols], y, categorical_feature=["rid", "btype"] if "btype" in cols else ["rid"])
100 − # validation interne : derniers 10 % chronologiques pour fixer le nombre d'arbres
101 − n = len(G); cut = int(n * 0.9)
102 − dtr = lgb.Dataset(G[cols].iloc[:cut], y[:cut], categorical_feature=["rid", "btype"])
103 − dva = lgb.Dataset(G[cols].iloc[cut:], y[cut:], reference=dtr)
104 − bst = lgb.train(params, dtr, num_boost_round=3000, valid_sets=[dva], callbacks=[lgb.early_stopping(100, verbose=False)])
105 − best = max(bst.best_iteration, 300) # plancher : l'arrêt précoce sur la tranche finale (mois partiels) peut être trop court
106 − bst = lgb.train(params, ds, num_boost_round=best)
107 − models[name] = bst
108 − Gall = gbm_frame(df); Gall["month"] = float(m_asof - 1) # f évalué au niveau du dernier mois connu pour toutes les ventes
109 − pred_all = bst.predict(Gall[cols])
110 − np.save(OUT / f"{name}_pred_{period}.npy", pred_all.astype(np.float32))
111 − rr = y - bst.predict(G[cols]); r2 = 1 - np.var(rr) / np.var(y)
112 − log(f" {name}: {best} arbres, R² (in-sample) = {r2:.3f}, résidu médian = {np.median(rr) * 100:.2f} %")
113 − bst.save_model(str(OUT / f"{name}_{period}.txt"))
114 − save_json({"period": period, "m_asof": m_asof, "start": start, "n_train": len(tr), "feats": FEATS,
115 − "beta_global": beta_global, "intercept_global": 0.0, "region_fe": reg_eff,
116 − "beta_region": beta_region, "mrc_eff": mrc_eff, "mun_eff": mun_eff, "uv_eff": uv_eff,
117 − "r2_within": float(r2w), "sd_resid_u": float(tr["res_u"].std()), "median_shift": median_shift},
118 − f"models_{period}.json")
119 −log("modèles terminés")
deleted M4M64aomparables-validation/pipeline/04_backtest.py +0 −193
@@ -1,193 +0,0 @@
1 −# -*- coding: utf-8 -*-
2 −"""04 — Moteur de rétro-évaluation.
3 −Pour chaque vente cible (2024 = validation, 2025-2026 = test), le prix est masqué ; seules les ventes
4 −conclues STRICTEMENT AVANT la date cible (et l'indice/les modèles connus à cette date) sont utilisées.
5 −On évalue d'un coup toutes les configurations (sélection × rayon × fenêtre × nombre × ajustement ×
6 −pondération × réconciliation) sur exactement les mêmes cibles.
7 −Sorties : targets.parquet, configs_E1.csv/est_E1.npy (surface k,d,T), configs_E2.csv/est_E2.npy/sd_E2.npy
8 −(factoriel sélection × ajustement × pondération × réconciliation), n_avail.npy."""
9 −import os
10 −for _v in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS", "VECLIB_MAXIMUM_THREADS"):
11 − os.environ[_v] = "1" # un seul fil BLAS par processus (fork + BLAS multi-fils = interblocage sur macOS)
12 −import multiprocessing as mp, time
13 −from scipy.spatial import cKDTree
14 −from common import *
15 −
16 −df = load_parquet("sales.parquet")
17 −meta = load_json("index_meta.json"); regions = meta["regions"]; M_EVAL0 = meta["M_EVAL0"]
18 −rid_map = {r: i for i, r in enumerate(regions)}
19 −IDX = np.load(OUT / "index_asof.npy")
20 −ASOF = load_json("asof.json")["asof"]
21 −MODELS = {a: load_json(f"models_{a}.json") for a in ASOF}
22 −FEATS = MODELS[ASOF[0]]["feats"]
23 −GBM = {a: np.load(OUT / f"gbm_pred_{a}.npy") for a in ASOF}
24 −
25 −# ----------------------------------------------------------------- tableaux globaux (partagés par fork)
26 −N = len(df)
27 −DAY = df["day"].to_numpy(np.int32); MONTH = df["month"].to_numpy(np.int32)
28 −RID = df["region"].map(rid_map).to_numpy(np.int32)
29 −X = df["x_km"].to_numpy(np.float64); Y = df["y_km"].to_numpy(np.float64)
30 −LNP = df["ln_price"].to_numpy(np.float64); P = df["amount"].to_numpy(np.float64)
31 −FLOOR = df["floor_area"].to_numpy(np.float64); LAND = df["land_area"].to_numpy(np.float64)
32 −AGE = df["age"].to_numpy(np.float64); LIEN = df["lien"].to_numpy(np.int8); ETAGES = df["etages"].to_numpy(np.float64)
33 −GENRE = df["genre"].to_numpy(np.int8)
34 −PROV = pd.factorize(df["id_provinc"])[0].astype(np.int32)
35 −MUN = pd.factorize(df["code_mun"])[0].astype(np.int32)
36 −VINT = df["role_vintage"].to_numpy(np.int8); LNROLE = np.log(df["role_vente"].to_numpy(np.float64))
37 −PART = df["part_terrain"].to_numpy(np.float64)
38 −NEUF = df["neuf"].to_numpy(np.float64)
39 −# matrice des caractéristiques hédoniques (même ordre que FEATS)
40 −XF = np.column_stack([np.log(FLOOR), np.log(LAND), AGE, AGE ** 2 / 100, (LIEN == 1), (LIEN >= 2),
41 − (GENRE == 1), (GENRE == 2), (GENRE == 3), ETAGES, NEUF]).astype(np.float64)
42 −BETA_G = {a: np.array([MODELS[a]["beta_global"][f] for f in FEATS]) for a in ASOF}
43 −BETA_R = {a: np.array([[MODELS[a]["beta_region"][r][f] for f in FEATS] for r in regions]) for a in ASOF}
44 −ASOF_ARR = np.array(ASOF)
45 −TREE = cKDTree(np.column_stack([X, Y]))
46 −
47 −D_MAX, T_MAX_DAYS = 25.0, 731
48 −KS = [1, 3, 5, 7, 10, 15, 20, 30, 50]
49 −DS = [0.5, 1, 2, 5, 10, 25]
50 −TS = [3, 6, 12, 24]
51 −SELS = ["geo", "sim", "mix", "adj"]
52 −ADJS = ["raw", "none", "conv", "hedo", "hedo_reg", "local", "gbm", "role"]
53 −WRS = [("equal", "median"), ("equal", "mean"), ("dist", "median"), ("dist", "mean"), ("rec", "median"), ("rec", "mean"),
54 − ("sim", "median"), ("sim", "mean"), ("all", "median"), ("all", "mean")]
55 −ANCHORS = [(3, 2, 6), (5, 5, 12), (10, 10, 24)]
56 −E1 = [dict(sel=s, d=d, T=T, k=k, adj=a, w="equal", recon="median")
57 − for s in ("geo", "sim", "mix") for d in DS for T in TS for k in KS for a in ("none", "hedo")]
58 −E2 = [dict(sel=s, d=d, T=T, k=k, adj=a, w=w, recon=r)
59 − for (k, d, T) in ANCHORS for s in SELS for a in ADJS for (w, r) in WRS]
60 −LOCAL_COLS = [0, 1, 2, 4, 5, 9] # ln_floor, ln_land, age, lien1, lien2, etages
61 −LAMBDA_LOCAL = 50.0
62 −
63 −def wmedian(v, w):
64 − o = np.argsort(v); v = v[o]; w = w[o]
65 − c = np.cumsum(w); return v[np.searchsorted(c, 0.5 * c[-1])]
66 −
67 −def recon(vals, w, how):
68 − ok = ~np.isnan(vals)
69 − if not ok.any(): return np.nan, np.nan
70 − v = vals[ok]; ww = w[ok]
71 − sd = v.std() if len(v) > 1 else np.nan
72 − if how == "median":
73 − return (np.median(v) if ww.min() == ww.max() else wmedian(v, ww)), sd
74 − return np.log(np.sum(ww * np.exp(v)) / ww.sum()), sd
75 −
76 −def evaluate(i):
77 − """Toutes les configurations pour la cible i. Renvoie (est_E1, est_E2, sd_E2, n_avail, info)."""
78 − period = int(ASOF_ARR[ASOF_ARR <= MONTH[i]].max()) # dernier trimestre de ré-estimation ≤ mois de la cible
79 − e1 = np.full(len(E1), np.nan, np.float32); e2 = np.full(len(E2), np.nan, np.float32)
80 − s2 = np.full(len(E2), np.nan, np.float32); nav = np.zeros(len(DS) * len(TS), np.int32)
81 − cand = np.asarray(TREE.query_ball_point((X[i], Y[i]), D_MAX), dtype=np.int64)
82 − keep = (DAY[cand] < DAY[i]) & (DAY[cand] >= DAY[i] - T_MAX_DAYS) & (PROV[cand] != PROV[i])
83 − cand = cand[keep]
84 − info = dict(idx=int(i), asof=period, n_cand=int(len(cand)))
85 − if len(cand) == 0: return e1, e2, s2, nav, info
86 − dist = np.hypot(X[cand] - X[i], Y[cand] - Y[i])
87 − mago = (DAY[i] - DAY[cand]) / 30.4375
88 − m_eval = MONTH[i]; lvl = IDX[m_eval - M_EVAL0]
89 − tadj = np.where(MONTH[cand] == m_eval, 0.0, lvl[RID[cand], m_eval - 1] - lvl[RID[cand], MONTH[cand]])
90 − tadj = np.nan_to_num(tadj, nan=0.0)
91 − lnT = LNP[cand] + tadj
92 − dX = XF[i] - XF[cand] # cible − comparable
93 − # --- ajustements
94 − bg = BETA_G[period]; br = BETA_R[period][RID[i]]
95 − adj = {}
96 − adj["raw"] = LNP[cand]
97 − adj["none"] = lnT
98 − Pt = np.exp(lnT)
99 − d_floor = (FLOOR[i] - FLOOR[cand]) * 0.5 * Pt / FLOOR[cand]
100 − d_land = np.clip((LAND[i] - LAND[cand]) * 0.5 * Pt * PART[cand] / LAND[cand], -0.15 * Pt, 0.15 * Pt)
101 − d_age = np.clip(-0.005 * Pt * (AGE[i] - AGE[cand]), -0.2 * Pt, 0.2 * Pt)
102 − d_lien = np.where((LIEN[i] == 0) & (LIEN[cand] > 0), 0.05 * Pt, np.where((LIEN[i] > 0) & (LIEN[cand] == 0), -0.05 * Pt, 0.0))
103 − tot = np.maximum(d_floor + d_land + d_age + d_lien, -0.6 * Pt)
104 − adj["conv"] = np.log(Pt + tot)
105 − gross_conv = (np.abs(d_floor) + np.abs(d_land) + np.abs(d_age) + np.abs(d_lien)) / Pt
106 − adj["hedo"] = lnT + dX @ bg
107 − gross_hedo = np.abs(dX * bg).sum(axis=1)
108 − adj["hedo_reg"] = lnT + dX @ br
109 − loc = dist <= 10.0
110 − if loc.sum() >= 40:
111 − Xl = XF[cand][loc][:, LOCAL_COLS]; yl = lnT[loc]
112 − b0 = br[LOCAL_COLS]
113 − Xc = Xl - Xl.mean(axis=0); yc = yl - yl.mean()
114 − A = Xc.T @ Xc + LAMBDA_LOCAL * np.eye(len(LOCAL_COLS))
115 − bl = np.linalg.solve(A, Xc.T @ yc + LAMBDA_LOCAL * b0)
116 − adj["local"] = lnT + dX[:, LOCAL_COLS] @ bl
117 − else:
118 − adj["local"] = adj["hedo_reg"]
119 − f = GBM[period]
120 − adj["gbm"] = lnT + (f[i] - f[cand])
121 − same = (MUN[cand] == MUN[i]) & (VINT[cand] == VINT[i])
122 − adj["role"] = np.where(same, LNROLE[i] + (lnT - LNROLE[cand]), np.nan)
123 − # --- scores de sélection
124 − dissim = np.sqrt((dX[:, 0] / 0.25) ** 2 + (dX[:, 1] / 0.6) ** 2 + (dX[:, 2] / 15) ** 2
125 − + (LIEN[cand] != LIEN[i]) * 1.0 + (ETAGES[cand] != ETAGES[i]) * 0.5)
126 − score = {"geo": dist, "sim": dissim + dist / 25.0, "mix": dissim + dist / 2.0, "adj": gross_hedo + dist / 25.0}
127 − # --- pondérations
128 − wts = {"equal": np.ones(len(cand)), "dist": 1.0 / (0.25 + dist), "rec": np.exp(-mago / 6.0), "sim": 1.0 / (0.1 + gross_hedo)}
129 − wts["all"] = wts["dist"] * wts["rec"] * wts["sim"]
130 − # --- masques (d, T) et classements
131 − top = {}
132 − for di, d in enumerate(DS):
133 − for ti, T in enumerate(TS):
134 − m = (dist <= d) & (mago <= T)
135 − nav[di * len(TS) + ti] = int(m.sum())
136 − if not m.any(): continue
137 − ids = np.flatnonzero(m)
138 − for s in SELS:
139 − sc = score[s][ids]
140 − if len(ids) > 50:
141 − part = np.argpartition(sc, 50)[:50]; part = part[np.argsort(sc[part])]
142 − else:
143 − part = np.argsort(sc)
144 − top[(s, d, T)] = ids[part]
145 − # --- E1
146 − for c, cfg in enumerate(E1):
147 − t = top.get((cfg["sel"], cfg["d"], cfg["T"]))
148 − if t is None: continue
149 − sub = t[:cfg["k"]]
150 − e1[c], _ = recon(adj[cfg["adj"]][sub], wts["equal"][sub], "median")
151 − # --- E2
152 − for c, cfg in enumerate(E2):
153 − t = top.get((cfg["sel"], cfg["d"], cfg["T"]))
154 − if t is None: continue
155 − sub = t[:cfg["k"]]
156 − e2[c], s2[c] = recon(adj[cfg["adj"]][sub], wts[cfg["w"]][sub], cfg["recon"])
157 − return e1, e2, s2, nav, info
158 −
159 −def worker(chunk):
160 − return [evaluate(i) for i in chunk]
161 −
162 −if __name__ == "__main__":
163 − targets = df.index[df["period"].isin(["val", "test"])].to_numpy()
164 − if os.environ.get("CV_SAMPLE"):
165 − rng = np.random.default_rng(1); targets = np.sort(rng.choice(targets, int(os.environ["CV_SAMPLE"]), replace=False))
166 − SUF = ""
167 − if os.environ.get("CV_SHARD"): # répartition sur plusieurs nœuds : CV_SHARD=i/N → cibles i, i+N, i+2N, …
168 − i_sh, n_sh = map(int, os.environ["CV_SHARD"].split("/")); targets = targets[i_sh::n_sh]; SUF = f".shard{i_sh}of{n_sh}"
169 − log(f"{len(targets):,} cibles ; {len(E1)} configurations E1, {len(E2)} E2 ; {N:,} ventes dans le bassin")
170 − pd.DataFrame(E1).to_csv(OUT / "configs_E1.csv", index_label="cfg")
171 − pd.DataFrame(E2).to_csv(OUT / "configs_E2.csv", index_label="cfg")
172 − est1 = np.full((len(targets), len(E1)), np.nan, np.float32)
173 − est2 = np.full((len(targets), len(E2)), np.nan, np.float32)
174 − sd2 = np.full((len(targets), len(E2)), np.nan, np.float32)
175 − nav = np.zeros((len(targets), len(DS) * len(TS)), np.int32)
176 − infos = []
177 − chunks = [targets[j:j + 400] for j in range(0, len(targets), 400)]
178 − t0 = time.time(); done = 0
179 − with mp.get_context(os.environ.get("CV_MP", "fork")).Pool(int(os.environ.get("CV_PROCS", 20))) as pool:
180 − for res in pool.imap(worker, chunks):
181 − for (e1, e2, s2, nv, info) in res:
182 − est1[done] = e1; est2[done] = e2; sd2[done] = s2; nav[done] = nv; infos.append(info); done += 1
183 − if done % 20000 < 400:
184 − log(f" {done:,}/{len(targets):,} cibles ({time.time() - t0:.0f} s)")
185 − np.save(OUT / f"est_E1{SUF}.npy", est1); np.save(OUT / f"est_E2{SUF}.npy", est2); np.save(OUT / f"sd_E2{SUF}.npy", sd2); np.save(OUT / f"n_avail{SUF}.npy", nav)
186 − T = df.loc[targets, ["idx", "id", "date", "period", "amount", "ln_price", "region", "region_nom", "mrc", "mrc_nom", "code_mun",
187 − "mun_nom", "unite_voisinage", "floor_area", "land_area", "age", "lien", "etages", "role_vente", "month", "lat", "lng"]].reset_index(drop=True)
188 − T["n_cand"] = [x["n_cand"] for x in infos]; T["asof"] = [x["asof"] for x in infos]
189 − T["n_2km_12m"] = nav[:, DS.index(2) * len(TS) + TS.index(12)]
190 − T["n_5km_12m"] = nav[:, DS.index(5) * len(TS) + TS.index(12)]
191 − save_parquet(T, f"targets{SUF}.parquet")
192 − pd.DataFrame({"DS": [DS], "TS": [TS], "KS": [KS]}).to_json(OUT / "grid_meta.json")
193 − log(f"rétro-évaluation terminée en {time.time() - t0:.0f} s")
deleted M4M64aomparables-validation/pipeline/04b_gather.py +0 −15
@@ -1,15 +0,0 @@
1 −# -*- coding: utf-8 -*-
2 −"""04b — Rassemble les fragments (shards) de la rétro-évaluation calculés sur plusieurs nœuds, dans l'ordre des cibles."""
3 −import glob
4 −from common import *
5 −sh = sorted(glob.glob(str(OUT / "targets.shard*.parquet")))
6 −log(f"{len(sh)} fragments")
7 −Ts = [pd.read_parquet(p) for p in sh]
8 −parts = {}
9 −for name in ["est_E1", "est_E2", "sd_E2", "n_avail"]:
10 − parts[name] = [np.load(p.replace("targets.", f"{name}.").replace(".parquet", ".npy")) for p in sh]
11 −T = pd.concat(Ts, ignore_index=True); order = np.argsort(T["idx"].to_numpy(), kind="stable")
12 −T = T.iloc[order].reset_index(drop=True); save_parquet(T, "targets.parquet")
13 −for name, arrs in parts.items():
14 − A = np.concatenate(arrs, axis=0)[order]; np.save(OUT / f"{name}.npy", A); log(f" {name}: {A.shape}")
15 −log("rassemblement terminé")
deleted M4M64aomparables-validation/pipeline/05_analyze.py +0 −358
@@ -1,358 +0,0 @@
1 −# -*- coding: utf-8 -*-
2 −"""05 — Analyse : métriques par configuration (validation 2024 → choix ; test 2025-26 → verdict),
3 −surface Erreur(k, d, t), effets des facteurs (sélection, ajustement, pondération, réconciliation),
4 −compétition de modèles M1-M13, hypothèses H1-H5, strates (région, densité, prix), intervalles,
5 −règles conditionnelles. Sorties : tables/*.csv, results.json, fig/*.pdf"""
6 −from scipy.optimize import nnls
7 −import matplotlib; matplotlib.use("Agg")
8 −import matplotlib.pyplot as plt
9 −from matplotlib import rcParams
10 −from common import *
11 −
12 −# ---------------------------------------------------------------- style (palette de référence, marques fines)
13 −PAL = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#008300", "#6250d6", "#e34948"]
14 −SEQ = ["#cde2fb", "#9ec5f4", "#6da7ec", "#3987e5", "#256abf", "#184f95", "#0d366b"]
15 −rcParams.update({"font.family": "Helvetica Neue", "font.size": 9, "axes.edgecolor": "#c3c2b7", "axes.linewidth": 0.6,
16 − "axes.grid": True, "grid.color": "#e8e7e3", "grid.linewidth": 0.5, "axes.spines.top": False,
17 − "axes.spines.right": False, "legend.frameon": False, "figure.dpi": 150, "savefig.bbox": "tight",
18 − "axes.titleweight": "bold", "axes.titlesize": 10, "axes.labelcolor": "#52514e", "xtick.color": "#52514e",
19 − "ytick.color": "#52514e", "figure.facecolor": "#fcfcfb", "axes.facecolor": "#fcfcfb"})
20 −from matplotlib.colors import LinearSegmentedColormap
21 −CMAP = LinearSegmentedColormap.from_list("seqblue", SEQ)
22 −TAB = OUT / "tables"; TAB.mkdir(exist_ok=True); FIG = OUT / "fig"
23 −def savefig(fig, name): fig.savefig(FIG / f"{name}.pdf"); fig.savefig(FIG / f"{name}.png"); plt.close(fig)
24 −
25 −# ---------------------------------------------------------------- chargement
26 −T = load_parquet("targets.parquet"); S = load_parquet("sales.parquet").set_index("idx")
27 −E1 = pd.read_csv(OUT / "configs_E1.csv"); E2 = pd.read_csv(OUT / "configs_E2.csv")
28 −est1 = np.load(OUT / "est_E1.npy"); est2 = np.load(OUT / "est_E2.npy"); sd2 = np.load(OUT / "sd_E2.npy"); nav = np.load(OUT / "n_avail.npy")
29 −meta = load_json("index_meta.json"); regions = meta["regions"]; M_EVAL0 = meta["M_EVAL0"]; IDX = np.load(OUT / "index_asof.npy")
30 −rid = {r: i for i, r in enumerate(regions)}
31 −lnP = T["ln_price"].to_numpy(np.float32)
32 −VAL = (T["period"] == "val").to_numpy(); TEST = (T["period"] == "test").to_numpy()
33 −log(f"{len(T):,} cibles (val {VAL.sum():,}, test {TEST.sum():,}) ; E1 {est1.shape[1]} ; E2 {est2.shape[1]}")
34 −
35 −def err_of(est): return (np.exp(est - lnP[:, None]) - 1).astype(np.float32)
36 −def metrics(err, sel):
37 − e = err[sel]; a = np.abs(e)
38 − ok = ~np.isnan(a); n = ok.sum(axis=0)
39 − with np.errstate(all="ignore"):
40 − return pd.DataFrame({"mdape": np.nanmedian(a, axis=0) * 100, "mape": np.nanmean(a, axis=0) * 100,
41 − "biais": np.nanmedian(e, axis=0) * 100, "biais_moy": np.nanmean(e, axis=0) * 100,
42 − "p5": np.nanmean(a <= 0.05, axis=0) * 100, "p10": np.nanmean(a <= 0.10, axis=0) * 100,
43 − "p20": np.nanmean(a <= 0.20, axis=0) * 100, "couverture": n / sel.sum() * 100, "n": n})
44 −err1 = err_of(est1); err2 = err_of(est2)
45 −del est1
46 −res = {}
47 −
48 −# ---------------------------------------------------------------- densité & strates
49 −T["dens"] = pd.cut(T["n_2km_12m"], [-1, 4, 19, 49, 199, 10 ** 9], labels=["< 5", "5-19", "20-49", "50-199", "≥ 200"])
50 −T["prix_q"] = pd.qcut(T["amount"], 5, labels=["Q1 (bas)", "Q2", "Q3", "Q4", "Q5 (haut)"])
51 −T["trimestre"] = pd.to_datetime(T["date"]).dt.to_period("Q").astype(str)
52 −T["age_cl"] = pd.cut(T["age"], [-1, 3, 15, 40, 70, 1000], labels=["≤ 3 ans", "4-15", "16-40", "41-70", "> 70"])
53 −T["cluster"] = pd.factorize(T["code_mun"].astype(str) + "|" + T["trimestre"])[0]
54 −
55 −# ---------------------------------------------------------------- E1 : surface Erreur(k, d, t)
56 −mv = metrics(err1, VAL).add_suffix("_val"); mt = metrics(err1, TEST).add_suffix("_test")
57 −surf = pd.concat([E1, mv, mt], axis=1)
58 −surf.to_csv(TAB / "surface_E1.csv", index=False)
59 −ok = surf["couverture_val"] >= 97
60 −best_e1 = surf[ok].sort_values("mdape_val").iloc[0]
61 −res["E1_best_val"] = best_e1.to_dict()
62 −log(f"E1 meilleure (val, couverture ≥ 97 %) : {best_e1[['sel','d','T','k','adj']].to_dict()} → val {best_e1.mdape_val:.2f} %, test {best_e1.mdape_test:.2f} %")
63 −for (s, a), g in surf[ok].groupby(["sel", "adj"]):
64 − b = g.sort_values("mdape_val").iloc[0]
65 − res.setdefault("E1_best_by_sel_adj", {})[f"{s}|{a}"] = dict(k=int(b.k), d=float(b.d), T=int(b["T"]), mdape_val=float(b.mdape_val), mdape_test=float(b.mdape_test), couverture_test=float(b.couverture_test))
66 −
67 −# figure : MdAPE vs k selon d (T = 12, geo, hedo, test)
68 −fig, axes = plt.subplots(1, 2, figsize=(9, 3.4), sharey=True)
69 −for ax, a in zip(axes, ["none", "hedo"]):
70 − for j, d in enumerate([0.5, 1, 2, 5, 10, 25]):
71 − g = surf[(surf.sel == "geo") & (surf["T"] == 12) & (surf.adj == a) & (surf.d == d)].sort_values("k")
72 − ax.plot(g.k, g.mdape_test, color=PAL[j], lw=2, marker="o", ms=4, label=f"d ≤ {d} km")
73 − ax.set_xscale("log"); ax.set_xticks([1, 3, 5, 10, 20, 50]); ax.set_xticklabels([1, 3, 5, 10, 20, 50])
74 − ax.set_xlabel("nombre de comparables k"); ax.set_title({"none": "sans ajustement (temps seulement)", "hedo": "grille hédonique"}[a])
75 −axes[0].set_ylabel("MdAPE, test 2025-26 (%)"); axes[1].legend(ncol=2, fontsize=8)
76 −savefig(fig, "f1_mdape_vs_k")
77 −# figure : carte d × T au meilleur k (geo, hedo)
78 −fig, axes = plt.subplots(1, 2, figsize=(8, 3.2))
79 −for ax, a in zip(axes, ["none", "hedo"]):
80 − g = surf[(surf.sel == "geo") & (surf.adj == a)].groupby(["d", "T"])["mdape_test"].min().unstack("T")
81 − im = ax.imshow(g.to_numpy(), cmap=CMAP, aspect="auto")
82 − ax.set_xticks(range(len(g.columns))); ax.set_xticklabels([f"{t} mois" for t in g.columns]); ax.set_yticks(range(len(g.index))); ax.set_yticklabels([f"{d} km" for d in g.index])
83 − for i in range(g.shape[0]):
84 − for j in range(g.shape[1]):
85 − v = g.iloc[i, j]; ax.text(j, i, f"{v:.1f}", ha="center", va="center", fontsize=7.5, color="white" if v < np.nanpercentile(g.to_numpy(), 40) else "#0b0b0b")
86 − ax.grid(False); ax.set_title({"none": "sans ajustement", "hedo": "grille hédonique"}[a] + " — MdAPE au meilleur k")
87 −savefig(fig, "f2_surface_d_T")
88 −
89 −# ---------------------------------------------------------------- E2 : facteurs
90 −mv2 = metrics(err2, VAL).add_suffix("_val"); mt2 = metrics(err2, TEST).add_suffix("_test")
91 −fac = pd.concat([E2, mv2, mt2], axis=1); fac.to_csv(TAB / "facteurs_E2.csv", index=False)
92 −anchor = (fac.k == 5) & (fac.d == 5) & (fac["T"] == 12)
93 −base = anchor & (fac.sel == "geo") & (fac.w == "equal") & (fac.recon == "median")
94 −res["E2_adjust_anchor"] = fac[base].set_index("adj")[["mdape_val", "mdape_test", "mape_test", "biais_test", "p10_test", "couverture_test"]].to_dict("index")
95 −res["E2_sel_anchor"] = fac[anchor & (fac.adj == "hedo") & (fac.w == "equal") & (fac.recon == "median")].set_index("sel")[["mdape_val", "mdape_test", "p10_test"]].to_dict("index")
96 −res["E2_wr_anchor"] = {f"{r.w}|{r.recon}": dict(mdape_val=r.mdape_val, mdape_test=r.mdape_test, p10_test=r.p10_test)
97 − for r in fac[anchor & (fac.adj == "hedo") & (fac.sel == "geo")].itertuples()}
98 −# effets marginaux moyens (moyenne de MdAPE test sur toutes les autres modalités)
99 −marg = {}
100 −for f in ["sel", "adj", "w", "recon"]:
101 − marg[f] = fac.groupby(f)["mdape_test"].mean().round(2).to_dict()
102 −res["E2_marginal"] = marg
103 −anc_tab = fac.groupby(["k", "d", "T"])["mdape_test"].min().round(2).to_dict()
104 −res["E2_anchor_best"] = {f"{k}|{d}|{t}": v for (k, d, t), v in anc_tab.items()}
105 −# figure : ajustements à l'ancre (val et test)
106 −fig, ax = plt.subplots(figsize=(7, 3.2))
107 −g = fac[base].set_index("adj").loc[["raw", "none", "conv", "hedo", "hedo_reg", "local", "gbm", "role"]]
108 −lab = {"raw": "aucun", "none": "temps seul", "conv": "grille conv.", "hedo": "hédonique", "hedo_reg": "hédo. régionale", "local": "locale", "gbm": "GBM", "role": "ratio au rôle"}
109 −x = np.arange(len(g)); ax.bar(x - 0.2, g.mdape_val, 0.38, color=PAL[0], label="validation 2024"); ax.bar(x + 0.2, g.mdape_test, 0.38, color=PAL[1], label="test 2025-26")
110 −for i, (v1, v2) in enumerate(zip(g.mdape_val, g.mdape_test)):
111 − ax.text(i - 0.2, v1 + 0.2, f"{v1:.1f}", ha="center", fontsize=7); ax.text(i + 0.2, v2 + 0.2, f"{v2:.1f}", ha="center", fontsize=7)
112 −ax.set_xticks(x); ax.set_xticklabels([lab[a] for a in g.index]); ax.set_ylabel("MdAPE (%)"); ax.legend(); ax.set_title("Type d'ajustement — 5 comparables les plus proches, ≤ 5 km, ≤ 12 mois, médiane")
113 −savefig(fig, "f3_ajustements")
114 −
115 −# ---------------------------------------------------------------- modèles de référence (M10, M11) et compétition
116 −def level_shift(m_asof):
117 − mt_ = T["month"].to_numpy(); r = T["region"].map(rid).to_numpy()
118 − return IDX[mt_ - M_EVAL0, r, mt_ - 1] - IDX[mt_ - M_EVAL0, r, m_asof - 1]
119 −Sx = S.loc[T["idx"]]
120 −XF = np.column_stack([Sx.ln_floor, Sx.ln_land, Sx.age, Sx.age ** 2 / 100, Sx.lien == 1, Sx.lien >= 2, Sx.genre == 1, Sx.genre == 2, Sx.genre == 3, Sx.etages, Sx.neuf]).astype(float)
121 −M10 = np.full(len(T), np.nan); M11 = np.full(len(T), np.nan); M11b = np.full(len(T), np.nan)
122 −for a in load_json("asof.json")["asof"]:
123 − mask = (T["asof"] == a).to_numpy(); period = str(a)
124 − if not mask.any(): continue
125 − mod = load_json(f"models_{period}.json"); beta = np.array([mod["beta_global"][f] for f in mod["feats"]])
126 − shift = level_shift(mod["m_asof"])
127 − fe = (T["region"].map(mod["region_fe"]).fillna(0) + T["mrc"].map(mod["mrc_eff"]).fillna(0) + T["code_mun"].map(mod["mun_eff"]).fillna(0)
128 − + (T["code_mun"].astype(str) + "|" + T["unite_voisinage"].astype(str)).map(mod["uv_eff"]).fillna(0)).to_numpy()
129 − M10[mask] = (XF @ beta + fe + shift + mod["median_shift"])[mask]
130 − M11[mask] = (np.load(OUT / f"gbm_role_pred_{period}.npy")[T["idx"].to_numpy()] + shift)[mask]
131 − M11b[mask] = (np.load(OUT / f"gbm_pred_{period}.npy")[T["idx"].to_numpy()] + shift)[mask]
132 −
133 −def e2col(sel, k, d, Tm, adj, w, recon):
134 − m = (E2.sel == sel) & (E2.k == k) & (E2.d == d) & (E2["T"] == Tm) & (E2.adj == adj) & (E2.w == w) & (E2.recon == recon)
135 − return int(E2.index[m][0])
136 −def e1col(sel, k, d, Tm, adj):
137 − m = (E1.sel == sel) & (E1.k == k) & (E1.d == d) & (E1["T"] == Tm) & (E1.adj == adj)
138 − return int(E1.index[m][0])
139 −MODELS = {
140 − "M1 médiane des 3 plus proches (temps seul)": ("E2", e2col("geo", 3, 2, 6, "none", "equal", "median")),
141 − "M2 moyenne des 5 plus proches (temps seul)": ("E2", e2col("geo", 5, 5, 12, "none", "equal", "mean")),
142 − "M3 sélection par similarité physique": ("E2", e2col("sim", 5, 5, 12, "none", "equal", "median")),
143 − "M4 pondération par distance": ("E2", e2col("geo", 5, 5, 12, "none", "dist", "mean")),
144 − "M5 pondération par récence": ("E2", e2col("geo", 5, 5, 12, "none", "rec", "mean")),
145 − "M6 grille d'ajustement conventionnelle": ("E2", e2col("geo", 5, 5, 12, "conv", "equal", "median")),
146 − "M7 grille d'ajustement hédonique": ("E2", e2col("geo", 5, 5, 12, "hedo", "equal", "median")),
147 − "M8a grille locale (régression sur le voisinage)": ("E2", e2col("geo", 5, 5, 12, "local", "equal", "median")),
148 − "M8b ajustements non linéaires (GBM)": ("E2", e2col("geo", 5, 5, 12, "gbm", "equal", "median")),
149 − "M13 ratio au rôle (5 plus proches, même rôle)": ("E2", e2col("geo", 5, 5, 12, "role", "equal", "median")),
150 − "M10 hédonique globale directe": ("X", M10),
151 − "M11a gradient boosting (sans rôle)": ("X", M11b),
152 − "M11b gradient boosting (avec rôle)": ("X", M11),
153 −}
154 −def est_of(spec):
155 − kind, c = spec
156 − return est2[:, c].astype(float) if kind == "E2" else c
157 −# M0 : meilleure configuration empirique choisie sur la validation (E1 ∪ E2, couverture ≥ 90 %)
158 −cand_all = pd.concat([surf.assign(src="E1"), fac.assign(src="E2")], ignore_index=True)
159 −cand_ok = cand_all[cand_all.couverture_val >= 97].sort_values("mdape_val")
160 −b0 = cand_ok.iloc[0]
161 −M0_est = (est2[:, int(b0.cfg)] if b0.src == "E2" else np.load(OUT / "est_E1.npy", mmap_mode="r")[:, int(b0.cfg)]).astype(float)
162 −res["M0"] = b0[["src", "sel", "d", "T", "k", "adj", "w", "recon", "mdape_val", "mdape_test", "couverture_test"]].to_dict()
163 −res["M0_sans_contrainte"] = cand_all[cand_all.couverture_val >= 50].sort_values("mdape_val").iloc[0][["src", "sel", "d", "T", "k", "adj", "w", "recon", "mdape_val", "mdape_test", "couverture_val", "couverture_test"]].to_dict()
164 −MODELS["M0 meilleure configuration empirique (choisie en 2024, couverture ≥ 97 %)"] = ("X", M0_est)
165 −# M9 : réconciliation optimisée = combinaison convexe (NNLS sur la validation) d'estimations de comparables
166 −stack_cols = {a: e2col("geo", 5, 5, 12, a, "equal", "median") for a in ["none", "conv", "hedo", "hedo_reg", "local", "gbm", "role"]}
167 −def stack(names, mats, fit_mask):
168 − """Combinaison convexe apprise par NNLS sur la validation ; si une composante manque pour une cible,
169 − les poids des composantes disponibles sont renormalisés."""
170 − A = np.column_stack(mats); okr = ~np.isnan(A).any(axis=1)
171 − w, _ = nnls(A[okr & fit_mask], lnP[okr & fit_mask].astype(float)); w = w / w.sum()
172 − W = np.where(np.isnan(A), 0.0, w[None, :]); den = W.sum(axis=1)
173 − out = np.nansum(A * W, axis=1) / np.where(den > 0, den, np.nan)
174 − return out, dict(zip(names, w.round(3)))
175 −M9_est, w9 = stack(list(stack_cols), [est2[:, c].astype(float) for c in stack_cols.values()], VAL)
176 −res["M9_weights"] = w9; MODELS["M9 réconciliation optimisée (poids appris en 2024)"] = ("X", M9_est)
177 −M12_est, w12 = stack(["gbm_role", "comps_gbm", "comps_hedo"], [M11, est2[:, stack_cols["gbm"]].astype(float), est2[:, stack_cols["hedo"]].astype(float)], VAL)
178 −res["M12_weights"] = w12; MODELS["M12 hybride comparables + GBM (poids appris en 2024)"] = ("X", M12_est)
179 −
180 −rows = []
181 −ERR = {}
182 −for name, spec in MODELS.items():
183 − e = (np.exp(est_of(spec) - lnP) - 1).astype(np.float32); ERR[name] = e
184 − for per, mask in [("val", VAL), ("test", TEST)]:
185 − m = metrics(e[:, None], mask).iloc[0].to_dict(); m.update(modele=name, periode=per); rows.append(m)
186 −comp = pd.DataFrame(rows); comp.to_csv(TAB / "competition_modeles.csv", index=False)
187 −pd.DataFrame({n.split(" ")[0]: est_of(s_) for n, s_ in MODELS.items()}).assign(idx=T["idx"].to_numpy()).to_parquet(OUT / "models_est.parquet", index=False)
188 −res["competition"] = comp[comp.periode == "test"].set_index("modele")[["mdape", "mape", "biais", "p5", "p10", "p20", "couverture"]].round(2).to_dict("index")
189 −res["competition_val"] = comp[comp.periode == "val"].set_index("modele")[["mdape", "p10", "couverture"]].round(2).to_dict("index")
190 −log("compétition (test) :\n" + comp[comp.periode == "test"][["modele", "mdape", "mape", "biais", "p10", "couverture"]].round(2).to_string(index=False))
191 −fig, ax = plt.subplots(figsize=(7.5, 5))
192 −g = comp[comp.periode == "test"].sort_values("mdape", ascending=False)
193 −cols = [PAL[0] if ("M1" in n and "M1 " not in n) or n.startswith(("M10", "M11", "M12")) else PAL[2] if n.startswith(("M0", "M9")) else PAL[1] for n in g.modele]
194 −cols = [PAL[0] if n.startswith(("M10", "M11", "M12")) else (PAL[2] if n.startswith(("M0", "M9")) else PAL[1]) for n in g.modele]
195 −ax.barh(g.modele, g.mdape, color=cols, height=0.65)
196 −for i, v in enumerate(g.mdape): ax.text(v + 0.15, i, f"{v:.1f} %", va="center", fontsize=8)
197 −ax.set_xlabel("MdAPE, test 2025-26 (%)"); ax.set_title("Compétition de modèles (orange : comparables ; vert : optimisés ; bleu : références externes)")
198 −savefig(fig, "f4_competition")
199 −
200 −# ---------------------------------------------------------------- bootstrap par grappes (municipalité × trimestre) des différences appariées
201 −rng = np.random.default_rng(7)
202 −def paired(eA, eB, mask, B=400):
203 − a = np.abs(eA[mask]); b = np.abs(eB[mask]); cl = T["cluster"].to_numpy()[mask]
204 − okp = ~np.isnan(a) & ~np.isnan(b); a, b, cl = a[okp], b[okp], cl[okp]
205 − d = a - b
206 − cl_ids, inv = np.unique(cl, return_inverse=True); nc = len(cl_ids)
207 − sums = np.bincount(inv, d, nc); cnts = np.bincount(inv, minlength=nc)
208 − boots_mean = []; boots_med = []
209 − order = np.argsort(inv); d_sorted = d[order]; starts = np.searchsorted(inv[order], np.arange(nc)); ends = np.append(starts[1:], len(d))
210 − for _ in range(B):
211 − pick = rng.integers(0, nc, nc)
212 − boots_mean.append(sums[pick].sum() / cnts[pick].sum())
213 − if _ < 150:
214 − idxs = np.concatenate([np.arange(starts[p], ends[p]) for p in pick])
215 − boots_med.append(np.median(a[order][idxs]) - np.median(b[order][idxs]))
216 − return dict(n=int(len(d)), diff_mape=float(d.mean() * 100), ci_mape=[float(np.percentile(boots_mean, 2.5) * 100), float(np.percentile(boots_mean, 97.5) * 100)],
217 − diff_mdape=float((np.median(a) - np.median(b)) * 100), ci_mdape=[float(np.percentile(boots_med, 2.5) * 100), float(np.percentile(boots_med, 97.5) * 100)],
218 − part_A_meilleur=float((a < b).mean() * 100))
219 −PAIRS = [
220 − ("M7 grille d'ajustement hédonique", "M2 moyenne des 5 plus proches (temps seul)"),
221 − ("M7 grille d'ajustement hédonique", "M6 grille d'ajustement conventionnelle"),
222 − ("M8a grille locale (régression sur le voisinage)", "M7 grille d'ajustement hédonique"),
223 − ("M8b ajustements non linéaires (GBM)", "M7 grille d'ajustement hédonique"),
224 − ("M11b gradient boosting (avec rôle)", "M0 meilleure configuration empirique (choisie en 2024, couverture ≥ 97 %)"),
225 − ("M11a gradient boosting (sans rôle)", "M0 meilleure configuration empirique (choisie en 2024, couverture ≥ 97 %)"),
226 − ("M12 hybride comparables + GBM (poids appris en 2024)", "M11b gradient boosting (avec rôle)"),
227 − ("M13 ratio au rôle (5 plus proches, même rôle)", "M7 grille d'ajustement hédonique"),
228 − ("M3 sélection par similarité physique", "M2 moyenne des 5 plus proches (temps seul)"),
229 − ("M9 réconciliation optimisée (poids appris en 2024)", "M7 grille d'ajustement hédonique"),
230 − ("M1 médiane des 3 plus proches (temps seul)", "M2 moyenne des 5 plus proches (temps seul)"),
231 −]
232 −res["paires"] = {f"{a} vs {b}": paired(ERR[a], ERR[b], TEST) for a, b in PAIRS}
233 −pd.DataFrame([dict(A=a, B=b, **res["paires"][f"{a} vs {b}"]) for a, b in PAIRS]).to_csv(TAB / "paires_bootstrap.csv", index=False)
234 −
235 −# ---------------------------------------------------------------- strates
236 −def strata(err, by):
237 − out = []
238 − for lev, g in T[TEST].groupby(by, observed=True):
239 − m = np.zeros(len(T), bool); m[g.index] = True
240 − r = metrics(err[:, None], m).iloc[0].to_dict(); r[by] = str(lev); out.append(r)
241 − return pd.DataFrame(out)
242 −KEY = ["M2 moyenne des 5 plus proches (temps seul)", "M7 grille d'ajustement hédonique", "M8a grille locale (régression sur le voisinage)",
243 − "M13 ratio au rôle (5 plus proches, même rôle)", "M0 meilleure configuration empirique (choisie en 2024, couverture ≥ 97 %)", "M10 hédonique globale directe", "M11b gradient boosting (avec rôle)", "M12 hybride comparables + GBM (poids appris en 2024)"]
244 −for by in ["region_nom", "dens", "prix_q", "trimestre", "age_cl", "lien"]:
245 − tabs = []
246 − for name in KEY:
247 − s = strata(ERR[name], by); s["modele"] = name.split(" ")[0]; tabs.append(s)
248 − st = pd.concat(tabs); st.to_csv(TAB / f"strates_{by}.csv", index=False)
249 − res[f"strates_{by}"] = st.pivot(index=by, columns="modele", values="mdape").round(2).to_dict("index")
250 − res[f"strates_{by}_n"] = st[st.modele == "M7"].set_index(by)["n"].to_dict()
251 −# figure : densité
252 −fig, ax = plt.subplots(figsize=(6.5, 3.4))
253 −st = pd.read_csv(TAB / "strates_dens.csv")
254 −order = ["< 5", "5-19", "20-49", "50-199", "≥ 200"]
255 −for j, (name, lab_) in enumerate([("M2", "M2 5 plus proches"), ("M7", "M7 grille hédonique"), ("M0", "M0 meilleure config."), ("M11b", "M11b GBM"), ("M12", "M12 hybride")]):
256 − g = st[st.modele == name].set_index("dens").loc[order]
257 − ax.plot(order, g.mdape, color=PAL[j], lw=2, marker="o", ms=4, label=lab_)
258 −ax.set_xlabel("ventes disponibles dans un rayon de 2 km au cours des 12 mois précédents"); ax.set_ylabel("MdAPE, test (%)"); ax.legend(fontsize=8); ax.set_title("Précision selon la liquidité du sous-marché")
259 −savefig(fig, "f5_densite")
260 −
261 −# ---------------------------------------------------------------- hypothèses
262 −H = {}
263 −# H1 : proche mais dissemblable vs loin mais semblable (k = 5, T = 12, sans ajustement)
264 −H["H1"] = {"geo_d1": surf.loc[e1col("geo", 5, 1, 12, "none"), ["mdape_test", "couverture_test"]].to_dict(),
265 − "sim_d10": surf.loc[e1col("sim", 5, 10, 12, "none"), ["mdape_test", "couverture_test"]].to_dict(),
266 − "geo_d10": surf.loc[e1col("geo", 5, 10, 12, "none"), ["mdape_test", "couverture_test"]].to_dict(),
267 − "mix_d10": surf.loc[e1col("mix", 5, 10, 12, "none"), ["mdape_test", "couverture_test"]].to_dict(),
268 − "sim_d10_hedo": surf.loc[e1col("sim", 5, 10, 12, "hedo"), ["mdape_test", "couverture_test"]].to_dict(),
269 − "geo_d10_hedo": surf.loc[e1col("geo", 5, 10, 12, "hedo"), ["mdape_test", "couverture_test"]].to_dict(),
270 − "mix_d10_hedo": surf.loc[e1col("mix", 5, 10, 12, "hedo"), ["mdape_test", "couverture_test"]].to_dict()}
271 −# H2 : k optimal selon la densité (geo, d = 10, T = 12, hedo et none)
272 −h2 = {}
273 −for a in ["none", "hedo"]:
274 − for lev in order:
275 − m = TEST & (T["dens"] == lev).to_numpy()
276 − cols_ = [e1col("geo", k, 10, 12, a) for k in [1, 3, 5, 7, 10, 15, 20, 30, 50]]
277 − md = np.nanmedian(np.abs(err1[m][:, cols_]), axis=0) * 100
278 − h2[f"{a}|{lev}"] = dict(zip([1, 3, 5, 7, 10, 15, 20, 30, 50], md.round(2)))
279 −H["H2"] = h2
280 −fig, axes = plt.subplots(1, 2, figsize=(9, 3.3), sharey=True)
281 −for ax, a in zip(axes, ["none", "hedo"]):
282 − for j, lev in enumerate(order):
283 − v = h2[f"{a}|{lev}"]; ax.plot(list(v), list(v.values()), color=PAL[j], lw=2, marker="o", ms=3.5, label=f"densité {lev}")
284 − ax.set_xscale("log"); ax.set_xticks([1, 3, 5, 10, 20, 50]); ax.set_xticklabels([1, 3, 5, 10, 20, 50]); ax.set_xlabel("k"); ax.set_title({"none": "temps seul", "hedo": "grille hédonique"}[a] + " — d ≤ 10 km, 12 mois")
285 −axes[0].set_ylabel("MdAPE, test (%)"); axes[1].legend(fontsize=8)
286 −savefig(fig, "f6_k_par_densite")
287 −# H3 : local vs hédonique fixe selon la densité, aux trois ancres
288 −h3 = {}
289 −for (k, d, Tm) in [(3, 2, 6), (5, 5, 12), (10, 10, 24)]:
290 − for lev in order:
291 − m = TEST & (T["dens"] == lev).to_numpy()
292 − h3[f"{k}|{d}|{Tm}|{lev}"] = {a: float(np.nanmedian(np.abs(err2[m][:, e2col("geo", k, d, Tm, a, "equal", "median")])) * 100) for a in ["conv", "hedo", "hedo_reg", "local", "gbm"]}
293 −H["H3"] = h3
294 −# H4 : pondération selon la densité (ancre 5/5/12, hedo)
295 −h4 = {}
296 −for lev in order:
297 − m = TEST & (T["dens"] == lev).to_numpy()
298 − h4[lev] = {f"{w}|{r}": float(np.nanmedian(np.abs(err2[m][:, e2col("geo", 5, 5, 12, "hedo", w, r)])) * 100) for (w, r) in [("equal", "median"), ("equal", "mean"), ("dist", "mean"), ("rec", "mean"), ("sim", "mean"), ("all", "mean"), ("all", "median")]}
299 −H["H4"] = h4
300 −# H5 : méthodes simples vs ML selon l'homogénéité (dispersion des prix ajustés des 10 voisins à 10 km / 24 mois)
301 −het = sd2[:, e2col("geo", 10, 10, 24, "hedo", "equal", "median")].astype(float)
302 −T["heterog"] = pd.qcut(pd.Series(np.where(np.isnan(het), np.nanmax(het), het)), 3, labels=["homogène", "moyen", "hétérogène"])
303 −h5 = {}
304 −for lev in ["homogène", "moyen", "hétérogène"]:
305 − m = TEST & (T["heterog"] == lev).to_numpy()
306 − h5[lev] = {n.split(" ")[0]: float(np.nanmedian(np.abs(ERR[n][m])) * 100) for n in KEY}
307 − h5[lev]["sd_median"] = float(np.nanmedian(het[m]))
308 −H["H5"] = h5
309 −res["hypotheses"] = H
310 −
311 −# ---------------------------------------------------------------- intervalles d'incertitude (couverture)
312 −intervals = {}
313 −for a in ["none", "hedo", "gbm"]:
314 − for (k, d, Tm) in [(5, 5, 12), (10, 10, 24)]:
315 − c = e2col("geo", k, d, Tm, a, "equal", "median"); e = est2[:, c].astype(float); s = sd2[:, c].astype(float)
316 − okv = VAL & ~np.isnan(e) & ~np.isnan(s) & (s > 0)
317 − z_needed = np.abs(lnP[okv] - e[okv]) / s[okv]; z80 = float(np.percentile(z_needed, 80))
318 − okt = TEST & ~np.isnan(e) & ~np.isnan(s) & (s > 0)
319 − cov_nominal = float(np.mean(np.abs(lnP[okt] - e[okt]) <= 1.2816 * s[okt]) * 100)
320 − cov_cal = float(np.mean(np.abs(lnP[okt] - e[okt]) <= z80 * s[okt]) * 100)
321 − width = float(np.median(2 * z80 * s[okt]) * 100)
322 − intervals[f"{a}|{k}|{d}|{Tm}"] = dict(z80_val=z80, couverture_nominale_80=cov_nominal, couverture_calibree=cov_cal, largeur_mediane_pct=width, n_test=int(okt.sum()))
323 −res["intervalles"] = intervals
324 −
325 −# ---------------------------------------------------------------- règles conditionnelles (système de recommandation)
326 −cond = {}; est_cond = np.full(len(T), np.nan)
327 −for lev in order:
328 − mv_ = VAL & (T["dens"] == lev).to_numpy(); mt_ = TEST & (T["dens"] == lev).to_numpy()
329 − m1 = metrics(err1, mv_); m2 = metrics(err2, mv_)
330 − allm = pd.concat([pd.concat([E1, m1], axis=1).assign(src="E1"), pd.concat([E2, m2], axis=1).assign(src="E2")], ignore_index=True)
331 − b = allm[allm.couverture >= 95].sort_values("mdape").iloc[0]
332 − e = (err1 if b.src == "E1" else err2)[:, int(b.cfg)]
333 − est_cond[mt_] = e[mt_]
334 − cond[lev] = dict(b[["src", "sel", "d", "T", "k", "adj", "w", "recon", "mdape", "couverture"]].to_dict(), mdape_test=float(np.nanmedian(np.abs(e[mt_])) * 100), n_test=int(mt_.sum()))
335 −res["regles_conditionnelles"] = cond
336 −res["regles_conditionnelles_global_test"] = dict(mdape=float(np.nanmedian(np.abs(est_cond[TEST])) * 100), p10=float(np.nanmean(np.abs(est_cond[TEST]) <= 0.1) * 100), couverture=float(np.mean(~np.isnan(est_cond[TEST])) * 100))
337 −pd.DataFrame(cond).T.to_csv(TAB / "regles_conditionnelles.csv")
338 −
339 −# ---------------------------------------------------------------- stabilité temporelle, indice, divers
340 −tq = []
341 −for q, g in T[TEST].groupby("trimestre"):
342 − m = np.zeros(len(T), bool); m[g.index] = True
343 − tq.append(dict(trimestre=q, n=int(m.sum()), **{n.split(" ")[0]: float(np.nanmedian(np.abs(ERR[n][m])) * 100) for n in KEY},
344 − **{"biais_" + n.split(" ")[0]: float(np.nanmedian(ERR[n][m]) * 100) for n in KEY}))
345 −pd.DataFrame(tq).to_csv(TAB / "stabilite_trimestres.csv", index=False); res["stabilite"] = tq
346 −fig, ax = plt.subplots(figsize=(7, 3.2))
347 −idxf = pd.read_csv(OUT / "index_full.csv")
348 −for j, (r, lab_) in enumerate([("QC", "Québec"), ("06", "Montréal"), ("03", "Capitale-Nationale"), ("07", "Outaouais"), ("05", "Estrie")]):
349 − g = idxf[idxf.region.astype(str).str.zfill(2) == r] if r != "QC" else idxf[idxf.region == "QC"]
350 − if len(g): ax.plot(pd.to_datetime(g.ym), np.exp(g.log_idx) * 100, color=PAL[j], lw=2 if r == "QC" else 1.4, label=lab_)
351 −ax.set_ylabel("indice hédonique (2021-01 = 100)"); ax.legend(fontsize=8); ax.set_title("Indice de prix mensuel (plein échantillon, descriptif)")
352 −savefig(fig, "f7_indice")
353 −res["dataset"] = load_json("dataset_stats.json")
354 −res["n_targets"] = dict(val=int(VAL.sum()), test=int(TEST.sum()))
355 −res["densite_distribution"] = T[TEST]["dens"].value_counts().sort_index().to_dict()
356 −res["n_cand_median"] = dict(val=float(np.median(T[VAL].n_cand)), test=float(np.median(T[TEST].n_cand)))
357 −save_json(res, "results.json")
358 −log("analyse terminée")
deleted M4M64aomparables-validation/pipeline/06_report.py +0 −313
@@ -1,313 +0,0 @@
1 −# -*- coding: utf-8 -*-
2 −"""06 — Rapport : assemble docs/RAPPORT.md à partir de results.json et des tables (texte + tableaux + figures).
3 −Les phrases d'interprétation sont calculées à partir des résultats (jamais de chiffre écrit à la main)."""
4 −from common import *
5 −
6 −R = load_json("results.json"); TAB = OUT / "tables"
7 −DOCS = ROOT / "docs"; DOCS.mkdir(exist_ok=True)
8 −D = R["dataset"]; comp = R["competition"]; compv = R["competition_val"]
9 −def f1(x): return f"{x:.1f}".replace(".", ",")
10 −def f2(x): return f"{x:.2f}".replace(".", ",")
11 −def n_(x): return f"{int(round(x)):,}".replace(",", " ")
12 −def pct(x): return f"{f1(x)} %"
13 −def md_table(df, cols=None, fmt=None, index=False):
14 − if cols: df = df[cols]
15 − if index: df = df.reset_index()
16 − fmt = fmt or {}
17 − head = "| " + " | ".join(str(c) for c in df.columns) + " |\n|" + "|".join("---" for _ in df.columns) + "|\n"
18 − rows = []
19 − for _, r in df.iterrows():
20 − cells = []
21 − for c in df.columns:
22 − v = r[c]
23 − if c in fmt: cells.append(fmt[c](v))
24 − elif isinstance(v, float): cells.append(f1(v))
25 − else: cells.append(str(v))
26 − rows.append("| " + " | ".join(cells) + " |")
27 − return head + "\n".join(rows) + "\n"
28 −short = lambda name: name.split(" ")[0]
29 −LAB = {"raw": "aucun ajustement", "none": "temps seulement", "conv": "grille conventionnelle", "hedo": "grille hédonique", "hedo_reg": "grille hédonique régionale",
30 − "local": "régression locale", "gbm": "ajustements GBM", "role": "ratio au rôle", "geo": "les plus proches (distance)", "sim": "les plus semblables (physique)",
31 − "mix": "compromis distance / similarité", "adj": "les moins ajustés", "equal": "égale", "dist": "1/distance", "rec": "récence", "all": "distance × récence × similarité",
32 − "median": "médiane", "mean": "moyenne"}
33 −
34 −# ------------------------------------------------------------------ tableaux
35 −excl = pd.DataFrame(D["exclusions"], columns=["Règle", "n"]); excl["n"] = excl["n"].map(n_)
36 −cm = pd.DataFrame(comp).T; cm.index.name = "Modèle"; cm = cm.reset_index()
37 −cm["val"] = cm["Modèle"].map(lambda m: compv[m]["mdape"])
38 −cm = cm.sort_values("mdape")
39 −t_comp = md_table(cm.rename(columns={"mdape": "MdAPE test", "val": "MdAPE val.", "mape": "MAPE", "biais": "Biais (médian)", "p5": "±5 %", "p10": "±10 %", "p20": "±20 %", "couverture": "Couverture"}),
40 − ["Modèle", "MdAPE val.", "MdAPE test", "MAPE", "Biais (médian)", "±5 %", "±10 %", "±20 %", "Couverture"])
41 −surf = pd.read_csv(TAB / "surface_E1.csv")
42 −def surf_tab(sel, adj, T_):
43 − g = surf[(surf.sel == sel) & (surf.adj == adj) & (surf["T"] == T_)].pivot(index="d", columns="k", values="mdape_test")
44 − g.index = [f"≤ {d} km" for d in g.index]; g.columns = [f"k = {k}" for k in g.columns]; g.index.name = "rayon"
45 − return md_table(g.round(1), index=True)
46 −kb = surf[(surf.sel == "geo") & (surf.adj == "hedo") & (surf.d == 10) & (surf["T"] == 12)].sort_values("k")
47 −fac = pd.read_csv(TAB / "facteurs_E2.csv")
48 −anchor = fac[(fac.k == 5) & (fac.d == 5) & (fac["T"] == 12)]
49 −t_adj = anchor[(anchor.sel == "geo") & (anchor.w == "equal") & (anchor.recon == "median")].set_index("adj").loc[["raw", "none", "conv", "hedo", "hedo_reg", "local", "gbm", "role"]]
50 −t_adj = t_adj.reset_index(); t_adj["Ajustement"] = t_adj["adj"].map(LAB)
51 −t_adj_md = md_table(t_adj.rename(columns={"mdape_val": "MdAPE val.", "mdape_test": "MdAPE test", "mape_test": "MAPE test", "biais_test": "Biais", "p10_test": "±10 %", "couverture_test": "Couverture"}),
52 − ["Ajustement", "MdAPE val.", "MdAPE test", "MAPE test", "Biais", "±10 %", "Couverture"])
53 −t_sel = anchor[(anchor.adj == "hedo") & (anchor.w == "equal") & (anchor.recon == "median")].copy(); t_sel["Sélection"] = t_sel["sel"].map(LAB)
54 −t_sel_md = md_table(t_sel.rename(columns={"mdape_val": "MdAPE val.", "mdape_test": "MdAPE test", "p10_test": "±10 %", "biais_test": "Biais"}), ["Sélection", "MdAPE val.", "MdAPE test", "Biais", "±10 %"])
55 −t_wr = anchor[(anchor.adj == "hedo") & (anchor.sel == "geo")].copy(); t_wr["Pondération"] = t_wr["w"].map(LAB); t_wr["Réconciliation"] = t_wr["recon"].map(LAB)
56 −t_wr_md = md_table(t_wr.rename(columns={"mdape_val": "MdAPE val.", "mdape_test": "MdAPE test", "p10_test": "±10 %", "biais_test": "Biais"}), ["Pondération", "Réconciliation", "MdAPE val.", "MdAPE test", "Biais", "±10 %"])
57 −pairs = pd.read_csv(TAB / "paires_bootstrap.csv")
58 −pairs["Comparaison"] = pairs.apply(lambda r: f"{short(r.A)} − {short(r.B)}", axis=1)
59 −pairs["Δ MdAPE (pts) [IC 95 %]"] = pairs.apply(lambda r: f"{f2(r.diff_mdape)} [{f2(eval(r.ci_mdape)[0])} ; {f2(eval(r.ci_mdape)[1])}]", axis=1)
60 −pairs["Δ MAPE (pts) [IC 95 %]"] = pairs.apply(lambda r: f"{f2(r.diff_mape)} [{f2(eval(r.ci_mape)[0])} ; {f2(eval(r.ci_mape)[1])}]", axis=1)
61 −pairs["A plus précis (% des cibles)"] = pairs["part_A_meilleur"].map(f1)
62 −t_pairs = md_table(pairs, ["Comparaison", "Δ MdAPE (pts) [IC 95 %]", "Δ MAPE (pts) [IC 95 %]", "A plus précis (% des cibles)", "n"], fmt={"n": n_})
63 −def strata_md(by, label):
64 − st = pd.read_csv(TAB / f"strates_{by}.csv")
65 − g = st.pivot(index=by, columns="modele", values="mdape").round(1)
66 − g["n"] = st[st.modele == "M7"].set_index(by)["n"]
67 − g = g[["n", "M2", "M7", "M8a", "M13", "M0", "M10", "M11b", "M12"]]
68 − if by == "dens": g = g.loc[["< 5", "5-19", "20-49", "50-199", "≥ 200"]]
69 − if by == "region_nom": g = g.sort_values("n", ascending=False)
70 − g.index.name = label
71 − return md_table(g, index=True, fmt={"n": n_})
72 −H = R["hypotheses"]
73 −h2 = pd.DataFrame({k.split("|")[1]: v for k, v in H["H2"].items() if k.startswith("hedo")}).T; h2.index.name = "densité"; h2.columns = [f"k = {c}" for c in h2.columns]
74 −h2_md = md_table(h2.loc[["< 5", "5-19", "20-49", "50-199", "≥ 200"]], index=True)
75 −h2n = pd.DataFrame({k.split("|")[1]: v for k, v in H["H2"].items() if k.startswith("none")}).T; h2n.index.name = "densité"; h2n.columns = [f"k = {c}" for c in h2n.columns]
76 −h2n_md = md_table(h2n.loc[["< 5", "5-19", "20-49", "50-199", "≥ 200"]], index=True)
77 −h3 = pd.DataFrame({k.split("|")[3]: v for k, v in H["H3"].items() if k.startswith("5|5|12")}).T.loc[["< 5", "5-19", "20-49", "50-199", "≥ 200"]]
78 −h3.columns = [LAB[c] for c in h3.columns]; h3.index.name = "densité"; h3_md = md_table(h3, index=True)
79 −h3s = pd.DataFrame({k.split("|")[3]: v for k, v in H["H3"].items() if k.startswith("3|2|6")}).T.loc[["< 5", "5-19", "20-49", "50-199", "≥ 200"]]
80 −h3s.columns = [LAB[c] for c in h3s.columns]; h3s.index.name = "densité"; h3s_md = md_table(h3s, index=True)
81 −h4 = pd.DataFrame(H["H4"]).T.loc[["< 5", "5-19", "20-49", "50-199", "≥ 200"]]; h4.columns = [f"{LAB[c.split('|')[0]]} / {LAB[c.split('|')[1]]}" for c in h4.columns]; h4.index.name = "densité"; h4_md = md_table(h4, index=True)
82 −h5 = pd.DataFrame(H["H5"]).T; h5.index.name = "sous-marché"; h5 = h5.rename(columns={"sd_median": "σ médian (log)"}); h5_md = md_table(h5[["σ médian (log)", "M2", "M7", "M8a", "M13", "M0", "M10", "M11b", "M12"]], index=True, fmt={"σ médian (log)": lambda v: f"{v:.3f}".replace(".", ",")})
83 −iv = pd.DataFrame(R["intervalles"]).T; iv.index = [f"{LAB[i.split('|')[0]]}, k = {i.split('|')[1]}, ≤ {i.split('|')[2]} km, {i.split('|')[3]} mois" for i in iv.index]; iv.index.name = "configuration"
84 −iv_md = md_table(iv.rename(columns={"z80_val": "z calibré (val.)", "couverture_nominale_80": "couverture à z = 1,28", "couverture_calibree": "couverture calibrée", "largeur_mediane_pct": "largeur médiane (%)", "n_test": "n"}), index=True, fmt={"n": n_, "z calibré (val.)": f2})
85 −rc = pd.DataFrame(R["regles_conditionnelles"]).T.loc[["< 5", "5-19", "20-49", "50-199", "≥ 200"]]
86 −rc["règle"] = rc.apply(lambda r: f"{LAB[r.sel]}, k = {int(r.k)}, ≤ {r.d} km, {int(r['T'])} mois, {LAB[r.adj]}, pond. {LAB[r.w]}, {LAB[r.recon]}", axis=1)
87 −rc.index.name = "densité"; rc_md = md_table(rc.rename(columns={"mdape": "MdAPE val.", "mdape_test": "MdAPE test", "couverture": "couverture val.", "n_test": "n test"}), ["règle", "MdAPE val.", "MdAPE test", "couverture val.", "n test"], index=True, fmt={"n test": n_})
88 −stab = pd.DataFrame(R["stabilite"]); stab_md = md_table(stab[["trimestre", "n", "M2", "M7", "M0", "M11b", "M12", "biais_M7", "biais_M11b"]].rename(columns={"biais_M7": "biais M7", "biais_M11b": "biais M11b"}), fmt={"n": n_})
89 −
90 −# ------------------------------------------------------------------ chiffres clés pour le texte
91 −best = min(comp, key=lambda m: comp[m]["mdape"]); best_comps = min([m for m in comp if short(m) in ("M1", "M2", "M3", "M4", "M5", "M6", "M7", "M8a", "M8b", "M13", "M0", "M9")], key=lambda m: comp[m]["mdape"])
92 −m0 = R["M0"]; m0u = R["M0_sans_contrainte"]
93 −gain_adj = comp["M2 moyenne des 5 plus proches (temps seul)"]["mdape"] - comp["M7 grille d'ajustement hédonique"]["mdape"]
94 −gain_sel = comp["M2 moyenne des 5 plus proches (temps seul)"]["mdape"] - comp["M3 sélection par similarité physique"]["mdape"]
95 −k_curve = {int(r.k): r.mdape_test for r in kb.itertuples()}
96 −k_best = min(k_curve, key=k_curve.get)
97 −e1b = R["E1_best_val"]
98 −pair = R["paires"]
99 −def pr(a, b): return pair[f"{a} vs {b}"]
100 −M7 = "M7 grille d'ajustement hédonique"; M2 = "M2 moyenne des 5 plus proches (temps seul)"; M6 = "M6 grille d'ajustement conventionnelle"
101 −M8a = "M8a grille locale (régression sur le voisinage)"; M8b = "M8b ajustements non linéaires (GBM)"; M11b = "M11b gradient boosting (avec rôle)"; M11a = "M11a gradient boosting (sans rôle)"
102 −M0 = "M0 meilleure configuration empirique (choisie en 2024, couverture ≥ 97 %)"; M12 = "M12 hybride comparables + GBM (poids appris en 2024)"; M13 = "M13 ratio au rôle (5 plus proches, même rôle)"; M9 = "M9 réconciliation optimisée (poids appris en 2024)"; M3 = "M3 sélection par similarité physique"; M1 = "M1 médiane des 3 plus proches (temps seul)"; M10 = "M10 hédonique globale directe"
103 −dens = R["strates_dens"]; densn = R["strates_dens_n"]
104 −h1 = H["H1"]
105 −marg = R["E2_marginal"]
106 −cond_g = R["regles_conditionnelles_global_test"]
107 −nT = R["n_targets"]
108 −
109 −txt = f"""---
110 −title: "La méthode des comparables à l'épreuve des données : validation empirique à grande échelle sur {n_(D['propre'])} ventes résidentielles québécoises"
111 −subtitle: "Sélection, nombre, ajustements, pondération et réconciliation des ventes comparables — rétro-évaluation point-dans-le-temps sur les transactions Vrai Prix 2021-2026"
112 −author: "Simon-Pierre Boucher — version {VERSION}"
113 −date: "{time.strftime('%d/%m/%Y')}"
114 −lang: fr-CA
115 −---
116 −
117 −# Résumé
118 −
119 −**Question.** La méthode de comparaison des ventes, telle qu'enseignée et pratiquée en évaluation immobilière, produit-elle des estimations fiables, et lesquelles de ses règles (choix des comparables, nombre, rayon, ancienneté, ajustements, pondération, réconciliation) sont empiriquement justifiées ?
120 −
121 −**Données et protocole.** {n_(D['propre'])} ventes de résidences unifamiliales du Québec (registre foncier jumelé au rôle d'évaluation, Vrai Prix, {D['dates'][0]} → {D['dates'][1]}), après un nettoyage journalisé de {n_(D['brut'])} transactions brutes. Pour chacune des **{n_(nT['val'] + nT['test'])} ventes cibles** de 2024 (validation, {n_(nT['val'])}) et de 2025-2026 (test hors échantillon, {n_(nT['test'])}), le prix est masqué et la valeur est estimée avec les seules ventes conclues **avant** la date de la cible, un indice de prix ré-estimé chaque mois avec les seules ventes antérieures, et des grilles d'ajustement et modèles ré-estimés chaque trimestre. **2 256 configurations** de la méthode des comparables et treize modèles de référence sont appliqués aux mêmes cibles ; les différences sont testées par bootstrap par grappes (municipalité × trimestre).
122 −
123 −**Résultats principaux (test 2025-2026).**
124 −
125 −1. **Les ajustements sont le facteur dominant.** Prendre la moyenne des cinq ventes les plus proches, ajustées seulement pour le temps (M2), donne une erreur absolue médiane (MdAPE) de **{pct(comp[M2]['mdape'])}** ; une grille d'ajustement hédonique (M7) la ramène à **{pct(comp[M7]['mdape'])}** (−{f1(gain_adj)} points, IC 95 % de la différence de MdAPE [{f2(pr(M7, M2)['ci_mdape'][0])} ; {f2(pr(M7, M2)['ci_mdape'][1])}]). Une grille conventionnelle stylisée (M6) fait presque aussi bien ({pct(comp[M6]['mdape'])}) : ce qui compte, c'est d'ajuster, plus que la finesse de la grille.
126 −2. **Choisir des comparables semblables vaut autant qu'ajuster.** Sans aucun ajustement physique, sélectionner les cinq ventes les plus **semblables** (aire, terrain, âge, lien physique, étages) plutôt que les cinq plus **proches** fait passer la MdAPE de {pct(comp[M2]['mdape'])} à **{pct(comp[M3]['mdape'])}** (H1 confirmée : un comparable un peu plus éloigné mais semblable vaut mieux qu'un voisin dissemblable).
127 −3. **Le nombre optimal de comparables est bien supérieur à trois.** Avec la grille hédonique, la MdAPE décroît de {pct(k_curve[1])} (k = 1) à {pct(k_curve[3])} (k = 3), {pct(k_curve[5])} (k = 5), {pct(k_curve[10])} (k = 10) et atteint son minimum à **k = {k_best}** ({pct(k_curve[k_best])}) avant de remonter ({pct(k_curve[50])} à k = 50) ; l'optimum dépend de la densité du marché (H2 confirmée).
128 −4. **Le rayon et la fenêtre temporelle doivent être larges.** La meilleure configuration de la surface Erreur(k, d, t) choisie en 2024 est « {LAB[e1b['sel']]}, k = {int(e1b['k'])}, ≤ {e1b['d']} km, {int(e1b['T'])} mois, {LAB[e1b['adj']]} » ({pct(e1b['mdape_val'])} en validation → **{pct(e1b['mdape_test'])}** en test). Les rayons de 0,5-1 km et les fenêtres de 3 mois, privilégiés par la pratique, sont dominés dès que les comparables sont ajustés.
129 −5. **La pondération et le mode de réconciliation comptent peu** (effets marginaux moyens de {f1(min(marg['w'].values()))} à {f1(max(marg['w'].values()))} points selon la pondération, médiane {f1(marg['recon']['median'])} contre moyenne {f1(marg['recon']['mean'])}), bien moins que la sélection ({f1(min(marg['sel'].values()))}-{f1(max(marg['sel'].values()))}) et l'ajustement ({f1(min(marg['adj'].values()))}-{f1(max(marg['adj'].values()))}).
130 −6. **La meilleure méthode des comparables, entièrement transparente et choisie en 2024, atteint {pct(comp[M0]['mdape'])} en 2025-2026** (M0 : {LAB[m0['sel']]}, k = {int(m0['k'])}, ≤ {m0['d']} km, {int(m0['T'])} mois, {LAB[m0['adj']]}, pondération {LAB[m0['w']]}, {LAB[m0['recon']]}), contre **{pct(comp[M11b]['mdape'])}** pour un gradient boosting ré-entraîné chaque trimestre avec la valeur au rôle (M11b) et {pct(comp[M11a]['mdape'])} sans le rôle (M11a). L'hybride comparables + GBM (M12) atteint **{pct(comp[M12]['mdape'])}**. Le coût de la transparence est donc de {f1(comp[M0]['mdape'] - comp[M11b]['mdape'])} point{'s' if abs(comp[M0]['mdape'] - comp[M11b]['mdape']) >= 2 else ''} de MdAPE face au meilleur modèle opaque, et il s'annule dans les sous-marchés homogènes (H5).
131 −7. **Le ratio au rôle est un comparable redoutable** : rôle de la cible × ratio prix/rôle médian des cinq plus proches ventes du même rôle (M13) donne {pct(comp[M13]['mdape'])}, mieux que toute grille d'ajustement physique, mais seulement pour {pct(comp[M13]['couverture'])} des cibles.
132 −8. **La fiabilité dépend d'abord de la liquidité du sous-marché** : la MdAPE de M7 passe de {pct(dens['≥ 200']['M7'])} là où plus de 200 ventes ont eu lieu à moins de 2 km dans l'année à {pct(dens['< 5']['M7'])} là où il y en a eu moins de 5 ; des règles conditionnelles à la densité (système de recommandation) obtiennent {pct(cond_g['mdape'])} globalement avec {pct(cond_g['couverture'])} de couverture.
133 −
134 −**Portée.** Nous validons la précision prédictive de règles professionnelles *simulées* sur des caractéristiques observables (aire, terrain, âge, étages, lien physique, rôle) ; les ajustements non observables (garage, piscine, chambres, qualité, rénovations) restent dans l'erreur résiduelle, et le jugement réel d'un évaluateur n'est pas reproduit.
135 −
136 −# 1. Données
137 −
138 −## 1.1 Source et nettoyage
139 −
140 −Instantané de la base de production Vrai Prix pris le 2026-10-10 : {n_(766596)} transactions résidentielles et 3,75 M d'unités d'évaluation. Nous retenons les ventes de type « unifamilial » jumelées à une unité du rôle, et appliquons des règles de nettoyage explicites et journalisées — rien n'est éliminé silencieusement.
141 −
142 −{md_table(excl)}
143 −Jeu propre : **{n_(D['propre'])} ventes** ({D['dates'][0]} → {D['dates'][1]}) ; prix médian {n_(D['prix']['50%'])} $, moyenne {n_(D['prix']['mean'])} $.
144 −
145 −Deux règles méritent un commentaire. R8 écarte les ratios prix/rôle extrêmes (< 0,33 ou > 3) : faute d'indicateur de lien de dépendance entre les parties, c'est notre proxy de « transaction non représentative » — un évaluateur les écarterait aussi. R6b écarte les ventes conclues l'année même de la construction : leur prix médian ({n_(346108)} $) et leur ratio au rôle (1,15) sont ceux de terrains ou de constructions inachevées, non de maisons achevées (450-480 k$ et 1,3 aux âges 1-4 ans).
146 −
147 −## 1.2 Répartition
148 −
149 −| Période | Rôle | Ventes |
150 −|---|---|---|
151 −| 2021-01 → 2023-12 | développement | {n_(D['par_periode']['train'])} |
152 −| 2024 | validation (choix des configurations) | {n_(D['par_periode']['val'])} |
153 −| 2025-01 → 2026-09 | test hors échantillon | {n_(D['par_periode']['test'])} |
154 −
155 −Régions les plus représentées : {', '.join(f"{k} ({n_(v)})" for k, v in list(D['par_region'].items())[:6])}.
156 −
157 −![Indice de prix mensuel par région (descriptif, plein échantillon).](f7_indice.pdf){{ width=85% }}
158 −
159 −## 1.3 Caractéristiques disponibles et limites
160 −
161 −Observées pour chaque vente : date, prix, coordonnées, municipalité, unité de voisinage, aire habitable, superficie du terrain, année de construction, nombre d'étages, lien physique (détaché, jumelé, en rangée), genre de construction, valeur au rôle en vigueur à la vente et sa part terrain. Non observées : garage, piscine, nombre de chambres et de salles de bains, qualité, état, rénovations. Les grilles d'ajustement testées ne portent donc que sur les premières ; les secondes sont une source d'erreur commune à toutes les méthodes.
162 −
163 −# 2. Protocole
164 −
165 −## 2.1 Rétro-évaluation point-dans-le-temps
166 −
167 −Pour chaque cible : (1) le prix est masqué ; (2) le bassin de comparables est constitué des ventes conclues strictement avant la date de la cible (la cible elle-même, si elle a déjà été vendue, est exclue : on évalue par comparables, non par vente répétée), dans un rayon de 25 km et une fenêtre de 24 mois ; (3) l'indice temporel régional utilisé pour ramener chaque comparable au niveau du dernier mois connu est ré-estimé chaque mois avec les seules ventes antérieures (régression hédonique à effets fixes de municipalité et effets mensuels, par région administrative, rétrécie vers l'indice provincial quand les ventes sont rares) ; (4) les coefficients d'ajustement hédoniques, les grilles régionales et les modèles d'apprentissage sont ré-estimés chaque trimestre avec les seules ventes antérieures au trimestre ; (5) chaque configuration produit une valeur et, le cas échéant, une dispersion des prix ajustés.
168 −
169 −Le niveau de prix du mois de la cible n'est jamais connu : toutes les méthodes travaillent au niveau du mois précédent, ce qui reproduit la situation réelle d'un évaluateur.
170 −
171 −## 2.2 Configurations testées
172 −
173 −**E1 — surface Erreur(k, d, t)** : k ∈ {{1, 3, 5, 7, 10, 15, 20, 30, 50}}, d ∈ {{0,5 ; 1 ; 2 ; 5 ; 10 ; 25 km}}, t ∈ {{3, 6, 12, 24 mois}}, sélection ∈ {{plus proches, plus semblables, compromis}}, ajustement ∈ {{temps seul, grille hédonique}}, médiane. 1 296 configurations.
174 −
175 −**E2 — plan factoriel des règles** aux ancres (k, d, t) = (3, 2 km, 6 mois), (5, 5 km, 12 mois), (10, 10 km, 24 mois) : sélection (4) × ajustement (8 : aucun, temps seul, grille conventionnelle, hédonique globale, hédonique régionale, régression locale, GBM, ratio au rôle) × pondération (5) × réconciliation (2). 960 configurations.
176 −
177 −*Grille conventionnelle stylisée* (M6) : aire habitable ajustée à la moitié du prix au m² du comparable ; terrain à la moitié de la valeur foncière implicite au m² (plafond ±15 %) ; âge à −0,5 % par année d'écart (plafond ±20 %) ; ±5 % entre détaché et attaché. *Grille hédonique* (M7) : coefficients d'une régression du log du prix sur les caractéristiques, à effets fixes de municipalité (within), ré-estimée chaque trimestre. *Régression locale* (M8a) : mêmes coefficients ré-estimés sur les ventes à moins de 10 km des 24 derniers mois (≥ 40 ventes, ridge vers la grille régionale). *GBM* (M8b) : l'ajustement est la différence des prédictions d'un gradient boosting entre la cible et le comparable. *Ratio au rôle* (M13) : valeur au rôle de la cible × ratio prix/rôle du comparable, pour les comparables de la même municipalité et du même rôle triennal.
178 −
179 −## 2.3 Mesures
180 −
181 −Erreur absolue en pourcentage APE = |P̂ − P| / P ; MdAPE (médiane), MAPE (moyenne), biais médian signé, parts à ±5, ±10 et ±20 %, couverture (part des cibles pour lesquelles la configuration produit une valeur — une configuration trop restrictive ne « rate » pas, elle s'abstient, et cela doit se voir). Les différences entre méthodes sont des différences d'erreurs appariées sur les mêmes cibles, avec intervalles de confiance à 95 % par bootstrap par grappes municipalité × trimestre (400 réplications).
182 −
183 −# 3. Résultats
184 −
185 −## 3.1 Combien de comparables ? À quelle distance ? De quelle ancienneté ?
186 −
187 −![MdAPE (test 2025-2026) selon le nombre de comparables, par rayon, fenêtre de 12 mois, sélection par distance.](f1_mdape_vs_k.pdf){{ width=100% }}
188 −
189 −![MdAPE au meilleur k pour chaque couple rayon × fenêtre.](f2_surface_d_T.pdf){{ width=90% }}
190 −
191 −**Sélection par distance, grille hédonique, fenêtre 12 mois — MdAPE test (%)**
192 −
193 −{surf_tab('geo', 'hedo', 12)}
194 −**Sélection par distance, temps seulement, fenêtre 12 mois — MdAPE test (%)**
195 −
196 −{surf_tab('geo', 'none', 12)}
197 −**Sélection par similarité physique, grille hédonique, fenêtre 12 mois — MdAPE test (%)**
198 −
199 −{surf_tab('sim', 'hedo', 12)}
200 −Lecture. (i) Sans ajustement, l'erreur décroît avec k et avec le rayon tant que l'on reste sous quelques kilomètres, puis se stabilise ; (ii) avec la grille hédonique, elle décroît nettement plus et l'optimum se déplace vers des k et des rayons plus grands ; (iii) la fenêtre de 3 mois est systématiquement dominée par 12-24 mois dès que l'on ajuste pour le temps. Meilleures configurations de E1 par sélection × ajustement (choisies en 2024, évaluées en 2025-2026) :
201 −
202 −{md_table(pd.DataFrame(R['E1_best_by_sel_adj']).T.reset_index().rename(columns={'index': 'sélection | ajustement', 'mdape_val': 'MdAPE val.', 'mdape_test': 'MdAPE test', 'couverture_test': 'couverture test'}), ['sélection | ajustement', 'k', 'd', 'T', 'MdAPE val.', 'MdAPE test', 'couverture test'], fmt={'k': lambda v: str(int(v)), 'T': lambda v: str(int(v)), 'd': lambda v: f1(v)})}
203 −### k optimal selon la densité (H2)
204 −
205 −{h2_md}
206 −*(sélection par distance, ≤ 10 km, 12 mois, grille hédonique ; MdAPE test en %, par nombre de ventes disponibles à moins de 2 km au cours des 12 mois précédents)*
207 −
208 −Sans ajustement :
209 −
210 −{h2n_md}
211 −![k optimal par strate de densité.](f6_k_par_densite.pdf){{ width=100% }}
212 −
213 −## 3.2 Quels ajustements sont justifiés ?
214 −
215 −![Type d'ajustement, cinq comparables les plus proches, ≤ 5 km, ≤ 12 mois, médiane.](f3_ajustements.pdf){{ width=90% }}
216 −
217 −{t_adj_md}
218 −Trois enseignements. **L'ajustement temporel seul** vaut {f1(t_adj.set_index('adj').loc['raw', 'mdape_test'] - t_adj.set_index('adj').loc['none', 'mdape_test'])} point ; **n'importe quelle grille physique** vaut {f1(t_adj.set_index('adj').loc['none', 'mdape_test'] - t_adj.set_index('adj').loc['conv', 'mdape_test'])} à {f1(t_adj.set_index('adj').loc['none', 'mdape_test'] - t_adj.set_index('adj').loc['gbm', 'mdape_test'])} points de plus ; **les raffinements** (grille régionale, régression locale, ajustements non linéaires) apportent peu face à la grille hédonique globale, et la régression locale est même {('moins bonne' if t_adj.set_index('adj').loc['local', 'mdape_test'] > t_adj.set_index('adj').loc['hedo', 'mdape_test'] else 'meilleure')} ({pct(t_adj.set_index('adj').loc['local', 'mdape_test'])} contre {pct(t_adj.set_index('adj').loc['hedo', 'mdape_test'])}).
219 −
220 −### Grilles locales et petits échantillons (H3)
221 −
222 −MdAPE test (%) par type d'ajustement et densité, ancre (5, 5 km, 12 mois) :
223 −
224 −{h3_md}
225 −Ancre (3, 2 km, 6 mois) :
226 −
227 −{h3s_md}
228 −## 3.3 Comment choisir les comparables ? (H1)
229 −
230 −Ancre (5, 5 km, 12 mois), grille hédonique, médiane :
231 −
232 −{t_sel_md}
233 −Test direct de H1 (k = 5, 12 mois, temps seulement) : les cinq voisins à moins de 1 km donnent une MdAPE de {pct(h1['geo_d1']['mdape_test'])} (couverture {pct(h1['geo_d1']['couverture_test'])}) ; les cinq ventes les plus semblables à moins de 10 km, {pct(h1['sim_d10']['mdape_test'])} (couverture {pct(h1['sim_d10']['couverture_test'])}) ; les cinq plus proches à moins de 10 km, {pct(h1['geo_d10']['mdape_test'])}. Avec la grille hédonique : plus proches {pct(h1['geo_d10_hedo']['mdape_test'])}, plus semblables {pct(h1['sim_d10_hedo']['mdape_test'])}, compromis {pct(h1['mix_d10_hedo']['mdape_test'])}.
234 −
235 −## 3.4 Pondération et réconciliation (H4)
236 −
237 −Ancre (5, 5 km, 12 mois), sélection par distance, grille hédonique :
238 −
239 −{t_wr_md}
240 −Selon la densité (MdAPE test, %) :
241 −
242 −{h4_md}
243 −Effets marginaux moyens (MdAPE test moyenne sur toutes les autres modalités de E2) : sélection {', '.join(f"{LAB[k]} {f1(v)}" for k, v in marg['sel'].items())} ; ajustement {', '.join(f"{LAB[k]} {f1(v)}" for k, v in marg['adj'].items())} ; pondération {', '.join(f"{LAB[k]} {f1(v)}" for k, v in marg['w'].items())} ; réconciliation {', '.join(f"{LAB[k]} {f1(v)}" for k, v in marg['recon'].items())}.
244 −
245 −## 3.5 Compétition de modèles
246 −
247 −![Compétition de modèles, test 2025-2026.](f4_competition.pdf){{ width=95% }}
248 −
249 −{t_comp}
250 −M0 : {LAB[m0['sel']]}, k = {int(m0['k'])}, ≤ {m0['d']} km, {int(m0['T'])} mois, {LAB[m0['adj']]}, pondération {LAB[m0['w']]}, {LAB[m0['recon']]} (meilleure des 2 256 configurations en 2024 parmi celles qui couvrent ≥ 97 % des cibles ; sans cette contrainte, la meilleure est « {LAB[m0u['sel']]}, k = {int(m0u['k'])}, ≤ {m0u['d']} km, {int(m0u['T'])} mois, {LAB[m0u['adj']]} » à {pct(m0u['mdape_test'])} mais pour {pct(m0u['couverture_test'])} des cibles seulement). Poids appris de M9 : {', '.join(f"{LAB[k]} {f2(v)}" for k, v in R['M9_weights'].items() if v > 0)}. Poids de M12 : {', '.join(f"{k} {f2(v)}" for k, v in R['M12_weights'].items() if v > 0)}.
251 −
252 −### Différences appariées (test ; bootstrap par grappes municipalité × trimestre)
253 −
254 −{t_pairs}
255 −Un Δ négatif signifie que A est plus précis que B ; l'intervalle qui exclut zéro signale une différence robuste aux dépendances spatiales et temporelles.
256 −
257 −## 3.6 Où la méthode tient-elle, où défaille-t-elle ?
258 −
259 −![Précision selon la liquidité du sous-marché.](f5_densite.pdf){{ width=85% }}
260 −
261 −**Par densité** (ventes à moins de 2 km dans les 12 mois précédents) — MdAPE test (%) :
262 −
263 −{strata_md('dens', 'densité')}
264 −**Par région administrative** :
265 −
266 −{strata_md('region_nom', 'région')}
267 −**Par quintile de prix** :
268 −
269 −{strata_md('prix_q', 'quintile de prix')}
270 −**Par âge du bâtiment** :
271 −
272 −{strata_md('age_cl', 'âge')}
273 −**Par lien physique** (0 détaché, 1 jumelé, 2 en rangée, 3 intégré) :
274 −
275 −{strata_md('lien', 'lien physique')}
276 −### Marchés homogènes et hétérogènes (H5)
277 −
278 −Terciles de la dispersion (écart-type du log des prix ajustés) des dix ventes les plus proches à ≤ 10 km / 24 mois — MdAPE test (%) :
279 −
280 −{h5_md}
281 −### Stabilité dans le temps
282 −
283 −{stab_md}
284 −Le biais trimestriel commun à toutes les méthodes reflète le fait que le niveau de prix du mois courant est inconnu (toutes travaillent au niveau du mois précédent) et la saisonnalité des prix.
285 −
286 −## 3.7 Intervalles de valeur
287 −
288 −La dispersion des prix ajustés des comparables retenus fournit un intervalle de valeur. Avec z = 1,28 (couverture nominale de 80 % si les erreurs étaient normales), puis avec un z calibré sur la validation 2024 :
289 −
290 −{iv_md}
291 −## 3.8 Vers un système de recommandation des comparables
292 −
293 −Règle choisie en 2024 pour chaque strate de densité (parmi les 2 256 configurations couvrant ≥ 95 % des cibles de la strate), évaluée en 2025-2026 :
294 −
295 −{rc_md}
296 −Globalement, ces règles conditionnelles atteignent une MdAPE de **{pct(cond_g['mdape'])}** ({pct(cond_g['p10'])} des estimations à ±10 %, couverture {pct(cond_g['couverture'])}), contre {pct(comp[M0]['mdape'])} pour la meilleure règle unique.
297 −
298 −# 4. Discussion
299 −
300 −**Ce que la méthode des comparables fait bien.** Correctement paramétrée — comparables semblables, nombre d'une dizaine, rayon et fenêtre larges, ajustements hédoniques ou même conventionnels — elle atteint une précision ({pct(comp[M0]['mdape'])}) que ne dépasse le meilleur modèle d'apprentissage ré-entraîné chaque trimestre que de {f1(comp[M0]['mdape'] - comp[M11b]['mdape'])} point{'s' if abs(comp[M0]['mdape'] - comp[M11b]['mdape']) >= 2 else ''}, tout en restant intégralement explicable : chaque valeur est la médiane de quelques ventes identifiées, ajustées par des coefficients lisibles.
301 −
302 −**Ce qui contredit la pratique.** Trois comparables, un rayon de quelques centaines de mètres et une fenêtre de trois mois — les conventions les plus répandues — sont dominés sur toute la surface Erreur(k, d, t) : {pct(surf[(surf.sel == 'geo') & (surf.adj == 'hedo') & (surf.k == 3) & (surf.d == 1) & (surf['T'] == 3)].mdape_test.iloc[0])} de MdAPE (k = 3, ≤ 1 km, 3 mois, grille hédonique, couverture {pct(surf[(surf.sel == 'geo') & (surf.adj == 'hedo') & (surf.k == 3) & (surf.d == 1) & (surf['T'] == 3)].couverture_test.iloc[0])}) contre {pct(e1b['mdape_test'])} pour la meilleure configuration. La rareté des comparables « parfaits » est un argument pour élargir la recherche et ajuster, non pour se restreindre.
303 −
304 −**Le rôle d'évaluation comme information.** Le ratio au rôle (M13) et le GBM avec rôle (M11b) dominent leurs équivalents sans rôle : la valeur au rôle encapsule des caractéristiques non observées ici (qualité, dépendances, état) et une appréciation locale que les modèles globaux ne captent pas. Les modèles globaux sans rôle (M10, M11a) présentent d'ailleurs un biais négatif en 2025-2026 : l'appréciation est spatialement hétérogène et un indice régional, même ré-estimé chaque trimestre, la sous-estime là où les ventes se concentrent.
305 −
306 −**Limites.** (1) Règles professionnelles simulées, non dossiers d'évaluateurs. (2) Caractéristiques limitées à celles du rôle et du registre. (3) Délai de publication des ventes ignoré. (4) Un seul type de bien (unifamilial) ; condos et plex restent à traiter. (5) Les configurations ont été choisies sur 2024 puis évaluées sur 2025-2026 : les écarts entre validation et test (tableaux ci-dessus) mesurent la stabilité des règles, et ils sont faibles.
307 −
308 −# 5. Reproductibilité
309 −
310 −Code : `pipeline/01_dataset.py` → `06_report.py` (Python 3.14, pandas, numpy, scipy, lightgbm, matplotlib) ; sorties versionnées `out/{VERSION}/` (configurations `configs_E1.csv`, `configs_E2.csv` ; estimations `est_E1.npy`, `est_E2.npy` ; cibles `targets.parquet` ; tables `tables/*.csv` ; `results.json`). Exécution : `./run_all.sh` sur le nœud de calcul (M2U64), `./make_pdf.sh` sur le poste de travail.
311 −"""
312 −(DOCS / "RAPPORT.md").write_text(txt, encoding="utf-8")
313 −log(f"rapport écrit : {DOCS / 'RAPPORT.md'} ({len(txt.split()):,} mots)")
deleted M4M64aomparables-validation/pipeline/common.py +0 −85
@@ -1,85 +0,0 @@
1 −# -*- coding: utf-8 -*-
2 −"""Validation empirique de la méthode des comparables — fonctions communes.
3 −Données : instantané Vrai Prix (transactions ⋈ units) du 2026-10-10.
4 −Toutes les sorties sont versionnées sous out/<version>/.
5 −"""
6 −from __future__ import annotations
7 −import json, math, os, sqlite3, time
8 −from pathlib import Path
9 −import numpy as np
10 −import pandas as pd
11 −
12 −ROOT = Path(os.environ.get("CV_ROOT", Path(__file__).resolve().parents[1]))
13 −DB = Path(os.environ.get("CV_DB", ROOT / "data" / "vraiprix-20261010.db"))
14 −REF = ROOT / "ref"
15 −VERSION = os.environ.get("CV_VERSION", time.strftime("%Y-%m-%d"))
16 −OUT = ROOT / "out" / VERSION
17 −OUT.mkdir(parents=True, exist_ok=True)
18 −(OUT / "fig").mkdir(exist_ok=True)
19 −
20 −# Périodes (séparation chronologique)
21 −TRAIN_END = "2023-12-31" # développement
22 −VAL_START, VAL_END = "2024-01-01", "2024-12-31" # choix des configurations
23 −TEST_START, TEST_END = "2025-01-01", "2026-09-29" # évaluation finale hors échantillon
24 −MONTH0 = "2021-01" # premier mois des données
25 −
26 −def log(msg: str) -> None:
27 − print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
28 −
29 −def connect(path: Path = DB) -> sqlite3.Connection:
30 − return sqlite3.connect(f"file:{path}?mode=ro", uri=True, timeout=60)
31 −
32 −def _json_default(o):
33 − if isinstance(o, np.integer): return int(o)
34 − if isinstance(o, np.floating): return None if np.isnan(o) else float(o)
35 − if isinstance(o, np.bool_): return bool(o)
36 − if isinstance(o, np.ndarray): return o.tolist()
37 − if isinstance(o, pd.Timestamp): return o.strftime("%Y-%m-%d")
38 − if isinstance(o, float) and math.isnan(o): return None
39 − return str(o)
40 −
41 −def save_json(obj, name: str) -> Path:
42 − p = OUT / name; p.parent.mkdir(parents=True, exist_ok=True)
43 − with open(p, "w", encoding="utf-8") as f:
44 − json.dump(obj, f, ensure_ascii=False, indent=2, default=_json_default)
45 − return p
46 −
47 −def load_json(name: str):
48 − with open(OUT / name, encoding="utf-8") as f:
49 − return json.load(f)
50 −
51 −def save_parquet(df: pd.DataFrame, name: str) -> Path:
52 − p = OUT / name; df.to_parquet(p, index=False); return p
53 −
54 −def load_parquet(name: str) -> pd.DataFrame:
55 − return pd.read_parquet(OUT / name)
56 −
57 −def month_index(dates) -> np.ndarray:
58 − """Mois entier depuis MONTH0 (2021-01 = 0)."""
59 − d = pd.to_datetime(dates)
60 − y0, m0 = map(int, MONTH0.split("-"))
61 − return ((d.dt.year - y0) * 12 + (d.dt.month - m0)).to_numpy()
62 −
63 −def to_xy_km(lat, lng, lat0=46.8, lng0=-71.2):
64 − """Projection équirectangulaire locale (km) : erreur < 1 % pour des distances < 50 km."""
65 − x = (np.asarray(lng, float) - lng0) * 111.32 * np.cos(np.radians(np.asarray(lat, float)))
66 − y = (np.asarray(lat, float) - lat0) * 110.57
67 − return x, y
68 −
69 −def load_repertoire() -> pd.DataFrame:
70 − mun = pd.read_csv(REF / "MUN.csv", dtype=str, encoding="utf-8-sig")
71 − mun = mun.rename(columns=str.lower)
72 − out = pd.DataFrame({
73 − "code_mun": mun["mcode"].str.zfill(5),
74 − "mun_nom": mun["munnom"],
75 − "region": mun["regadm"].str.extract(r"\((\d+)\)")[0],
76 − "region_nom": mun["regadm"].str.replace(r"\s*\(\d+\)", "", regex=True),
77 − "mrc": mun["mrc"].str.extract(r"\((\d+)\)")[0],
78 − "mrc_nom": mun["mrc"].str.replace(r"\s*\(\d+\)", "", regex=True),
79 − })
80 − return out.drop_duplicates("code_mun")
81 −
82 −# Codages catégoriels (ordre stable)
83 −LIEN = {"Détaché": 0, "Jumelé": 1, "En rangée 1 côté": 2, "En rangée plus de 1 côté": 2, "Intégré": 3}
84 −GENRE = {"De plain-pied": 0, "Unimodulaire": 0, "À étages entiers": 1, "À niveaux décalés": 2, "À étage mansardé": 3}
85 −BTYPE = {"single-story": 0, "full-story": 1, "mansard": 2, "split-level": 3, "single-wide": 4}
deleted M4M64aomparables-validation/run_all.sh +0 −14
@@ -1,14 +0,0 @@
1 −#!/bin/zsh
2 −# Chaîne complète sur le nœud de calcul (M2U64). Usage : CV_VERSION=2026-10-10 ./run_all.sh [étape de départ]
3 −set -e
4 −cd "$(dirname "$0")"
5 −PY=${PY:-$HOME/uqo-eval-terrains/env/bin/python}
6 −export CV_VERSION=${CV_VERSION:-$(date +%F)}
7 −START=${1:-1}
8 −for s in 01_dataset 02_index 03_models 04_backtest 05_analyze 06_report; do
9 − n=${s%%_*}
10 − [ "$((10#$n))" -lt "$START" ] && continue
11 − echo "=== $s ($(date +%T)) ==="
12 − (cd pipeline && $PY $s.py)
13 −done
14 −echo "=== terminé $(date +%T) ==="
deleted M4M64bomparables-validation/.gitignore +0 −5
@@ -1,5 +0,0 @@
1 −out/
2 −data/
3 −*.pyc
4 −__pycache__/
5 −.DS_Store
deleted M4M64bomparables-validation/README.md +0 −33
@@ -1,33 +0,0 @@
1 −# Validation empirique de la méthode des comparables (Vrai Prix)
2 −
3 −Projet de recherche : rétro-évaluation à grande échelle de la méthode de comparaison des ventes sur les transactions
4 −résidentielles unifamiliales du Québec (Vrai Prix, 2021-01 → 2026-09). Protocole : `docs/PROTOCOLE.md`.
5 −Rapport de résultats : `docs/RAPPORT.md` / `docs/RAPPORT.pdf`.
6 −
7 −## Organisation
8 −
9 −```
10 −pipeline/ 01_dataset → 02_index → 03_models → 04_backtest → 05_analyze → 06_report
11 −docs/ PROTOCOLE.md, RAPPORT.md(.pdf), header.tex
12 −out/<version>/ sales.parquet, index_asof.npy, models_*.json, est_E*.npy, targets.parquet, tables/, fig/, results.json
13 −data/ (nœud seulement) vraiprix-20261010.db — instantané de la base de production Vrai Prix
14 −ref/ MUN.csv (répertoire des municipalités MAMH : code → MRC → région)
15 −```
16 −
17 −## Exécution
18 −
19 −Le calcul se fait sur **M2U64** (`~/comparables-validation`, venv `~/uqo-eval-terrains/env`, Python 3.14 : pandas, numpy, scipy,
20 −lightgbm, matplotlib). Le laptop garde le code, les docs et une copie des sorties.
21 −
22 −```bash
23 −rsync -a --exclude out --exclude data --exclude .git ~/Desktop/Validation-Comparables/ M2U64:comparables-validation/
24 −ssh M2U64 'cd ~/comparables-validation && CV_VERSION=2026-10-10 ./run_all.sh' # ~ 30 min
25 −./make_pdf.sh # rapatrie out/ + docs/ et compile le PDF
26 −```
27 −
28 −Variables : `CV_VERSION` (dossier de sortie), `CV_SAMPLE=3000` (échantillon de cibles pour un essai), `CV_PROCS` (processus, 20 par défaut).
29 −
30 −## Données
31 −
32 −Instantané `vraiprix.db` pris sur M3U96a (`~/apps/vrai-prix/data`) le 2026-10-10 : 766 596 transactions, 3,75 M d'unités du rôle.
33 −Jeu propre : ventes `unifamilial` jumelées au rôle, règles R0-R11 journalisées dans `out/<v>/exclusions.csv`.
deleted M4M64bomparables-validation/docs/PROTOCOLE.md +0 −101
@@ -1,101 +0,0 @@
1 −# Revisiter la méthode de comparaison des ventes : protocole de validation empirique à grande échelle
2 −
3 −**Titre de travail (article A)** — *Revisiting the Sales Comparison Approach: A Large-Scale Empirical Validation of Comparable Selection, Price Adjustments, and Value Reconciliation*
4 −
5 −**Variante** — *Does the Sales Comparison Approach Actually Work? Evidence from Large-Scale Residential Property Transactions*
6 −
7 −Données : transactions historiques **Vrai Prix** (registre foncier du Québec jumelé au rôle d'évaluation), instantané du 2026-10-10.
8 −
9 −## 1. Question de recherche
10 −
11 −La méthode des comparables, telle qu'enseignée et pratiquée par les évaluateurs agréés, produit-elle réellement les estimations les plus fiables ? Et surtout, quelles règles de **sélection**, d'**ajustement**, de **pondération** et de **réconciliation** des comparables sont empiriquement justifiées ?
12 −
13 −| Dimension | Question testée | Où dans le pipeline |
14 −|---|---|---|
15 −| Sélection | Quels comparables sont réellement les plus pertinents (proximité, similarité physique, « moins ajustés ») ? | E1 (facteur `sel`), E2 |
16 −| Nombre | Faut-il 1, 3, 5, 10, 20 ou 50 ventes ? | E1 (facteur `k`) |
17 −| Ajustements | Quels ajustements sont statistiquement justifiés (grille conventionnelle, hédonique, locale, non linéaire, ratio au rôle) ? | E2 (facteur `adj`) |
18 −| Pondération | Égalitaire, distance, récence, similarité ? | E2 (facteur `w`) |
19 −| Réconciliation | Médiane, moyenne, moyenne pondérée, combinaison optimisée (M9) ? | E2 (facteur `recon`), M9 |
20 −| Fiabilité | Dans quels marchés (densité, région, segment de prix, hétérogénéité) la méthode tient-elle ou défaille-t-elle ? | strates, H2-H5 |
21 −
22 −## 2. Protocole expérimental (rétro-évaluation « point dans le temps »)
23 −
24 −Pour chaque transaction cible :
25 −
26 −1. **Masquage** du prix de vente réel.
27 −2. **Reconstitution du marché à la date d'évaluation** : seules les ventes conclues *strictement avant* la date de la cible sont admissibles (le bien lui-même, s'il a déjà été vendu, est exclu du bassin : on évalue par comparables, pas par vente répétée). L'indice temporel, les coefficients hédoniques et les modèles d'apprentissage sont ré-estimés avec les seules ventes antérieures à la période d'évaluation (indice : ré-estimé chaque mois avec toutes les ventes des mois précédents ; modèles : estimés sur les ventes < 2024-01 pour la validation 2024 et < 2025-01 pour le test 2025-26).
28 −3. **Application** de chaque configuration (sélection × rayon × fenêtre × nombre × ajustement × pondération × réconciliation) sur exactement les mêmes cibles.
29 −4. **Comparaison** avec le prix réel : erreur absolue en pourcentage, biais, part des estimations à ±5/10/20 %, couverture (part des cibles pour lesquelles la configuration produit une valeur), dispersion des prix ajustés (intervalle de valeur).
30 −
31 −### Séparation chronologique
32 −
33 −| Période | Rôle | Ventes |
34 −|---|---|---|
35 −| 2021-01 → 2023-12 | développement (estimation des grilles d'ajustement et des modèles) | ~ 200 000 |
36 −| 2024 | validation : choix des configurations et des hyperparamètres | ~ 65 000 cibles |
37 −| 2025-01 → 2026-09 | évaluation finale hors échantillon | ~ 125 000 cibles |
38 −
39 −Les comparables d'une cible de 2025 peuvent être des ventes de 2024 ou de 2025 antérieures à sa date : c'est l'information dont disposait un évaluateur ce jour-là (nous ignorons le délai de publication au registre foncier, ce qui avantage légèrement toutes les méthodes de la même façon).
40 −
41 −## 3. Expériences
42 −
43 −### E1 — surface empirique de performance Erreur(k, d, t)
44 −
45 −- k ∈ {1, 3, 5, 7, 10, 15, 20, 30, 50} comparables ;
46 −- d ∈ {0,5 ; 1 ; 2 ; 5 ; 10 ; 25} km (rayon maximal) ;
47 −- t ∈ {3, 6, 12, 24} mois (ancienneté maximale) ;
48 −- sélection ∈ {`geo` les plus proches, `sim` les plus semblables physiquement, `mix` compromis} ;
49 −- ajustement ∈ {`none` temps seulement, `hedo` grille hédonique} ; réconciliation = médiane.
50 −
51 −1 296 configurations × toutes les cibles.
52 −
53 −### E2 — plan factoriel des règles (trois ancres (k, d, t) = (3, 2 km, 6 mois), (5, 5 km, 12 mois), (10, 10 km, 24 mois))
54 −
55 −- sélection ∈ {`geo`, `sim`, `mix`, `adj` = comparables nécessitant le moins d'ajustements} ;
56 −- ajustement ∈ {`raw` aucun, `none` temps seul, `conv` grille conventionnelle stylisée, `hedo` grille hédonique globale (within-municipalité), `hedo_reg` grille régionale rétrécie, `local` régression sur le voisinage (≤ 10 km, ≥ 40 ventes, ridge vers la grille régionale), `gbm` ajustements non linéaires (LightGBM), `role` ratio prix/rôle (comparables de la même municipalité et du même rôle)} ;
57 −- pondération ∈ {égale, 1/(0,25 + d), exp(−mois/6), 1/(0,1 + ajustement brut), produit des trois} ;
58 −- réconciliation ∈ {médiane (pondérée), moyenne (pondérée) en dollars}.
59 −
60 −960 configurations.
61 −
62 −### Compétition de modèles
63 −
64 −| Modèle | Définition opérationnelle |
65 −|---|---|
66 −| M1 | médiane des 3 ventes les plus proches (≤ 2 km, ≤ 6 mois), temps seul |
67 −| M2 | moyenne des 5 plus proches (≤ 5 km, ≤ 12 mois), temps seul |
68 −| M3 | 5 comparables choisis par similarité physique |
69 −| M4 | 5 plus proches pondérés par la distance |
70 −| M5 | 5 plus proches pondérés par la récence |
71 −| M6 | grille d'ajustement conventionnelle stylisée (½ du prix au m² pour l'aire, ½ de la valeur foncière au m² pour le terrain plafonnée à 15 %, −0,5 %/an d'âge plafonné à 20 %, ±5 % détaché/attaché) |
72 −| M7 | grille d'ajustement hédonique (coefficients within-municipalité estimés sur les ventes antérieures) |
73 −| M8a / M8b | grille locale (régression sur le voisinage) / ajustements non linéaires (GBM) |
74 −| M9 | réconciliation optimisée : combinaison convexe (NNLS) de sept estimations par comparables, poids appris en 2024 |
75 −| M10 | modèle hédonique global direct (β within + effets région, MRC, municipalité, unité de voisinage) |
76 −| M11a / M11b | gradient boosting sans / avec la valeur au rôle |
77 −| M12 | hybride : combinaison convexe de M11b et des estimations par comparables ajustées (GBM, hédonique) |
78 −| M13 | ratio au rôle : rôle de la cible × ratio prix/rôle médian des 5 plus proches du même rôle |
79 −| M0 | meilleure configuration de E1 ∪ E2 choisie sur la validation 2024, évaluée en 2025-26 |
80 −
81 −### Hypothèses
82 −
83 −- **H1** — un comparable très proche mais peu semblable peut être moins informatif qu'un comparable plus éloigné mais très semblable.
84 −- **H2** — l'ajout de comparables améliore la précision jusqu'à un seuil, au-delà duquel l'hétérogénéité domine ; le seuil dépend de la densité.
85 −- **H3** — les ajustements empiriques locaux surpassent les grilles fixes mais deviennent instables dans les petits échantillons.
86 −- **H4** — la pondération optimale dépend de la liquidité du sous-marché.
87 −- **H5** — les méthodes simples et transparentes rivalisent avec l'apprentissage automatique dans les marchés homogènes.
88 −
89 −### Inférence
90 −
91 −Différences d'erreurs appariées (mêmes cibles) avec intervalles de confiance par **bootstrap par grappes** (municipalité × trimestre), qui respecte les dépendances spatiales et temporelles.
92 −
93 −## 4. Ce que les données permettent — et ne permettent pas
94 −
95 −Caractéristiques observées pour chaque vente (registre foncier ⋈ rôle) : date, prix, coordonnées, municipalité, unité de voisinage, aire habitable (étages), superficie du terrain, année de construction, nombre d'étages, lien physique (détaché, jumelé, en rangée), genre de construction, valeur au rôle (en vigueur à la vente) et sa part terrain. **Non observés** : garage, piscine, nombre de chambres et de salles de bains, qualité et état, rénovations. Les ajustements correspondants ne peuvent donc pas être testés ici ; les erreurs résiduelles les contiennent.
96 −
97 −Nous validons la **précision prédictive** de règles professionnelles *simulées*, pas le jugement réel d'un évaluateur (il faudrait ses dossiers, ses comparables et ses ajustements). La distinction est maintenue partout dans le rapport.
98 −
99 −## 5. Article B (suite envisagée)
100 −
101 −Vers une méthode des comparables optimisée empiriquement : algorithme transparent de sélection/ajustement entraîné sur les ventes historiques et validé sur les ventes futures ; règles conditionnelles au type de bien, à la densité et à la qualité des données ; intégration dans UQO Éval pour montrer aux étudiants la comparaison entre règles traditionnelles et règles estimées.
deleted M4M64bomparables-validation/docs/header.tex +0 −28
@@ -1,28 +0,0 @@
1 −% En-tête LaTeX — Grille empirique d'ajustement des terrains (UQO Éval)
2 −\usepackage{fontspec}
3 −\setmainfont{Helvetica Neue}[Ligatures=TeX]
4 −\setsansfont{Helvetica Neue}
5 −\setmonofont{Menlo}[Scale=0.82]
6 −\usepackage{newunicodechar}
7 −\newunicodechar{→}{\ensuremath{\rightarrow}}\newunicodechar{≥}{\ensuremath{\geq}}\newunicodechar{≤}{\ensuremath{\leq}}\newunicodechar{×}{\ensuremath{\times}}\newunicodechar{−}{\ensuremath{-}}\newunicodechar{≈}{\ensuremath{\approx}}\newunicodechar{∂}{\ensuremath{\partial}}\newunicodechar{⋈}{\ensuremath{\bowtie}}\newunicodechar{Δ}{\ensuremath{\Delta}}\newunicodechar{τ}{\ensuremath{\tau}}\newunicodechar{σ}{\ensuremath{\sigma}}\newunicodechar{β}{\ensuremath{\beta}}\newunicodechar{θ}{\ensuremath{\theta}}\newunicodechar{γ}{\ensuremath{\gamma}}\newunicodechar{α}{\ensuremath{\alpha}}\newunicodechar{δ}{\ensuremath{\delta}}\newunicodechar{ε}{\ensuremath{\varepsilon}}\newunicodechar{²}{\textsuperscript{2}}\newunicodechar{⇒}{\ensuremath{\Rightarrow}}\newunicodechar{▶}{\ensuremath{\blacktriangleright}}\newunicodechar{│}{|}\newunicodechar{▼}{\ensuremath{\blacktriangledown}}\newunicodechar{─}{-}\newunicodechar{┌}{+}\newunicodechar{┐}{+}\newunicodechar{└}{+}\newunicodechar{┘}{+}\newunicodechar{├}{+}\newunicodechar{┤}{+}
8 −\usepackage{xcolor}
9 −\definecolor{uqo}{HTML}{006085}\definecolor{turq}{HTML}{2BCCAE}\definecolor{corail}{HTML}{EA6047}\definecolor{encre}{HTML}{16181B}\definecolor{papier}{HTML}{F4F5F3}
10 −\usepackage{titlesec}
11 −\titleformat{\section}{\Large\bfseries\color{uqo}}{}{0em}{}[{\color{uqo}\titlerule[0.8pt]}]
12 −\titleformat{\subsection}{\large\bfseries\color{uqo}}{}{0em}{}
13 −\titlespacing*{\section}{0pt}{18pt plus 4pt}{8pt}
14 −\usepackage{etoolbox}
15 −\AtBeginEnvironment{longtable}{\scriptsize\renewcommand{\arraystretch}{1.18}}
16 −\usepackage{colortbl}\arrayrulecolor{uqo!35}
17 −\usepackage{tcolorbox}
18 −\renewenvironment{quote}{\begin{tcolorbox}[colback=papier,colframe=turq,boxrule=0pt,leftrule=3pt,arc=1pt,left=6pt,right=6pt,top=4pt,bottom=4pt]}{\end{tcolorbox}}
19 −\usepackage{fancyhdr}
20 −\pagestyle{fancy}\fancyhf{}
21 −\fancyhead[L]{\footnotesize\color{gray}Grille empirique d'ajustement des terrains — UQO Éval}\fancyhead[R]{\footnotesize\color{gray}IMM1003 · IMM1033}
22 −\fancyfoot[C]{\footnotesize\color{gray}\thepage}\renewcommand{\headrulewidth}{0.3pt}
23 −\usepackage{caption}\captionsetup{font=small,labelfont={bf,color=uqo}}
24 −\usepackage{float}\floatplacement{figure}{H}
25 −\setlength{\emergencystretch}{3em}\hyphenpenalty=300\tolerance=1500
26 −\usepackage{microtype}
27 −\makeatletter\renewcommand{\maketitle}{\begin{center}{\color{uqo}\Huge\bfseries\@title\par}\vspace{8pt}{\normalsize\color{gray}\@author\par\@date\par}\end{center}\vspace{10pt}}\makeatother
28 −\usepackage{polyglossia}\setdefaultlanguage[frenchpart=false]{french}
deleted M4M64bomparables-validation/make_pdf.sh +0 −13
@@ -1,13 +0,0 @@
1 −#!/bin/zsh
2 −# Rapatrie les résultats depuis M2U64 et produit docs/RAPPORT.pdf (pandoc → XeLaTeX)
3 −set -e
4 −cd "$(dirname "$0")"
5 −V=${CV_VERSION:-2026-10-10}
6 −mkdir -p out/$V
7 −rsync -az --exclude 'est_E*.npy' --exclude 'sd_E2.npy' --exclude '*_pred_*.npy' --exclude 'sales.parquet' M2U64:comparables-validation/out/$V/ out/$V/
8 −rsync -az M2U64:comparables-validation/docs/ docs/ 2>/dev/null || true
9 −cd docs
10 −pandoc RAPPORT.md -o RAPPORT.pdf --pdf-engine=xelatex -H header.tex --toc --toc-depth=2 \
11 − -V geometry:"a4paper,margin=17mm,top=20mm,bottom=20mm" -V fontsize=10pt -V linkcolor=uqo -V urlcolor=uqo -V toccolor=uqo -V colorlinks=true \
12 − -V toc-title="Table des matières" --resource-path=.:../out/$V/fig 2>&1 | grep -v "Missing character\|Overfull\|Underfull" | tail -8 || true
13 −echo "PDF : $(pwd)/RAPPORT.pdf ($(du -h RAPPORT.pdf | cut -f1))"
deleted M4M64bomparables-validation/pipeline/01_dataset.py +0 −97
@@ -1,97 +0,0 @@
1 −# -*- coding: utf-8 -*-
2 −"""01 — Jeu de données : transactions unifamiliales Vrai Prix ⋈ rôle, nettoyage journalisé,
3 −reventes, géographie, périodes. Sorties : sales.parquet, exclusions.csv, dataset_stats.json."""
4 −from common import *
5 −
6 −con = connect()
7 −log(f"lecture {DB}")
8 −sql = """
9 −select t.id, t.date, t.amount, t.lat, t.lng, t.city, t.property_type, t.year_built, t.floor_area,
10 − t.building_type, t.id_provinc, t.valeur_role as role_vente, t.land_area,
11 − u.code_mun, u.municipalite, u.type_prop as u_type, u.annee_construction, u.aire_etages_m2,
12 − u.superficie_terrain_m2, u.front_terrain_m, u.nb_etages, u.nb_logements, u.lien_physique,
13 − u.genre_construction, u.unite_voisinage, u.dat_cond_marche, u.valeur_terrain, u.valeur_batiment,
14 − u.valeur_role as role_actuel, u.valeur_anterieure as role_anterieur, u.cubf
15 −from transactions t left join units u on u.id_provinc = t.id_provinc
16 −where t.property_type = 'unifamilial'
17 −"""
18 −df = pd.read_sql(sql, con)
19 −log(f"{len(df):,} transactions unifamiliales brutes")
20 −n0 = len(df)
21 −excl = [] # (règle, n)
22 −def drop(mask, rule):
23 − global df
24 − n = int(mask.sum()); excl.append((rule, n)); df = df[~mask].copy(); log(f" {rule}: -{n:,} → {len(df):,}")
25 −
26 −df["date"] = pd.to_datetime(df["date"])
27 −df = df.sort_values(["id_provinc", "date"]).reset_index(drop=True)
28 −drop(df["id_provinc"].isna() | df["code_mun"].isna(), "R0 sans jumelage au rôle (id_provinc / code_mun)")
29 −drop(df.duplicated(["id_provinc", "date"]), "R1 doublons (même unité, même date)")
30 −drop(~df["u_type"].isin(["unifamilial"]) | (df["nb_logements"].fillna(1) > 1), "R2 unité du rôle non unifamiliale (chalet, plex, terrain…) ou > 1 logement")
31 −drop(df["lat"].isna() | df["lng"].isna() | (df["lat"] < 44.5) | (df["lat"] > 63) | (df["lng"] > -56) | (df["lng"] < -80), "R3 coordonnées absentes ou hors Québec")
32 −fa = df["floor_area"].fillna(df["aire_etages_m2"])
33 −df["floor_area"] = fa
34 −drop(fa.isna() | (fa < 40) | (fa > 600), "R4 aire habitable absente ou hors [40, 600] m²")
35 −la = df["land_area"].fillna(df["superficie_terrain_m2"])
36 −df["land_area"] = la
37 −drop(la.isna() | (la < 80) | (la > 20000), "R5 superficie de terrain absente ou hors [80, 20 000] m²")
38 −yb = df["year_built"].fillna(df["annee_construction"])
39 −df["year_built"] = yb
40 −drop(yb.isna() | (yb < 1850) | (yb > df["date"].dt.year), "R6 année de construction absente, < 1850 ou postérieure à la vente (vente de terrain)")
41 −drop((df["year_built"] == df["date"].dt.year), "R6b vente l'année même de la construction (prix du terrain ou d'une construction inachevée : médiane 346 k$ et ratio au rôle 1,15 contre 450-480 k$ et 1,3 aux âges 1-4)")
42 −drop((df["amount"] < 50000) | (df["amount"] > 3_000_000), "R7 prix hors [50 k$, 3 M$]")
43 −ratio = df["amount"] / df["role_vente"]
44 −drop(df["role_vente"].isna() | (df["role_vente"] <= 0) | (ratio < 0.33) | (ratio > 3.0), "R8 rôle absent ou ratio prix/rôle hors [0,33 ; 3] (transactions présumées non représentatives)")
45 −# reventes rapides avec forte variation (flips / rénovations : les caractéristiques enregistrées ne décrivent plus le bien)
46 −df = df.sort_values(["id_provinc", "date"]).reset_index(drop=True)
47 −g = df.groupby("id_provinc")
48 −prev_date = g["date"].shift(1); prev_amt = g["amount"].shift(1)
49 −dd = (df["date"] - prev_date).dt.days
50 −chg = df["amount"] / prev_amt - 1
51 −flip = (dd <= 180) & (chg.abs() > 0.40)
52 −drop(flip.fillna(False), "R9 revente ≤ 180 jours avec variation > 40 % (flip)")
53 −drop(df["lien_physique"].isna() | df["genre_construction"].isna(), "R10 lien physique ou genre de construction absent au rôle")
54 −
55 −# ----------------------------------------------------------------- variables dérivées
56 −rep = load_repertoire()
57 −df = df.merge(rep, on="code_mun", how="left")
58 −drop(df["region"].isna(), "R11 municipalité absente du répertoire MAMH")
59 −df["sale_year"] = df["date"].dt.year
60 −df["month"] = month_index(df["date"])
61 −df["day"] = (df["date"] - pd.Timestamp("2021-01-01")).dt.days.astype(int)
62 −df["age"] = (df["sale_year"] - df["year_built"]).clip(lower=0)
63 −df["ln_price"] = np.log(df["amount"])
64 −df["ln_floor"] = np.log(df["floor_area"])
65 −df["ln_land"] = np.log(df["land_area"])
66 −df["lien"] = df["lien_physique"].map(LIEN).fillna(0).astype(int)
67 −df["genre"] = df["genre_construction"].map(GENRE).fillna(0).astype(int)
68 −df["btype"] = df["building_type"].map(BTYPE).fillna(-1).astype(int)
69 −df["etages"] = df["nb_etages"].fillna(1).clip(1, 3)
70 −df["x_km"], df["y_km"] = to_xy_km(df["lat"], df["lng"])
71 −# millésime du rôle au moment de la vente (rôle courant / antérieur / autre)
72 −df["role_vintage"] = np.where(np.isclose(df["role_vente"], df["role_actuel"].fillna(-1)), 2,
73 − np.where(np.isclose(df["role_vente"], df["role_anterieur"].fillna(-1)), 1, 0))
74 −df["ratio_role"] = df["amount"] / df["role_vente"]
75 −df["part_terrain"] = (df["valeur_terrain"] / df["role_actuel"]).clip(0.02, 0.95).fillna(0.2)
76 −df["neuf"] = (df["age"] <= 3).astype(int)
77 −df["period"] = np.select([df["date"] <= TRAIN_END, df["date"] <= VAL_END], ["train", "val"], "test")
78 −df = df.sort_values("date").reset_index(drop=True)
79 −df["idx"] = np.arange(len(df), dtype=np.int32)
80 −
81 −keep = ["idx", "id", "id_provinc", "date", "day", "month", "sale_year", "period", "amount", "ln_price",
82 − "lat", "lng", "x_km", "y_km", "code_mun", "mun_nom", "mrc", "mrc_nom", "region", "region_nom",
83 − "unite_voisinage", "floor_area", "ln_floor", "land_area", "ln_land", "year_built", "age", "neuf",
84 − "lien", "genre", "btype", "etages", "role_vente", "role_vintage", "ratio_role", "part_terrain"]
85 −df = df[keep]
86 −save_parquet(df, "sales.parquet")
87 −pd.DataFrame(excl, columns=["regle", "n"]).to_csv(OUT / "exclusions.csv", index=False)
88 −stats = {
89 − "brut": n0, "propre": len(df), "exclusions": excl,
90 − "par_periode": df["period"].value_counts().to_dict(),
91 − "par_annee": df["sale_year"].value_counts().sort_index().to_dict(),
92 − "par_region": df.groupby("region_nom").size().sort_values(ascending=False).to_dict(),
93 − "prix": {k: float(v) for k, v in df["amount"].describe().items()},
94 − "dates": [str(df["date"].min().date()), str(df["date"].max().date())],
95 −}
96 −save_json(stats, "dataset_stats.json")
97 −log(f"jeu propre : {len(df):,} ventes ({stats['par_periode']})")
deleted M4M64bomparables-validation/pipeline/02_index.py +0 −78
@@ -1,78 +0,0 @@
1 −# -*- coding: utf-8 -*-
2 −"""02 — Indice temporel régional « point dans le temps ».
3 −Pour chaque mois d'évaluation m, on ré-estime un indice hédonique mensuel par région administrative
4 −avec toutes les ventes antérieures au mois m (fenêtre expansive) (jamais le mois m lui-même). Niveau de référence = m-1,
5 −dernier mois entièrement connu. Sortie : index_asof.npy [m_eval, région, mois] (log), regions.json,
6 −et un indice « plein échantillon » descriptif (index_full.csv)."""
7 −from common import *
8 −
9 −df = load_parquet("sales.parquet")
10 −regions = sorted(df["region"].unique().tolist())
11 −rid = {r: i for i, r in enumerate(regions)}
12 −df["rid"] = df["region"].map(rid).astype(int)
13 −M = int(df["month"].max()) + 1
14 −M_EVAL0 = int(month_index(pd.Series([VAL_START]))[0])
15 −WINDOW = 999 # fenêtre « expansive » : toutes les ventes antérieures au mois m
16 −SHRINK = 40.0 # n/(n+SHRINK) : rétrécissement de l'effet mensuel régional vers l'effet provincial
17 −
18 −X_cols = ["ln_floor", "ln_land", "age"]
19 −def design(d):
20 − X = np.column_stack([np.ones(len(d)), d["ln_floor"], d["ln_land"], d["age"], d["age"] ** 2 / 100,
21 − (d["lien"] == 1), (d["lien"] >= 2), (d["genre"] == 1), (d["genre"] == 2), (d["genre"] == 3),
22 − d["etages"], d["neuf"]]).astype(float)
23 − return X
24 −
25 −def fit_month_effects(d, months):
26 − """OLS à effets fixes de municipalité (estimateur within) : ln P ~ X + effets mensuels (base = dernier mois),
27 − toutes les variables étant centrées par municipalité pour que l'indice ne soit pas contaminé par les
28 − déplacements du mélange des ventes entre municipalités chères et bon marché. Renvoie effets et n par mois."""
29 − X = design(d)[:, 1:]
30 − mm = d["month"].to_numpy()
31 − base = months[-1]
32 − dums = np.column_stack([(mm == m).astype(float) for m in months if m != base])
33 − A = np.hstack([X, dums]); y = d["ln_price"].to_numpy(float)
34 − codes, inv = np.unique(d["code_mun"].to_numpy(), return_inverse=True)
35 − cnt = np.bincount(inv).astype(float)
36 − mA = np.zeros((len(codes), A.shape[1])); np.add.at(mA, inv, A); A = A - mA[inv] / cnt[inv, None]
37 − my = np.bincount(inv, y) / cnt; y = y - my[inv]
38 − beta, *_ = np.linalg.lstsq(A, y, rcond=None)
39 − eff = {base: 0.0}
40 − k = X.shape[1]
41 − for j, m in enumerate([m for m in months if m != base]):
42 − eff[m] = float(beta[k + j])
43 − n = {m: int((mm == m).sum()) for m in months}
44 − return eff, n
45 −
46 −idx = np.full((M - M_EVAL0, len(regions), M), np.nan, dtype=np.float32)
47 −log(f"indice point-dans-le-temps : mois d'évaluation {M_EVAL0}..{M-1}, {len(regions)} régions")
48 −for m in range(M_EVAL0, M):
49 − months = list(range(max(0, m - WINDOW), m))
50 − win = df[(df["month"] >= months[0]) & (df["month"] < m)]
51 − prov_eff, _ = fit_month_effects(win, months)
52 − for r in regions:
53 − dr = win[win["region"] == r]
54 − if len(dr) < 200:
55 − for mo in months: idx[m - M_EVAL0, rid[r], mo] = prov_eff[mo]
56 − continue
57 − eff, n = fit_month_effects(dr, months)
58 − for mo in months:
59 − w = n[mo] / (n[mo] + SHRINK)
60 − idx[m - M_EVAL0, rid[r], mo] = w * eff[mo] + (1 - w) * prov_eff[mo]
61 − if m % 6 == 0: log(f" mois {m} ({len(win):,} ventes dans la fenêtre)")
62 −np.save(OUT / "index_asof.npy", idx)
63 −save_json({"regions": regions, "M_EVAL0": M_EVAL0, "M": M, "window": WINDOW, "shrink": SHRINK}, "index_meta.json")
64 −
65 −# indice descriptif plein échantillon (pour le rapport seulement — pas utilisé dans la rétro-évaluation)
66 −rows = []
67 −months = list(range(0, M))
68 −eff_p, n_p = fit_month_effects(df, months)
69 −for mo in months:
70 − rows.append({"month": mo, "ym": f"{2021 + mo // 12}-{mo % 12 + 1:02d}", "region": "QC", "log_idx": eff_p[mo] - eff_p[months[0]], "n": n_p[mo]})
71 −for r in regions:
72 − dr = df[df["region"] == r]
73 − if len(dr) < 2000: continue
74 − eff, n = fit_month_effects(dr, months)
75 − for mo in months:
76 − rows.append({"month": mo, "ym": f"{2021 + mo // 12}-{mo % 12 + 1:02d}", "region": r, "log_idx": eff[mo] - eff[months[0]], "n": n[mo]})
77 −pd.DataFrame(rows).to_csv(OUT / "index_full.csv", index=False)
78 −log("indice terminé")
deleted M4M64bomparables-validation/pipeline/03_models.py +0 −119
@@ -1,119 +0,0 @@
1 −# -*- coding: utf-8 -*-
2 −"""03 — Modèles d'ajustement et modèles de référence, ré-estimés chaque trimestre avec les seules ventes
3 −antérieures au trimestre (as-of 2024-01, 2024-04, …, 2026-07) — comme le ferait un AVM ré-entraîné régulièrement :
4 − • grille hédonique globale (coefficients) et régionale rétrécie → ajustements B / C
5 − • LightGBM (objectif L1 = médiane) sur le prix ajusté dans le temps (avec et sans rôle) → ajustements C-GBM, M11
6 − • hédonique globale directe avec effets de municipalité → M10
7 −Sorties : models_<période>.json, gbm_pred_<période>.npy (prédiction f pour TOUTES les ventes)."""
8 −import pickle
9 −from common import *
10 −import lightgbm as lgb
11 −
12 −df = load_parquet("sales.parquet")
13 −meta = load_json("index_meta.json"); regions = meta["regions"]; M_EVAL0 = meta["M_EVAL0"]
14 −rid = {r: i for i, r in enumerate(regions)}
15 −df["rid"] = df["region"].map(rid).astype(int)
16 −idx = np.load(OUT / "index_asof.npy")
17 −
18 −FEATS = ["ln_floor", "ln_land", "age", "age2", "lien1", "lien2", "genre1", "genre2", "genre3", "etages", "neuf"]
19 −def feats(d):
20 − return pd.DataFrame({
21 − "ln_floor": d["ln_floor"], "ln_land": d["ln_land"], "age": d["age"], "age2": d["age"] ** 2 / 100,
22 − "lien1": (d["lien"] == 1).astype(float), "lien2": (d["lien"] >= 2).astype(float),
23 − "genre1": (d["genre"] == 1).astype(float), "genre2": (d["genre"] == 2).astype(float), "genre3": (d["genre"] == 3).astype(float),
24 − "etages": d["etages"].astype(float), "neuf": d["neuf"].astype(float)}, index=d.index)
25 −
26 −def time_adjusted(d, m_asof):
27 − """ln P ramené au niveau du mois m_asof-1 avec l'indice connu au mois m_asof."""
28 − lvl = idx[m_asof - M_EVAL0]
29 − return d["ln_price"].to_numpy() - lvl[d["rid"].to_numpy(), d["month"].to_numpy()] + lvl[d["rid"].to_numpy(), m_asof - 1]
30 −
31 −def ols(X, y):
32 − A = np.column_stack([np.ones(len(X)), X])
33 − b, *_ = np.linalg.lstsq(A, y, rcond=None)
34 − return b
35 −
36 −ASOF = list(range(M_EVAL0, int(df["month"].max()) + 1, 3)) # ré-estimation trimestrielle (2024-01, 2024-04, …)
37 −save_json({"asof": ASOF}, "asof.json")
38 −TODO = [int(a) for a in os.environ["CV_ASOF"].split(",")] if os.environ.get("CV_ASOF") else ASOF # sous-ensemble (répartition sur plusieurs nœuds)
39 −for m_asof in TODO:
40 − period = str(m_asof); start = f"{2021 + m_asof // 12}-{m_asof % 12 + 1:02d}-01"
41 − tr = df[df["month"] < m_asof].copy()
42 − y = time_adjusted(tr, m_asof)
43 − F = feats(tr)
44 − log(f"[{period}] entraînement sur {len(tr):,} ventes < {start} (indice connu au mois {m_asof})")
45 − # --- grille hédonique globale : estimateur « within » (effets fixes de municipalité), pour que les β
46 − # mesurent des différences entre biens voisins et non entre marchés
47 − def within(frame, cols, by):
48 − return frame[cols] - frame.groupby(by)[cols].transform("mean")
49 − # effets mensuels résiduels (base = dernier mois connu m_asof-1) : l'indice régional ne retire pas toute la
50 − # tendance commune ; sans eux, les modèles globaux sous-estiment les ventes récentes
51 − months_tr = sorted(tr["month"].unique()); MCOLS = [f"m{m}" for m in months_tr if m != m_asof - 1]
52 − Fm = pd.concat([F] + [pd.Series((tr["month"] == m).astype(float), index=tr.index, name=f"m{m}") for m in months_tr if m != m_asof - 1], axis=1)
53 − Fw = within(pd.concat([Fm, tr[["code_mun"]]], axis=1), FEATS + MCOLS, "code_mun")
54 − yw = y - pd.Series(y, index=tr.index).groupby(tr["code_mun"]).transform("mean").to_numpy()
55 − bw_all, *_ = np.linalg.lstsq(Fw.to_numpy(), yw, rcond=None)
56 − bw = bw_all[:len(FEATS)]; bm = bw_all[len(FEATS):]
57 − beta_global = dict(zip(FEATS, bw.tolist()))
58 − xb = F.to_numpy() @ bw + Fm[MCOLS].to_numpy() @ bm
59 − resid = y - xb
60 − r2w = 1 - np.var(yw - Fw.to_numpy() @ bw_all) / np.var(yw)
61 − Fw = Fw[FEATS]
62 − log(f" hédonique within-municipalité : R² within = {r2w:.3f} ; β = { {k: round(v, 3) for k, v in beta_global.items()} }")
63 − # --- grille régionale (within-municipalité) rétrécie vers la globale (n/(n+300))
64 − beta_region = {}
65 − for r in regions:
66 − sel = (tr["region"] == r).to_numpy()
67 − if sel.sum() < 100: beta_region[r] = beta_global; continue
68 − b, *_ = np.linalg.lstsq(Fw.to_numpy()[sel], yw[sel], rcond=None)
69 − w = sel.sum() / (sel.sum() + 300.0)
70 − beta_region[r] = {k: float(w * b[j] + (1 - w) * beta_global[k]) for j, k in enumerate(FEATS)}
71 − # --- M10 : hédonique directe = Xβ + effet de région + MRC + municipalité + unité de voisinage (rétrécis en cascade)
72 − reg_eff = pd.Series(resid, index=tr.index).groupby(tr["region"]).mean().to_dict()
73 − tr["res_g"] = resid - tr["region"].map(reg_eff).to_numpy()
74 − mrc_eff = tr.groupby("mrc")["res_g"].agg(["sum", "count"])
75 − mrc_eff = (mrc_eff["sum"] / (mrc_eff["count"] + 30)).to_dict()
76 − tr["res_m"] = tr["res_g"] - tr["mrc"].map(mrc_eff).fillna(0)
77 − mun_eff = tr.groupby("code_mun")["res_m"].agg(["sum", "count"])
78 − mun_eff = (mun_eff["sum"] / (mun_eff["count"] + 20)).to_dict()
79 − tr["res_u"] = tr["res_m"] - tr["code_mun"].map(mun_eff).fillna(0)
80 − # correction médiane : la MCO vise la moyenne du log ; la distribution est asymétrique (queue basse), on recentre sur la médiane
81 − uv_tmp = tr.groupby(["code_mun", "unite_voisinage"])["res_u"].transform(lambda v: v.sum() / (len(v) + 10))
82 − median_shift = float(np.median(tr["res_u"] - uv_tmp))
83 − uv = tr.groupby(["code_mun", "unite_voisinage"])["res_u"].agg(["sum", "count"])
84 − uv_eff = {f"{a}|{b}": v for (a, b), v in (uv["sum"] / (uv["count"] + 10)).items()}
85 − # --- LightGBM (prix ajusté dans le temps) avec rôle et sans rôle
86 − def gbm_frame(d):
87 − G = feats(d)
88 − G["lat"] = d["lat"]; G["lng"] = d["lng"]; G["x_km"] = d["x_km"]; G["y_km"] = d["y_km"]
89 − G["rid"] = d["rid"]; G["btype"] = d["btype"]
90 − G["ln_role"] = np.log(d["role_vente"]); G["part_terrain"] = d["part_terrain"]; G["role_vintage"] = d["role_vintage"]
91 − G["month"] = np.minimum(d["month"], m_asof - 1).astype(float) # tendance résiduelle ; hors échantillon = dernier mois connu
92 − return G
93 − G = gbm_frame(tr)
94 − params = dict(objective="regression_l1", learning_rate=0.05, num_leaves=127, min_data_in_leaf=40, feature_fraction=0.8,
95 − bagging_fraction=0.8, bagging_freq=1, lambda_l2=1.0, verbose=-1, num_threads=int(os.environ.get("CV_THREADS", 20)))
96 − models = {}
97 − for name, cols in [("gbm", [c for c in G.columns if c not in ("ln_role", "part_terrain", "role_vintage")]),
98 − ("gbm_role", list(G.columns))]:
99 − ds = lgb.Dataset(G[cols], y, categorical_feature=["rid", "btype"] if "btype" in cols else ["rid"])
100 − # validation interne : derniers 10 % chronologiques pour fixer le nombre d'arbres
101 − n = len(G); cut = int(n * 0.9)
102 − dtr = lgb.Dataset(G[cols].iloc[:cut], y[:cut], categorical_feature=["rid", "btype"])
103 − dva = lgb.Dataset(G[cols].iloc[cut:], y[cut:], reference=dtr)
104 − bst = lgb.train(params, dtr, num_boost_round=3000, valid_sets=[dva], callbacks=[lgb.early_stopping(100, verbose=False)])
105 − best = bst.best_iteration
106 − bst = lgb.train(params, ds, num_boost_round=best)
107 − models[name] = bst
108 − Gall = gbm_frame(df); Gall["month"] = float(m_asof - 1) # f évalué au niveau du dernier mois connu pour toutes les ventes
109 − pred_all = bst.predict(Gall[cols])
110 − np.save(OUT / f"{name}_pred_{period}.npy", pred_all.astype(np.float32))
111 − rr = y - bst.predict(G[cols]); r2 = 1 - np.var(rr) / np.var(y)
112 − log(f" {name}: {best} arbres, R² (in-sample) = {r2:.3f}, résidu médian = {np.median(rr) * 100:.2f} %")
113 − bst.save_model(str(OUT / f"{name}_{period}.txt"))
114 − save_json({"period": period, "m_asof": m_asof, "start": start, "n_train": len(tr), "feats": FEATS,
115 − "beta_global": beta_global, "intercept_global": 0.0, "region_fe": reg_eff,
116 − "beta_region": beta_region, "mrc_eff": mrc_eff, "mun_eff": mun_eff, "uv_eff": uv_eff,
117 − "r2_within": float(r2w), "sd_resid_u": float(tr["res_u"].std()), "median_shift": median_shift},
118 − f"models_{period}.json")
119 −log("modèles terminés")
deleted M4M64bomparables-validation/pipeline/04_backtest.py +0 −193
@@ -1,193 +0,0 @@
1 −# -*- coding: utf-8 -*-
2 −"""04 — Moteur de rétro-évaluation.
3 −Pour chaque vente cible (2024 = validation, 2025-2026 = test), le prix est masqué ; seules les ventes
4 −conclues STRICTEMENT AVANT la date cible (et l'indice/les modèles connus à cette date) sont utilisées.
5 −On évalue d'un coup toutes les configurations (sélection × rayon × fenêtre × nombre × ajustement ×
6 −pondération × réconciliation) sur exactement les mêmes cibles.
7 −Sorties : targets.parquet, configs_E1.csv/est_E1.npy (surface k,d,T), configs_E2.csv/est_E2.npy/sd_E2.npy
8 −(factoriel sélection × ajustement × pondération × réconciliation), n_avail.npy."""
9 −import os
10 −for _v in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS", "VECLIB_MAXIMUM_THREADS"):
11 − os.environ[_v] = "1" # un seul fil BLAS par processus (fork + BLAS multi-fils = interblocage sur macOS)
12 −import multiprocessing as mp, time
13 −from scipy.spatial import cKDTree
14 −from common import *
15 −
16 −df = load_parquet("sales.parquet")
17 −meta = load_json("index_meta.json"); regions = meta["regions"]; M_EVAL0 = meta["M_EVAL0"]
18 −rid_map = {r: i for i, r in enumerate(regions)}
19 −IDX = np.load(OUT / "index_asof.npy")
20 −ASOF = load_json("asof.json")["asof"]
21 −MODELS = {a: load_json(f"models_{a}.json") for a in ASOF}
22 −FEATS = MODELS[ASOF[0]]["feats"]
23 −GBM = {a: np.load(OUT / f"gbm_pred_{a}.npy") for a in ASOF}
24 −
25 −# ----------------------------------------------------------------- tableaux globaux (partagés par fork)
26 −N = len(df)
27 −DAY = df["day"].to_numpy(np.int32); MONTH = df["month"].to_numpy(np.int32)
28 −RID = df["region"].map(rid_map).to_numpy(np.int32)
29 −X = df["x_km"].to_numpy(np.float64); Y = df["y_km"].to_numpy(np.float64)
30 −LNP = df["ln_price"].to_numpy(np.float64); P = df["amount"].to_numpy(np.float64)
31 −FLOOR = df["floor_area"].to_numpy(np.float64); LAND = df["land_area"].to_numpy(np.float64)
32 −AGE = df["age"].to_numpy(np.float64); LIEN = df["lien"].to_numpy(np.int8); ETAGES = df["etages"].to_numpy(np.float64)
33 −GENRE = df["genre"].to_numpy(np.int8)
34 −PROV = pd.factorize(df["id_provinc"])[0].astype(np.int32)
35 −MUN = pd.factorize(df["code_mun"])[0].astype(np.int32)
36 −VINT = df["role_vintage"].to_numpy(np.int8); LNROLE = np.log(df["role_vente"].to_numpy(np.float64))
37 −PART = df["part_terrain"].to_numpy(np.float64)
38 −NEUF = df["neuf"].to_numpy(np.float64)
39 −# matrice des caractéristiques hédoniques (même ordre que FEATS)
40 −XF = np.column_stack([np.log(FLOOR), np.log(LAND), AGE, AGE ** 2 / 100, (LIEN == 1), (LIEN >= 2),
41 − (GENRE == 1), (GENRE == 2), (GENRE == 3), ETAGES, NEUF]).astype(np.float64)
42 −BETA_G = {a: np.array([MODELS[a]["beta_global"][f] for f in FEATS]) for a in ASOF}
43 −BETA_R = {a: np.array([[MODELS[a]["beta_region"][r][f] for f in FEATS] for r in regions]) for a in ASOF}
44 −ASOF_ARR = np.array(ASOF)
45 −TREE = cKDTree(np.column_stack([X, Y]))
46 −
47 −D_MAX, T_MAX_DAYS = 25.0, 731
48 −KS = [1, 3, 5, 7, 10, 15, 20, 30, 50]
49 −DS = [0.5, 1, 2, 5, 10, 25]
50 −TS = [3, 6, 12, 24]
51 −SELS = ["geo", "sim", "mix", "adj"]
52 −ADJS = ["raw", "none", "conv", "hedo", "hedo_reg", "local", "gbm", "role"]
53 −WRS = [("equal", "median"), ("equal", "mean"), ("dist", "median"), ("dist", "mean"), ("rec", "median"), ("rec", "mean"),
54 − ("sim", "median"), ("sim", "mean"), ("all", "median"), ("all", "mean")]
55 −ANCHORS = [(3, 2, 6), (5, 5, 12), (10, 10, 24)]
56 −E1 = [dict(sel=s, d=d, T=T, k=k, adj=a, w="equal", recon="median")
57 − for s in ("geo", "sim", "mix") for d in DS for T in TS for k in KS for a in ("none", "hedo")]
58 −E2 = [dict(sel=s, d=d, T=T, k=k, adj=a, w=w, recon=r)
59 − for (k, d, T) in ANCHORS for s in SELS for a in ADJS for (w, r) in WRS]
60 −LOCAL_COLS = [0, 1, 2, 4, 5, 9] # ln_floor, ln_land, age, lien1, lien2, etages
61 −LAMBDA_LOCAL = 50.0
62 −
63 −def wmedian(v, w):
64 − o = np.argsort(v); v = v[o]; w = w[o]
65 − c = np.cumsum(w); return v[np.searchsorted(c, 0.5 * c[-1])]
66 −
67 −def recon(vals, w, how):
68 − ok = ~np.isnan(vals)
69 − if not ok.any(): return np.nan, np.nan
70 − v = vals[ok]; ww = w[ok]
71 − sd = v.std() if len(v) > 1 else np.nan
72 − if how == "median":
73 − return (np.median(v) if ww.min() == ww.max() else wmedian(v, ww)), sd
74 − return np.log(np.sum(ww * np.exp(v)) / ww.sum()), sd
75 −
76 −def evaluate(i):
77 − """Toutes les configurations pour la cible i. Renvoie (est_E1, est_E2, sd_E2, n_avail, info)."""
78 − period = int(ASOF_ARR[ASOF_ARR <= MONTH[i]].max()) # dernier trimestre de ré-estimation ≤ mois de la cible
79 − e1 = np.full(len(E1), np.nan, np.float32); e2 = np.full(len(E2), np.nan, np.float32)
80 − s2 = np.full(len(E2), np.nan, np.float32); nav = np.zeros(len(DS) * len(TS), np.int32)
81 − cand = np.asarray(TREE.query_ball_point((X[i], Y[i]), D_MAX), dtype=np.int64)
82 − keep = (DAY[cand] < DAY[i]) & (DAY[cand] >= DAY[i] - T_MAX_DAYS) & (PROV[cand] != PROV[i])
83 − cand = cand[keep]
84 − info = dict(idx=int(i), asof=period, n_cand=int(len(cand)))
85 − if len(cand) == 0: return e1, e2, s2, nav, info
86 − dist = np.hypot(X[cand] - X[i], Y[cand] - Y[i])
87 − mago = (DAY[i] - DAY[cand]) / 30.4375
88 − m_eval = MONTH[i]; lvl = IDX[m_eval - M_EVAL0]
89 − tadj = np.where(MONTH[cand] == m_eval, 0.0, lvl[RID[cand], m_eval - 1] - lvl[RID[cand], MONTH[cand]])
90 − tadj = np.nan_to_num(tadj, nan=0.0)
91 − lnT = LNP[cand] + tadj
92 − dX = XF[i] - XF[cand] # cible − comparable
93 − # --- ajustements
94 − bg = BETA_G[period]; br = BETA_R[period][RID[i]]
95 − adj = {}
96 − adj["raw"] = LNP[cand]
97 − adj["none"] = lnT
98 − Pt = np.exp(lnT)
99 − d_floor = (FLOOR[i] - FLOOR[cand]) * 0.5 * Pt / FLOOR[cand]
100 − d_land = np.clip((LAND[i] - LAND[cand]) * 0.5 * Pt * PART[cand] / LAND[cand], -0.15 * Pt, 0.15 * Pt)
101 − d_age = np.clip(-0.005 * Pt * (AGE[i] - AGE[cand]), -0.2 * Pt, 0.2 * Pt)
102 − d_lien = np.where((LIEN[i] == 0) & (LIEN[cand] > 0), 0.05 * Pt, np.where((LIEN[i] > 0) & (LIEN[cand] == 0), -0.05 * Pt, 0.0))
103 − tot = np.maximum(d_floor + d_land + d_age + d_lien, -0.6 * Pt)
104 − adj["conv"] = np.log(Pt + tot)
105 − gross_conv = (np.abs(d_floor) + np.abs(d_land) + np.abs(d_age) + np.abs(d_lien)) / Pt
106 − adj["hedo"] = lnT + dX @ bg
107 − gross_hedo = np.abs(dX * bg).sum(axis=1)
108 − adj["hedo_reg"] = lnT + dX @ br
109 − loc = dist <= 10.0
110 − if loc.sum() >= 40:
111 − Xl = XF[cand][loc][:, LOCAL_COLS]; yl = lnT[loc]
112 − b0 = br[LOCAL_COLS]
113 − Xc = Xl - Xl.mean(axis=0); yc = yl - yl.mean()
114 − A = Xc.T @ Xc + LAMBDA_LOCAL * np.eye(len(LOCAL_COLS))
115 − bl = np.linalg.solve(A, Xc.T @ yc + LAMBDA_LOCAL * b0)
116 − adj["local"] = lnT + dX[:, LOCAL_COLS] @ bl
117 − else:
118 − adj["local"] = adj["hedo_reg"]
119 − f = GBM[period]
120 − adj["gbm"] = lnT + (f[i] - f[cand])
121 − same = (MUN[cand] == MUN[i]) & (VINT[cand] == VINT[i])
122 − adj["role"] = np.where(same, LNROLE[i] + (lnT - LNROLE[cand]), np.nan)
123 − # --- scores de sélection
124 − dissim = np.sqrt((dX[:, 0] / 0.25) ** 2 + (dX[:, 1] / 0.6) ** 2 + (dX[:, 2] / 15) ** 2
125 − + (LIEN[cand] != LIEN[i]) * 1.0 + (ETAGES[cand] != ETAGES[i]) * 0.5)
126 − score = {"geo": dist, "sim": dissim + dist / 25.0, "mix": dissim + dist / 2.0, "adj": gross_hedo + dist / 25.0}
127 − # --- pondérations
128 − wts = {"equal": np.ones(len(cand)), "dist": 1.0 / (0.25 + dist), "rec": np.exp(-mago / 6.0), "sim": 1.0 / (0.1 + gross_hedo)}
129 − wts["all"] = wts["dist"] * wts["rec"] * wts["sim"]
130 − # --- masques (d, T) et classements
131 − top = {}
132 − for di, d in enumerate(DS):
133 − for ti, T in enumerate(TS):
134 − m = (dist <= d) & (mago <= T)
135 − nav[di * len(TS) + ti] = int(m.sum())
136 − if not m.any(): continue
137 − ids = np.flatnonzero(m)
138 − for s in SELS:
139 − sc = score[s][ids]
140 − if len(ids) > 50:
141 − part = np.argpartition(sc, 50)[:50]; part = part[np.argsort(sc[part])]
142 − else:
143 − part = np.argsort(sc)
144 − top[(s, d, T)] = ids[part]
145 − # --- E1
146 − for c, cfg in enumerate(E1):
147 − t = top.get((cfg["sel"], cfg["d"], cfg["T"]))
148 − if t is None: continue
149 − sub = t[:cfg["k"]]
150 − e1[c], _ = recon(adj[cfg["adj"]][sub], wts["equal"][sub], "median")
151 − # --- E2
152 − for c, cfg in enumerate(E2):
153 − t = top.get((cfg["sel"], cfg["d"], cfg["T"]))
154 − if t is None: continue
155 − sub = t[:cfg["k"]]
156 − e2[c], s2[c] = recon(adj[cfg["adj"]][sub], wts[cfg["w"]][sub], cfg["recon"])
157 − return e1, e2, s2, nav, info
158 −
159 −def worker(chunk):
160 − return [evaluate(i) for i in chunk]
161 −
162 −if __name__ == "__main__":
163 − targets = df.index[df["period"].isin(["val", "test"])].to_numpy()
164 − if os.environ.get("CV_SAMPLE"):
165 − rng = np.random.default_rng(1); targets = np.sort(rng.choice(targets, int(os.environ["CV_SAMPLE"]), replace=False))
166 − SUF = ""
167 − if os.environ.get("CV_SHARD"): # répartition sur plusieurs nœuds : CV_SHARD=i/N → cibles i, i+N, i+2N, …
168 − i_sh, n_sh = map(int, os.environ["CV_SHARD"].split("/")); targets = targets[i_sh::n_sh]; SUF = f".shard{i_sh}of{n_sh}"
169 − log(f"{len(targets):,} cibles ; {len(E1)} configurations E1, {len(E2)} E2 ; {N:,} ventes dans le bassin")
170 − pd.DataFrame(E1).to_csv(OUT / "configs_E1.csv", index_label="cfg")
171 − pd.DataFrame(E2).to_csv(OUT / "configs_E2.csv", index_label="cfg")
172 − est1 = np.full((len(targets), len(E1)), np.nan, np.float32)
173 − est2 = np.full((len(targets), len(E2)), np.nan, np.float32)
174 − sd2 = np.full((len(targets), len(E2)), np.nan, np.float32)
175 − nav = np.zeros((len(targets), len(DS) * len(TS)), np.int32)
176 − infos = []
177 − chunks = [targets[j:j + 400] for j in range(0, len(targets), 400)]
178 − t0 = time.time(); done = 0
179 − with mp.get_context(os.environ.get("CV_MP", "fork")).Pool(int(os.environ.get("CV_PROCS", 20))) as pool:
180 − for res in pool.imap(worker, chunks):
181 − for (e1, e2, s2, nv, info) in res:
182 − est1[done] = e1; est2[done] = e2; sd2[done] = s2; nav[done] = nv; infos.append(info); done += 1
183 − if done % 20000 < 400:
184 − log(f" {done:,}/{len(targets):,} cibles ({time.time() - t0:.0f} s)")
185 − np.save(OUT / f"est_E1{SUF}.npy", est1); np.save(OUT / f"est_E2{SUF}.npy", est2); np.save(OUT / f"sd_E2{SUF}.npy", sd2); np.save(OUT / f"n_avail{SUF}.npy", nav)
186 − T = df.loc[targets, ["idx", "id", "date", "period", "amount", "ln_price", "region", "region_nom", "mrc", "mrc_nom", "code_mun",
187 − "mun_nom", "unite_voisinage", "floor_area", "land_area", "age", "lien", "etages", "role_vente", "month", "lat", "lng"]].reset_index(drop=True)
188 − T["n_cand"] = [x["n_cand"] for x in infos]; T["asof"] = [x["asof"] for x in infos]
189 − T["n_2km_12m"] = nav[:, DS.index(2) * len(TS) + TS.index(12)]
190 − T["n_5km_12m"] = nav[:, DS.index(5) * len(TS) + TS.index(12)]
191 − save_parquet(T, f"targets{SUF}.parquet")
192 − pd.DataFrame({"DS": [DS], "TS": [TS], "KS": [KS]}).to_json(OUT / "grid_meta.json")
193 − log(f"rétro-évaluation terminée en {time.time() - t0:.0f} s")
deleted M4M64bomparables-validation/pipeline/04b_gather.py +0 −15
@@ -1,15 +0,0 @@
1 −# -*- coding: utf-8 -*-
2 −"""04b — Rassemble les fragments (shards) de la rétro-évaluation calculés sur plusieurs nœuds, dans l'ordre des cibles."""
3 −import glob
4 −from common import *
5 −sh = sorted(glob.glob(str(OUT / "targets.shard*.parquet")))
6 −log(f"{len(sh)} fragments")
7 −Ts = [pd.read_parquet(p) for p in sh]
8 −parts = {}
9 −for name in ["est_E1", "est_E2", "sd_E2", "n_avail"]:
10 − parts[name] = [np.load(p.replace("targets.", f"{name}.").replace(".parquet", ".npy")) for p in sh]
11 −T = pd.concat(Ts, ignore_index=True); order = np.argsort(T["idx"].to_numpy(), kind="stable")
12 −T = T.iloc[order].reset_index(drop=True); save_parquet(T, "targets.parquet")
13 −for name, arrs in parts.items():
14 − A = np.concatenate(arrs, axis=0)[order]; np.save(OUT / f"{name}.npy", A); log(f" {name}: {A.shape}")
15 −log("rassemblement terminé")
deleted M4M64bomparables-validation/pipeline/05_analyze.py +0 −358
@@ -1,358 +0,0 @@
1 −# -*- coding: utf-8 -*-
2 −"""05 — Analyse : métriques par configuration (validation 2024 → choix ; test 2025-26 → verdict),
3 −surface Erreur(k, d, t), effets des facteurs (sélection, ajustement, pondération, réconciliation),
4 −compétition de modèles M1-M13, hypothèses H1-H5, strates (région, densité, prix), intervalles,
5 −règles conditionnelles. Sorties : tables/*.csv, results.json, fig/*.pdf"""
6 −from scipy.optimize import nnls
7 −import matplotlib; matplotlib.use("Agg")
8 −import matplotlib.pyplot as plt
9 −from matplotlib import rcParams
10 −from common import *
11 −
12 −# ---------------------------------------------------------------- style (palette de référence, marques fines)
13 −PAL = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#008300", "#6250d6", "#e34948"]
14 −SEQ = ["#cde2fb", "#9ec5f4", "#6da7ec", "#3987e5", "#256abf", "#184f95", "#0d366b"]
15 −rcParams.update({"font.family": "Helvetica Neue", "font.size": 9, "axes.edgecolor": "#c3c2b7", "axes.linewidth": 0.6,
16 − "axes.grid": True, "grid.color": "#e8e7e3", "grid.linewidth": 0.5, "axes.spines.top": False,
17 − "axes.spines.right": False, "legend.frameon": False, "figure.dpi": 150, "savefig.bbox": "tight",
18 − "axes.titleweight": "bold", "axes.titlesize": 10, "axes.labelcolor": "#52514e", "xtick.color": "#52514e",
19 − "ytick.color": "#52514e", "figure.facecolor": "#fcfcfb", "axes.facecolor": "#fcfcfb"})
20 −from matplotlib.colors import LinearSegmentedColormap
21 −CMAP = LinearSegmentedColormap.from_list("seqblue", SEQ)
22 −TAB = OUT / "tables"; TAB.mkdir(exist_ok=True); FIG = OUT / "fig"
23 −def savefig(fig, name): fig.savefig(FIG / f"{name}.pdf"); fig.savefig(FIG / f"{name}.png"); plt.close(fig)
24 −
25 −# ---------------------------------------------------------------- chargement
26 −T = load_parquet("targets.parquet"); S = load_parquet("sales.parquet").set_index("idx")
27 −E1 = pd.read_csv(OUT / "configs_E1.csv"); E2 = pd.read_csv(OUT / "configs_E2.csv")
28 −est1 = np.load(OUT / "est_E1.npy"); est2 = np.load(OUT / "est_E2.npy"); sd2 = np.load(OUT / "sd_E2.npy"); nav = np.load(OUT / "n_avail.npy")
29 −meta = load_json("index_meta.json"); regions = meta["regions"]; M_EVAL0 = meta["M_EVAL0"]; IDX = np.load(OUT / "index_asof.npy")
30 −rid = {r: i for i, r in enumerate(regions)}
31 −lnP = T["ln_price"].to_numpy(np.float32)
32 −VAL = (T["period"] == "val").to_numpy(); TEST = (T["period"] == "test").to_numpy()
33 −log(f"{len(T):,} cibles (val {VAL.sum():,}, test {TEST.sum():,}) ; E1 {est1.shape[1]} ; E2 {est2.shape[1]}")
34 −
35 −def err_of(est): return (np.exp(est - lnP[:, None]) - 1).astype(np.float32)
36 −def metrics(err, sel):
37 − e = err[sel]; a = np.abs(e)
38 − ok = ~np.isnan(a); n = ok.sum(axis=0)
39 − with np.errstate(all="ignore"):
40 − return pd.DataFrame({"mdape": np.nanmedian(a, axis=0) * 100, "mape": np.nanmean(a, axis=0) * 100,
41 − "biais": np.nanmedian(e, axis=0) * 100, "biais_moy": np.nanmean(e, axis=0) * 100,
42 − "p5": np.nanmean(a <= 0.05, axis=0) * 100, "p10": np.nanmean(a <= 0.10, axis=0) * 100,
43 − "p20": np.nanmean(a <= 0.20, axis=0) * 100, "couverture": n / sel.sum() * 100, "n": n})
44 −err1 = err_of(est1); err2 = err_of(est2)
45 −del est1
46 −res = {}
47 −
48 −# ---------------------------------------------------------------- densité & strates
49 −T["dens"] = pd.cut(T["n_2km_12m"], [-1, 4, 19, 49, 199, 10 ** 9], labels=["< 5", "5-19", "20-49", "50-199", "≥ 200"])
50 −T["prix_q"] = pd.qcut(T["amount"], 5, labels=["Q1 (bas)", "Q2", "Q3", "Q4", "Q5 (haut)"])
51 −T["trimestre"] = pd.to_datetime(T["date"]).dt.to_period("Q").astype(str)
52 −T["age_cl"] = pd.cut(T["age"], [-1, 3, 15, 40, 70, 1000], labels=["≤ 3 ans", "4-15", "16-40", "41-70", "> 70"])
53 −T["cluster"] = pd.factorize(T["code_mun"].astype(str) + "|" + T["trimestre"])[0]
54 −
55 −# ---------------------------------------------------------------- E1 : surface Erreur(k, d, t)
56 −mv = metrics(err1, VAL).add_suffix("_val"); mt = metrics(err1, TEST).add_suffix("_test")
57 −surf = pd.concat([E1, mv, mt], axis=1)
58 −surf.to_csv(TAB / "surface_E1.csv", index=False)
59 −ok = surf["couverture_val"] >= 97
60 −best_e1 = surf[ok].sort_values("mdape_val").iloc[0]
61 −res["E1_best_val"] = best_e1.to_dict()
62 −log(f"E1 meilleure (val, couverture ≥ 97 %) : {best_e1[['sel','d','T','k','adj']].to_dict()} → val {best_e1.mdape_val:.2f} %, test {best_e1.mdape_test:.2f} %")
63 −for (s, a), g in surf[ok].groupby(["sel", "adj"]):
64 − b = g.sort_values("mdape_val").iloc[0]
65 − res.setdefault("E1_best_by_sel_adj", {})[f"{s}|{a}"] = dict(k=int(b.k), d=float(b.d), T=int(b["T"]), mdape_val=float(b.mdape_val), mdape_test=float(b.mdape_test), couverture_test=float(b.couverture_test))
66 −
67 −# figure : MdAPE vs k selon d (T = 12, geo, hedo, test)
68 −fig, axes = plt.subplots(1, 2, figsize=(9, 3.4), sharey=True)
69 −for ax, a in zip(axes, ["none", "hedo"]):
70 − for j, d in enumerate([0.5, 1, 2, 5, 10, 25]):
71 − g = surf[(surf.sel == "geo") & (surf["T"] == 12) & (surf.adj == a) & (surf.d == d)].sort_values("k")
72 − ax.plot(g.k, g.mdape_test, color=PAL[j], lw=2, marker="o", ms=4, label=f"d ≤ {d} km")
73 − ax.set_xscale("log"); ax.set_xticks([1, 3, 5, 10, 20, 50]); ax.set_xticklabels([1, 3, 5, 10, 20, 50])
74 − ax.set_xlabel("nombre de comparables k"); ax.set_title({"none": "sans ajustement (temps seulement)", "hedo": "grille hédonique"}[a])
75 −axes[0].set_ylabel("MdAPE, test 2025-26 (%)"); axes[1].legend(ncol=2, fontsize=8)
76 −savefig(fig, "f1_mdape_vs_k")
77 −# figure : carte d × T au meilleur k (geo, hedo)
78 −fig, axes = plt.subplots(1, 2, figsize=(8, 3.2))
79 −for ax, a in zip(axes, ["none", "hedo"]):
80 − g = surf[(surf.sel == "geo") & (surf.adj == a)].groupby(["d", "T"])["mdape_test"].min().unstack("T")
81 − im = ax.imshow(g.to_numpy(), cmap=CMAP, aspect="auto")
82 − ax.set_xticks(range(len(g.columns))); ax.set_xticklabels([f"{t} mois" for t in g.columns]); ax.set_yticks(range(len(g.index))); ax.set_yticklabels([f"{d} km" for d in g.index])
83 − for i in range(g.shape[0]):
84 − for j in range(g.shape[1]):
85 − v = g.iloc[i, j]; ax.text(j, i, f"{v:.1f}", ha="center", va="center", fontsize=7.5, color="white" if v < np.nanpercentile(g.to_numpy(), 40) else "#0b0b0b")
86 − ax.grid(False); ax.set_title({"none": "sans ajustement", "hedo": "grille hédonique"}[a] + " — MdAPE au meilleur k")
87 −savefig(fig, "f2_surface_d_T")
88 −
89 −# ---------------------------------------------------------------- E2 : facteurs
90 −mv2 = metrics(err2, VAL).add_suffix("_val"); mt2 = metrics(err2, TEST).add_suffix("_test")
91 −fac = pd.concat([E2, mv2, mt2], axis=1); fac.to_csv(TAB / "facteurs_E2.csv", index=False)
92 −anchor = (fac.k == 5) & (fac.d == 5) & (fac["T"] == 12)
93 −base = anchor & (fac.sel == "geo") & (fac.w == "equal") & (fac.recon == "median")
94 −res["E2_adjust_anchor"] = fac[base].set_index("adj")[["mdape_val", "mdape_test", "mape_test", "biais_test", "p10_test", "couverture_test"]].to_dict("index")
95 −res["E2_sel_anchor"] = fac[anchor & (fac.adj == "hedo") & (fac.w == "equal") & (fac.recon == "median")].set_index("sel")[["mdape_val", "mdape_test", "p10_test"]].to_dict("index")
96 −res["E2_wr_anchor"] = {f"{r.w}|{r.recon}": dict(mdape_val=r.mdape_val, mdape_test=r.mdape_test, p10_test=r.p10_test)
97 − for r in fac[anchor & (fac.adj == "hedo") & (fac.sel == "geo")].itertuples()}
98 −# effets marginaux moyens (moyenne de MdAPE test sur toutes les autres modalités)
99 −marg = {}
100 −for f in ["sel", "adj", "w", "recon"]:
101 − marg[f] = fac.groupby(f)["mdape_test"].mean().round(2).to_dict()
102 −res["E2_marginal"] = marg
103 −anc_tab = fac.groupby(["k", "d", "T"])["mdape_test"].min().round(2).to_dict()
104 −res["E2_anchor_best"] = {f"{k}|{d}|{t}": v for (k, d, t), v in anc_tab.items()}
105 −# figure : ajustements à l'ancre (val et test)
106 −fig, ax = plt.subplots(figsize=(7, 3.2))
107 −g = fac[base].set_index("adj").loc[["raw", "none", "conv", "hedo", "hedo_reg", "local", "gbm", "role"]]
108 −lab = {"raw": "aucun", "none": "temps seul", "conv": "grille conv.", "hedo": "hédonique", "hedo_reg": "hédo. régionale", "local": "locale", "gbm": "GBM", "role": "ratio au rôle"}
109 −x = np.arange(len(g)); ax.bar(x - 0.2, g.mdape_val, 0.38, color=PAL[0], label="validation 2024"); ax.bar(x + 0.2, g.mdape_test, 0.38, color=PAL[1], label="test 2025-26")
110 −for i, (v1, v2) in enumerate(zip(g.mdape_val, g.mdape_test)):
111 − ax.text(i - 0.2, v1 + 0.2, f"{v1:.1f}", ha="center", fontsize=7); ax.text(i + 0.2, v2 + 0.2, f"{v2:.1f}", ha="center", fontsize=7)
112 −ax.set_xticks(x); ax.set_xticklabels([lab[a] for a in g.index]); ax.set_ylabel("MdAPE (%)"); ax.legend(); ax.set_title("Type d'ajustement — 5 comparables les plus proches, ≤ 5 km, ≤ 12 mois, médiane")
113 −savefig(fig, "f3_ajustements")
114 −
115 −# ---------------------------------------------------------------- modèles de référence (M10, M11) et compétition
116 −def level_shift(m_asof):
117 − mt_ = T["month"].to_numpy(); r = T["region"].map(rid).to_numpy()
118 − return IDX[mt_ - M_EVAL0, r, mt_ - 1] - IDX[mt_ - M_EVAL0, r, m_asof - 1]
119 −Sx = S.loc[T["idx"]]
120 −XF = np.column_stack([Sx.ln_floor, Sx.ln_land, Sx.age, Sx.age ** 2 / 100, Sx.lien == 1, Sx.lien >= 2, Sx.genre == 1, Sx.genre == 2, Sx.genre == 3, Sx.etages, Sx.neuf]).astype(float)
121 −M10 = np.full(len(T), np.nan); M11 = np.full(len(T), np.nan); M11b = np.full(len(T), np.nan)
122 −for a in load_json("asof.json")["asof"]:
123 − mask = (T["asof"] == a).to_numpy(); period = str(a)
124 − if not mask.any(): continue
125 − mod = load_json(f"models_{period}.json"); beta = np.array([mod["beta_global"][f] for f in mod["feats"]])
126 − shift = level_shift(mod["m_asof"])
127 − fe = (T["region"].map(mod["region_fe"]).fillna(0) + T["mrc"].map(mod["mrc_eff"]).fillna(0) + T["code_mun"].map(mod["mun_eff"]).fillna(0)
128 − + (T["code_mun"].astype(str) + "|" + T["unite_voisinage"].astype(str)).map(mod["uv_eff"]).fillna(0)).to_numpy()
129 − M10[mask] = (XF @ beta + fe + shift + mod["median_shift"])[mask]
130 − M11[mask] = (np.load(OUT / f"gbm_role_pred_{period}.npy")[T["idx"].to_numpy()] + shift)[mask]
131 − M11b[mask] = (np.load(OUT / f"gbm_pred_{period}.npy")[T["idx"].to_numpy()] + shift)[mask]
132 −
133 −def e2col(sel, k, d, Tm, adj, w, recon):
134 − m = (E2.sel == sel) & (E2.k == k) & (E2.d == d) & (E2["T"] == Tm) & (E2.adj == adj) & (E2.w == w) & (E2.recon == recon)
135 − return int(E2.index[m][0])
136 −def e1col(sel, k, d, Tm, adj):
137 − m = (E1.sel == sel) & (E1.k == k) & (E1.d == d) & (E1["T"] == Tm) & (E1.adj == adj)
138 − return int(E1.index[m][0])
139 −MODELS = {
140 − "M1 médiane des 3 plus proches (temps seul)": ("E2", e2col("geo", 3, 2, 6, "none", "equal", "median")),
141 − "M2 moyenne des 5 plus proches (temps seul)": ("E2", e2col("geo", 5, 5, 12, "none", "equal", "mean")),
142 − "M3 sélection par similarité physique": ("E2", e2col("sim", 5, 5, 12, "none", "equal", "median")),
143 − "M4 pondération par distance": ("E2", e2col("geo", 5, 5, 12, "none", "dist", "mean")),
144 − "M5 pondération par récence": ("E2", e2col("geo", 5, 5, 12, "none", "rec", "mean")),
145 − "M6 grille d'ajustement conventionnelle": ("E2", e2col("geo", 5, 5, 12, "conv", "equal", "median")),
146 − "M7 grille d'ajustement hédonique": ("E2", e2col("geo", 5, 5, 12, "hedo", "equal", "median")),
147 − "M8a grille locale (régression sur le voisinage)": ("E2", e2col("geo", 5, 5, 12, "local", "equal", "median")),
148 − "M8b ajustements non linéaires (GBM)": ("E2", e2col("geo", 5, 5, 12, "gbm", "equal", "median")),
149 − "M13 ratio au rôle (5 plus proches, même rôle)": ("E2", e2col("geo", 5, 5, 12, "role", "equal", "median")),
150 − "M10 hédonique globale directe": ("X", M10),
151 − "M11a gradient boosting (sans rôle)": ("X", M11b),
152 − "M11b gradient boosting (avec rôle)": ("X", M11),
153 −}
154 −def est_of(spec):
155 − kind, c = spec
156 − return est2[:, c].astype(float) if kind == "E2" else c
157 −# M0 : meilleure configuration empirique choisie sur la validation (E1 ∪ E2, couverture ≥ 90 %)
158 −cand_all = pd.concat([surf.assign(src="E1"), fac.assign(src="E2")], ignore_index=True)
159 −cand_ok = cand_all[cand_all.couverture_val >= 97].sort_values("mdape_val")
160 −b0 = cand_ok.iloc[0]
161 −M0_est = (est2[:, int(b0.cfg)] if b0.src == "E2" else np.load(OUT / "est_E1.npy", mmap_mode="r")[:, int(b0.cfg)]).astype(float)
162 −res["M0"] = b0[["src", "sel", "d", "T", "k", "adj", "w", "recon", "mdape_val", "mdape_test", "couverture_test"]].to_dict()
163 −res["M0_sans_contrainte"] = cand_all[cand_all.couverture_val >= 50].sort_values("mdape_val").iloc[0][["src", "sel", "d", "T", "k", "adj", "w", "recon", "mdape_val", "mdape_test", "couverture_val", "couverture_test"]].to_dict()
164 −MODELS["M0 meilleure configuration empirique (choisie en 2024, couverture ≥ 97 %)"] = ("X", M0_est)
165 −# M9 : réconciliation optimisée = combinaison convexe (NNLS sur la validation) d'estimations de comparables
166 −stack_cols = {a: e2col("geo", 5, 5, 12, a, "equal", "median") for a in ["none", "conv", "hedo", "hedo_reg", "local", "gbm", "role"]}
167 −def stack(names, mats, fit_mask):
168 − """Combinaison convexe apprise par NNLS sur la validation ; si une composante manque pour une cible,
169 − les poids des composantes disponibles sont renormalisés."""
170 − A = np.column_stack(mats); okr = ~np.isnan(A).any(axis=1)
171 − w, _ = nnls(A[okr & fit_mask], lnP[okr & fit_mask].astype(float)); w = w / w.sum()
172 − W = np.where(np.isnan(A), 0.0, w[None, :]); den = W.sum(axis=1)
173 − out = np.nansum(A * W, axis=1) / np.where(den > 0, den, np.nan)
174 − return out, dict(zip(names, w.round(3)))
175 −M9_est, w9 = stack(list(stack_cols), [est2[:, c].astype(float) for c in stack_cols.values()], VAL)
176 −res["M9_weights"] = w9; MODELS["M9 réconciliation optimisée (poids appris en 2024)"] = ("X", M9_est)
177 −M12_est, w12 = stack(["gbm_role", "comps_gbm", "comps_hedo"], [M11, est2[:, stack_cols["gbm"]].astype(float), est2[:, stack_cols["hedo"]].astype(float)], VAL)
178 −res["M12_weights"] = w12; MODELS["M12 hybride comparables + GBM (poids appris en 2024)"] = ("X", M12_est)
179 −
180 −rows = []
181 −ERR = {}
182 −for name, spec in MODELS.items():
183 − e = (np.exp(est_of(spec) - lnP) - 1).astype(np.float32); ERR[name] = e
184 − for per, mask in [("val", VAL), ("test", TEST)]:
185 − m = metrics(e[:, None], mask).iloc[0].to_dict(); m.update(modele=name, periode=per); rows.append(m)
186 −comp = pd.DataFrame(rows); comp.to_csv(TAB / "competition_modeles.csv", index=False)
187 −pd.DataFrame({n.split(" ")[0]: est_of(s_) for n, s_ in MODELS.items()}).assign(idx=T["idx"].to_numpy()).to_parquet(OUT / "models_est.parquet", index=False)
188 −res["competition"] = comp[comp.periode == "test"].set_index("modele")[["mdape", "mape", "biais", "p5", "p10", "p20", "couverture"]].round(2).to_dict("index")
189 −res["competition_val"] = comp[comp.periode == "val"].set_index("modele")[["mdape", "p10", "couverture"]].round(2).to_dict("index")
190 −log("compétition (test) :\n" + comp[comp.periode == "test"][["modele", "mdape", "mape", "biais", "p10", "couverture"]].round(2).to_string(index=False))
191 −fig, ax = plt.subplots(figsize=(7.5, 5))
192 −g = comp[comp.periode == "test"].sort_values("mdape", ascending=False)
193 −cols = [PAL[0] if ("M1" in n and "M1 " not in n) or n.startswith(("M10", "M11", "M12")) else PAL[2] if n.startswith(("M0", "M9")) else PAL[1] for n in g.modele]
194 −cols = [PAL[0] if n.startswith(("M10", "M11", "M12")) else (PAL[2] if n.startswith(("M0", "M9")) else PAL[1]) for n in g.modele]
195 −ax.barh(g.modele, g.mdape, color=cols, height=0.65)
196 −for i, v in enumerate(g.mdape): ax.text(v + 0.15, i, f"{v:.1f} %", va="center", fontsize=8)
197 −ax.set_xlabel("MdAPE, test 2025-26 (%)"); ax.set_title("Compétition de modèles (orange : comparables ; vert : optimisés ; bleu : références externes)")
198 −savefig(fig, "f4_competition")
199 −
200 −# ---------------------------------------------------------------- bootstrap par grappes (municipalité × trimestre) des différences appariées
201 −rng = np.random.default_rng(7)
202 −def paired(eA, eB, mask, B=400):
203 − a = np.abs(eA[mask]); b = np.abs(eB[mask]); cl = T["cluster"].to_numpy()[mask]
204 − okp = ~np.isnan(a) & ~np.isnan(b); a, b, cl = a[okp], b[okp], cl[okp]
205 − d = a - b
206 − cl_ids, inv = np.unique(cl, return_inverse=True); nc = len(cl_ids)
207 − sums = np.bincount(inv, d, nc); cnts = np.bincount(inv, minlength=nc)
208 − boots_mean = []; boots_med = []
209 − order = np.argsort(inv); d_sorted = d[order]; starts = np.searchsorted(inv[order], np.arange(nc)); ends = np.append(starts[1:], len(d))
210 − for _ in range(B):
211 − pick = rng.integers(0, nc, nc)
212 − boots_mean.append(sums[pick].sum() / cnts[pick].sum())
213 − if _ < 150:
214 − idxs = np.concatenate([np.arange(starts[p], ends[p]) for p in pick])
215 − boots_med.append(np.median(a[order][idxs]) - np.median(b[order][idxs]))
216 − return dict(n=int(len(d)), diff_mape=float(d.mean() * 100), ci_mape=[float(np.percentile(boots_mean, 2.5) * 100), float(np.percentile(boots_mean, 97.5) * 100)],
217 − diff_mdape=float((np.median(a) - np.median(b)) * 100), ci_mdape=[float(np.percentile(boots_med, 2.5) * 100), float(np.percentile(boots_med, 97.5) * 100)],
218 − part_A_meilleur=float((a < b).mean() * 100))
219 −PAIRS = [
220 − ("M7 grille d'ajustement hédonique", "M2 moyenne des 5 plus proches (temps seul)"),
221 − ("M7 grille d'ajustement hédonique", "M6 grille d'ajustement conventionnelle"),
222 − ("M8a grille locale (régression sur le voisinage)", "M7 grille d'ajustement hédonique"),
223 − ("M8b ajustements non linéaires (GBM)", "M7 grille d'ajustement hédonique"),
224 − ("M11b gradient boosting (avec rôle)", "M0 meilleure configuration empirique (choisie en 2024, couverture ≥ 97 %)"),
225 − ("M11a gradient boosting (sans rôle)", "M0 meilleure configuration empirique (choisie en 2024, couverture ≥ 97 %)"),
226 − ("M12 hybride comparables + GBM (poids appris en 2024)", "M11b gradient boosting (avec rôle)"),
227 − ("M13 ratio au rôle (5 plus proches, même rôle)", "M7 grille d'ajustement hédonique"),
228 − ("M3 sélection par similarité physique", "M2 moyenne des 5 plus proches (temps seul)"),
229 − ("M9 réconciliation optimisée (poids appris en 2024)", "M7 grille d'ajustement hédonique"),
230 − ("M1 médiane des 3 plus proches (temps seul)", "M2 moyenne des 5 plus proches (temps seul)"),
231 −]
232 −res["paires"] = {f"{a} vs {b}": paired(ERR[a], ERR[b], TEST) for a, b in PAIRS}
233 −pd.DataFrame([dict(A=a, B=b, **res["paires"][f"{a} vs {b}"]) for a, b in PAIRS]).to_csv(TAB / "paires_bootstrap.csv", index=False)
234 −
235 −# ---------------------------------------------------------------- strates
236 −def strata(err, by):
237 − out = []
238 − for lev, g in T[TEST].groupby(by, observed=True):
239 − m = np.zeros(len(T), bool); m[g.index] = True
240 − r = metrics(err[:, None], m).iloc[0].to_dict(); r[by] = str(lev); out.append(r)
241 − return pd.DataFrame(out)
242 −KEY = ["M2 moyenne des 5 plus proches (temps seul)", "M7 grille d'ajustement hédonique", "M8a grille locale (régression sur le voisinage)",
243 − "M13 ratio au rôle (5 plus proches, même rôle)", "M0 meilleure configuration empirique (choisie en 2024, couverture ≥ 97 %)", "M10 hédonique globale directe", "M11b gradient boosting (avec rôle)", "M12 hybride comparables + GBM (poids appris en 2024)"]
244 −for by in ["region_nom", "dens", "prix_q", "trimestre", "age_cl", "lien"]:
245 − tabs = []
246 − for name in KEY:
247 − s = strata(ERR[name], by); s["modele"] = name.split(" ")[0]; tabs.append(s)
248 − st = pd.concat(tabs); st.to_csv(TAB / f"strates_{by}.csv", index=False)
249 − res[f"strates_{by}"] = st.pivot(index=by, columns="modele", values="mdape").round(2).to_dict("index")
250 − res[f"strates_{by}_n"] = st[st.modele == "M7"].set_index(by)["n"].to_dict()
251 −# figure : densité
252 −fig, ax = plt.subplots(figsize=(6.5, 3.4))
253 −st = pd.read_csv(TAB / "strates_dens.csv")
254 −order = ["< 5", "5-19", "20-49", "50-199", "≥ 200"]
255 −for j, (name, lab_) in enumerate([("M2", "M2 5 plus proches"), ("M7", "M7 grille hédonique"), ("M0", "M0 meilleure config."), ("M11b", "M11b GBM"), ("M12", "M12 hybride")]):
256 − g = st[st.modele == name].set_index("dens").loc[order]
257 − ax.plot(order, g.mdape, color=PAL[j], lw=2, marker="o", ms=4, label=lab_)
258 −ax.set_xlabel("ventes disponibles dans un rayon de 2 km au cours des 12 mois précédents"); ax.set_ylabel("MdAPE, test (%)"); ax.legend(fontsize=8); ax.set_title("Précision selon la liquidité du sous-marché")
259 −savefig(fig, "f5_densite")
260 −
261 −# ---------------------------------------------------------------- hypothèses
262 −H = {}
263 −# H1 : proche mais dissemblable vs loin mais semblable (k = 5, T = 12, sans ajustement)
264 −H["H1"] = {"geo_d1": surf.loc[e1col("geo", 5, 1, 12, "none"), ["mdape_test", "couverture_test"]].to_dict(),
265 − "sim_d10": surf.loc[e1col("sim", 5, 10, 12, "none"), ["mdape_test", "couverture_test"]].to_dict(),
266 − "geo_d10": surf.loc[e1col("geo", 5, 10, 12, "none"), ["mdape_test", "couverture_test"]].to_dict(),
267 − "mix_d10": surf.loc[e1col("mix", 5, 10, 12, "none"), ["mdape_test", "couverture_test"]].to_dict(),
268 − "sim_d10_hedo": surf.loc[e1col("sim", 5, 10, 12, "hedo"), ["mdape_test", "couverture_test"]].to_dict(),
269 − "geo_d10_hedo": surf.loc[e1col("geo", 5, 10, 12, "hedo"), ["mdape_test", "couverture_test"]].to_dict(),
270 − "mix_d10_hedo": surf.loc[e1col("mix", 5, 10, 12, "hedo"), ["mdape_test", "couverture_test"]].to_dict()}
271 −# H2 : k optimal selon la densité (geo, d = 10, T = 12, hedo et none)
272 −h2 = {}
273 −for a in ["none", "hedo"]:
274 − for lev in order:
275 − m = TEST & (T["dens"] == lev).to_numpy()
276 − cols_ = [e1col("geo", k, 10, 12, a) for k in [1, 3, 5, 7, 10, 15, 20, 30, 50]]
277 − md = np.nanmedian(np.abs(err1[m][:, cols_]), axis=0) * 100
278 − h2[f"{a}|{lev}"] = dict(zip([1, 3, 5, 7, 10, 15, 20, 30, 50], md.round(2)))
279 −H["H2"] = h2
280 −fig, axes = plt.subplots(1, 2, figsize=(9, 3.3), sharey=True)
281 −for ax, a in zip(axes, ["none", "hedo"]):
282 − for j, lev in enumerate(order):
283 − v = h2[f"{a}|{lev}"]; ax.plot(list(v), list(v.values()), color=PAL[j], lw=2, marker="o", ms=3.5, label=f"densité {lev}")
284 − ax.set_xscale("log"); ax.set_xticks([1, 3, 5, 10, 20, 50]); ax.set_xticklabels([1, 3, 5, 10, 20, 50]); ax.set_xlabel("k"); ax.set_title({"none": "temps seul", "hedo": "grille hédonique"}[a] + " — d ≤ 10 km, 12 mois")
285 −axes[0].set_ylabel("MdAPE, test (%)"); axes[1].legend(fontsize=8)
286 −savefig(fig, "f6_k_par_densite")
287 −# H3 : local vs hédonique fixe selon la densité, aux trois ancres
288 −h3 = {}
289 −for (k, d, Tm) in [(3, 2, 6), (5, 5, 12), (10, 10, 24)]:
290 − for lev in order:
291 − m = TEST & (T["dens"] == lev).to_numpy()
292 − h3[f"{k}|{d}|{Tm}|{lev}"] = {a: float(np.nanmedian(np.abs(err2[m][:, e2col("geo", k, d, Tm, a, "equal", "median")])) * 100) for a in ["conv", "hedo", "hedo_reg", "local", "gbm"]}
293 −H["H3"] = h3
294 −# H4 : pondération selon la densité (ancre 5/5/12, hedo)
295 −h4 = {}
296 −for lev in order:
297 − m = TEST & (T["dens"] == lev).to_numpy()
298 − h4[lev] = {f"{w}|{r}": float(np.nanmedian(np.abs(err2[m][:, e2col("geo", 5, 5, 12, "hedo", w, r)])) * 100) for (w, r) in [("equal", "median"), ("equal", "mean"), ("dist", "mean"), ("rec", "mean"), ("sim", "mean"), ("all", "mean"), ("all", "median")]}
299 −H["H4"] = h4
300 −# H5 : méthodes simples vs ML selon l'homogénéité (dispersion des prix ajustés des 10 voisins à 10 km / 24 mois)
301 −het = sd2[:, e2col("geo", 10, 10, 24, "hedo", "equal", "median")].astype(float)
302 −T["heterog"] = pd.qcut(pd.Series(np.where(np.isnan(het), np.nanmax(het), het)), 3, labels=["homogène", "moyen", "hétérogène"])
303 −h5 = {}
304 −for lev in ["homogène", "moyen", "hétérogène"]:
305 − m = TEST & (T["heterog"] == lev).to_numpy()
306 − h5[lev] = {n.split(" ")[0]: float(np.nanmedian(np.abs(ERR[n][m])) * 100) for n in KEY}
307 − h5[lev]["sd_median"] = float(np.nanmedian(het[m]))
308 −H["H5"] = h5
309 −res["hypotheses"] = H
310 −
311 −# ---------------------------------------------------------------- intervalles d'incertitude (couverture)
312 −intervals = {}
313 −for a in ["none", "hedo", "gbm"]:
314 − for (k, d, Tm) in [(5, 5, 12), (10, 10, 24)]:
315 − c = e2col("geo", k, d, Tm, a, "equal", "median"); e = est2[:, c].astype(float); s = sd2[:, c].astype(float)
316 − okv = VAL & ~np.isnan(e) & ~np.isnan(s) & (s > 0)
317 − z_needed = np.abs(lnP[okv] - e[okv]) / s[okv]; z80 = float(np.percentile(z_needed, 80))
318 − okt = TEST & ~np.isnan(e) & ~np.isnan(s) & (s > 0)
319 − cov_nominal = float(np.mean(np.abs(lnP[okt] - e[okt]) <= 1.2816 * s[okt]) * 100)
320 − cov_cal = float(np.mean(np.abs(lnP[okt] - e[okt]) <= z80 * s[okt]) * 100)
321 − width = float(np.median(2 * z80 * s[okt]) * 100)
322 − intervals[f"{a}|{k}|{d}|{Tm}"] = dict(z80_val=z80, couverture_nominale_80=cov_nominal, couverture_calibree=cov_cal, largeur_mediane_pct=width, n_test=int(okt.sum()))
323 −res["intervalles"] = intervals
324 −
325 −# ---------------------------------------------------------------- règles conditionnelles (système de recommandation)
326 −cond = {}; est_cond = np.full(len(T), np.nan)
327 −for lev in order:
328 − mv_ = VAL & (T["dens"] == lev).to_numpy(); mt_ = TEST & (T["dens"] == lev).to_numpy()
329 − m1 = metrics(err1, mv_); m2 = metrics(err2, mv_)
330 − allm = pd.concat([pd.concat([E1, m1], axis=1).assign(src="E1"), pd.concat([E2, m2], axis=1).assign(src="E2")], ignore_index=True)
331 − b = allm[allm.couverture >= 95].sort_values("mdape").iloc[0]
332 − e = (err1 if b.src == "E1" else err2)[:, int(b.cfg)]
333 − est_cond[mt_] = e[mt_]
334 − cond[lev] = dict(b[["src", "sel", "d", "T", "k", "adj", "w", "recon", "mdape", "couverture"]].to_dict(), mdape_test=float(np.nanmedian(np.abs(e[mt_])) * 100), n_test=int(mt_.sum()))
335 −res["regles_conditionnelles"] = cond
336 −res["regles_conditionnelles_global_test"] = dict(mdape=float(np.nanmedian(np.abs(est_cond[TEST])) * 100), p10=float(np.nanmean(np.abs(est_cond[TEST]) <= 0.1) * 100), couverture=float(np.mean(~np.isnan(est_cond[TEST])) * 100))
337 −pd.DataFrame(cond).T.to_csv(TAB / "regles_conditionnelles.csv")
338 −
339 −# ---------------------------------------------------------------- stabilité temporelle, indice, divers
340 −tq = []
341 −for q, g in T[TEST].groupby("trimestre"):
342 − m = np.zeros(len(T), bool); m[g.index] = True
343 − tq.append(dict(trimestre=q, n=int(m.sum()), **{n.split(" ")[0]: float(np.nanmedian(np.abs(ERR[n][m])) * 100) for n in KEY},
344 − **{"biais_" + n.split(" ")[0]: float(np.nanmedian(ERR[n][m]) * 100) for n in KEY}))
345 −pd.DataFrame(tq).to_csv(TAB / "stabilite_trimestres.csv", index=False); res["stabilite"] = tq
346 −fig, ax = plt.subplots(figsize=(7, 3.2))
347 −idxf = pd.read_csv(OUT / "index_full.csv")
348 −for j, (r, lab_) in enumerate([("QC", "Québec"), ("06", "Montréal"), ("03", "Capitale-Nationale"), ("07", "Outaouais"), ("05", "Estrie")]):
349 − g = idxf[idxf.region.astype(str).str.zfill(2) == r] if r != "QC" else idxf[idxf.region == "QC"]
350 − if len(g): ax.plot(pd.to_datetime(g.ym), np.exp(g.log_idx) * 100, color=PAL[j], lw=2 if r == "QC" else 1.4, label=lab_)
351 −ax.set_ylabel("indice hédonique (2021-01 = 100)"); ax.legend(fontsize=8); ax.set_title("Indice de prix mensuel (plein échantillon, descriptif)")
352 −savefig(fig, "f7_indice")
353 −res["dataset"] = load_json("dataset_stats.json")
354 −res["n_targets"] = dict(val=int(VAL.sum()), test=int(TEST.sum()))
355 −res["densite_distribution"] = T[TEST]["dens"].value_counts().sort_index().to_dict()
356 −res["n_cand_median"] = dict(val=float(np.median(T[VAL].n_cand)), test=float(np.median(T[TEST].n_cand)))
357 −save_json(res, "results.json")
358 −log("analyse terminée")
deleted M4M64bomparables-validation/pipeline/06_report.py +0 −313
@@ -1,313 +0,0 @@
1 −# -*- coding: utf-8 -*-
2 −"""06 — Rapport : assemble docs/RAPPORT.md à partir de results.json et des tables (texte + tableaux + figures).
3 −Les phrases d'interprétation sont calculées à partir des résultats (jamais de chiffre écrit à la main)."""
4 −from common import *
5 −
6 −R = load_json("results.json"); TAB = OUT / "tables"
7 −DOCS = ROOT / "docs"; DOCS.mkdir(exist_ok=True)
8 −D = R["dataset"]; comp = R["competition"]; compv = R["competition_val"]
9 −def f1(x): return f"{x:.1f}".replace(".", ",")
10 −def f2(x): return f"{x:.2f}".replace(".", ",")
11 −def n_(x): return f"{int(round(x)):,}".replace(",", " ")
12 −def pct(x): return f"{f1(x)} %"
13 −def md_table(df, cols=None, fmt=None, index=False):
14 − if cols: df = df[cols]
15 − if index: df = df.reset_index()
16 − fmt = fmt or {}
17 − head = "| " + " | ".join(str(c) for c in df.columns) + " |\n|" + "|".join("---" for _ in df.columns) + "|\n"
18 − rows = []
19 − for _, r in df.iterrows():
20 − cells = []
21 − for c in df.columns:
22 − v = r[c]
23 − if c in fmt: cells.append(fmt[c](v))
24 − elif isinstance(v, float): cells.append(f1(v))
25 − else: cells.append(str(v))
26 − rows.append("| " + " | ".join(cells) + " |")
27 − return head + "\n".join(rows) + "\n"
28 −short = lambda name: name.split(" ")[0]
29 −LAB = {"raw": "aucun ajustement", "none": "temps seulement", "conv": "grille conventionnelle", "hedo": "grille hédonique", "hedo_reg": "grille hédonique régionale",
30 − "local": "régression locale", "gbm": "ajustements GBM", "role": "ratio au rôle", "geo": "les plus proches (distance)", "sim": "les plus semblables (physique)",
31 − "mix": "compromis distance / similarité", "adj": "les moins ajustés", "equal": "égale", "dist": "1/distance", "rec": "récence", "all": "distance × récence × similarité",
32 − "median": "médiane", "mean": "moyenne"}
33 −
34 −# ------------------------------------------------------------------ tableaux
35 −excl = pd.DataFrame(D["exclusions"], columns=["Règle", "n"]); excl["n"] = excl["n"].map(n_)
36 −cm = pd.DataFrame(comp).T; cm.index.name = "Modèle"; cm = cm.reset_index()
37 −cm["val"] = cm["Modèle"].map(lambda m: compv[m]["mdape"])
38 −cm = cm.sort_values("mdape")
39 −t_comp = md_table(cm.rename(columns={"mdape": "MdAPE test", "val": "MdAPE val.", "mape": "MAPE", "biais": "Biais (médian)", "p5": "±5 %", "p10": "±10 %", "p20": "±20 %", "couverture": "Couverture"}),
40 − ["Modèle", "MdAPE val.", "MdAPE test", "MAPE", "Biais (médian)", "±5 %", "±10 %", "±20 %", "Couverture"])
41 −surf = pd.read_csv(TAB / "surface_E1.csv")
42 −def surf_tab(sel, adj, T_):
43 − g = surf[(surf.sel == sel) & (surf.adj == adj) & (surf["T"] == T_)].pivot(index="d", columns="k", values="mdape_test")
44 − g.index = [f"≤ {d} km" for d in g.index]; g.columns = [f"k = {k}" for k in g.columns]; g.index.name = "rayon"
45 − return md_table(g.round(1), index=True)
46 −kb = surf[(surf.sel == "geo") & (surf.adj == "hedo") & (surf.d == 10) & (surf["T"] == 12)].sort_values("k")
47 −fac = pd.read_csv(TAB / "facteurs_E2.csv")
48 −anchor = fac[(fac.k == 5) & (fac.d == 5) & (fac["T"] == 12)]
49 −t_adj = anchor[(anchor.sel == "geo") & (anchor.w == "equal") & (anchor.recon == "median")].set_index("adj").loc[["raw", "none", "conv", "hedo", "hedo_reg", "local", "gbm", "role"]]
50 −t_adj = t_adj.reset_index(); t_adj["Ajustement"] = t_adj["adj"].map(LAB)
51 −t_adj_md = md_table(t_adj.rename(columns={"mdape_val": "MdAPE val.", "mdape_test": "MdAPE test", "mape_test": "MAPE test", "biais_test": "Biais", "p10_test": "±10 %", "couverture_test": "Couverture"}),
52 − ["Ajustement", "MdAPE val.", "MdAPE test", "MAPE test", "Biais", "±10 %", "Couverture"])
53 −t_sel = anchor[(anchor.adj == "hedo") & (anchor.w == "equal") & (anchor.recon == "median")].copy(); t_sel["Sélection"] = t_sel["sel"].map(LAB)
54 −t_sel_md = md_table(t_sel.rename(columns={"mdape_val": "MdAPE val.", "mdape_test": "MdAPE test", "p10_test": "±10 %", "biais_test": "Biais"}), ["Sélection", "MdAPE val.", "MdAPE test", "Biais", "±10 %"])
55 −t_wr = anchor[(anchor.adj == "hedo") & (anchor.sel == "geo")].copy(); t_wr["Pondération"] = t_wr["w"].map(LAB); t_wr["Réconciliation"] = t_wr["recon"].map(LAB)
56 −t_wr_md = md_table(t_wr.rename(columns={"mdape_val": "MdAPE val.", "mdape_test": "MdAPE test", "p10_test": "±10 %", "biais_test": "Biais"}), ["Pondération", "Réconciliation", "MdAPE val.", "MdAPE test", "Biais", "±10 %"])
57 −pairs = pd.read_csv(TAB / "paires_bootstrap.csv")
58 −pairs["Comparaison"] = pairs.apply(lambda r: f"{short(r.A)} − {short(r.B)}", axis=1)
59 −pairs["Δ MdAPE (pts) [IC 95 %]"] = pairs.apply(lambda r: f"{f2(r.diff_mdape)} [{f2(eval(r.ci_mdape)[0])} ; {f2(eval(r.ci_mdape)[1])}]", axis=1)
60 −pairs["Δ MAPE (pts) [IC 95 %]"] = pairs.apply(lambda r: f"{f2(r.diff_mape)} [{f2(eval(r.ci_mape)[0])} ; {f2(eval(r.ci_mape)[1])}]", axis=1)
61 −pairs["A plus précis (% des cibles)"] = pairs["part_A_meilleur"].map(f1)
62 −t_pairs = md_table(pairs, ["Comparaison", "Δ MdAPE (pts) [IC 95 %]", "Δ MAPE (pts) [IC 95 %]", "A plus précis (% des cibles)", "n"], fmt={"n": n_})
63 −def strata_md(by, label):
64 − st = pd.read_csv(TAB / f"strates_{by}.csv")
65 − g = st.pivot(index=by, columns="modele", values="mdape").round(1)
66 − g["n"] = st[st.modele == "M7"].set_index(by)["n"]
67 − g = g[["n", "M2", "M7", "M8a", "M13", "M0", "M10", "M11b", "M12"]]
68 − if by == "dens": g = g.loc[["< 5", "5-19", "20-49", "50-199", "≥ 200"]]
69 − if by == "region_nom": g = g.sort_values("n", ascending=False)
70 − g.index.name = label
71 − return md_table(g, index=True, fmt={"n": n_})
72 −H = R["hypotheses"]
73 −h2 = pd.DataFrame({k.split("|")[1]: v for k, v in H["H2"].items() if k.startswith("hedo")}).T; h2.index.name = "densité"; h2.columns = [f"k = {c}" for c in h2.columns]
74 −h2_md = md_table(h2.loc[["< 5", "5-19", "20-49", "50-199", "≥ 200"]], index=True)
75 −h2n = pd.DataFrame({k.split("|")[1]: v for k, v in H["H2"].items() if k.startswith("none")}).T; h2n.index.name = "densité"; h2n.columns = [f"k = {c}" for c in h2n.columns]
76 −h2n_md = md_table(h2n.loc[["< 5", "5-19", "20-49", "50-199", "≥ 200"]], index=True)
77 −h3 = pd.DataFrame({k.split("|")[3]: v for k, v in H["H3"].items() if k.startswith("5|5|12")}).T.loc[["< 5", "5-19", "20-49", "50-199", "≥ 200"]]
78 −h3.columns = [LAB[c] for c in h3.columns]; h3.index.name = "densité"; h3_md = md_table(h3, index=True)
79 −h3s = pd.DataFrame({k.split("|")[3]: v for k, v in H["H3"].items() if k.startswith("3|2|6")}).T.loc[["< 5", "5-19", "20-49", "50-199", "≥ 200"]]
80 −h3s.columns = [LAB[c] for c in h3s.columns]; h3s.index.name = "densité"; h3s_md = md_table(h3s, index=True)
81 −h4 = pd.DataFrame(H["H4"]).T.loc[["< 5", "5-19", "20-49", "50-199", "≥ 200"]]; h4.columns = [f"{LAB[c.split('|')[0]]} / {LAB[c.split('|')[1]]}" for c in h4.columns]; h4.index.name = "densité"; h4_md = md_table(h4, index=True)
82 −h5 = pd.DataFrame(H["H5"]).T; h5.index.name = "sous-marché"; h5 = h5.rename(columns={"sd_median": "σ médian (log)"}); h5_md = md_table(h5[["σ médian (log)", "M2", "M7", "M8a", "M13", "M0", "M10", "M11b", "M12"]], index=True, fmt={"σ médian (log)": lambda v: f"{v:.3f}".replace(".", ",")})
83 −iv = pd.DataFrame(R["intervalles"]).T; iv.index = [f"{LAB[i.split('|')[0]]}, k = {i.split('|')[1]}, ≤ {i.split('|')[2]} km, {i.split('|')[3]} mois" for i in iv.index]; iv.index.name = "configuration"
84 −iv_md = md_table(iv.rename(columns={"z80_val": "z calibré (val.)", "couverture_nominale_80": "couverture à z = 1,28", "couverture_calibree": "couverture calibrée", "largeur_mediane_pct": "largeur médiane (%)", "n_test": "n"}), index=True, fmt={"n": n_, "z calibré (val.)": f2})
85 −rc = pd.DataFrame(R["regles_conditionnelles"]).T.loc[["< 5", "5-19", "20-49", "50-199", "≥ 200"]]
86 −rc["règle"] = rc.apply(lambda r: f"{LAB[r.sel]}, k = {int(r.k)}, ≤ {r.d} km, {int(r['T'])} mois, {LAB[r.adj]}, pond. {LAB[r.w]}, {LAB[r.recon]}", axis=1)
87 −rc.index.name = "densité"; rc_md = md_table(rc.rename(columns={"mdape": "MdAPE val.", "mdape_test": "MdAPE test", "couverture": "couverture val.", "n_test": "n test"}), ["règle", "MdAPE val.", "MdAPE test", "couverture val.", "n test"], index=True, fmt={"n test": n_})
88 −stab = pd.DataFrame(R["stabilite"]); stab_md = md_table(stab[["trimestre", "n", "M2", "M7", "M0", "M11b", "M12", "biais_M7", "biais_M11b"]].rename(columns={"biais_M7": "biais M7", "biais_M11b": "biais M11b"}), fmt={"n": n_})
89 −
90 −# ------------------------------------------------------------------ chiffres clés pour le texte
91 −best = min(comp, key=lambda m: comp[m]["mdape"]); best_comps = min([m for m in comp if short(m) in ("M1", "M2", "M3", "M4", "M5", "M6", "M7", "M8a", "M8b", "M13", "M0", "M9")], key=lambda m: comp[m]["mdape"])
92 −m0 = R["M0"]; m0u = R["M0_sans_contrainte"]
93 −gain_adj = comp["M2 moyenne des 5 plus proches (temps seul)"]["mdape"] - comp["M7 grille d'ajustement hédonique"]["mdape"]
94 −gain_sel = comp["M2 moyenne des 5 plus proches (temps seul)"]["mdape"] - comp["M3 sélection par similarité physique"]["mdape"]
95 −k_curve = {int(r.k): r.mdape_test for r in kb.itertuples()}
96 −k_best = min(k_curve, key=k_curve.get)
97 −e1b = R["E1_best_val"]
98 −pair = R["paires"]
99 −def pr(a, b): return pair[f"{a} vs {b}"]
100 −M7 = "M7 grille d'ajustement hédonique"; M2 = "M2 moyenne des 5 plus proches (temps seul)"; M6 = "M6 grille d'ajustement conventionnelle"
101 −M8a = "M8a grille locale (régression sur le voisinage)"; M8b = "M8b ajustements non linéaires (GBM)"; M11b = "M11b gradient boosting (avec rôle)"; M11a = "M11a gradient boosting (sans rôle)"
102 −M0 = "M0 meilleure configuration empirique (choisie en 2024, couverture ≥ 97 %)"; M12 = "M12 hybride comparables + GBM (poids appris en 2024)"; M13 = "M13 ratio au rôle (5 plus proches, même rôle)"; M9 = "M9 réconciliation optimisée (poids appris en 2024)"; M3 = "M3 sélection par similarité physique"; M1 = "M1 médiane des 3 plus proches (temps seul)"; M10 = "M10 hédonique globale directe"
103 −dens = R["strates_dens"]; densn = R["strates_dens_n"]
104 −h1 = H["H1"]
105 −marg = R["E2_marginal"]
106 −cond_g = R["regles_conditionnelles_global_test"]
107 −nT = R["n_targets"]
108 −
109 −txt = f"""---
110 −title: "La méthode des comparables à l'épreuve des données : validation empirique à grande échelle sur {n_(D['propre'])} ventes résidentielles québécoises"
111 −subtitle: "Sélection, nombre, ajustements, pondération et réconciliation des ventes comparables — rétro-évaluation point-dans-le-temps sur les transactions Vrai Prix 2021-2026"
112 −author: "Simon-Pierre Boucher — version {VERSION}"
113 −date: "{time.strftime('%d/%m/%Y')}"
114 −lang: fr-CA
115 −---
116 −
117 −# Résumé
118 −
119 −**Question.** La méthode de comparaison des ventes, telle qu'enseignée et pratiquée en évaluation immobilière, produit-elle des estimations fiables, et lesquelles de ses règles (choix des comparables, nombre, rayon, ancienneté, ajustements, pondération, réconciliation) sont empiriquement justifiées ?
120 −
121 −**Données et protocole.** {n_(D['propre'])} ventes de résidences unifamiliales du Québec (registre foncier jumelé au rôle d'évaluation, Vrai Prix, {D['dates'][0]} → {D['dates'][1]}), après un nettoyage journalisé de {n_(D['brut'])} transactions brutes. Pour chacune des **{n_(nT['val'] + nT['test'])} ventes cibles** de 2024 (validation, {n_(nT['val'])}) et de 2025-2026 (test hors échantillon, {n_(nT['test'])}), le prix est masqué et la valeur est estimée avec les seules ventes conclues **avant** la date de la cible, un indice de prix ré-estimé chaque mois avec les seules ventes antérieures, et des grilles d'ajustement et modèles ré-estimés chaque trimestre. **2 256 configurations** de la méthode des comparables et treize modèles de référence sont appliqués aux mêmes cibles ; les différences sont testées par bootstrap par grappes (municipalité × trimestre).
122 −
123 −**Résultats principaux (test 2025-2026).**
124 −
125 −1. **Les ajustements sont le facteur dominant.** Prendre la moyenne des cinq ventes les plus proches, ajustées seulement pour le temps (M2), donne une erreur absolue médiane (MdAPE) de **{pct(comp[M2]['mdape'])}** ; une grille d'ajustement hédonique (M7) la ramène à **{pct(comp[M7]['mdape'])}** (−{f1(gain_adj)} points, IC 95 % de la différence de MdAPE [{f2(pr(M7, M2)['ci_mdape'][0])} ; {f2(pr(M7, M2)['ci_mdape'][1])}]). Une grille conventionnelle stylisée (M6) fait presque aussi bien ({pct(comp[M6]['mdape'])}) : ce qui compte, c'est d'ajuster, plus que la finesse de la grille.
126 −2. **Choisir des comparables semblables vaut autant qu'ajuster.** Sans aucun ajustement physique, sélectionner les cinq ventes les plus **semblables** (aire, terrain, âge, lien physique, étages) plutôt que les cinq plus **proches** fait passer la MdAPE de {pct(comp[M2]['mdape'])} à **{pct(comp[M3]['mdape'])}** (H1 confirmée : un comparable un peu plus éloigné mais semblable vaut mieux qu'un voisin dissemblable).
127 −3. **Le nombre optimal de comparables est bien supérieur à trois.** Avec la grille hédonique, la MdAPE décroît de {pct(k_curve[1])} (k = 1) à {pct(k_curve[3])} (k = 3), {pct(k_curve[5])} (k = 5), {pct(k_curve[10])} (k = 10) et atteint son minimum à **k = {k_best}** ({pct(k_curve[k_best])}) avant de remonter ({pct(k_curve[50])} à k = 50) ; l'optimum dépend de la densité du marché (H2 confirmée).
128 −4. **Le rayon et la fenêtre temporelle doivent être larges.** La meilleure configuration de la surface Erreur(k, d, t) choisie en 2024 est « {LAB[e1b['sel']]}, k = {int(e1b['k'])}, ≤ {e1b['d']} km, {int(e1b['T'])} mois, {LAB[e1b['adj']]} » ({pct(e1b['mdape_val'])} en validation → **{pct(e1b['mdape_test'])}** en test). Les rayons de 0,5-1 km et les fenêtres de 3 mois, privilégiés par la pratique, sont dominés dès que les comparables sont ajustés.
129 −5. **La pondération et le mode de réconciliation comptent peu** (effets marginaux moyens de {f1(min(marg['w'].values()))} à {f1(max(marg['w'].values()))} points selon la pondération, médiane {f1(marg['recon']['median'])} contre moyenne {f1(marg['recon']['mean'])}), bien moins que la sélection ({f1(min(marg['sel'].values()))}-{f1(max(marg['sel'].values()))}) et l'ajustement ({f1(min(marg['adj'].values()))}-{f1(max(marg['adj'].values()))}).
130 −6. **La meilleure méthode des comparables, entièrement transparente et choisie en 2024, atteint {pct(comp[M0]['mdape'])} en 2025-2026** (M0 : {LAB[m0['sel']]}, k = {int(m0['k'])}, ≤ {m0['d']} km, {int(m0['T'])} mois, {LAB[m0['adj']]}, pondération {LAB[m0['w']]}, {LAB[m0['recon']]}), contre **{pct(comp[M11b]['mdape'])}** pour un gradient boosting ré-entraîné chaque trimestre avec la valeur au rôle (M11b) et {pct(comp[M11a]['mdape'])} sans le rôle (M11a). L'hybride comparables + GBM (M12) atteint **{pct(comp[M12]['mdape'])}**. Le coût de la transparence est donc de {f1(comp[M0]['mdape'] - comp[M11b]['mdape'])} point{'s' if abs(comp[M0]['mdape'] - comp[M11b]['mdape']) >= 2 else ''} de MdAPE face au meilleur modèle opaque, et il s'annule dans les sous-marchés homogènes (H5).
131 −7. **Le ratio au rôle est un comparable redoutable** : rôle de la cible × ratio prix/rôle médian des cinq plus proches ventes du même rôle (M13) donne {pct(comp[M13]['mdape'])}, mieux que toute grille d'ajustement physique, mais seulement pour {pct(comp[M13]['couverture'])} des cibles.
132 −8. **La fiabilité dépend d'abord de la liquidité du sous-marché** : la MdAPE de M7 passe de {pct(dens['≥ 200']['M7'])} là où plus de 200 ventes ont eu lieu à moins de 2 km dans l'année à {pct(dens['< 5']['M7'])} là où il y en a eu moins de 5 ; des règles conditionnelles à la densité (système de recommandation) obtiennent {pct(cond_g['mdape'])} globalement avec {pct(cond_g['couverture'])} de couverture.
133 −
134 −**Portée.** Nous validons la précision prédictive de règles professionnelles *simulées* sur des caractéristiques observables (aire, terrain, âge, étages, lien physique, rôle) ; les ajustements non observables (garage, piscine, chambres, qualité, rénovations) restent dans l'erreur résiduelle, et le jugement réel d'un évaluateur n'est pas reproduit.
135 −
136 −# 1. Données
137 −
138 −## 1.1 Source et nettoyage
139 −
140 −Instantané de la base de production Vrai Prix pris le 2026-10-10 : {n_(766596)} transactions résidentielles et 3,75 M d'unités d'évaluation. Nous retenons les ventes de type « unifamilial » jumelées à une unité du rôle, et appliquons des règles de nettoyage explicites et journalisées — rien n'est éliminé silencieusement.
141 −
142 −{md_table(excl)}
143 −Jeu propre : **{n_(D['propre'])} ventes** ({D['dates'][0]} → {D['dates'][1]}) ; prix médian {n_(D['prix']['50%'])} $, moyenne {n_(D['prix']['mean'])} $.
144 −
145 −Deux règles méritent un commentaire. R8 écarte les ratios prix/rôle extrêmes (< 0,33 ou > 3) : faute d'indicateur de lien de dépendance entre les parties, c'est notre proxy de « transaction non représentative » — un évaluateur les écarterait aussi. R6b écarte les ventes conclues l'année même de la construction : leur prix médian ({n_(346108)} $) et leur ratio au rôle (1,15) sont ceux de terrains ou de constructions inachevées, non de maisons achevées (450-480 k$ et 1,3 aux âges 1-4 ans).
146 −
147 −## 1.2 Répartition
148 −
149 −| Période | Rôle | Ventes |
150 −|---|---|---|
151 −| 2021-01 → 2023-12 | développement | {n_(D['par_periode']['train'])} |
152 −| 2024 | validation (choix des configurations) | {n_(D['par_periode']['val'])} |
153 −| 2025-01 → 2026-09 | test hors échantillon | {n_(D['par_periode']['test'])} |
154 −
155 −Régions les plus représentées : {', '.join(f"{k} ({n_(v)})" for k, v in list(D['par_region'].items())[:6])}.
156 −
157 −![Indice de prix mensuel par région (descriptif, plein échantillon).](f7_indice.pdf){{ width=85% }}
158 −
159 −## 1.3 Caractéristiques disponibles et limites
160 −
161 −Observées pour chaque vente : date, prix, coordonnées, municipalité, unité de voisinage, aire habitable, superficie du terrain, année de construction, nombre d'étages, lien physique (détaché, jumelé, en rangée), genre de construction, valeur au rôle en vigueur à la vente et sa part terrain. Non observées : garage, piscine, nombre de chambres et de salles de bains, qualité, état, rénovations. Les grilles d'ajustement testées ne portent donc que sur les premières ; les secondes sont une source d'erreur commune à toutes les méthodes.
162 −
163 −# 2. Protocole
164 −
165 −## 2.1 Rétro-évaluation point-dans-le-temps
166 −
167 −Pour chaque cible : (1) le prix est masqué ; (2) le bassin de comparables est constitué des ventes conclues strictement avant la date de la cible (la cible elle-même, si elle a déjà été vendue, est exclue : on évalue par comparables, non par vente répétée), dans un rayon de 25 km et une fenêtre de 24 mois ; (3) l'indice temporel régional utilisé pour ramener chaque comparable au niveau du dernier mois connu est ré-estimé chaque mois avec les seules ventes antérieures (régression hédonique à effets fixes de municipalité et effets mensuels, par région administrative, rétrécie vers l'indice provincial quand les ventes sont rares) ; (4) les coefficients d'ajustement hédoniques, les grilles régionales et les modèles d'apprentissage sont ré-estimés chaque trimestre avec les seules ventes antérieures au trimestre ; (5) chaque configuration produit une valeur et, le cas échéant, une dispersion des prix ajustés.
168 −
169 −Le niveau de prix du mois de la cible n'est jamais connu : toutes les méthodes travaillent au niveau du mois précédent, ce qui reproduit la situation réelle d'un évaluateur.
170 −
171 −## 2.2 Configurations testées
172 −
173 −**E1 — surface Erreur(k, d, t)** : k ∈ {{1, 3, 5, 7, 10, 15, 20, 30, 50}}, d ∈ {{0,5 ; 1 ; 2 ; 5 ; 10 ; 25 km}}, t ∈ {{3, 6, 12, 24 mois}}, sélection ∈ {{plus proches, plus semblables, compromis}}, ajustement ∈ {{temps seul, grille hédonique}}, médiane. 1 296 configurations.
174 −
175 −**E2 — plan factoriel des règles** aux ancres (k, d, t) = (3, 2 km, 6 mois), (5, 5 km, 12 mois), (10, 10 km, 24 mois) : sélection (4) × ajustement (8 : aucun, temps seul, grille conventionnelle, hédonique globale, hédonique régionale, régression locale, GBM, ratio au rôle) × pondération (5) × réconciliation (2). 960 configurations.
176 −
177 −*Grille conventionnelle stylisée* (M6) : aire habitable ajustée à la moitié du prix au m² du comparable ; terrain à la moitié de la valeur foncière implicite au m² (plafond ±15 %) ; âge à −0,5 % par année d'écart (plafond ±20 %) ; ±5 % entre détaché et attaché. *Grille hédonique* (M7) : coefficients d'une régression du log du prix sur les caractéristiques, à effets fixes de municipalité (within), ré-estimée chaque trimestre. *Régression locale* (M8a) : mêmes coefficients ré-estimés sur les ventes à moins de 10 km des 24 derniers mois (≥ 40 ventes, ridge vers la grille régionale). *GBM* (M8b) : l'ajustement est la différence des prédictions d'un gradient boosting entre la cible et le comparable. *Ratio au rôle* (M13) : valeur au rôle de la cible × ratio prix/rôle du comparable, pour les comparables de la même municipalité et du même rôle triennal.
178 −
179 −## 2.3 Mesures
180 −
181 −Erreur absolue en pourcentage APE = |P̂ − P| / P ; MdAPE (médiane), MAPE (moyenne), biais médian signé, parts à ±5, ±10 et ±20 %, couverture (part des cibles pour lesquelles la configuration produit une valeur — une configuration trop restrictive ne « rate » pas, elle s'abstient, et cela doit se voir). Les différences entre méthodes sont des différences d'erreurs appariées sur les mêmes cibles, avec intervalles de confiance à 95 % par bootstrap par grappes municipalité × trimestre (400 réplications).
182 −
183 −# 3. Résultats
184 −
185 −## 3.1 Combien de comparables ? À quelle distance ? De quelle ancienneté ?
186 −
187 −![MdAPE (test 2025-2026) selon le nombre de comparables, par rayon, fenêtre de 12 mois, sélection par distance.](f1_mdape_vs_k.pdf){{ width=100% }}
188 −
189 −![MdAPE au meilleur k pour chaque couple rayon × fenêtre.](f2_surface_d_T.pdf){{ width=90% }}
190 −
191 −**Sélection par distance, grille hédonique, fenêtre 12 mois — MdAPE test (%)**
192 −
193 −{surf_tab('geo', 'hedo', 12)}
194 −**Sélection par distance, temps seulement, fenêtre 12 mois — MdAPE test (%)**
195 −
196 −{surf_tab('geo', 'none', 12)}
197 −**Sélection par similarité physique, grille hédonique, fenêtre 12 mois — MdAPE test (%)**
198 −
199 −{surf_tab('sim', 'hedo', 12)}
200 −Lecture. (i) Sans ajustement, l'erreur décroît avec k et avec le rayon tant que l'on reste sous quelques kilomètres, puis se stabilise ; (ii) avec la grille hédonique, elle décroît nettement plus et l'optimum se déplace vers des k et des rayons plus grands ; (iii) la fenêtre de 3 mois est systématiquement dominée par 12-24 mois dès que l'on ajuste pour le temps. Meilleures configurations de E1 par sélection × ajustement (choisies en 2024, évaluées en 2025-2026) :
201 −
202 −{md_table(pd.DataFrame(R['E1_best_by_sel_adj']).T.reset_index().rename(columns={'index': 'sélection | ajustement', 'mdape_val': 'MdAPE val.', 'mdape_test': 'MdAPE test', 'couverture_test': 'couverture test'}), ['sélection | ajustement', 'k', 'd', 'T', 'MdAPE val.', 'MdAPE test', 'couverture test'], fmt={'k': lambda v: str(int(v)), 'T': lambda v: str(int(v)), 'd': lambda v: f1(v)})}
203 −### k optimal selon la densité (H2)
204 −
205 −{h2_md}
206 −*(sélection par distance, ≤ 10 km, 12 mois, grille hédonique ; MdAPE test en %, par nombre de ventes disponibles à moins de 2 km au cours des 12 mois précédents)*
207 −
208 −Sans ajustement :
209 −
210 −{h2n_md}
211 −![k optimal par strate de densité.](f6_k_par_densite.pdf){{ width=100% }}
212 −
213 −## 3.2 Quels ajustements sont justifiés ?
214 −
215 −![Type d'ajustement, cinq comparables les plus proches, ≤ 5 km, ≤ 12 mois, médiane.](f3_ajustements.pdf){{ width=90% }}
216 −
217 −{t_adj_md}
218 −Trois enseignements. **L'ajustement temporel seul** vaut {f1(t_adj.set_index('adj').loc['raw', 'mdape_test'] - t_adj.set_index('adj').loc['none', 'mdape_test'])} point ; **n'importe quelle grille physique** vaut {f1(t_adj.set_index('adj').loc['none', 'mdape_test'] - t_adj.set_index('adj').loc['conv', 'mdape_test'])} à {f1(t_adj.set_index('adj').loc['none', 'mdape_test'] - t_adj.set_index('adj').loc['gbm', 'mdape_test'])} points de plus ; **les raffinements** (grille régionale, régression locale, ajustements non linéaires) apportent peu face à la grille hédonique globale, et la régression locale est même {('moins bonne' if t_adj.set_index('adj').loc['local', 'mdape_test'] > t_adj.set_index('adj').loc['hedo', 'mdape_test'] else 'meilleure')} ({pct(t_adj.set_index('adj').loc['local', 'mdape_test'])} contre {pct(t_adj.set_index('adj').loc['hedo', 'mdape_test'])}).
219 −
220 −### Grilles locales et petits échantillons (H3)
221 −
222 −MdAPE test (%) par type d'ajustement et densité, ancre (5, 5 km, 12 mois) :
223 −
224 −{h3_md}
225 −Ancre (3, 2 km, 6 mois) :
226 −
227 −{h3s_md}
228 −## 3.3 Comment choisir les comparables ? (H1)
229 −
230 −Ancre (5, 5 km, 12 mois), grille hédonique, médiane :
231 −
232 −{t_sel_md}
233 −Test direct de H1 (k = 5, 12 mois, temps seulement) : les cinq voisins à moins de 1 km donnent une MdAPE de {pct(h1['geo_d1']['mdape_test'])} (couverture {pct(h1['geo_d1']['couverture_test'])}) ; les cinq ventes les plus semblables à moins de 10 km, {pct(h1['sim_d10']['mdape_test'])} (couverture {pct(h1['sim_d10']['couverture_test'])}) ; les cinq plus proches à moins de 10 km, {pct(h1['geo_d10']['mdape_test'])}. Avec la grille hédonique : plus proches {pct(h1['geo_d10_hedo']['mdape_test'])}, plus semblables {pct(h1['sim_d10_hedo']['mdape_test'])}, compromis {pct(h1['mix_d10_hedo']['mdape_test'])}.
234 −
235 −## 3.4 Pondération et réconciliation (H4)
236 −
237 −Ancre (5, 5 km, 12 mois), sélection par distance, grille hédonique :
238 −
239 −{t_wr_md}
240 −Selon la densité (MdAPE test, %) :
241 −
242 −{h4_md}
243 −Effets marginaux moyens (MdAPE test moyenne sur toutes les autres modalités de E2) : sélection {', '.join(f"{LAB[k]} {f1(v)}" for k, v in marg['sel'].items())} ; ajustement {', '.join(f"{LAB[k]} {f1(v)}" for k, v in marg['adj'].items())} ; pondération {', '.join(f"{LAB[k]} {f1(v)}" for k, v in marg['w'].items())} ; réconciliation {', '.join(f"{LAB[k]} {f1(v)}" for k, v in marg['recon'].items())}.
244 −
245 −## 3.5 Compétition de modèles
246 −
247 −![Compétition de modèles, test 2025-2026.](f4_competition.pdf){{ width=95% }}
248 −
249 −{t_comp}
250 −M0 : {LAB[m0['sel']]}, k = {int(m0['k'])}, ≤ {m0['d']} km, {int(m0['T'])} mois, {LAB[m0['adj']]}, pondération {LAB[m0['w']]}, {LAB[m0['recon']]} (meilleure des 2 256 configurations en 2024 parmi celles qui couvrent ≥ 97 % des cibles ; sans cette contrainte, la meilleure est « {LAB[m0u['sel']]}, k = {int(m0u['k'])}, ≤ {m0u['d']} km, {int(m0u['T'])} mois, {LAB[m0u['adj']]} » à {pct(m0u['mdape_test'])} mais pour {pct(m0u['couverture_test'])} des cibles seulement). Poids appris de M9 : {', '.join(f"{LAB[k]} {f2(v)}" for k, v in R['M9_weights'].items() if v > 0)}. Poids de M12 : {', '.join(f"{k} {f2(v)}" for k, v in R['M12_weights'].items() if v > 0)}.
251 −
252 −### Différences appariées (test ; bootstrap par grappes municipalité × trimestre)
253 −
254 −{t_pairs}
255 −Un Δ négatif signifie que A est plus précis que B ; l'intervalle qui exclut zéro signale une différence robuste aux dépendances spatiales et temporelles.
256 −
257 −## 3.6 Où la méthode tient-elle, où défaille-t-elle ?
258 −
259 −![Précision selon la liquidité du sous-marché.](f5_densite.pdf){{ width=85% }}
260 −
261 −**Par densité** (ventes à moins de 2 km dans les 12 mois précédents) — MdAPE test (%) :
262 −
263 −{strata_md('dens', 'densité')}
264 −**Par région administrative** :
265 −
266 −{strata_md('region_nom', 'région')}
267 −**Par quintile de prix** :
268 −
269 −{strata_md('prix_q', 'quintile de prix')}
270 −**Par âge du bâtiment** :
271 −
272 −{strata_md('age_cl', 'âge')}
273 −**Par lien physique** (0 détaché, 1 jumelé, 2 en rangée, 3 intégré) :
274 −
275 −{strata_md('lien', 'lien physique')}
276 −### Marchés homogènes et hétérogènes (H5)
277 −
278 −Terciles de la dispersion (écart-type du log des prix ajustés) des dix ventes les plus proches à ≤ 10 km / 24 mois — MdAPE test (%) :
279 −
280 −{h5_md}
281 −### Stabilité dans le temps
282 −
283 −{stab_md}
284 −Le biais trimestriel commun à toutes les méthodes reflète le fait que le niveau de prix du mois courant est inconnu (toutes travaillent au niveau du mois précédent) et la saisonnalité des prix.
285 −
286 −## 3.7 Intervalles de valeur
287 −
288 −La dispersion des prix ajustés des comparables retenus fournit un intervalle de valeur. Avec z = 1,28 (couverture nominale de 80 % si les erreurs étaient normales), puis avec un z calibré sur la validation 2024 :
289 −
290 −{iv_md}
291 −## 3.8 Vers un système de recommandation des comparables
292 −
293 −Règle choisie en 2024 pour chaque strate de densité (parmi les 2 256 configurations couvrant ≥ 95 % des cibles de la strate), évaluée en 2025-2026 :
294 −
295 −{rc_md}
296 −Globalement, ces règles conditionnelles atteignent une MdAPE de **{pct(cond_g['mdape'])}** ({pct(cond_g['p10'])} des estimations à ±10 %, couverture {pct(cond_g['couverture'])}), contre {pct(comp[M0]['mdape'])} pour la meilleure règle unique.
297 −
298 −# 4. Discussion
299 −
300 −**Ce que la méthode des comparables fait bien.** Correctement paramétrée — comparables semblables, nombre d'une dizaine, rayon et fenêtre larges, ajustements hédoniques ou même conventionnels — elle atteint une précision ({pct(comp[M0]['mdape'])}) que ne dépasse le meilleur modèle d'apprentissage ré-entraîné chaque trimestre que de {f1(comp[M0]['mdape'] - comp[M11b]['mdape'])} point{'s' if abs(comp[M0]['mdape'] - comp[M11b]['mdape']) >= 2 else ''}, tout en restant intégralement explicable : chaque valeur est la médiane de quelques ventes identifiées, ajustées par des coefficients lisibles.
301 −
302 −**Ce qui contredit la pratique.** Trois comparables, un rayon de quelques centaines de mètres et une fenêtre de trois mois — les conventions les plus répandues — sont dominés sur toute la surface Erreur(k, d, t) : {pct(surf[(surf.sel == 'geo') & (surf.adj == 'hedo') & (surf.k == 3) & (surf.d == 1) & (surf['T'] == 3)].mdape_test.iloc[0])} de MdAPE (k = 3, ≤ 1 km, 3 mois, grille hédonique, couverture {pct(surf[(surf.sel == 'geo') & (surf.adj == 'hedo') & (surf.k == 3) & (surf.d == 1) & (surf['T'] == 3)].couverture_test.iloc[0])}) contre {pct(e1b['mdape_test'])} pour la meilleure configuration. La rareté des comparables « parfaits » est un argument pour élargir la recherche et ajuster, non pour se restreindre.
303 −
304 −**Le rôle d'évaluation comme information.** Le ratio au rôle (M13) et le GBM avec rôle (M11b) dominent leurs équivalents sans rôle : la valeur au rôle encapsule des caractéristiques non observées ici (qualité, dépendances, état) et une appréciation locale que les modèles globaux ne captent pas. Les modèles globaux sans rôle (M10, M11a) présentent d'ailleurs un biais négatif en 2025-2026 : l'appréciation est spatialement hétérogène et un indice régional, même ré-estimé chaque trimestre, la sous-estime là où les ventes se concentrent.
305 −
306 −**Limites.** (1) Règles professionnelles simulées, non dossiers d'évaluateurs. (2) Caractéristiques limitées à celles du rôle et du registre. (3) Délai de publication des ventes ignoré. (4) Un seul type de bien (unifamilial) ; condos et plex restent à traiter. (5) Les configurations ont été choisies sur 2024 puis évaluées sur 2025-2026 : les écarts entre validation et test (tableaux ci-dessus) mesurent la stabilité des règles, et ils sont faibles.
307 −
308 −# 5. Reproductibilité
309 −
310 −Code : `pipeline/01_dataset.py` → `06_report.py` (Python 3.14, pandas, numpy, scipy, lightgbm, matplotlib) ; sorties versionnées `out/{VERSION}/` (configurations `configs_E1.csv`, `configs_E2.csv` ; estimations `est_E1.npy`, `est_E2.npy` ; cibles `targets.parquet` ; tables `tables/*.csv` ; `results.json`). Exécution : `./run_all.sh` sur le nœud de calcul (M2U64), `./make_pdf.sh` sur le poste de travail.
311 −"""
312 −(DOCS / "RAPPORT.md").write_text(txt, encoding="utf-8")
313 −log(f"rapport écrit : {DOCS / 'RAPPORT.md'} ({len(txt.split()):,} mots)")
deleted M4M64bomparables-validation/pipeline/common.py +0 −85
@@ -1,85 +0,0 @@
1 −# -*- coding: utf-8 -*-
2 −"""Validation empirique de la méthode des comparables — fonctions communes.
3 −Données : instantané Vrai Prix (transactions ⋈ units) du 2026-10-10.
4 −Toutes les sorties sont versionnées sous out/<version>/.
5 −"""
6 −from __future__ import annotations
7 −import json, math, os, sqlite3, time
8 −from pathlib import Path
9 −import numpy as np
10 −import pandas as pd
11 −
12 −ROOT = Path(os.environ.get("CV_ROOT", Path(__file__).resolve().parents[1]))
13 −DB = Path(os.environ.get("CV_DB", ROOT / "data" / "vraiprix-20261010.db"))
14 −REF = ROOT / "ref"
15 −VERSION = os.environ.get("CV_VERSION", time.strftime("%Y-%m-%d"))
16 −OUT = ROOT / "out" / VERSION
17 −OUT.mkdir(parents=True, exist_ok=True)
18 −(OUT / "fig").mkdir(exist_ok=True)
19 −
20 −# Périodes (séparation chronologique)
21 −TRAIN_END = "2023-12-31" # développement
22 −VAL_START, VAL_END = "2024-01-01", "2024-12-31" # choix des configurations
23 −TEST_START, TEST_END = "2025-01-01", "2026-09-29" # évaluation finale hors échantillon
24 −MONTH0 = "2021-01" # premier mois des données
25 −
26 −def log(msg: str) -> None:
27 − print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
28 −
29 −def connect(path: Path = DB) -> sqlite3.Connection:
30 − return sqlite3.connect(f"file:{path}?mode=ro", uri=True, timeout=60)
31 −
32 −def _json_default(o):
33 − if isinstance(o, np.integer): return int(o)
34 − if isinstance(o, np.floating): return None if np.isnan(o) else float(o)
35 − if isinstance(o, np.bool_): return bool(o)
36 − if isinstance(o, np.ndarray): return o.tolist()
37 − if isinstance(o, pd.Timestamp): return o.strftime("%Y-%m-%d")
38 − if isinstance(o, float) and math.isnan(o): return None
39 − return str(o)
40 −
41 −def save_json(obj, name: str) -> Path:
42 − p = OUT / name; p.parent.mkdir(parents=True, exist_ok=True)
43 − with open(p, "w", encoding="utf-8") as f:
44 − json.dump(obj, f, ensure_ascii=False, indent=2, default=_json_default)
45 − return p
46 −
47 −def load_json(name: str):
48 − with open(OUT / name, encoding="utf-8") as f:
49 − return json.load(f)
50 −
51 −def save_parquet(df: pd.DataFrame, name: str) -> Path:
52 − p = OUT / name; df.to_parquet(p, index=False); return p
53 −
54 −def load_parquet(name: str) -> pd.DataFrame:
55 − return pd.read_parquet(OUT / name)
56 −
57 −def month_index(dates) -> np.ndarray:
58 − """Mois entier depuis MONTH0 (2021-01 = 0)."""
59 − d = pd.to_datetime(dates)
60 − y0, m0 = map(int, MONTH0.split("-"))
61 − return ((d.dt.year - y0) * 12 + (d.dt.month - m0)).to_numpy()
62 −
63 −def to_xy_km(lat, lng, lat0=46.8, lng0=-71.2):
64 − """Projection équirectangulaire locale (km) : erreur < 1 % pour des distances < 50 km."""
65 − x = (np.asarray(lng, float) - lng0) * 111.32 * np.cos(np.radians(np.asarray(lat, float)))
66 − y = (np.asarray(lat, float) - lat0) * 110.57
67 − return x, y
68 −
69 −def load_repertoire() -> pd.DataFrame:
70 − mun = pd.read_csv(REF / "MUN.csv", dtype=str, encoding="utf-8-sig")
71 − mun = mun.rename(columns=str.lower)
72 − out = pd.DataFrame({
73 − "code_mun": mun["mcode"].str.zfill(5),
74 − "mun_nom": mun["munnom"],
75 − "region": mun["regadm"].str.extract(r"\((\d+)\)")[0],
76 − "region_nom": mun["regadm"].str.replace(r"\s*\(\d+\)", "", regex=True),
77 − "mrc": mun["mrc"].str.extract(r"\((\d+)\)")[0],
78 − "mrc_nom": mun["mrc"].str.replace(r"\s*\(\d+\)", "", regex=True),
79 − })
80 − return out.drop_duplicates("code_mun")
81 −
82 −# Codages catégoriels (ordre stable)
83 −LIEN = {"Détaché": 0, "Jumelé": 1, "En rangée 1 côté": 2, "En rangée plus de 1 côté": 2, "Intégré": 3}
84 −GENRE = {"De plain-pied": 0, "Unimodulaire": 0, "À étages entiers": 1, "À niveaux décalés": 2, "À étage mansardé": 3}
85 −BTYPE = {"single-story": 0, "full-story": 1, "mansard": 2, "split-level": 3, "single-wide": 4}
deleted M4M64bomparables-validation/run_all.sh +0 −14
@@ -1,14 +0,0 @@
1 −#!/bin/zsh
2 −# Chaîne complète sur le nœud de calcul (M2U64). Usage : CV_VERSION=2026-10-10 ./run_all.sh [étape de départ]
3 −set -e
4 −cd "$(dirname "$0")"
5 −PY=${PY:-$HOME/uqo-eval-terrains/env/bin/python}
6 −export CV_VERSION=${CV_VERSION:-$(date +%F)}
7 −START=${1:-1}
8 −for s in 01_dataset 02_index 03_models 04_backtest 05_analyze 06_report; do
9 − n=${s%%_*}
10 − [ "$((10#$n))" -lt "$START" ] && continue
11 − echo "=== $s ($(date +%T)) ==="
12 − (cd pipeline && $PY $s.py)
13 −done
14 −echo "=== terminé $(date +%T) ==="
modified README.md +131 −31
@@ -1,49 +1,149 @@
1 −# Validation empirique de la méthode des comparables (Vrai Prix)
1 +<!-- Author: Simon-Pierre Boucher — simon-pierre.boucher@uqo.ca -->
2 +# ⚖️ Does the Sales Comparison Approach Actually Work?
2 3
3 −Projet de recherche : rétro-évaluation à grande échelle de la méthode de comparaison des ventes sur les transactions
4 −résidentielles unifamiliales du Québec (Vrai Prix, 2021-01 → 2026-09). Protocole : `docs/PROTOCOLE.md`.
5 −Rapport de résultats : `docs/RAPPORT.md` / `docs/RAPPORT.pdf`.
4 +**UQO Working Paper No. 16** — *A Large-Scale Point-in-Time Validation of Comparable Selection, Price Adjustments, and
5 +Value Reconciliation — Evidence from 391,000 Single-Family Transactions in Quebec, 2021–2026*
6 6
7 −## Organisation
7 +[![Paper](https://img.shields.io/badge/paper-PDF%20(38%20p.)-b31b1b?logo=latex&logoColor=white)](paper/main.pdf)
8 +[![Python](https://img.shields.io/badge/python-3.12%2B-3776AB?logo=python&logoColor=white)](pipeline/)
9 +[![Data](https://img.shields.io/badge/data-390%2C996%20sales%20·%20Vrai%20Prix%20%2B%20assessment%20roll-4051b5)](docs/PROTOCOLE.md)
10 +[![Reproducible](https://img.shields.io/badge/reproducible-end--to--end%20pipeline-2e7d32)](pipeline/)
11 +[![Institution](https://img.shields.io/badge/UQO-D%C3%A9pt.%20des%20sciences%20administratives-16365c)](https://uqo.ca)
12 +[![Author](https://img.shields.io/badge/author-Simon--Pierre%20Boucher-16365c)](mailto:simon-pierre.boucher@uqo.ca)
13 +
14 +> **TL;DR** — The sales comparison approach rests on professional convention: three comparables, a short window, a
15 +> small radius, an adjustment grid, a reconciliation rule. We backtest 2,256 configurations of the approach, point in
16 +> time, on 189,354 masked target sales drawn from 390,996 Quebec single-family transactions matched to the assessment
17 +> roll (configurations chosen on 2024, evaluated on 2025–2026). **Adjustments dominate** (median absolute error of the
18 +> five nearest sales: 18.1% time-only → 13.0% with a hedonic grid); **similar beats near** (selecting physically similar
19 +> sales gives the same gain with no adjustment); **the optimal number of comparables is ~20–30, not 3**; windows should
20 +> be wide, radius barely matters, weighting and reconciliation matter little. The best transparent rule reaches
21 +> **11.1%** out of sample versus 10–11% for a quarterly-retrained gradient-boosting model: the cost of transparency is
22 +> small, and accuracy is governed by submarket liquidity.
23 +
24 +---
25 +
26 +## 📌 Status
27 +
28 +| Item | Value |
29 +|---|---|
30 +| Version | 1.0 (October 2026) |
31 +| SSRN | abstract **7626720** (PRELIMINARY_UPLOAD, 2026-10-17) — uploaded PDF carries the old cover "Working Paper No. 11"; **re-upload `paper/main.pdf` (No. 16)** |
32 +| Journal | — |
33 +| Code & data | pipeline in `pipeline/`, run on the MacLustr node M2U64; the Vrai Prix database snapshot is not redistributed |
34 +
35 +## 🎯 What this paper does
36 +
37 +For every target sale the price is masked and the approach is applied with only the information available on the
38 +valuation date: candidate comparables are selected (nearest / most similar / compromise, k ∈ {1 … 50}, radius d,
39 +window T), adjusted (none / time only / stylized conventional grid / hedonic grid estimated quarterly / assessment
40 +ratio), weighted and reconciled (mean, median, weighted). Error surfaces Error(k, d, T) are mapped, the best
41 +configuration is chosen on the 2024 validation year and evaluated out of sample on 2025–2026 against hedonic,
42 +gradient-boosting, assessment-ratio and hybrid benchmarks. Accuracy is then decomposed by submarket liquidity, region,
43 +price stratum and property type.
44 +
45 +## 🏆 Headline results
46 +
47 +| Object | Estimate (test 2025–2026, MdAPE) |
48 +|---|---|
49 +| 5 nearest sales, time adjustment only (M2) | 18.1% |
50 +| 5 nearest sales, hedonic adjustment grid (M7) | 13.0% |
51 +| 5 most similar sales, no physical adjustment | ≈ same gain as the grid |
52 +| Number of comparables with a grid, k = 1 → 3 → 5 → 10 → 20–30 | 16.5% → 13.8% → 13.0% → 12.4% → minimum |
53 +| Best transparent rule chosen in 2024 (M0: compromise, k = 30, ≤ 25 km, 24 months, hedonic grid) | **11.1%** |
54 +| Assessment-ratio comparable (M13) | 11.5% |
55 +| Quarterly-retrained GBM using assessed value (M11b) / hybrid (M12) | ≈ 10–11% |
56 +| Liquidity effect (M7): > 200 sales within 2 km / year vs < 20 | 10.2% vs 25.3% |
57 +
58 +## 🗂 Repository layout
8 59
9 60 ```
10 −pipeline/ 01_dataset → 02_index → 03_models → 04_backtest → 05_analyze → 06_report
11 −docs/ PROTOCOLE.md, RAPPORT.md(.pdf), header.tex
12 −out/<version>/ sales.parquet, index_asof.npy, models_*.json, est_E*.npy, targets.parquet, tables/, fig/, results.json
13 −data/ (nœud seulement) vraiprix-20261010.db — instantané de la base de production Vrai Prix
14 −ref/ MUN.csv (répertoire des municipalités MAMH : code → MRC → région)
61 +sales-comparison-validation/
62 +├── pipeline/ # 01_dataset → 02_index → 03_models → 04_backtest (+04b_gather) → 05_analyze → 06_report
63 +├── run_all.sh # full chain on the compute node (CV_VERSION=YYYY-MM-DD)
64 +├── run_distributed.sh # 15-shard backtest across 5 MacLustr nodes, gather on M2U64
65 +├── make_pdf.sh # pull out/<version>/ + docs/ from M2U64, compile docs/RAPPORT.pdf (French report)
66 +├── docs/ # PROTOCOLE.md (protocol), RAPPORT.md/.pdf (French results report), README-fr.md
67 +├── ref/ # MUN.csv — municipality → MRC → region (MAMH)
68 +├── out/<version>/ # sales.parquet, index_asof.npy, models_*.json, est_E*.npy, tables/, fig/, results.json — not committed
69 +├── data/ # vraiprix-<date>.db snapshot (node only) — not committed
70 +└── paper/ # main.tex + sections/ + tables/ (generated) + figures/ → main.pdf
15 71 ```
16 72
17 −## Exécution
18 −
19 −Le calcul se fait sur **M2U64** (`~/comparables-validation`, venv `~/uqo-eval-terrains/env`, Python 3.14 : pandas, numpy, scipy,
20 −lightgbm, matplotlib). Le laptop garde le code, les docs et une copie des sorties.
73 +## 🚀 Quick start
21 74
22 75 ```bash
23 −rsync -a --exclude out --exclude data --exclude .git ~/Desktop/Recherche-UQO/immobilier/sales-comparison-validation/ M2U64:comparables-validation/
24 −ssh M2U64 'cd ~/comparables-validation && CV_VERSION=2026-10-10 ./run_all.sh' # ~ 30 min
25 −./make_pdf.sh # rapatrie out/ + docs/ et compile le PDF
76 +rsync -a --exclude out --exclude data --exclude .git ./ M2U64:comparables-validation/
77 +ssh M2U64 'cd ~/comparables-validation && CV_VERSION=2026-10-10 ./run_all.sh' # ≈ 30 min (or ./run_distributed.sh)
78 +./make_pdf.sh # pull out/ + docs/, build RAPPORT.pdf
79 +cd paper && python3 make_paper_tables.py && make # tables + numbers.tex macros → main.pdf
26 80 ```
27 81
28 −Variables : `CV_VERSION` (dossier de sortie), `CV_SAMPLE=3000` (échantillon de cibles pour un essai), `CV_PROCS` (processus, 20 par défaut).
82 +Variables: `CV_VERSION` (output folder), `CV_SAMPLE=3000` (target subsample for a dry run), `CV_PROCS` (processes, 20).
83 +Environment on M2U64: `~/uqo-eval-terrains/env` (Python 3.14, pandas, numpy, scipy, lightgbm, matplotlib).
29 84
30 −## Données
85 +## 🗃 Data
31 86
32 −Instantané `vraiprix.db` pris sur M3U96a (`~/apps/vrai-prix/data`) le 2026-10-10 : 766 596 transactions, 3,75 M d'unités du rôle.
33 −Jeu propre : ventes `unifamilial` jumelées au rôle, règles R0-R11 journalisées dans `out/<v>/exclusions.csv`.
87 +Snapshot of the Vrai Prix production database (2026-10-10): 766,596 residential transactions and 3.75 M assessment-roll
88 +units. Single-family sales matched to the roll, cleaned by logged rules R0–R11 (`out/<v>/exclusions.csv`):
89 +456,950 → **390,996** sales (2021-01-05 → 2026-09-29), split train 201,642 / validation 64,795 (2024) / test 124,559
90 +(2025–2026). Median price $400,000. All numbers in the paper are macros in `paper/tables/numbers.tex`, regenerated by
91 +`paper/make_paper_tables.py` from `out/<version>/`.
34 92
35 −## Working paper (UQO WP 11)
93 +## 🔬 Methodology
36 94
37 −`paper/` : article en anglais au gabarit UQO (`main.tex`, `sections/*.tex`, `appendix/`, `references.bib`).
38 −Les chiffres du texte sont des macros (`tables/numbers.tex`) et les tableaux sont générés depuis `out/<version>/` :
95 +1. **Point-in-time index and quarterly hedonic grids** (`02_index.py`, `03_models.py`) — nothing after the valuation date is used.
96 +2. **Backtest of 2,256 configurations** × benchmarks on 189,354 targets (`04_backtest.py`, sharded; `04b_gather.py`).
97 +3. **Selection on 2024, evaluation on 2025–2026**; error surfaces, marginal effects of each rule, conditional rules by stratum (`05_analyze.py`).
98 +4. **Decomposition** by liquidity, region, price, type; stability over time; prediction intervals (`06_report.py`, `paper/make_paper_tables.py`).
39 99
40 −```bash
41 −./make_pdf.sh # rapatrie out/ et compile docs/RAPPORT.pdf (français)
42 −cd paper && python3 make_paper_tables.py && latexmk -pdf main.tex # tables + macros + PDF du WP
100 +## 🖼 Figures & tables inventory
101 +
102 +| Figure | Content |
103 +|---|---|
104 +| `f1_mdape_vs_k` | error vs number of comparables, by adjustment |
105 +| `f2_surface_d_T` | error surface over radius and window |
106 +| `f3_ajustements` | contribution of adjustment types |
107 +| `f4_competition` | comparables vs hedonic / GBM / ratio / hybrid |
108 +| `f5_densite`, `f6_k_par_densite` | accuracy and optimal k by submarket liquidity |
109 +| `f7_indice` | point-in-time price index |
110 +
111 +Tables (`paper/tables/*.tex`, 28 generated): descriptives, exclusions, selection, adjustments, grid, weighting,
112 +surfaces, competition, density, regions, strata, stability, intervals, conditional rules, hypotheses H2–H5.
113 +
114 +## ⚠️ Limitations
115 +
116 +- Adjustments simulated on observable characteristics (area, lot, age, storeys, attachment, assessed value); unobserved
117 + condition and quality adjustments made by appraisers are not modelled.
118 +- Single-family houses in Quebec only; results for condos/plexes not covered.
119 +- Registry prices may include non-arm's-length transfers surviving the screens.
120 +
121 +## 📝 Citation
122 +
123 +```bibtex
124 +@techreport{boucher2026salescomparison,
125 + author = {Boucher, Simon-Pierre},
126 + title = {Does the Sales Comparison Approach Actually Work? A Large-Scale Point-in-Time Validation of Comparable Selection, Price Adjustments, and Value Reconciliation},
127 + institution = {Universit\'e du Qu\'ebec en Outaouais, D\'epartement des sciences administratives},
128 + type = {Working Paper},
129 + number = {16},
130 + year = {2026},
131 + month = {October}
132 +}
43 133 ```
44 134
45 −## Calcul réparti
135 +## 👤 Author & contact
136 +
137 +**Simon-Pierre Boucher** — Département des sciences administratives,
138 +Université du Québec en Outaouais (UQO), Gatineau, QC.
139 +📧 [simon-pierre.boucher@uqo.ca](mailto:simon-pierre.boucher@uqo.ca)
140 +
141 +## 🗒 Notes
46 142
47 −`run_distributed.sh` : distribue les modèles trimestriels aux nœuds, lance la rétro-évaluation en 15 fragments
48 −(M3U96a ×4, M3U96b ×4, M2U64 ×3, M4M64a ×2, M4M64b ×2), rassemble sur M2U64 (`04b_gather.py`) puis exécute 05 et 06.
49 −Ré-estimation trimestrielle répartie : `CV_ASOF=54,57 CV_THREADS=28 ../env/bin/python 03_models.py` sur chaque nœud.
143 +- Numbering: issued on 2026-10-10 as "Working Paper No. 11" (number already used by *Half a Million Prices, Twenty
144 + Models*); renumbered **16** on 2026-10-17. The SSRN upload 7626720 predates the renumbering.
145 +- Deviation from `Recherche-UQO/HARMONISATION.md` §2: the pipeline keeps `pipeline/` (not `scripts/`) and `out/` (not
146 + `results/`) because the same tree runs on M2U64 under `~/comparables-validation` with these paths.
147 +- French protocol and report in `docs/` (`PROTOCOLE.md`, `RAPPORT.md/.pdf`); original French README in `docs/README-fr.md`.
148 +- Per-node copies of the working tree pulled on 2026-10-10 (`M*omparables-validation/`) were removed from the repository
149 + and archived in `Recherche-UQO/_archives/sales-comparison-node-copies-20261010/`.
deleted paper/WP11_Sales_Comparison_Validation.pdf +0 −0

Binary file not shown.

deleted paper/main.aux +0 −205
@@ -1,205 +0,0 @@
1 −\relax
2 −\providecommand \babel@aux [2]{\global \let \babel@toc \@gobbletwo }
3 −\@nameuse{bbl@beforestart}
4 −\providecommand\hyper@newdestlabel[2]{}
5 −\providecommand\HyField@AuxAddToFields[1]{}
6 −\providecommand\HyField@AuxAddToCoFields[2]{}
7 −\bibstyle{aer}
8 −\babel@aux{english}{}
9 −\citation{appraisal2020,iaao2017}
10 −\citation{colwell1983,vandell1991,gau1992,isakson2002}
11 −\@writefile{toc}{\contentsline {section}{\numberline {1}Introduction}{3}{section.1}\protected@file@percent }
12 −\newlabel{sec:introduction}{{1}{3}{Introduction}{section.1}{}}
13 −\citation{colwell1983,vandell1991,gau1992,gau1994,isakson2002}
14 −\citation{kang1991,lentz1998,pace1998}
15 −\citation{mccluskey2013,mayer2019,bourassa2010,pace2020}
16 −\citation{appraisal2020}
17 −\citation{iaao2018}
18 −\citation{diaz1990,diaz2001}
19 −\citation{colwell1983}
20 −\citation{vandell1991}
21 −\citation{gau1992,gau1994}
22 −\citation{isakson1986,isakson2002}
23 −\citation{lentz1998}
24 −\@writefile{toc}{\contentsline {section}{\numberline {2}Related literature}{5}{section.2}\protected@file@percent }
25 −\newlabel{sec:literature}{{2}{5}{Related literature}{section.2}{}}
26 −\@writefile{toc}{\contentsline {subsection}{\numberline {2.1}The sales comparison approach and its conventions}{5}{subsection.2.1}\protected@file@percent }
27 −\citation{rosen1974}
28 −\citation{kang1991,pace1998}
29 −\citation{kang1991}
30 −\citation{pace1998}
31 −\citation{pace1997}
32 −\citation{bourassa2010,case2004}
33 −\citation{bailey1963}
34 −\citation{case1989}
35 −\citation{hill2013}
36 −\citation{gloudemans1999}
37 −\citation{pagourtzi2003}
38 −\citation{mccluskey2013}
39 −\citation{mayer2019}
40 −\citation{bourassa2010}
41 −\citation{case2004}
42 −\citation{pace2020}
43 −\citation{iaao2018}
44 −\@writefile{toc}{\contentsline {subsection}{\numberline {2.2}Estimating adjustments: grids, hedonics and local models}{6}{subsection.2.2}\protected@file@percent }
45 −\citation{mayer2019}
46 −\citation{diaz1990,diaz2001,northcraft1987}
47 −\@writefile{toc}{\contentsline {subsection}{\numberline {2.3}Automated valuation and machine learning}{7}{subsection.2.3}\protected@file@percent }
48 −\@writefile{toc}{\contentsline {subsection}{\numberline {2.4}Appraiser behaviour and the limits of simulation}{7}{subsection.2.4}\protected@file@percent }
49 −\@writefile{toc}{\contentsline {section}{\numberline {3}Data}{8}{section.3}\protected@file@percent }
50 −\newlabel{sec:data}{{3}{8}{Data}{section.3}{}}
51 −\@writefile{toc}{\contentsline {subsection}{\numberline {3.1}The Vrai Prix transaction database}{8}{subsection.3.1}\protected@file@percent }
52 −\@writefile{toc}{\contentsline {subsection}{\numberline {3.2}Sample construction}{8}{subsection.3.2}\protected@file@percent }
53 −\@writefile{lot}{\contentsline {table}{\numberline {1}{\ignorespaces Sample construction}}{9}{table.caption.1}\protected@file@percent }
54 −\providecommand*\caption@xref[2]{\@setref\relax\@undefined{#1}}
55 −\newlabel{tab:exclusions}{{1}{9}{Sample construction}{table.caption.1}{}}
56 −\@writefile{toc}{\contentsline {subsection}{\numberline {3.3}Chronological partition and descriptive statistics}{9}{subsection.3.3}\protected@file@percent }
57 −\@writefile{lot}{\contentsline {table}{\numberline {2}{\ignorespaces Descriptive statistics by period}}{10}{table.caption.2}\protected@file@percent }
58 −\newlabel{tab:descriptives}{{2}{10}{Descriptive statistics by period}{table.caption.2}{}}
59 −\@writefile{lof}{\contentsline {figure}{\numberline {1}{\ignorespaces Hedonic price index by region, full sample (descriptive). Monthly effects from a within-municipality regression of log price on characteristics; January 2021 $=100$. The point-in-time indices used in the backtest are re-estimated every month from prior sales only.}}{10}{figure.caption.3}\protected@file@percent }
60 −\newlabel{fig:index}{{1}{10}{Hedonic price index by region, full sample (descriptive). Monthly effects from a within-municipality regression of log price on characteristics; January 2021 $=100$. The point-in-time indices used in the backtest are re-estimated every month from prior sales only}{figure.caption.3}{}}
61 −\@writefile{toc}{\contentsline {subsection}{\numberline {3.4}Market density}{11}{subsection.3.4}\protected@file@percent }
62 −\@writefile{lot}{\contentsline {table}{\numberline {3}{\ignorespaces Distribution of test targets by market density}}{11}{table.caption.4}\protected@file@percent }
63 −\newlabel{tab:density_dist}{{3}{11}{Distribution of test targets by market density}{table.caption.4}{}}
64 −\@writefile{toc}{\contentsline {section}{\numberline {4}Methodology}{11}{section.4}\protected@file@percent }
65 −\newlabel{sec:methodology}{{4}{11}{Methodology}{section.4}{}}
66 −\@writefile{toc}{\contentsline {subsection}{\numberline {4.1}Point-in-time backtesting}{11}{subsection.4.1}\protected@file@percent }
67 −\newlabel{eq:pool}{{1}{11}{Point-in-time backtesting}{equation.1}{}}
68 −\@writefile{toc}{\contentsline {subsection}{\numberline {4.2}Point-in-time price index}{12}{subsection.4.2}\protected@file@percent }
69 −\newlabel{sec:index}{{4.2}{12}{Point-in-time price index}{subsection.4.2}{}}
70 −\newlabel{eq:index}{{2}{12}{Point-in-time price index}{equation.2}{}}
71 −\@writefile{toc}{\contentsline {subsection}{\numberline {4.3}Configurations of the sales comparison approach}{12}{subsection.4.3}\protected@file@percent }
72 −\newlabel{sec:config}{{4.3}{12}{Configurations of the sales comparison approach}{subsection.4.3}{}}
73 −\@writefile{toc}{\contentsline {paragraph}{Selection.}{12}{subsection.4.3}\protected@file@percent }
74 −\@writefile{toc}{\contentsline {paragraph}{Adjustment.}{12}{subsection.4.3}\protected@file@percent }
75 −\@writefile{toc}{\contentsline {paragraph}{Weighting and reconciliation.}{13}{Item.8}\protected@file@percent }
76 −\@writefile{toc}{\contentsline {paragraph}{Experiments.}{13}{Item.8}\protected@file@percent }
77 −\@writefile{toc}{\contentsline {subsection}{\numberline {4.4}Benchmark models}{13}{subsection.4.4}\protected@file@percent }
78 −\newlabel{sec:benchmarks}{{4.4}{13}{Benchmark models}{subsection.4.4}{}}
79 −\@writefile{lot}{\contentsline {table}{\numberline {4}{\ignorespaces Models in the horse race}}{14}{table.caption.5}\protected@file@percent }
80 −\newlabel{tab:models}{{4}{14}{Models in the horse race}{table.caption.5}{}}
81 −\@writefile{toc}{\contentsline {subsection}{\numberline {4.5}Accuracy measures and inference}{14}{subsection.4.5}\protected@file@percent }
82 −\citation{cameron2008}
83 −\@writefile{toc}{\contentsline {subsection}{\numberline {4.6}Hypotheses}{15}{subsection.4.6}\protected@file@percent }
84 −\@writefile{toc}{\contentsline {section}{\numberline {5}Results}{15}{section.5}\protected@file@percent }
85 −\newlabel{sec:results}{{5}{15}{Results}{section.5}{}}
86 −\@writefile{toc}{\contentsline {subsection}{\numberline {5.1}How many comparables, how far, how far back?}{15}{subsection.5.1}\protected@file@percent }
87 −\newlabel{sec:surface}{{5.1}{15}{How many comparables, how far, how far back?}{subsection.5.1}{}}
88 −\@writefile{lof}{\contentsline {figure}{\numberline {2}{\ignorespaces Out-of-sample MdAPE (2025--2026) by number of comparables and search radius; twelve-month window, selection by distance, median reconciliation. Left: time adjustment only. Right: hedonic adjustment grid.}}{16}{figure.caption.6}\protected@file@percent }
89 −\newlabel{fig:surface_k}{{2}{16}{Out-of-sample MdAPE (2025--2026) by number of comparables and search radius; twelve-month window, selection by distance, median reconciliation. Left: time adjustment only. Right: hedonic adjustment grid}{figure.caption.6}{}}
90 −\@writefile{lof}{\contentsline {figure}{\numberline {3}{\ignorespaces Best attainable out-of-sample MdAPE (over $k$) for each search radius and look-back window; selection by distance.}}{16}{figure.caption.7}\protected@file@percent }
91 −\newlabel{fig:surface_dt}{{3}{16}{Best attainable out-of-sample MdAPE (over $k$) for each search radius and look-back window; selection by distance}{figure.caption.7}{}}
92 −\@writefile{lot}{\contentsline {table}{\numberline {5}{\ignorespaces Error surface $\mathrm {Error}(k,d)$: out-of-sample MdAPE (\%), selection by distance, hedonic grid, twelve-month window}}{17}{table.caption.8}\protected@file@percent }
93 −\newlabel{tab:surface}{{5}{17}{Error surface $\mathrm {Error}(k,d)$: out-of-sample MdAPE (\%), selection by distance, hedonic grid, twelve-month window}{table.caption.8}{}}
94 −\citation{colwell1983}
95 −\@writefile{lot}{\contentsline {table}{\numberline {6}{\ignorespaces Optimal number of comparables by market density (H2): out-of-sample MdAPE (\%)}}{18}{table.caption.9}\protected@file@percent }
96 −\newlabel{tab:h2}{{6}{18}{Optimal number of comparables by market density (H2): out-of-sample MdAPE (\%)}{table.caption.9}{}}
97 −\@writefile{toc}{\contentsline {subsection}{\numberline {5.2}Which adjustments are warranted?}{18}{subsection.5.2}\protected@file@percent }
98 −\newlabel{sec:adjustments}{{5.2}{18}{Which adjustments are warranted?}{subsection.5.2}{}}
99 −\@writefile{lot}{\contentsline {table}{\numberline {7}{\ignorespaces Adjustment schemes at the $(5,\,5\text { km},\,12\text { months})$ anchor}}{19}{table.caption.10}\protected@file@percent }
100 −\newlabel{tab:adjustments}{{7}{19}{Adjustment schemes at the $(5,\,5\text { km},\,12\text { months})$ anchor}{table.caption.10}{}}
101 −\@writefile{lof}{\contentsline {figure}{\numberline {4}{\ignorespaces MdAPE by adjustment scheme, validation (2024) and test (2025--2026); five nearest sales, $\le 5$~km, $\le 12$ months, median.}}{19}{figure.caption.11}\protected@file@percent }
102 −\newlabel{fig:adjustments}{{4}{19}{MdAPE by adjustment scheme, validation (2024) and test (2025--2026); five nearest sales, $\le 5$~km, $\le 12$ months, median}{figure.caption.11}{}}
103 −\@writefile{lot}{\contentsline {table}{\numberline {8}{\ignorespaces Adjustment schemes by market density (H3): out-of-sample MdAPE (\%)}}{20}{table.caption.12}\protected@file@percent }
104 −\newlabel{tab:h3}{{8}{20}{Adjustment schemes by market density (H3): out-of-sample MdAPE (\%)}{table.caption.12}{}}
105 −\@writefile{lot}{\contentsline {table}{\numberline {9}{\ignorespaces Hedonic adjustment grid (within-municipality coefficients, log price)}}{21}{table.caption.13}\protected@file@percent }
106 −\newlabel{tab:grid}{{9}{21}{Hedonic adjustment grid (within-municipality coefficients, log price)}{table.caption.13}{}}
107 −\@writefile{toc}{\contentsline {subsection}{\numberline {5.3}How should comparables be selected? (H1)}{21}{subsection.5.3}\protected@file@percent }
108 −\newlabel{sec:selection}{{5.3}{21}{How should comparables be selected? (H1)}{subsection.5.3}{}}
109 −\@writefile{lot}{\contentsline {table}{\numberline {10}{\ignorespaces Selection criteria at the $(5,\,5\text { km},\,12\text { months})$ anchor, hedonic grid}}{21}{table.caption.14}\protected@file@percent }
110 −\newlabel{tab:selection}{{10}{21}{Selection criteria at the $(5,\,5\text { km},\,12\text { months})$ anchor, hedonic grid}{table.caption.14}{}}
111 −\@writefile{toc}{\contentsline {subsection}{\numberline {5.4}Weighting and reconciliation (H4)}{22}{subsection.5.4}\protected@file@percent }
112 −\newlabel{sec:weighting}{{5.4}{22}{Weighting and reconciliation (H4)}{subsection.5.4}{}}
113 −\@writefile{lot}{\contentsline {table}{\numberline {11}{\ignorespaces Weighting and reconciliation at the $(5,\,5\text { km},\,12\text { months})$ anchor, hedonic grid}}{22}{table.caption.15}\protected@file@percent }
114 −\newlabel{tab:weighting}{{11}{22}{Weighting and reconciliation at the $(5,\,5\text { km},\,12\text { months})$ anchor, hedonic grid}{table.caption.15}{}}
115 −\@writefile{lot}{\contentsline {table}{\numberline {12}{\ignorespaces Weighting by market density (H4): out-of-sample MdAPE (\%)}}{23}{table.caption.16}\protected@file@percent }
116 −\newlabel{tab:h4}{{12}{23}{Weighting by market density (H4): out-of-sample MdAPE (\%)}{table.caption.16}{}}
117 −\@writefile{lot}{\contentsline {table}{\numberline {13}{\ignorespaces Marginal effects of the four rule factors in the E2 design}}{23}{table.caption.17}\protected@file@percent }
118 −\newlabel{tab:marginal}{{13}{23}{Marginal effects of the four rule factors in the E2 design}{table.caption.17}{}}
119 −\@writefile{toc}{\contentsline {subsection}{\numberline {5.5}The horse race}{24}{subsection.5.5}\protected@file@percent }
120 −\newlabel{sec:horserace}{{5.5}{24}{The horse race}{subsection.5.5}{}}
121 −\@writefile{lot}{\contentsline {table}{\numberline {14}{\ignorespaces Horse race: out-of-sample accuracy, 2025--2026}}{24}{table.caption.18}\protected@file@percent }
122 −\newlabel{tab:competition}{{14}{24}{Horse race: out-of-sample accuracy, 2025--2026}{table.caption.18}{}}
123 −\@writefile{lof}{\contentsline {figure}{\numberline {5}{\ignorespaces Out-of-sample MdAPE by model, 2025--2026. Orange: configurations of the sales comparison approach; green: empirically optimised configurations; blue: external benchmarks.}}{25}{figure.caption.19}\protected@file@percent }
124 −\newlabel{fig:competition}{{5}{25}{Out-of-sample MdAPE by model, 2025--2026. Orange: configurations of the sales comparison approach; green: empirically optimised configurations; blue: external benchmarks}{figure.caption.19}{}}
125 −\@writefile{lot}{\contentsline {table}{\numberline {15}{\ignorespaces Paired differences in accuracy with cluster-bootstrap 95\% confidence intervals}}{26}{table.caption.20}\protected@file@percent }
126 −\newlabel{tab:pairs}{{15}{26}{Paired differences in accuracy with cluster-bootstrap 95\% confidence intervals}{table.caption.20}{}}
127 −\@writefile{toc}{\contentsline {subsection}{\numberline {5.6}Where does the method hold, and where does it fail?}{26}{subsection.5.6}\protected@file@percent }
128 −\newlabel{sec:strata}{{5.6}{26}{Where does the method hold, and where does it fail?}{subsection.5.6}{}}
129 −\@writefile{lot}{\contentsline {table}{\numberline {16}{\ignorespaces Out-of-sample MdAPE (\%) by market density}}{27}{table.caption.21}\protected@file@percent }
130 −\newlabel{tab:density}{{16}{27}{Out-of-sample MdAPE (\%) by market density}{table.caption.21}{}}
131 −\@writefile{lof}{\contentsline {figure}{\numberline {6}{\ignorespaces Out-of-sample MdAPE by market density (sales within 2~km in the preceding twelve months), selected models.}}{27}{figure.caption.22}\protected@file@percent }
132 −\newlabel{fig:density}{{6}{27}{Out-of-sample MdAPE by market density (sales within 2~km in the preceding twelve months), selected models}{figure.caption.22}{}}
133 −\@writefile{lot}{\contentsline {table}{\numberline {17}{\ignorespaces Accuracy by submarket heterogeneity (H5): out-of-sample MdAPE (\%)}}{28}{table.caption.23}\protected@file@percent }
134 −\newlabel{tab:h5}{{17}{28}{Accuracy by submarket heterogeneity (H5): out-of-sample MdAPE (\%)}{table.caption.23}{}}
135 −\@writefile{toc}{\contentsline {subsection}{\numberline {5.7}Value intervals}{28}{subsection.5.7}\protected@file@percent }
136 −\newlabel{sec:intervals}{{5.7}{28}{Value intervals}{subsection.5.7}{}}
137 −\@writefile{lot}{\contentsline {table}{\numberline {18}{\ignorespaces Coverage of value intervals derived from the dispersion of adjusted indications}}{28}{table.caption.24}\protected@file@percent }
138 −\newlabel{tab:intervals}{{18}{28}{Coverage of value intervals derived from the dispersion of adjusted indications}{table.caption.24}{}}
139 −\@writefile{toc}{\contentsline {subsection}{\numberline {5.8}Towards an empirical comparable-recommendation system}{28}{subsection.5.8}\protected@file@percent }
140 −\newlabel{sec:recommend}{{5.8}{28}{Towards an empirical comparable-recommendation system}{subsection.5.8}{}}
141 −\@writefile{lot}{\contentsline {table}{\numberline {19}{\ignorespaces Density-conditional rules chosen on 2024 and evaluated on 2025--2026}}{29}{table.caption.25}\protected@file@percent }
142 −\newlabel{tab:cond}{{19}{29}{Density-conditional rules chosen on 2024 and evaluated on 2025--2026}{table.caption.25}{}}
143 −\@writefile{toc}{\contentsline {section}{\numberline {6}Robustness}{29}{section.6}\protected@file@percent }
144 −\newlabel{sec:robustness}{{6}{29}{Robustness}{section.6}{}}
145 −\@writefile{toc}{\contentsline {subsection}{\numberline {6.1}Stability over time}{29}{subsection.6.1}\protected@file@percent }
146 −\@writefile{lot}{\contentsline {table}{\numberline {20}{\ignorespaces Stability over the test period: MdAPE and bias (\%) by quarter}}{29}{table.caption.26}\protected@file@percent }
147 −\newlabel{tab:stability}{{20}{29}{Stability over the test period: MdAPE and bias (\%) by quarter}{table.caption.26}{}}
148 −\@writefile{toc}{\contentsline {subsection}{\numberline {6.2}Coverage and the common-support comparison}{30}{subsection.6.2}\protected@file@percent }
149 −\@writefile{toc}{\contentsline {subsection}{\numberline {6.3}Exclusion rules}{30}{subsection.6.3}\protected@file@percent }
150 −\@writefile{toc}{\contentsline {subsection}{\numberline {6.4}Alternative accuracy measures}{30}{subsection.6.4}\protected@file@percent }
151 −\@writefile{toc}{\contentsline {subsection}{\numberline {6.5}Index construction}{30}{subsection.6.5}\protected@file@percent }
152 −\@writefile{toc}{\contentsline {subsection}{\numberline {6.6}Benchmark specification}{31}{subsection.6.6}\protected@file@percent }
153 −\@writefile{toc}{\contentsline {section}{\numberline {7}Conclusion}{31}{section.7}\protected@file@percent }
154 −\newlabel{sec:conclusion}{{7}{31}{Conclusion}{section.7}{}}
155 −\bibdata{references}
156 −\bibcite{appraisal2020}{{1}{2020}{{Appraisal Institute}}{{Appraisal Institute}}}
157 −\bibcite{bailey1963}{{2}{1963}{{Bailey et al.}}{{Bailey, Muth and Nourse}}}
158 −\bibcite{bourassa2010}{{3}{2010}{{Bourassa et al.}}{{Bourassa, Cantoni and Hoesli}}}
159 −\bibcite{cameron2008}{{4}{2008}{{Cameron et al.}}{{Cameron, Gelbach and Miller}}}
160 −\bibcite{case2004}{{5}{2004}{{Case et al.}}{{Case, Clapp, Dubin and Rodriguez}}}
161 −\bibcite{case1989}{{6}{1989}{{Case and Shiller}}{{Case and Shiller}}}
162 −\bibcite{colwell1983}{{7}{1983}{{Colwell et al.}}{{Colwell, Cannaday and Wu}}}
163 −\bibcite{diaz1990}{{8}{1990}{{Diaz}}{{Diaz}}}
164 −\bibcite{diaz2001}{{9}{2001}{{Diaz and Hansz}}{{Diaz and Hansz}}}
165 −\bibcite{gau1992}{{10}{1992}{{Gau et al.}}{{Gau, Lai and Wang}}}
166 −\bibcite{gau1994}{{11}{1994}{{Gau et al.}}{{Gau, Lai and Wang}}}
167 −\bibcite{gloudemans1999}{{12}{1999}{{Gloudemans}}{{Gloudemans}}}
168 −\bibcite{hill2013}{{13}{2013}{{Hill}}{{Hill}}}
169 −\bibcite{iaao2017}{{14}{2017}{{International Association of Assessing Officers}}{{International Association of Assessing Officers}}}
170 −\bibcite{iaao2018}{{15}{2018}{{International Association of Assessing Officers}}{{International Association of Assessing Officers}}}
171 −\bibcite{isakson1986}{{16}{1986}{{Isakson}}{{Isakson}}}
172 −\bibcite{isakson2002}{{17}{2002}{{Isakson}}{{Isakson}}}
173 −\bibcite{kang1991}{{18}{1991}{{Kang and Reichert}}{{Kang and Reichert}}}
174 −\bibcite{lentz1998}{{19}{1998}{{Lentz and Wang}}{{Lentz and Wang}}}
175 −\bibcite{mayer2019}{{20}{2019}{{Mayer et al.}}{{Mayer, Bourassa, Hoesli and Scognamiglio}}}
176 −\bibcite{mccluskey2013}{{21}{2013}{{McCluskey et al.}}{{McCluskey, McCord, Davis, Haran and McIlhatton}}}
177 −\bibcite{northcraft1987}{{22}{1987}{{Northcraft and Neale}}{{Northcraft and Neale}}}
178 −\bibcite{pace1998}{{23}{1998}{{Pace}}{{Pace}}}
179 −\bibcite{pace2020}{{24}{2020}{{Pace and Hayunga}}{{Pace and Hayunga}}}
180 −\bibcite{pace1997}{{25}{1997}{{Pace and Gilley}}{{Pace and Gilley}}}
181 −\bibcite{pagourtzi2003}{{26}{2003}{{Pagourtzi et al.}}{{Pagourtzi, Assimakopoulos, Hatzichristos and French}}}
182 −\bibcite{rosen1974}{{27}{1974}{{Rosen}}{{Rosen}}}
183 −\bibcite{vandell1991}{{28}{1991}{{Vandell}}{{Vandell}}}
184 −\@writefile{toc}{\contentsline {section}{Appendices}{35}{section*.27}\protected@file@percent }
185 −\@writefile{toc}{\contentsline {section}{\numberline {A}Additional tables}{35}{appendix.1.A}\protected@file@percent }
186 −\newlabel{app:tables}{{A}{35}{Additional tables}{appendix.1.A}{}}
187 −\@writefile{lot}{\contentsline {table}{\numberline {21}{\ignorespaces Coverage of the error surface: share of test targets (\%) for which a value is returned, by radius and window}}{35}{table.caption.28}\protected@file@percent }
188 −\newlabel{tab:surface_cov}{{21}{35}{Coverage of the error surface: share of test targets (\%) for which a value is returned, by radius and window}{table.caption.28}{}}
189 −\@writefile{lot}{\contentsline {table}{\numberline {22}{\ignorespaces Error surface without physical adjustment: out-of-sample MdAPE (\%), selection by distance, time adjustment only, twelve-month window}}{35}{table.caption.29}\protected@file@percent }
190 −\newlabel{tab:surface_none}{{22}{35}{Error surface without physical adjustment: out-of-sample MdAPE (\%), selection by distance, time adjustment only, twelve-month window}{table.caption.29}{}}
191 −\@writefile{lot}{\contentsline {table}{\numberline {23}{\ignorespaces Error surface with selection by similarity: out-of-sample MdAPE (\%), hedonic grid, twelve-month window}}{36}{table.caption.30}\protected@file@percent }
192 −\newlabel{tab:surface_sim}{{23}{36}{Error surface with selection by similarity: out-of-sample MdAPE (\%), hedonic grid, twelve-month window}{table.caption.30}{}}
193 −\@writefile{lot}{\contentsline {table}{\numberline {24}{\ignorespaces Best configuration of the error surface by selection and adjustment (chosen on 2024, evaluated on 2025--2026)}}{36}{table.caption.31}\protected@file@percent }
194 −\newlabel{tab:e1best}{{24}{36}{Best configuration of the error surface by selection and adjustment (chosen on 2024, evaluated on 2025--2026)}{table.caption.31}{}}
195 −\@writefile{lot}{\contentsline {table}{\numberline {25}{\ignorespaces Out-of-sample MdAPE (\%) by administrative region}}{37}{table.caption.32}\protected@file@percent }
196 −\newlabel{tab:regions}{{25}{37}{Out-of-sample MdAPE (\%) by administrative region}{table.caption.32}{}}
197 −\@writefile{lot}{\contentsline {table}{\numberline {26}{\ignorespaces Out-of-sample MdAPE (\%) by price quintile, age and physical link}}{37}{table.caption.33}\protected@file@percent }
198 −\newlabel{tab:price}{{26}{37}{Out-of-sample MdAPE (\%) by price quintile, age and physical link}{table.caption.33}{}}
199 −\@writefile{toc}{\contentsline {section}{\numberline {B}Implementation details}{38}{appendix.1.B}\protected@file@percent }
200 −\newlabel{app:impl}{{B}{38}{Implementation details}{appendix.1.B}{}}
201 −\@writefile{toc}{\contentsline {paragraph}{Computation.}{38}{appendix.1.B}\protected@file@percent }
202 −\@writefile{toc}{\contentsline {paragraph}{Local grid.}{38}{appendix.1.B}\protected@file@percent }
203 −\@writefile{toc}{\contentsline {paragraph}{Gradient boosting.}{38}{appendix.1.B}\protected@file@percent }
204 −\@writefile{toc}{\contentsline {paragraph}{Cluster bootstrap.}{38}{appendix.1.B}\protected@file@percent }
205 −\gdef \@abspage@last{38}
deleted paper/main.bbl +0 −148
@@ -1,148 +0,0 @@
1 −\ifx\undefined\bysame
2 −\newcommand{\bysame}{\leavevmode\hbox to\leftmargin{\hrulefill\,\,}}
3 −\fi
4 −\begin{thebibliography}{xx}
5 −
6 −\harvarditem[{Appraisal Institute}]{{Appraisal Institute}}{2020}{appraisal2020}
7 −{\bf {Appraisal Institute}}, {\it The Appraisal of Real Estate}, 15th ed.,
8 − Chicago: Appraisal Institute, 2020.
9 −
10 −\harvarditem[Bailey et al.]{Bailey, Muth and Nourse}{1963}{bailey1963}
11 −{\bf Bailey, Martin~J., Richard~F. Muth, and Hugh~O. Nourse}, ``A Regression
12 − Method for Real Estate Price Index Construction,'' {\it Journal of the
13 − American Statistical Association}, 1963, {\it 58} (304), 933--942.
14 −
15 −\harvarditem[Bourassa et al.]{Bourassa, Cantoni and Hoesli}{2010}{bourassa2010}
16 −{\bf Bourassa, Steven~C., Eva Cantoni, and Martin Hoesli}, ``Predicting House
17 − Prices with Spatial Dependence: A Comparison of Alternative Methods,'' {\it
18 − Journal of Real Estate Research}, 2010, {\it 32} (2), 139--159.
19 −
20 −\harvarditem[Cameron et al.]{Cameron, Gelbach and Miller}{2008}{cameron2008}
21 −{\bf Cameron, A.~Colin, Jonah~B. Gelbach, and Douglas~L. Miller},
22 − ``Bootstrap-Based Improvements for Inference with Clustered Errors,'' {\it
23 − Review of Economics and Statistics}, 2008, {\it 90} (3), 414--427.
24 −
25 −\harvarditem[Case et al.]{Case, Clapp, Dubin and Rodriguez}{2004}{case2004}
26 −{\bf Case, Bradford, John Clapp, Robin Dubin, and Mauricio Rodriguez},
27 − ``Modeling Spatial and Temporal House Price Patterns: A Comparison of Four
28 − Models,'' {\it Journal of Real Estate Finance and Economics}, 2004, {\it 29}
29 − (2), 167--191.
30 −
31 −\harvarditem[Case and Shiller]{Case and Shiller}{1989}{case1989}
32 −{\bf Case, Karl~E. and Robert~J. Shiller}, ``The Efficiency of the Market for
33 − Single-Family Homes,'' {\it American Economic Review}, 1989, {\it 79} (1),
34 − 125--137.
35 −
36 −\harvarditem[Colwell et al.]{Colwell, Cannaday and Wu}{1983}{colwell1983}
37 −{\bf Colwell, Peter~F., Roger~E. Cannaday, and Chunchi Wu}, ``The Analytical
38 − Foundations of Adjustment Grid Methods,'' {\it Real Estate Economics}, 1983,
39 − {\it 11} (1), 11--29.
40 −
41 −\harvarditem[Diaz]{Diaz}{1990}{diaz1990}
42 −{\bf Diaz, Julian}, ``How Appraisers Do Their Work: A Test of the Appraisal
43 − Process and the Development of a Descriptive Model,'' {\it Journal of Real
44 − Estate Research}, 1990, {\it 5} (1), 1--15.
45 −
46 −\harvarditem[Diaz and Hansz]{Diaz and Hansz}{2001}{diaz2001}
47 −{\bf \bysame{} and J.~Andrew Hansz}, ``The Use of Reference Points in Valuation
48 − Judgment,'' {\it Journal of Property Research}, 2001, {\it 18} (2), 141--148.
49 −
50 −\harvarditem[Gau et al.]{Gau, Lai and Wang}{1992}{gau1992}
51 −{\bf Gau, George~W., Tsong-Yue Lai, and Ko~Wang}, ``Optimal Comparable
52 − Selection and Weighting in Real Property Valuation: An Extension,'' {\it Real
53 − Estate Economics}, 1992, {\it 20} (1), 107--123.
54 −
55 −\harvarditem[Gau et al.]{Gau, Lai and Wang}{1994}{gau1994}
56 −{\bf \bysame{}, \bysame{}, and \bysame{}}, ``A Further Discussion of Optimal
57 − Comparable Selection and Weighting, and a Response to {Green},'' {\it Real
58 − Estate Economics}, 1994, {\it 22} (4), 655--663.
59 −
60 −\harvarditem[Gloudemans]{Gloudemans}{1999}{gloudemans1999}
61 −{\bf Gloudemans, Robert~J.}, {\it Mass Appraisal of Real Property}, Chicago:
62 − International Association of Assessing Officers, 1999.
63 −
64 −\harvarditem[Hill]{Hill}{2013}{hill2013}
65 −{\bf Hill, Robert~J.}, ``Hedonic Price Indexes for Residential Housing: A
66 − Survey, Evaluation and Taxonomy,'' {\it Journal of Economic Surveys}, 2013,
67 − {\it 27} (5), 879--914.
68 −
69 −\harvarditem[{International Association of Assessing Officers}]{{International
70 − Association of Assessing Officers}}{2017}{iaao2017}
71 −{\bf {International Association of Assessing Officers}}, ``Standard on Mass
72 − Appraisal of Real Property,'' Technical Report, IAAO, Kansas City 2017.
73 −
74 −\harvarditem[{International Association of Assessing Officers}]{{International
75 − Association of Assessing Officers}}{2018}{iaao2018}
76 −{\bf \bysame{}}, ``Standard on Automated Valuation Models ({AVM}s),'' Technical
77 − Report, IAAO, Kansas City 2018.
78 −
79 −\harvarditem[Isakson]{Isakson}{1986}{isakson1986}
80 −{\bf Isakson, Hans~R.}, ``The Nearest Neighbors Appraisal Technique: An
81 − Alternative to the Adjustment Grid Methods,'' {\it Real Estate Economics},
82 − 1986, {\it 14} (2), 274--286.
83 −
84 −\harvarditem[Isakson]{Isakson}{2002}{isakson2002}
85 −{\bf \bysame{}}, ``The Linear Algebra of the Sales Comparison Approach,'' {\it
86 − Journal of Real Estate Research}, 2002, {\it 24} (2), 117--128.
87 −
88 −\harvarditem[Kang and Reichert]{Kang and Reichert}{1991}{kang1991}
89 −{\bf Kang, Han-Bin and Alan~K. Reichert}, ``An Empirical Analysis of Hedonic
90 − Regression and Grid-Adjustment Techniques in Real Estate Appraisal,'' {\it
91 − Real Estate Economics}, 1991, {\it 19} (1), 70--91.
92 −
93 −\harvarditem[Lentz and Wang]{Lentz and Wang}{1998}{lentz1998}
94 −{\bf Lentz, George~H. and Ko~Wang}, ``Residential Appraisal and the Lending
95 − Process: A Survey of Issues,'' {\it Journal of Real Estate Research}, 1998,
96 − {\it 15} (1), 11--39.
97 −
98 −\harvarditem[Mayer et al.]{Mayer, Bourassa, Hoesli and
99 − Scognamiglio}{2019}{mayer2019}
100 −{\bf Mayer, Michael, Steven~C. Bourassa, Martin Hoesli, and Donato
101 − Scognamiglio}, ``Estimation and Updating Methods for Hedonic Valuation,''
102 − {\it Journal of European Real Estate Research}, 2019, {\it 12} (1), 134--150.
103 −
104 −\harvarditem[McCluskey et al.]{McCluskey, McCord, Davis, Haran and
105 − McIlhatton}{2013}{mccluskey2013}
106 −{\bf McCluskey, William~J., Michael McCord, Peadar~T. Davis, Martin Haran, and
107 − David McIlhatton}, ``Prediction Accuracy in Mass Appraisal: A Comparison of
108 − Modern Approaches,'' {\it Journal of Property Research}, 2013, {\it 30} (4),
109 − 239--265.
110 −
111 −\harvarditem[Northcraft and Neale]{Northcraft and Neale}{1987}{northcraft1987}
112 −{\bf Northcraft, Gregory~B. and Margaret~A. Neale}, ``Experts, Amateurs, and
113 − Real Estate: An Anchoring-and-Adjustment Perspective on Property Pricing
114 − Decisions,'' {\it Organizational Behavior and Human Decision Processes},
115 − 1987, {\it 39} (1), 84--97.
116 −
117 −\harvarditem[Pace]{Pace}{1998}{pace1998}
118 −{\bf Pace, R.~Kelley}, ``Appraisal Using Generalized Additive Models,'' {\it
119 − Journal of Real Estate Research}, 1998, {\it 15} (1), 77--99.
120 −
121 −\harvarditem[Pace and Hayunga]{Pace and Hayunga}{2020}{pace2020}
122 −{\bf \bysame{} and Darren Hayunga}, ``Examining the Information Content of
123 − Residuals from Hedonic and Spatial Models Using Trees and Forests,'' {\it
124 − Journal of Real Estate Finance and Economics}, 2020, {\it 60} (1--2),
125 − 170--180.
126 −
127 −\harvarditem[Pace and Gilley]{Pace and Gilley}{1997}{pace1997}
128 −{\bf \bysame{} and Otis~W. Gilley}, ``Using the Spatial Configuration of the
129 − Data to Improve Estimation,'' {\it Journal of Real Estate Finance and
130 − Economics}, 1997, {\it 14} (3), 333--340.
131 −
132 −\harvarditem[Pagourtzi et al.]{Pagourtzi, Assimakopoulos, Hatzichristos and
133 − French}{2003}{pagourtzi2003}
134 −{\bf Pagourtzi, Elli, Vassilis Assimakopoulos, Thomas Hatzichristos, and Nick
135 − French}, ``Real Estate Appraisal: A Review of Valuation Methods,'' {\it
136 − Journal of Property Investment \& Finance}, 2003, {\it 21} (4), 383--401.
137 −
138 −\harvarditem[Rosen]{Rosen}{1974}{rosen1974}
139 −{\bf Rosen, Sherwin}, ``Hedonic Prices and Implicit Markets: Product
140 − Differentiation in Pure Competition,'' {\it Journal of Political Economy},
141 − 1974, {\it 82} (1), 34--55.
142 −
143 −\harvarditem[Vandell]{Vandell}{1991}{vandell1991}
144 −{\bf Vandell, Kerry~D.}, ``Optimal Comparable Selection and Weighting in Real
145 − Property Valuation,'' {\it Real Estate Economics}, 1991, {\it 19} (2),
146 − 213--239.
147 −
148 −\end{thebibliography}
deleted paper/main.blg +0 −48
@@ -1,48 +0,0 @@
1 −This is BibTeX, Version 0.99e (TeX Live 2026)
2 −Capacity: max_strings=200000, hash_size=200000, hash_prime=170003
3 −The top-level auxiliary file: main.aux
4 −The style file: aer.bst
5 −Reallocated singl_function (elt_size=4) to 100 items from 50.
6 −Reallocated singl_function (elt_size=4) to 100 items from 50.
7 −Database file #1: references.bib
8 −You've used 28 entries,
9 − 2984 wiz_defined-function locations,
10 − 821 strings with 8352 characters,
11 −and the built_in function-call counts, 24844 in all, are:
12 −= -- 4009
13 −> -- 1349
14 −< -- 399
15 −+ -- 438
16 −- -- 240
17 −* -- 1622
18 −:= -- 5571
19 −add.period$ -- 28
20 −call.type$ -- 28
21 −change.case$ -- 197
22 −chr.to.int$ -- 28
23 −cite$ -- 28
24 −duplicate$ -- 311
25 −empty$ -- 769
26 −format.name$ -- 670
27 −if$ -- 6155
28 −int.to.chr$ -- 1
29 −int.to.str$ -- 0
30 −missing$ -- 28
31 −newline$ -- 90
32 −num.names$ -- 336
33 −pop$ -- 425
34 −preamble$ -- 1
35 −purify$ -- 224
36 −quote$ -- 0
37 −skip$ -- 0
38 −stack$ -- 0
39 −substring$ -- 881
40 −swap$ -- 104
41 −text.length$ -- 0
42 −text.prefix$ -- 0
43 −top$ -- 0
44 −type$ -- 108
45 −warning$ -- 0
46 −while$ -- 328
47 −width$ -- 0
48 −write$ -- 476
deleted paper/main.fdb_latexmk +0 −200
@@ -1,200 +0,0 @@
1 −# Fdb version 4
2 −["bibtex main"] 1791670452.56951 "main.aux" "main.bbl" "main" 1791670477.19181 0
3 − "./references.bib" 1791669372.52602 8208 317d4516e8f0f47f17cb222b3493cf06 ""
4 − "/usr/local/texlive/2026/texmf-dist/bibtex/bst/economic/aer.bst" 1389482635 26812 2ae6e48450a8c17a93d98067b7084a7a ""
5 − "main.aux" 1791670477.04709 22155 96efb98c7723c6a6c804fa37d2813fcb "pdflatex"
6 − (generated)
7 − "main.bbl"
8 − "main.blg"
9 − (rewritten before read)
10 −["pdflatex"] 1791670476.36241 "main.tex" "main.pdf" "main" 1791670477.19192 0
11 − "/usr/local/texlive/2026/texmf-dist/fonts/enc/dvips/base/8r.enc" 1165713224 4850 80dc9bab7f31fb78a000ccfed0e27cab ""
12 − "/usr/local/texlive/2026/texmf-dist/fonts/map/fontname/texfonts.map" 1577235249 3524 cb3e574dea2d1052e39280babc910dc8 ""
13 − "/usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/symbol/psyr.tfm" 1136768653 1408 5937f58aa508ea2cea4901c07d10f5fe ""
14 − "/usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/psyro.tfm" 1136768653 1544 23a042a74981a3e4b6ce2e350e390409 ""
15 − "/usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmb7t.tfm" 1136768653 2172 fd0c924230362ff848a33632ed45dc23 ""
16 − "/usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmb8r.tfm" 1136768653 4524 6bce29db5bc272ba5f332261583fee9c ""
17 − "/usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmb8t.tfm" 1136768653 6880 f19b8995b61c334d78fc734065f6b4d4 ""
18 − "/usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmr8r.tfm" 1136768653 4408 25b74d011a4c66b7f212c0cc3c90061b ""
19 − "/usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmr8t.tfm" 1136768653 6672 e3ab9e37e925f3045c9005e6d1473d56 ""
20 − "/usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmrc8t.tfm" 1136768653 16648 7e91c60ebf91b9c60e16ef52b3070f9c ""
21 − "/usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmri7t.tfm" 1136768653 2288 f478fc8fed18759effb59f3dad7f3084 ""
22 − "/usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmri8r.tfm" 1136768653 4640 532ca3305aad10cc01d769f3f91f1029 ""
23 − "/usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmri8t.tfm" 1136768653 6944 94c55ad86e6ea2826f78ba2240d50df9 ""
24 − "/usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/zptmcm7m.tfm" 1136768653 2232 db256afffc8202da192b4641df14d602 ""
25 − "/usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/zptmcm7t.tfm" 1136768653 2172 1d00c2a0d10f23031be62329457a870c ""
26 − "/usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/zptmcm7v.tfm" 1136768653 1032 20febbd0f0c9a48eb78616f897008286 ""
27 − "/usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/zptmcm7y.tfm" 1136768653 1520 ad7b3c1a480a03b3e41b5fbb13d938f2 ""
28 − "/usr/local/texlive/2026/texmf-dist/fonts/tfm/jknappen/ec/ecrm1200.tfm" 1136768653 3584 f80ddd985bd00e29e9a6047ebd9d4781 ""
29 − "/usr/local/texlive/2026/texmf-dist/fonts/tfm/public/amsfonts/symbols/msam10.tfm" 1246382020 916 f87d7c45f9c908e672703b83b72241a3 ""
30 − "/usr/local/texlive/2026/texmf-dist/fonts/tfm/public/amsfonts/symbols/msbm10.tfm" 1246382020 908 2921f8a10601f252058503cc6570e581 ""
31 − "/usr/local/texlive/2026/texmf-dist/fonts/tfm/public/cm/cmex10.tfm" 1136768653 992 662f679a0b3d2d53c1b94050fdaa3f50 ""
32 − "/usr/local/texlive/2026/texmf-dist/fonts/tfm/public/cm/cmmi10.tfm" 1136768653 1528 abec98dbc43e172678c11b3b9031252a ""
33 − "/usr/local/texlive/2026/texmf-dist/fonts/tfm/public/cm/cmr10.tfm" 1136768653 1296 45809c5a464d5f32c8f98ba97c1bb47f ""
34 − "/usr/local/texlive/2026/texmf-dist/fonts/tfm/public/cm/cmr12.tfm" 1136768653 1288 655e228510b4c2a1abe905c368440826 ""
35 − "/usr/local/texlive/2026/texmf-dist/fonts/tfm/public/cm/cmsy10.tfm" 1136768653 1124 6c73e740cf17375f03eec0ee63599741 ""
36 − "/usr/local/texlive/2026/texmf-dist/fonts/tfm/public/rsfs/rsfs10.tfm" 1229303445 688 37338d6ab346c2f1466b29e195316aa4 ""
37 − "/usr/local/texlive/2026/texmf-dist/fonts/type1/public/amsfonts/cm/cmex10.pfb" 1248133631 30251 6afa5cb1d0204815a708a080681d4674 ""
38 − "/usr/local/texlive/2026/texmf-dist/fonts/type1/public/amsfonts/cm/cmmi10.pfb" 1248133631 36299 5f9df58c2139e7edcf37c8fca4bd384d ""
39 − "/usr/local/texlive/2026/texmf-dist/fonts/type1/public/amsfonts/cm/cmr10.pfb" 1248133631 35752 024fb6c41858982481f6968b5fc26508 ""
40 − "/usr/local/texlive/2026/texmf-dist/fonts/type1/public/amsfonts/cm/cmsy10.pfb" 1248133631 32569 5e5ddc8df908dea60932f3c484a54c0d ""
41 − "/usr/local/texlive/2026/texmf-dist/fonts/type1/public/amsfonts/symbols/msbm10.pfb" 1248133631 34694 ad62b13721ee8eda1dcc8993c8bd7041 ""
42 − "/usr/local/texlive/2026/texmf-dist/fonts/type1/public/rsfs/rsfs10.pfb" 1229303445 16077 4737ac34f0fb5608550f3780a0202c22 ""
43 − "/usr/local/texlive/2026/texmf-dist/fonts/type1/urw/symbol/usyr.pfb" 1136849748 33709 b09d2e140b7e807d3a97058263ab6693 ""
44 − "/usr/local/texlive/2026/texmf-dist/fonts/type1/urw/times/utmb8a.pfb" 1136849748 44729 811d6c62865936705a31c797a1d5dada ""
45 − "/usr/local/texlive/2026/texmf-dist/fonts/type1/urw/times/utmr8a.pfb" 1136849748 46026 6dab18b61c907687b520c72847215a68 ""
46 − "/usr/local/texlive/2026/texmf-dist/fonts/type1/urw/times/utmri8a.pfb" 1136849748 45458 a3faba884469519614ca56ba5f6b1de1 ""
47 − "/usr/local/texlive/2026/texmf-dist/fonts/vf/adobe/times/ptmb7t.vf" 1136768653 1372 788387fea833ef5963f4c5bffe33eb89 ""
48 − "/usr/local/texlive/2026/texmf-dist/fonts/vf/adobe/times/ptmb8t.vf" 1136768653 2340 df9c920cc5688ebbf16a93f45ce7bdd3 ""
49 − "/usr/local/texlive/2026/texmf-dist/fonts/vf/adobe/times/ptmr8t.vf" 1136768653 2348 91706c542228501c410c266421fbe30c ""
50 − "/usr/local/texlive/2026/texmf-dist/fonts/vf/adobe/times/ptmrc8t.vf" 1136768653 3608 78e4c999d9e69f03f448158d01821741 ""
51 − "/usr/local/texlive/2026/texmf-dist/fonts/vf/adobe/times/ptmri8t.vf" 1136768653 2328 6cd7df782b09b29cfc4d93e55b6b9a59 ""
52 − "/usr/local/texlive/2026/texmf-dist/fonts/vf/adobe/times/zptmcm7m.vf" 1136768653 1132 27520247d3fe18d4266a226b461885c2 ""
53 − "/usr/local/texlive/2026/texmf-dist/fonts/vf/adobe/times/zptmcm7t.vf" 1136768653 1108 d271d6f9de4122c3f8d3b65666167fac ""
54 − "/usr/local/texlive/2026/texmf-dist/fonts/vf/adobe/times/zptmcm7v.vf" 1136768653 1012 046369ac6a83af997c3aa05a43256ad5 ""
55 − "/usr/local/texlive/2026/texmf-dist/fonts/vf/adobe/times/zptmcm7y.vf" 1136768653 964 5673178ff30617b900214de28ab32b38 ""
56 − "/usr/local/texlive/2026/texmf-dist/tex/context/base/mkii/supp-pdf.mkii" 1461363279 71627 94eb9990bed73c364d7f53f960cc8c5b ""
57 − "/usr/local/texlive/2026/texmf-dist/tex/generic/babel-english/english.ldf" 1496785618 7008 9ff5fdcc865b01beca2b0fe4a46231d4 ""
58 − "/usr/local/texlive/2026/texmf-dist/tex/generic/babel/babel.sty" 1773085155 147019 a132d5463be1676209afefa6c8d009c6 ""
59 − "/usr/local/texlive/2026/texmf-dist/tex/generic/babel/locale/en/babel-en.ini" 1763931111 3936 fef897bbcfe8e9d18c93a3d2d53cf9d8 ""
60 − "/usr/local/texlive/2026/texmf-dist/tex/generic/babel/locale/en/babel-english.tex" 1752438283 374 a9c05f002d9437e6de45ac1cc23d56c2 ""
61 − "/usr/local/texlive/2026/texmf-dist/tex/generic/babel/txtbabel.def" 1768770623 5231 c1599e5bf7d2ee42743212a7f8d20a56 ""
62 − "/usr/local/texlive/2026/texmf-dist/tex/generic/bigintcalc/bigintcalc.sty" 1576625341 40635 c40361e206be584d448876bba8a64a3b ""
63 − "/usr/local/texlive/2026/texmf-dist/tex/generic/bitset/bitset.sty" 1576016050 33961 6b5c75130e435b2bfdb9f480a09a39f9 ""
64 − "/usr/local/texlive/2026/texmf-dist/tex/generic/gettitlestring/gettitlestring.sty" 1576625223 8371 9d55b8bd010bc717624922fb3477d92e ""
65 − "/usr/local/texlive/2026/texmf-dist/tex/generic/iftex/iftex.sty" 1734129479 7984 7dbb9280f03c0a315425f1b4f35d43ee ""
66 − "/usr/local/texlive/2026/texmf-dist/tex/generic/iftex/ifvtex.sty" 1572645307 1057 525c2192b5febbd8c1f662c9468335bb ""
67 − "/usr/local/texlive/2026/texmf-dist/tex/generic/infwarerr/infwarerr.sty" 1575499628 8356 7bbb2c2373aa810be568c29e333da8ed ""
68 − "/usr/local/texlive/2026/texmf-dist/tex/generic/intcalc/intcalc.sty" 1576625065 31769 002a487f55041f8e805cfbf6385ffd97 ""
69 − "/usr/local/texlive/2026/texmf-dist/tex/generic/kvdefinekeys/kvdefinekeys.sty" 1576878844 5412 d5a2436094cd7be85769db90f29250a6 ""
70 − "/usr/local/texlive/2026/texmf-dist/tex/generic/ltxcmds/ltxcmds.sty" 1701727651 17865 1a9bd36b4f98178fa551aca822290953 ""
71 − "/usr/local/texlive/2026/texmf-dist/tex/generic/pdfescape/pdfescape.sty" 1576015897 19007 15924f7228aca6c6d184b115f4baa231 ""
72 − "/usr/local/texlive/2026/texmf-dist/tex/generic/pdftexcmds/pdftexcmds.sty" 1593379760 20089 80423eac55aa175305d35b49e04fe23b ""
73 − "/usr/local/texlive/2026/texmf-dist/tex/generic/stringenc/stringenc.sty" 1575152242 21514 b7557edcee22835ef6b03ede1802dad4 ""
74 − "/usr/local/texlive/2026/texmf-dist/tex/generic/uniquecounter/uniquecounter.sty" 1576624663 7008 f92eaa0a3872ed622bbf538217cd2ab7 ""
75 − "/usr/local/texlive/2026/texmf-dist/tex/latex/amscls/amsthm.sty" 1591045760 12594 0d51ac3a545aaaa555021326ff22a6cc ""
76 − "/usr/local/texlive/2026/texmf-dist/tex/latex/amsfonts/amsfonts.sty" 1359763108 5949 3f3fd50a8cc94c3d4cbf4fc66cd3df1c ""
77 − "/usr/local/texlive/2026/texmf-dist/tex/latex/amsfonts/amssymb.sty" 1359763108 13829 94730e64147574077f8ecfea9bb69af4 ""
78 − "/usr/local/texlive/2026/texmf-dist/tex/latex/amsmath/amsbsy.sty" 1748806692 2222 27db7d52163edae53881b71ff62e754e ""
79 − "/usr/local/texlive/2026/texmf-dist/tex/latex/amsmath/amsgen.sty" 1748806692 4173 1b3e76addfb8afcb47db4811d66e1dc6 ""
80 − "/usr/local/texlive/2026/texmf-dist/tex/latex/amsmath/amsmath.sty" 1761946296 88471 b1bb09142edddebd46ba986341b867bd ""
81 − "/usr/local/texlive/2026/texmf-dist/tex/latex/amsmath/amsopn.sty" 1748806692 4474 c510a88aa5f51b8c773b50a7ee92befd ""
82 − "/usr/local/texlive/2026/texmf-dist/tex/latex/amsmath/amstext.sty" 1748806692 2444 9983e1d0683f102e3b190c64a49313aa ""
83 − "/usr/local/texlive/2026/texmf-dist/tex/latex/appendix/appendix.sty" 1581200180 8878 d9f65b39ca82f1d70030390eca653b1c ""
84 − "/usr/local/texlive/2026/texmf-dist/tex/latex/base/article.cls" 1748806692 20144 b966087dda3b194755eb460d32e2ef75 ""
85 − "/usr/local/texlive/2026/texmf-dist/tex/latex/base/fontenc.sty" 1761946296 5275 6f9d359641b36842524cdb97716ab75f ""
86 − "/usr/local/texlive/2026/texmf-dist/tex/latex/base/inputenc.sty" 1738182759 5048 0270515b828149155424600fd2d58ac5 ""
87 − "/usr/local/texlive/2026/texmf-dist/tex/latex/base/size12.clo" 1748806692 8449 8dc66c6c313c8eb2d774af83bca435dd ""
88 − "/usr/local/texlive/2026/texmf-dist/tex/latex/booktabs/booktabs.sty" 1579038678 6078 f1cb470c9199e7110a27851508ed7a5c ""
89 − "/usr/local/texlive/2026/texmf-dist/tex/latex/caption/caption.sty" 1696191071 56128 c2ccf1a29d78c33bc553880402e4fb9a ""
90 − "/usr/local/texlive/2026/texmf-dist/tex/latex/caption/caption3.sty" 1696191071 72619 ee90b6612147680fd73c3b1406a74245 ""
91 − "/usr/local/texlive/2026/texmf-dist/tex/latex/caption/subcaption.sty" 1690576852 12494 0c0cdb824278a4d51cefeb2e79901315 ""
92 − "/usr/local/texlive/2026/texmf-dist/tex/latex/enumitem/enumitem.sty" 1738874546 52272 63d293bc0d496619edb57585740861a2 ""
93 − "/usr/local/texlive/2026/texmf-dist/tex/latex/epstopdf-pkg/epstopdf-base.sty" 1579991033 13886 d1306dcf79a944f6988e688c1785f9ce ""
94 − "/usr/local/texlive/2026/texmf-dist/tex/latex/etoolbox/etoolbox.sty" 1759437024 46885 8953c67ffba03252c6090aa19568b8ba ""
95 − "/usr/local/texlive/2026/texmf-dist/tex/latex/fancyhdr/fancyhdr.sty" 1739048179 31715 19e60610b63819fe670dfa1cd84a4e94 ""
96 − "/usr/local/texlive/2026/texmf-dist/tex/latex/float/float.sty" 1137110151 6749 16d2656a1984957e674b149555f1ea1d ""
97 − "/usr/local/texlive/2026/texmf-dist/tex/latex/geometry/geometry.sty" 1772916678 41904 7711079366884e0d15963c9d40019853 ""
98 − "/usr/local/texlive/2026/texmf-dist/tex/latex/graphics-cfg/color.cfg" 1459978653 1213 620bba36b25224fa9b7e1ccb4ecb76fd ""
99 − "/usr/local/texlive/2026/texmf-dist/tex/latex/graphics-cfg/graphics.cfg" 1465944070 1224 978390e9c2234eab29404bc21b268d1e ""
100 − "/usr/local/texlive/2026/texmf-dist/tex/latex/graphics-def/pdftex.def" 1759176675 19626 23e2822b9b2b5005f4c549ca98b9334d ""
101 − "/usr/local/texlive/2026/texmf-dist/tex/latex/graphics/color.sty" 1748806692 7245 a7e8457a46cda4920df85d975267efb4 ""
102 − "/usr/local/texlive/2026/texmf-dist/tex/latex/graphics/dvipsnam.def" 1748806692 5009 f48e1006c67ca6c50094e2f41e884d72 ""
103 − "/usr/local/texlive/2026/texmf-dist/tex/latex/graphics/graphics.sty" 1748806692 18363 69bb4f5538964bfea50d1e6d89cbe69f ""
104 − "/usr/local/texlive/2026/texmf-dist/tex/latex/graphics/graphicx.sty" 1748806692 8118 43b99e52946c33a23f5f43b52d5cc5ec ""
105 − "/usr/local/texlive/2026/texmf-dist/tex/latex/graphics/keyval.sty" 1748806692 2671 d9941f4bf4750e9b0603c9a2ec54693b ""
106 − "/usr/local/texlive/2026/texmf-dist/tex/latex/graphics/mathcolor.ltx" 1667332637 2885 9c645d672ae17285bba324998918efd8 ""
107 − "/usr/local/texlive/2026/texmf-dist/tex/latex/graphics/trig.sty" 1748806692 4023 e66acf578d6b564c4670fb57ff336a7a ""
108 − "/usr/local/texlive/2026/texmf-dist/tex/latex/hycolor/hycolor.sty" 1580250785 17914 4c28a13fc3d975e6e81c9bea1d697276 ""
109 − "/usr/local/texlive/2026/texmf-dist/tex/latex/hyperref/hpdftex.def" 1769721417 48140 0d317d7fb0c7460a10b7b2713db57305 ""
110 − "/usr/local/texlive/2026/texmf-dist/tex/latex/hyperref/hyperref.sty" 1769721417 223349 c7928c099a8656537a829ba316c95536 ""
111 − "/usr/local/texlive/2026/texmf-dist/tex/latex/hyperref/nameref.sty" 1769721417 11459 697f11f6c439d25d39d2674b99566af4 ""
112 − "/usr/local/texlive/2026/texmf-dist/tex/latex/hyperref/pd1enc.def" 1769721417 14249 b94983bbccc8d5739c16cc91d1fd1c3b ""
113 − "/usr/local/texlive/2026/texmf-dist/tex/latex/hyperref/puenc.def" 1769721417 117118 2e3ba580751de5583beacf2e5fee69a9 ""
114 − "/usr/local/texlive/2026/texmf-dist/tex/latex/kvoptions/kvoptions.sty" 1655478651 22555 6d8e155cfef6d82c3d5c742fea7c992e ""
115 − "/usr/local/texlive/2026/texmf-dist/tex/latex/kvsetkeys/kvsetkeys.sty" 1665067230 13815 760b0c02f691ea230f5359c4e1de23a7 ""
116 − "/usr/local/texlive/2026/texmf-dist/tex/latex/l3backend/l3backend-pdftex.def" 1761248321 30662 bfd6e864f4ffc5018b0e2b6260c3181c ""
117 − "/usr/local/texlive/2026/texmf-dist/tex/latex/latexconfig/epstopdf-sys.cfg" 1279039959 678 4792914a8f45be57bb98413425e4c7af ""
118 − "/usr/local/texlive/2026/texmf-dist/tex/latex/microtype/microtype-pdftex.def" 1772383374 49656 1c61dbb6f95479ba3d6c0033b53590e2 ""
119 − "/usr/local/texlive/2026/texmf-dist/tex/latex/microtype/microtype.cfg" 1772383374 27642 f0cea12315babf4d40608f4eeb5c8458 ""
120 − "/usr/local/texlive/2026/texmf-dist/tex/latex/microtype/microtype.sty" 1772383374 102845 043c56602c7d94c8a716319df1af5479 ""
121 − "/usr/local/texlive/2026/texmf-dist/tex/latex/microtype/mt-msa.cfg" 1772226603 5929 2b35ae0f0fb46984dfffa2bc9d09de5c ""
122 − "/usr/local/texlive/2026/texmf-dist/tex/latex/microtype/mt-msb.cfg" 1772226603 5594 992ef5c3f8fd1168bb7101a957333065 ""
123 − "/usr/local/texlive/2026/texmf-dist/tex/latex/microtype/mt-ptm.cfg" 1772226603 12427 a6802929d6bd2a4ba6d4f5db602da2b4 ""
124 − "/usr/local/texlive/2026/texmf-dist/tex/latex/multirow/multirow.sty" 1731446765 6696 886c9f3087d0b973ed2c19aa79cb3023 ""
125 − "/usr/local/texlive/2026/texmf-dist/tex/latex/natbib/natbib.sty" 1291685959 45456 1c8843383c0bd05870c45fa0ebea6cc2 ""
126 − "/usr/local/texlive/2026/texmf-dist/tex/latex/psnfss/mathptmx.sty" 1586716065 4631 6e41de2b7a83dfa5d2c4b0a2fe01f046 ""
127 − "/usr/local/texlive/2026/texmf-dist/tex/latex/psnfss/omlztmcm.fd" 1137110629 411 12564a37a279e4e0b533cdf5e03eeb7c ""
128 − "/usr/local/texlive/2026/texmf-dist/tex/latex/psnfss/omsztmcm.fd" 1137110629 348 f4ce75d394e7d9ac12ca7aac4045ed77 ""
129 − "/usr/local/texlive/2026/texmf-dist/tex/latex/psnfss/omxztmcm.fd" 1137110629 329 c8cddcc90b6f567b28408eb374773c9c ""
130 − "/usr/local/texlive/2026/texmf-dist/tex/latex/psnfss/ot1ptm.fd" 1137110629 961 15056f4a61917ceed3a44e4ac11fcc52 ""
131 − "/usr/local/texlive/2026/texmf-dist/tex/latex/psnfss/ot1ztmcm.fd" 1137110629 329 aee7226812ba4138ac67a018466b488d ""
132 − "/usr/local/texlive/2026/texmf-dist/tex/latex/psnfss/t1ptm.fd" 1137110629 774 61d7da1e9f9e74989b196d147e623736 ""
133 − "/usr/local/texlive/2026/texmf-dist/tex/latex/refcount/refcount.sty" 1576624809 9878 9e94e8fa600d95f9c7731bb21dfb67a4 ""
134 − "/usr/local/texlive/2026/texmf-dist/tex/latex/rerunfilecheck/rerunfilecheck.sty" 1750533675 9684 a33a14b82ce60d6e77cb9be689d79ee6 ""
135 − "/usr/local/texlive/2026/texmf-dist/tex/latex/setspace/setspace.sty" 1670275497 22490 8cac309b79a4c53a4ffce4b1b07aead0 ""
136 − "/usr/local/texlive/2026/texmf-dist/tex/latex/siunitx/siunitx.sty" 1752176849 344221 f0c98c737eca26294f5039e8afde731f ""
137 − "/usr/local/texlive/2026/texmf-dist/tex/latex/threeparttable/threeparttable.sty" 1267981840 13506 a4e71a27db1a69b6fabada5beebf0844 ""
138 − "/usr/local/texlive/2026/texmf-dist/tex/latex/titlesec/titlesec.sty" 1736023606 48766 87a17a4ef312a39cd43896e34a679a56 ""
139 − "/usr/local/texlive/2026/texmf-dist/tex/latex/tools/array.sty" 1761946296 15651 9d7c62df82cb29a555c00550babfe364 ""
140 − "/usr/local/texlive/2026/texmf-dist/tex/latex/tools/tabularx.sty" 1748806692 7243 a2c17f18e2c9b702b84fad03d5f9c78b ""
141 − "/usr/local/texlive/2026/texmf-dist/tex/latex/translations/translations-basic-dictionary-english.trsl" 1644096163 5588 0c1628daf15f4411ff1f463114c634a3 ""
142 − "/usr/local/texlive/2026/texmf-dist/tex/latex/translations/translations.sty" 1644096163 44247 2188b95d0ee74e31eca4d316263c58a7 ""
143 − "/usr/local/texlive/2026/texmf-dist/tex/latex/url/url.sty" 1388531844 12796 8edb7d69a20b857904dd0ea757c14ec9 ""
144 − "/usr/local/texlive/2026/texmf-dist/tex/latex/xcolor/xcolor.sty" 1727642399 55384 b454dec21c2d9f45ec0b793f0995b992 ""
145 − "/usr/local/texlive/2026/texmf-dist/web2c/texmf.cnf" 1770478701 43630 e9fa012910491fe5ae08a72c3c2997a7 ""
146 − "/usr/local/texlive/2026/texmf-var/fonts/map/pdftex/updmap/pdftex.map" 1776992453 5573038 b99aad5e4898a9034e953632f940512b ""
147 − "/usr/local/texlive/2026/texmf-var/web2c/pdftex/pdflatex.fmt" 1774209607 3636776 dc5fa9b6b1453b9d196cd42edf876004 ""
148 − "/usr/local/texlive/2026/texmf.cnf" 1774209515 577 648cd9f92f9c0eac486f4c71c85cfbbe ""
149 − "appendix/appendix.tex" 1791670366.63355 3713 3c209a468f8efb590c399cc7d8469a09 ""
150 − "figures/f1_mdape_vs_k.pdf" 1791670475.91696 27169 0c005eb391b60aa1d00add0ad806410a ""
151 − "figures/f2_surface_d_T.pdf" 1791670475.98217 20647 502067f6b85784f3b92c01cba1a937ba ""
152 − "figures/f3_ajustements.pdf" 1791670476.0322 22112 0c48453356f612027122af03683154b7 ""
153 − "figures/f4_competition.pdf" 1791670476.0883 24756 7efc9280a746f9cb1c94ab1a8e024359 ""
154 − "figures/f5_densite.pdf" 1791670476.12572 24029 f9619610fb03bbff798cf793b84e6540 ""
155 − "figures/f7_indice.pdf" 1791670476.23258 18511 49ece81f37b1b18549aa327042b8221a ""
156 − "main.aux" 1791670477.04709 22155 96efb98c7723c6a6c804fa37d2813fcb "pdflatex"
157 − "main.bbl" 1791670452.62535 7426 189002991017ce2edb4cbc51edc5c910 "bibtex main"
158 − "main.out" 1791670477.04828 7416 bf1dd9fe3c2a7a6e19ac40b70b222a94 "pdflatex"
159 − "main.tex" 1791670296.77182 5936 d6af559ad74be8510b14b9f849dadcfd ""
160 − "sections/conclusion.tex" 1791670296.77266 3426 7154925f40eb2317b532f569a705b93a ""
161 − "sections/data.tex" 1791670296.77222 6697 9225db7b9c13c4aa53084d43e1f992a5 ""
162 − "sections/introduction.tex" 1791670296.77212 8399 009d11f0e20d9189cf7eb9babb0cfe24 ""
163 − "sections/literature.tex" 1791669397.57194 7416 fcf0f91922c2a753315e7447161f83ad ""
164 − "sections/methodology.tex" 1791670414.73425 12030 67de73be572a1014e3a3c4e910287111 ""
165 − "sections/results.tex" 1791670434.77141 22208 df52c99453a030d79904126ff4028f1f ""
166 − "sections/robustness.tex" 1791670296.77256 5042 20c9093705bf638f891182f66ce8ef14 ""
167 − "sections/titlepage.tex" 1791668938.15793 1458 1242e8e90e0d8dba3af4619bac574330 ""
168 − "tables/adjustments.tex" 1791670475.64138 620 9e74ac71ffc244def8bb425ea654cdad ""
169 − "tables/competition.tex" 1791670475.67752 1568 6bc492b7ee1abf62d1b6e8925cdf4c78 ""
170 − "tables/cond_rules.tex" 1791670475.69003 716 31ad922e1b21fa015ca2158a94253a33 ""
171 − "tables/density.tex" 1791670475.6812 503 4cc1130d3d78ff10aeacc60c988d9209 ""
172 − "tables/density_dist.tex" 1791670475.63022 264 a26d733c83dcbcce6c43a7e60a485963 ""
173 − "tables/descriptives.tex" 1791670475.62995 631 3937ad9db5b3a1b9f452f2ccc81c25b9 ""
174 − "tables/e1_best.tex" 1791670475.63789 548 20318ac49e30097b292868a9ea70144d ""
175 − "tables/exclusions.tex" 1791670475.62947 927 b72303a7475e35d2d59e0edc401f5446 ""
176 − "tables/grid.tex" 1791670475.67302 468 ca25d30bb81416d1e88594d7477d619a ""
177 − "tables/h2.tex" 1791670475.63847 574 88b2d6ff6d2eee2c102025c2ec6c4e98 ""
178 − "tables/h3.tex" 1791670475.64232 861 01916a2ff64066c9f649c5b8bc3a8c3d ""
179 − "tables/h4.tex" 1791670475.67604 511 94675ab5c615c62853f93d8c49d9660e ""
180 − "tables/h5.tex" 1791670475.68853 387 dc7809572420662b25f407f106bdf382 ""
181 − "tables/intervals.tex" 1791670475.6891 698 23ff1d4780f1d33d7e680b637c8b4c28 ""
182 − "tables/marginal.tex" 1791670475.67642 779 7fcc610a63f1544a8100f6f04588592f ""
183 − "tables/numbers.tex" 1791670476.23484 3372 664717abb13647ad16f84e7e17a5de8e ""
184 − "tables/pairs.tex" 1791670475.67936 999 0bb1e085a84223bfbd1dac507690dddd ""
185 − "tables/regions.tex" 1791670475.68375 1513 4ebac079ff4f26f9c68db838e47e3b5d ""
186 − "tables/selection.tex" 1791670475.67415 307 f26524e26b577b837d9b253d895e3f77 ""
187 − "tables/stability.tex" 1791670475.69043 600 7adacfe8156b0bfc5d205138265c3c6a ""
188 − "tables/strata_other.tex" 1791670475.68799 1296 6331ddafefc63da1d9cb1429f1dd7cec ""
189 − "tables/surface_cov.tex" 1791670475.63709 385 7ebf6d8a42ece1474abb276b4f68b2f0 ""
190 − "tables/surface_geo_hedo.tex" 1791670475.63426 679 8331abe120b6d7df5cf79c463b146963 ""
191 − "tables/surface_geo_none.tex" 1791670475.63532 679 57dd201f2aadfbb692746c565337326b ""
192 − "tables/surface_sim_hedo.tex" 1791670475.63628 679 47495d9403b904fee24bb1a34ede853c ""
193 − "tables/weighting.tex" 1791670475.67526 662 0fe5cc0224cd44cbfba909f5f2aad2db ""
194 − "uq_logo.jpg" 1791668938.162 58719 668023c2daab9af750d1f0889564af82 ""
195 − (generated)
196 − "main.aux"
197 − "main.log"
198 − "main.out"
199 − "main.pdf"
200 − (rewritten before read)
deleted paper/main.fls +0 −608
@@ -1,608 +0,0 @@
1 −PWD /Users/simon-pierreboucher/Desktop/Validation-Comparables/paper
2 −INPUT /usr/local/texlive/2026/texmf.cnf
3 −INPUT /usr/local/texlive/2026/texmf-dist/web2c/texmf.cnf
4 −INPUT /usr/local/texlive/2026/texmf-var/web2c/pdftex/pdflatex.fmt
5 −INPUT main.tex
6 −OUTPUT main.log
7 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/base/article.cls
8 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/base/article.cls
9 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/base/size12.clo
10 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/base/size12.clo
11 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/base/size12.clo
12 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/map/fontname/texfonts.map
13 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/public/cm/cmr12.tfm
14 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/base/inputenc.sty
15 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/base/inputenc.sty
16 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/base/fontenc.sty
17 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/base/fontenc.sty
18 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/jknappen/ec/ecrm1200.tfm
19 −INPUT /usr/local/texlive/2026/texmf-dist/tex/generic/babel/babel.sty
20 −INPUT /usr/local/texlive/2026/texmf-dist/tex/generic/babel/babel.sty
21 −INPUT /usr/local/texlive/2026/texmf-dist/tex/generic/babel/txtbabel.def
22 −INPUT /usr/local/texlive/2026/texmf-dist/tex/generic/babel/locale/en/babel-english.tex
23 −INPUT /usr/local/texlive/2026/texmf-dist/tex/generic/babel/locale/en/babel-english.tex
24 −INPUT /usr/local/texlive/2026/texmf-dist/tex/generic/babel/locale/en/babel-english.tex
25 −INPUT /usr/local/texlive/2026/texmf-dist/tex/generic/babel/locale/en/babel-en.ini
26 −INPUT /usr/local/texlive/2026/texmf-dist/tex/generic/babel-english/english.ldf
27 −INPUT /usr/local/texlive/2026/texmf-dist/tex/generic/babel-english/english.ldf
28 −INPUT /usr/local/texlive/2026/texmf-dist/tex/generic/babel-english/english.ldf
29 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/geometry/geometry.sty
30 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/geometry/geometry.sty
31 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/graphics/keyval.sty
32 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/graphics/keyval.sty
33 −INPUT /usr/local/texlive/2026/texmf-dist/tex/generic/iftex/ifvtex.sty
34 −INPUT /usr/local/texlive/2026/texmf-dist/tex/generic/iftex/ifvtex.sty
35 −INPUT /usr/local/texlive/2026/texmf-dist/tex/generic/iftex/iftex.sty
36 −INPUT /usr/local/texlive/2026/texmf-dist/tex/generic/iftex/iftex.sty
37 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/psnfss/mathptmx.sty
38 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/psnfss/mathptmx.sty
39 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/microtype/microtype.sty
40 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/microtype/microtype.sty
41 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/etoolbox/etoolbox.sty
42 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/etoolbox/etoolbox.sty
43 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/microtype/microtype-pdftex.def
44 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/microtype/microtype-pdftex.def
45 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/microtype/microtype-pdftex.def
46 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/microtype/microtype.cfg
47 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/microtype/microtype.cfg
48 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/microtype/microtype.cfg
49 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/setspace/setspace.sty
50 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/setspace/setspace.sty
51 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/amsmath/amsmath.sty
52 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/amsmath/amsmath.sty
53 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/amsmath/amsopn.sty
54 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/amsmath/amstext.sty
55 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/amsmath/amstext.sty
56 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/amsmath/amsgen.sty
57 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/amsmath/amsgen.sty
58 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/amsmath/amsbsy.sty
59 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/amsmath/amsbsy.sty
60 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/amsmath/amsopn.sty
61 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/amsfonts/amssymb.sty
62 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/amsfonts/amssymb.sty
63 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/amsfonts/amsfonts.sty
64 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/amsfonts/amsfonts.sty
65 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/amscls/amsthm.sty
66 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/amscls/amsthm.sty
67 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/booktabs/booktabs.sty
68 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/booktabs/booktabs.sty
69 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/tools/tabularx.sty
70 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/tools/tabularx.sty
71 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/tools/array.sty
72 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/tools/array.sty
73 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/multirow/multirow.sty
74 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/multirow/multirow.sty
75 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/threeparttable/threeparttable.sty
76 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/threeparttable/threeparttable.sty
77 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/siunitx/siunitx.sty
78 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/siunitx/siunitx.sty
79 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/translations/translations.sty
80 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/translations/translations.sty
81 −INPUT /usr/local/texlive/2026/texmf-dist/tex/generic/pdftexcmds/pdftexcmds.sty
82 −INPUT /usr/local/texlive/2026/texmf-dist/tex/generic/pdftexcmds/pdftexcmds.sty
83 −INPUT /usr/local/texlive/2026/texmf-dist/tex/generic/infwarerr/infwarerr.sty
84 −INPUT /usr/local/texlive/2026/texmf-dist/tex/generic/infwarerr/infwarerr.sty
85 −INPUT /usr/local/texlive/2026/texmf-dist/tex/generic/ltxcmds/ltxcmds.sty
86 −INPUT /usr/local/texlive/2026/texmf-dist/tex/generic/ltxcmds/ltxcmds.sty
87 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/graphics/graphicx.sty
88 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/graphics/graphicx.sty
89 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/graphics/graphics.sty
90 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/graphics/graphics.sty
91 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/graphics/trig.sty
92 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/graphics/trig.sty
93 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/graphics-cfg/graphics.cfg
94 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/graphics-cfg/graphics.cfg
95 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/graphics-cfg/graphics.cfg
96 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/graphics-def/pdftex.def
97 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/graphics-def/pdftex.def
98 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/graphics-def/pdftex.def
99 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/caption/subcaption.sty
100 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/caption/subcaption.sty
101 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/caption/caption.sty
102 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/caption/caption.sty
103 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/caption/caption3.sty
104 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/caption/caption3.sty
105 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/float/float.sty
106 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/float/float.sty
107 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/xcolor/xcolor.sty
108 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/xcolor/xcolor.sty
109 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/graphics-cfg/color.cfg
110 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/graphics-cfg/color.cfg
111 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/graphics-cfg/color.cfg
112 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/graphics/mathcolor.ltx
113 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/graphics/mathcolor.ltx
114 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/graphics/mathcolor.ltx
115 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/graphics/dvipsnam.def
116 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/graphics/dvipsnam.def
117 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/graphics/dvipsnam.def
118 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/hyperref/hyperref.sty
119 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/hyperref/hyperref.sty
120 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/kvsetkeys/kvsetkeys.sty
121 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/kvsetkeys/kvsetkeys.sty
122 −INPUT /usr/local/texlive/2026/texmf-dist/tex/generic/kvdefinekeys/kvdefinekeys.sty
123 −INPUT /usr/local/texlive/2026/texmf-dist/tex/generic/kvdefinekeys/kvdefinekeys.sty
124 −INPUT /usr/local/texlive/2026/texmf-dist/tex/generic/pdfescape/pdfescape.sty
125 −INPUT /usr/local/texlive/2026/texmf-dist/tex/generic/pdfescape/pdfescape.sty
126 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/hycolor/hycolor.sty
127 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/hycolor/hycolor.sty
128 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/hyperref/nameref.sty
129 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/hyperref/nameref.sty
130 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/refcount/refcount.sty
131 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/refcount/refcount.sty
132 −INPUT /usr/local/texlive/2026/texmf-dist/tex/generic/gettitlestring/gettitlestring.sty
133 −INPUT /usr/local/texlive/2026/texmf-dist/tex/generic/gettitlestring/gettitlestring.sty
134 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/kvoptions/kvoptions.sty
135 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/kvoptions/kvoptions.sty
136 −INPUT /usr/local/texlive/2026/texmf-dist/tex/generic/stringenc/stringenc.sty
137 −INPUT /usr/local/texlive/2026/texmf-dist/tex/generic/stringenc/stringenc.sty
138 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/hyperref/pd1enc.def
139 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/hyperref/pd1enc.def
140 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/hyperref/pd1enc.def
141 −INPUT /usr/local/texlive/2026/texmf-dist/tex/generic/intcalc/intcalc.sty
142 −INPUT /usr/local/texlive/2026/texmf-dist/tex/generic/intcalc/intcalc.sty
143 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/hyperref/puenc.def
144 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/hyperref/puenc.def
145 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/hyperref/puenc.def
146 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/url/url.sty
147 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/url/url.sty
148 −INPUT /usr/local/texlive/2026/texmf-dist/tex/generic/bitset/bitset.sty
149 −INPUT /usr/local/texlive/2026/texmf-dist/tex/generic/bitset/bitset.sty
150 −INPUT /usr/local/texlive/2026/texmf-dist/tex/generic/bigintcalc/bigintcalc.sty
151 −INPUT /usr/local/texlive/2026/texmf-dist/tex/generic/bigintcalc/bigintcalc.sty
152 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/hyperref/hpdftex.def
153 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/hyperref/hpdftex.def
154 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/hyperref/hpdftex.def
155 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/rerunfilecheck/rerunfilecheck.sty
156 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/rerunfilecheck/rerunfilecheck.sty
157 −INPUT /usr/local/texlive/2026/texmf-dist/tex/generic/uniquecounter/uniquecounter.sty
158 −INPUT /usr/local/texlive/2026/texmf-dist/tex/generic/uniquecounter/uniquecounter.sty
159 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/natbib/natbib.sty
160 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/natbib/natbib.sty
161 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/fancyhdr/fancyhdr.sty
162 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/fancyhdr/fancyhdr.sty
163 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/titlesec/titlesec.sty
164 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/titlesec/titlesec.sty
165 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/appendix/appendix.sty
166 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/appendix/appendix.sty
167 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/enumitem/enumitem.sty
168 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/enumitem/enumitem.sty
169 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/graphics/color.sty
170 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/psnfss/t1ptm.fd
171 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/psnfss/t1ptm.fd
172 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/psnfss/t1ptm.fd
173 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmr8t.tfm
174 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/l3backend/l3backend-pdftex.def
175 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/l3backend/l3backend-pdftex.def
176 −INPUT ./main.aux
177 −INPUT ./main.aux
178 −INPUT main.aux
179 −OUTPUT main.aux
180 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/microtype/mt-ptm.cfg
181 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/microtype/mt-ptm.cfg
182 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/microtype/mt-ptm.cfg
183 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/translations/translations-basic-dictionary-english.trsl
184 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/translations/translations-basic-dictionary-english.trsl
185 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/translations/translations-basic-dictionary-english.trsl
186 −INPUT /usr/local/texlive/2026/texmf-dist/tex/context/base/mkii/supp-pdf.mkii
187 −INPUT /usr/local/texlive/2026/texmf-dist/tex/context/base/mkii/supp-pdf.mkii
188 −INPUT /usr/local/texlive/2026/texmf-dist/tex/context/base/mkii/supp-pdf.mkii
189 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/epstopdf-pkg/epstopdf-base.sty
190 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/epstopdf-pkg/epstopdf-base.sty
191 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/latexconfig/epstopdf-sys.cfg
192 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/latexconfig/epstopdf-sys.cfg
193 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/latexconfig/epstopdf-sys.cfg
194 −INPUT ./main.out
195 −INPUT ./main.out
196 −INPUT main.out
197 −INPUT main.out
198 −OUTPUT main.pdf
199 −INPUT ./main.out
200 −INPUT ./main.out
201 −OUTPUT main.out
202 −INPUT ./tables/numbers.tex
203 −INPUT ./tables/numbers.tex
204 −INPUT ./tables/numbers.tex
205 −INPUT ./tables/numbers.tex
206 −INPUT tables/numbers.tex
207 −INPUT ./sections/titlepage.tex
208 −INPUT ./sections/titlepage.tex
209 −INPUT ./sections/titlepage.tex
210 −INPUT ./sections/titlepage.tex
211 −INPUT sections/titlepage.tex
212 −INPUT ./uq_logo.jpg
213 −INPUT ./uq_logo.jpg
214 −INPUT ./uq_logo.jpg
215 −INPUT ./uq_logo.jpg
216 −INPUT ./uq_logo.jpg
217 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmr8t.tfm
218 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmrc8t.tfm
219 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmr8t.tfm
220 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmb8t.tfm
221 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmr8t.tfm
222 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmri8t.tfm
223 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmr8t.tfm
224 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/vf/adobe/times/ptmrc8t.vf
225 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmr8r.tfm
226 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmr8r.tfm
227 −INPUT /usr/local/texlive/2026/texmf-var/fonts/map/pdftex/updmap/pdftex.map
228 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/enc/dvips/base/8r.enc
229 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/vf/adobe/times/ptmb8t.vf
230 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmb8r.tfm
231 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/vf/adobe/times/ptmri8t.vf
232 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmri8r.tfm
233 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/vf/adobe/times/ptmr8t.vf
234 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmr8r.tfm
235 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/vf/adobe/times/ptmr8t.vf
236 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmr8r.tfm
237 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/vf/adobe/times/ptmr8t.vf
238 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmr8r.tfm
239 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmb8t.tfm
240 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/vf/adobe/times/ptmb8t.vf
241 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmb8r.tfm
242 −INPUT ./sections/introduction.tex
243 −INPUT ./sections/introduction.tex
244 −INPUT ./sections/introduction.tex
245 −INPUT ./sections/introduction.tex
246 −INPUT sections/introduction.tex
247 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmb8t.tfm
248 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmri8t.tfm
249 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/psnfss/ot1ztmcm.fd
250 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/psnfss/ot1ztmcm.fd
251 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/psnfss/ot1ztmcm.fd
252 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/zptmcm7t.tfm
253 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/zptmcm7t.tfm
254 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/zptmcm7t.tfm
255 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/psnfss/omlztmcm.fd
256 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/psnfss/omlztmcm.fd
257 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/psnfss/omlztmcm.fd
258 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/zptmcm7m.tfm
259 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/zptmcm7m.tfm
260 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/zptmcm7m.tfm
261 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/psnfss/omsztmcm.fd
262 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/psnfss/omsztmcm.fd
263 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/psnfss/omsztmcm.fd
264 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/zptmcm7y.tfm
265 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/zptmcm7y.tfm
266 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/zptmcm7y.tfm
267 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/psnfss/omxztmcm.fd
268 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/psnfss/omxztmcm.fd
269 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/psnfss/omxztmcm.fd
270 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/zptmcm7v.tfm
271 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/zptmcm7v.tfm
272 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/zptmcm7v.tfm
273 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/psnfss/ot1ptm.fd
274 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/psnfss/ot1ptm.fd
275 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/psnfss/ot1ptm.fd
276 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmb7t.tfm
277 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmb7t.tfm
278 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmb7t.tfm
279 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmri7t.tfm
280 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmri7t.tfm
281 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmri7t.tfm
282 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/public/amsfonts/symbols/msam10.tfm
283 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/microtype/mt-msa.cfg
284 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/microtype/mt-msa.cfg
285 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/microtype/mt-msa.cfg
286 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/public/amsfonts/symbols/msam10.tfm
287 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/public/amsfonts/symbols/msam10.tfm
288 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/public/amsfonts/symbols/msbm10.tfm
289 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/microtype/mt-msb.cfg
290 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/microtype/mt-msb.cfg
291 −INPUT /usr/local/texlive/2026/texmf-dist/tex/latex/microtype/mt-msb.cfg
292 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/public/amsfonts/symbols/msbm10.tfm
293 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/public/amsfonts/symbols/msbm10.tfm
294 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/vf/adobe/times/ptmb8t.vf
295 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmb8r.tfm
296 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/vf/adobe/times/ptmri8t.vf
297 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmri8r.tfm
298 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/vf/adobe/times/zptmcm7t.vf
299 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/symbol/psyr.tfm
300 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/public/cm/cmr10.tfm
301 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/vf/adobe/times/zptmcm7m.vf
302 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/psyro.tfm
303 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/public/cm/cmmi10.tfm
304 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/vf/adobe/times/zptmcm7y.vf
305 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/public/cm/cmsy10.tfm
306 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/public/rsfs/rsfs10.tfm
307 −INPUT ./sections/literature.tex
308 −INPUT ./sections/literature.tex
309 −INPUT ./sections/literature.tex
310 −INPUT ./sections/literature.tex
311 −INPUT sections/literature.tex
312 −INPUT ./sections/data.tex
313 −INPUT ./sections/data.tex
314 −INPUT ./sections/data.tex
315 −INPUT ./sections/data.tex
316 −INPUT sections/data.tex
317 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmb8t.tfm
318 −INPUT ./tables/exclusions.tex
319 −INPUT ./tables/exclusions.tex
320 −INPUT ./tables/exclusions.tex
321 −INPUT ./tables/exclusions.tex
322 −INPUT tables/exclusions.tex
323 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/vf/adobe/times/ptmb8t.vf
324 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmb8r.tfm
325 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/vf/adobe/times/zptmcm7t.vf
326 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/symbol/psyr.tfm
327 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmr8r.tfm
328 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/public/cm/cmr10.tfm
329 −INPUT ./tables/descriptives.tex
330 −INPUT ./tables/descriptives.tex
331 −INPUT ./tables/descriptives.tex
332 −INPUT ./tables/descriptives.tex
333 −INPUT tables/descriptives.tex
334 −INPUT ./figures/f7_indice.pdf
335 −INPUT ./figures/f7_indice.pdf
336 −INPUT ./figures/f7_indice.pdf
337 −INPUT ./figures/f7_indice.pdf
338 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/zptmcm7t.tfm
339 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/zptmcm7t.tfm
340 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/zptmcm7t.tfm
341 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/zptmcm7m.tfm
342 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/zptmcm7m.tfm
343 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/zptmcm7m.tfm
344 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/zptmcm7y.tfm
345 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/zptmcm7y.tfm
346 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/zptmcm7y.tfm
347 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/zptmcm7v.tfm
348 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/zptmcm7v.tfm
349 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/zptmcm7v.tfm
350 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmb7t.tfm
351 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmb7t.tfm
352 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmb7t.tfm
353 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmri7t.tfm
354 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmri7t.tfm
355 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmri7t.tfm
356 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/public/amsfonts/symbols/msam10.tfm
357 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/public/amsfonts/symbols/msam10.tfm
358 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/public/amsfonts/symbols/msam10.tfm
359 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/public/amsfonts/symbols/msbm10.tfm
360 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/public/amsfonts/symbols/msbm10.tfm
361 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/public/amsfonts/symbols/msbm10.tfm
362 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/vf/adobe/times/zptmcm7t.vf
363 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/symbol/psyr.tfm
364 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/public/cm/cmr10.tfm
365 −INPUT ./tables/density_dist.tex
366 −INPUT ./tables/density_dist.tex
367 −INPUT ./tables/density_dist.tex
368 −INPUT ./tables/density_dist.tex
369 −INPUT tables/density_dist.tex
370 −INPUT ./sections/methodology.tex
371 −INPUT ./sections/methodology.tex
372 −INPUT ./sections/methodology.tex
373 −INPUT ./sections/methodology.tex
374 −INPUT sections/methodology.tex
375 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmr8t.tfm
376 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmr8t.tfm
377 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/vf/adobe/times/zptmcm7m.vf
378 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/psyro.tfm
379 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/public/cm/cmmi10.tfm
380 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmri8r.tfm
381 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/vf/adobe/times/ptmb7t.vf
382 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/vf/adobe/times/zptmcm7y.vf
383 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/public/cm/cmsy10.tfm
384 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/public/rsfs/rsfs10.tfm
385 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/vf/adobe/times/zptmcm7v.vf
386 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/public/cm/cmex10.tfm
387 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/public/cm/cmex10.tfm
388 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/symbol/psyr.tfm
389 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/vf/adobe/times/zptmcm7y.vf
390 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/public/cm/cmsy10.tfm
391 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/public/rsfs/rsfs10.tfm
392 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/vf/adobe/times/zptmcm7m.vf
393 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/psyro.tfm
394 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/public/cm/cmmi10.tfm
395 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmri8r.tfm
396 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/vf/adobe/times/zptmcm7m.vf
397 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/psyro.tfm
398 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/public/cm/cmmi10.tfm
399 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmri8r.tfm
400 −INPUT ./sections/results.tex
401 −INPUT ./sections/results.tex
402 −INPUT ./sections/results.tex
403 −INPUT ./sections/results.tex
404 −INPUT sections/results.tex
405 −INPUT ./figures/f1_mdape_vs_k.pdf
406 −INPUT ./figures/f1_mdape_vs_k.pdf
407 −INPUT ./figures/f1_mdape_vs_k.pdf
408 −INPUT ./figures/f1_mdape_vs_k.pdf
409 −INPUT ./figures/f2_surface_d_T.pdf
410 −INPUT ./figures/f2_surface_d_T.pdf
411 −INPUT ./figures/f2_surface_d_T.pdf
412 −INPUT ./figures/f2_surface_d_T.pdf
413 −INPUT ./tables/surface_geo_hedo.tex
414 −INPUT ./tables/surface_geo_hedo.tex
415 −INPUT ./tables/surface_geo_hedo.tex
416 −INPUT ./tables/surface_geo_hedo.tex
417 −INPUT tables/surface_geo_hedo.tex
418 −INPUT ./tables/h2.tex
419 −INPUT ./tables/h2.tex
420 −INPUT ./tables/h2.tex
421 −INPUT ./tables/h2.tex
422 −INPUT tables/h2.tex
423 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/zptmcm7t.tfm
424 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/zptmcm7t.tfm
425 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/zptmcm7m.tfm
426 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/zptmcm7m.tfm
427 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/zptmcm7y.tfm
428 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/zptmcm7y.tfm
429 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/zptmcm7v.tfm
430 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/zptmcm7v.tfm
431 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmb7t.tfm
432 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmb7t.tfm
433 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmri7t.tfm
434 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmri7t.tfm
435 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/public/amsfonts/symbols/msam10.tfm
436 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/public/amsfonts/symbols/msam10.tfm
437 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/public/amsfonts/symbols/msbm10.tfm
438 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/public/amsfonts/symbols/msbm10.tfm
439 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/vf/adobe/times/ptmr8t.vf
440 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmr8t.tfm
441 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmr8t.tfm
442 −INPUT ./tables/adjustments.tex
443 −INPUT ./tables/adjustments.tex
444 −INPUT ./tables/adjustments.tex
445 −INPUT ./tables/adjustments.tex
446 −INPUT tables/adjustments.tex
447 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/vf/adobe/times/zptmcm7m.vf
448 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/psyro.tfm
449 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmri8r.tfm
450 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/vf/adobe/times/zptmcm7y.vf
451 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/symbol/psyr.tfm
452 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/public/rsfs/rsfs10.tfm
453 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/vf/adobe/times/zptmcm7t.vf
454 −INPUT ./figures/f3_ajustements.pdf
455 −INPUT ./figures/f3_ajustements.pdf
456 −INPUT ./figures/f3_ajustements.pdf
457 −INPUT ./figures/f3_ajustements.pdf
458 −INPUT ./tables/h3.tex
459 −INPUT ./tables/h3.tex
460 −INPUT ./tables/h3.tex
461 −INPUT ./tables/h3.tex
462 −INPUT tables/h3.tex
463 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmri8t.tfm
464 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmri8t.tfm
465 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmri8t.tfm
466 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmr8t.tfm
467 −INPUT ./tables/grid.tex
468 −INPUT ./tables/grid.tex
469 −INPUT ./tables/grid.tex
470 −INPUT ./tables/grid.tex
471 −INPUT tables/grid.tex
472 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/vf/adobe/times/ptmri8t.vf
473 −INPUT ./tables/selection.tex
474 −INPUT ./tables/selection.tex
475 −INPUT ./tables/selection.tex
476 −INPUT ./tables/selection.tex
477 −INPUT tables/selection.tex
478 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/vf/adobe/times/zptmcm7t.vf
479 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/symbol/psyr.tfm
480 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmr8r.tfm
481 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/public/cm/cmr10.tfm
482 −INPUT ./tables/weighting.tex
483 −INPUT ./tables/weighting.tex
484 −INPUT ./tables/weighting.tex
485 −INPUT ./tables/weighting.tex
486 −INPUT tables/weighting.tex
487 −INPUT ./tables/h4.tex
488 −INPUT ./tables/h4.tex
489 −INPUT ./tables/h4.tex
490 −INPUT ./tables/h4.tex
491 −INPUT tables/h4.tex
492 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmb8t.tfm
493 −INPUT ./tables/marginal.tex
494 −INPUT ./tables/marginal.tex
495 −INPUT ./tables/marginal.tex
496 −INPUT ./tables/marginal.tex
497 −INPUT tables/marginal.tex
498 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/vf/adobe/times/ptmb8t.vf
499 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmb8r.tfm
500 −INPUT ./tables/competition.tex
501 −INPUT ./tables/competition.tex
502 −INPUT ./tables/competition.tex
503 −INPUT ./tables/competition.tex
504 −INPUT tables/competition.tex
505 −INPUT ./figures/f4_competition.pdf
506 −INPUT ./figures/f4_competition.pdf
507 −INPUT ./figures/f4_competition.pdf
508 −INPUT ./figures/f4_competition.pdf
509 −INPUT ./tables/pairs.tex
510 −INPUT ./tables/pairs.tex
511 −INPUT ./tables/pairs.tex
512 −INPUT ./tables/pairs.tex
513 −INPUT tables/pairs.tex
514 −INPUT ./tables/density.tex
515 −INPUT ./tables/density.tex
516 −INPUT ./tables/density.tex
517 −INPUT ./tables/density.tex
518 −INPUT tables/density.tex
519 −INPUT ./figures/f5_densite.pdf
520 −INPUT ./figures/f5_densite.pdf
521 −INPUT ./figures/f5_densite.pdf
522 −INPUT ./figures/f5_densite.pdf
523 −INPUT ./tables/h5.tex
524 −INPUT ./tables/h5.tex
525 −INPUT ./tables/h5.tex
526 −INPUT ./tables/h5.tex
527 −INPUT tables/h5.tex
528 −INPUT ./tables/intervals.tex
529 −INPUT ./tables/intervals.tex
530 −INPUT ./tables/intervals.tex
531 −INPUT ./tables/intervals.tex
532 −INPUT tables/intervals.tex
533 −INPUT ./tables/cond_rules.tex
534 −INPUT ./tables/cond_rules.tex
535 −INPUT ./tables/cond_rules.tex
536 −INPUT ./tables/cond_rules.tex
537 −INPUT tables/cond_rules.tex
538 −INPUT ./sections/robustness.tex
539 −INPUT ./sections/robustness.tex
540 −INPUT ./sections/robustness.tex
541 −INPUT ./sections/robustness.tex
542 −INPUT sections/robustness.tex
543 −INPUT ./tables/stability.tex
544 −INPUT ./tables/stability.tex
545 −INPUT ./tables/stability.tex
546 −INPUT ./tables/stability.tex
547 −INPUT tables/stability.tex
548 −INPUT ./sections/conclusion.tex
549 −INPUT ./sections/conclusion.tex
550 −INPUT ./sections/conclusion.tex
551 −INPUT ./sections/conclusion.tex
552 −INPUT sections/conclusion.tex
553 −INPUT ./main.bbl
554 −INPUT ./main.bbl
555 −INPUT main.bbl
556 −INPUT ./appendix/appendix.tex
557 −INPUT ./appendix/appendix.tex
558 −INPUT ./appendix/appendix.tex
559 −INPUT ./appendix/appendix.tex
560 −INPUT appendix/appendix.tex
561 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmr8t.tfm
562 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmb8t.tfm
563 −INPUT ./tables/surface_cov.tex
564 −INPUT ./tables/surface_cov.tex
565 −INPUT ./tables/surface_cov.tex
566 −INPUT ./tables/surface_cov.tex
567 −INPUT tables/surface_cov.tex
568 −INPUT ./tables/surface_geo_none.tex
569 −INPUT ./tables/surface_geo_none.tex
570 −INPUT ./tables/surface_geo_none.tex
571 −INPUT ./tables/surface_geo_none.tex
572 −INPUT tables/surface_geo_none.tex
573 −INPUT ./tables/surface_sim_hedo.tex
574 −INPUT ./tables/surface_sim_hedo.tex
575 −INPUT ./tables/surface_sim_hedo.tex
576 −INPUT ./tables/surface_sim_hedo.tex
577 −INPUT tables/surface_sim_hedo.tex
578 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/vf/adobe/times/ptmb8t.vf
579 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/tfm/adobe/times/ptmb8r.tfm
580 −INPUT ./tables/e1_best.tex
581 −INPUT ./tables/e1_best.tex
582 −INPUT ./tables/e1_best.tex
583 −INPUT ./tables/e1_best.tex
584 −INPUT tables/e1_best.tex
585 −INPUT ./tables/regions.tex
586 −INPUT ./tables/regions.tex
587 −INPUT ./tables/regions.tex
588 −INPUT ./tables/regions.tex
589 −INPUT tables/regions.tex
590 −INPUT ./tables/strata_other.tex
591 −INPUT ./tables/strata_other.tex
592 −INPUT ./tables/strata_other.tex
593 −INPUT ./tables/strata_other.tex
594 −INPUT tables/strata_other.tex
595 −INPUT main.aux
596 −INPUT ./main.out
597 −INPUT ./main.out
598 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/type1/public/amsfonts/cm/cmex10.pfb
599 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/type1/public/amsfonts/cm/cmmi10.pfb
600 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/type1/public/amsfonts/cm/cmr10.pfb
601 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/type1/public/amsfonts/cm/cmsy10.pfb
602 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/type1/public/amsfonts/symbols/msbm10.pfb
603 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/type1/public/rsfs/rsfs10.pfb
604 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/type1/urw/symbol/usyr.pfb
605 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/type1/urw/symbol/usyr.pfb
606 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/type1/urw/times/utmb8a.pfb
607 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/type1/urw/times/utmr8a.pfb
608 −INPUT /usr/local/texlive/2026/texmf-dist/fonts/type1/urw/times/utmri8a.pfb
deleted paper/main.log +0 −891
@@ -1,891 +0,0 @@
1 −This is pdfTeX, Version 3.141592653-2.6-1.40.29 (TeX Live 2026) (preloaded format=pdflatex 2026.3.22) 10 OCT 2026 18:14
2 −entering extended mode
3 − restricted \write18 enabled.
4 − %&-line parsing enabled.
5 −**main.tex
6 −(./main.tex
7 −LaTeX2e <2025-11-01>
8 −L3 programming layer <2026-01-19>
9 −(/usr/local/texlive/2026/texmf-dist/tex/latex/base/article.cls
10 −Document Class: article 2025/01/22 v1.4n Standard LaTeX document class
11 −(/usr/local/texlive/2026/texmf-dist/tex/latex/base/size12.clo
12 −File: size12.clo 2025/01/22 v1.4n Standard LaTeX file (size option)
13 −)
14 −\c@part=\count275
15 −\c@section=\count276
16 −\c@subsection=\count277
17 −\c@subsubsection=\count278
18 −\c@paragraph=\count279
19 −\c@subparagraph=\count280
20 −\c@figure=\count281
21 −\c@table=\count282
22 −\abovecaptionskip=\skip49
23 −\belowcaptionskip=\skip50
24 −\bibindent=\dimen148
25 −)
26 −(/usr/local/texlive/2026/texmf-dist/tex/latex/base/inputenc.sty
27 −Package: inputenc 2024/02/08 v1.3d Input encoding file
28 −\inpenc@prehook=\toks17
29 −\inpenc@posthook=\toks18
30 −)
31 −(/usr/local/texlive/2026/texmf-dist/tex/latex/base/fontenc.sty
32 −Package: fontenc 2025/07/18 v2.1d Standard LaTeX package
33 −)
34 −(/usr/local/texlive/2026/texmf-dist/tex/generic/babel/babel.sty
35 −Package: babel 2026/03/08 v26.4 The multilingual framework for LuaLaTeX, pdfLaT
36 −eX and XeLaTeX
37 −\babel@savecnt=\count283
38 −LaTeX Encoding Info: Redeclaring text command \ij (encoding OT1) on input li
39 −ne 2078.
40 −LaTeX Encoding Info: Redeclaring text command \IJ (encoding OT1) on input li
41 −ne 2080.
42 −LaTeX Encoding Info: Redeclaring text command \ij (encoding T1) on input lin
43 −e 2082.
44 −LaTeX Encoding Info: Redeclaring text command \IJ (encoding T1) on input lin
45 −e 2083.
46 −LaTeX Encoding Info: Ignoring declaration for text command \ij (encoding ?)
47 −on input line 2084.
48 −LaTeX Encoding Info: Ignoring declaration for text command \IJ (encoding ?)
49 −on input line 2086.
50 −LaTeX Encoding Info: Ignoring declaration for text command \SS (encoding ?)
51 −on input line 2111.
52 −\U@D=\dimen149
53 −\l@unhyphenated=\language91
54 −
55 −(/usr/local/texlive/2026/texmf-dist/tex/generic/babel/txtbabel.def)
56 −\bbl@readstream=\read2
57 −\bbl@dirlevel=\count284
58 −
59 −(/usr/local/texlive/2026/texmf-dist/tex/generic/babel/locale/en/babel-english.t
60 −ex)
61 −Package babel Info: Importing font and identification data for english
62 −(babel) from babel-en.ini. Reported on input line 4330.
63 − (/usr/local/texlive/2026/texmf-dist/tex/generic/babel-english/english.ldf
64 −Language: english 2017/06/06 v3.3r English support from the babel system
65 −Package babel Info: Hyphen rules for 'canadian' set to \l@english
66 −(babel) (\language0). Reported on input line 102.
67 −Package babel Info: Hyphen rules for 'australian' set to \l@ukenglish
68 −(babel) (\language22). Reported on input line 105.
69 −Package babel Info: Hyphen rules for 'newzealand' set to \l@ukenglish
70 −(babel) (\language22). Reported on input line 108.
71 −)) (/usr/local/texlive/2026/texmf-dist/tex/latex/geometry/geometry.sty
72 −Package: geometry 2026/03/07 v6.0 Page Geometry
73 −
74 −(/usr/local/texlive/2026/texmf-dist/tex/latex/graphics/keyval.sty
75 −Package: keyval 2022/05/29 v1.15 key=value parser (DPC)
76 −\KV@toks@=\toks19
77 −)
78 −(/usr/local/texlive/2026/texmf-dist/tex/generic/iftex/ifvtex.sty
79 −Package: ifvtex 2019/10/25 v1.7 ifvtex legacy package. Use iftex instead.
80 −
81 −(/usr/local/texlive/2026/texmf-dist/tex/generic/iftex/iftex.sty
82 −Package: iftex 2024/12/12 v1.0g TeX engine tests
83 −))
84 −\Gm@cnth=\count285
85 −\Gm@cntv=\count286
86 −\c@Gm@tempcnt=\count287
87 −\Gm@bindingoffset=\dimen150
88 −\Gm@wd@mp=\dimen151
89 −\Gm@odd@mp=\dimen152
90 −\Gm@even@mp=\dimen153
91 −\Gm@layoutwidth=\dimen154
92 −\Gm@layoutheight=\dimen155
93 −\Gm@layouthoffset=\dimen156
94 −\Gm@layoutvoffset=\dimen157
95 −\Gm@dimlist=\toks20
96 −)
97 −(/usr/local/texlive/2026/texmf-dist/tex/latex/psnfss/mathptmx.sty
98 −Package: mathptmx 2020/03/25 PSNFSS-v9.3 Times w/ Math, improved (SPQR, WaS)
99 −LaTeX Font Info: Redeclaring symbol font `operators' on input line 28.
100 −LaTeX Font Info: Overwriting symbol font `operators' in version `normal'
101 −(Font) OT1/cmr/m/n --> OT1/ztmcm/m/n on input line 28.
102 −LaTeX Font Info: Overwriting symbol font `operators' in version `bold'
103 −(Font) OT1/cmr/bx/n --> OT1/ztmcm/m/n on input line 28.
104 −LaTeX Font Info: Redeclaring symbol font `letters' on input line 29.
105 −LaTeX Font Info: Overwriting symbol font `letters' in version `normal'
106 −(Font) OML/cmm/m/it --> OML/ztmcm/m/it on input line 29.
107 −LaTeX Font Info: Overwriting symbol font `letters' in version `bold'
108 −(Font) OML/cmm/b/it --> OML/ztmcm/m/it on input line 29.
109 −LaTeX Font Info: Redeclaring symbol font `symbols' on input line 30.
110 −LaTeX Font Info: Overwriting symbol font `symbols' in version `normal'
111 −(Font) OMS/cmsy/m/n --> OMS/ztmcm/m/n on input line 30.
112 −LaTeX Font Info: Overwriting symbol font `symbols' in version `bold'
113 −(Font) OMS/cmsy/b/n --> OMS/ztmcm/m/n on input line 30.
114 −LaTeX Font Info: Redeclaring symbol font `largesymbols' on input line 31.
115 −LaTeX Font Info: Overwriting symbol font `largesymbols' in version `normal'
116 −(Font) OMX/cmex/m/n --> OMX/ztmcm/m/n on input line 31.
117 −LaTeX Font Info: Overwriting symbol font `largesymbols' in version `bold'
118 −(Font) OMX/cmex/m/n --> OMX/ztmcm/m/n on input line 31.
119 −\symbold=\mathgroup4
120 −\symitalic=\mathgroup5
121 −LaTeX Font Info: Redeclaring math alphabet \mathbf on input line 34.
122 −LaTeX Font Info: Overwriting math alphabet `\mathbf' in version `normal'
123 −(Font) OT1/cmr/bx/n --> OT1/ptm/bx/n on input line 34.
124 −LaTeX Font Info: Overwriting math alphabet `\mathbf' in version `bold'
125 −(Font) OT1/cmr/bx/n --> OT1/ptm/bx/n on input line 34.
126 −LaTeX Font Info: Redeclaring math alphabet \mathit on input line 35.
127 −LaTeX Font Info: Overwriting math alphabet `\mathit' in version `normal'
128 −(Font) OT1/cmr/m/it --> OT1/ptm/m/it on input line 35.
129 −LaTeX Font Info: Overwriting math alphabet `\mathit' in version `bold'
130 −(Font) OT1/cmr/bx/it --> OT1/ptm/m/it on input line 35.
131 −LaTeX Info: Redefining \hbar on input line 50.
132 −)
133 −(/usr/local/texlive/2026/texmf-dist/tex/latex/microtype/microtype.sty
134 −Package: microtype 2026/03/01 v3.2d Micro-typographical refinements (RS)
135 −
136 −(/usr/local/texlive/2026/texmf-dist/tex/latex/etoolbox/etoolbox.sty
137 −Package: etoolbox 2025/10/02 v2.5m e-TeX tools for LaTeX (JAW)
138 −\etb@tempcnta=\count288
139 −)
140 −\MT@toks=\toks21
141 −\MT@tempbox=\box53
142 −\MT@count=\count289
143 −LaTeX Info: Redefining \noprotrusionifhmode on input line 1084.
144 −LaTeX Info: Redefining \leftprotrusion on input line 1085.
145 −\MT@prot@toks=\toks22
146 −LaTeX Info: Redefining \rightprotrusion on input line 1104.
147 −LaTeX Info: Redefining \textls on input line 1449.
148 −\MT@outer@kern=\dimen158
149 −LaTeX Info: Redefining \microtypecontext on input line 2053.
150 −LaTeX Info: Redefining \textmicrotypecontext on input line 2070.
151 −\MT@listname@count=\count290
152 −
153 −(/usr/local/texlive/2026/texmf-dist/tex/latex/microtype/microtype-pdftex.def
154 −File: microtype-pdftex.def 2026/03/01 v3.2d Definitions specific to pdftex (RS)
155 −
156 −LaTeX Info: Redefining \lsstyle on input line 944.
157 −LaTeX Info: Redefining \lslig on input line 944.
158 −\MT@outer@space=\skip51
159 −)
160 −Package microtype Info: Loading configuration file microtype.cfg.
161 −
162 −(/usr/local/texlive/2026/texmf-dist/tex/latex/microtype/microtype.cfg
163 −File: microtype.cfg 2026/03/01 v3.2d microtype main configuration file (RS)
164 −)
165 −LaTeX Info: Redefining \microtypesetup on input line 3065.
166 −)
167 −(/usr/local/texlive/2026/texmf-dist/tex/latex/setspace/setspace.sty
168 −Package: setspace 2022/12/04 v6.7b set line spacing
169 −)
170 −(/usr/local/texlive/2026/texmf-dist/tex/latex/amsmath/amsmath.sty
171 −Package: amsmath 2025/07/09 v2.17z AMS math features
172 −\@mathmargin=\skip52
173 −
174 −For additional information on amsmath, use the `?' option.
175 −(/usr/local/texlive/2026/texmf-dist/tex/latex/amsmath/amstext.sty
176 −Package: amstext 2024/11/17 v2.01 AMS text
177 −
178 −(/usr/local/texlive/2026/texmf-dist/tex/latex/amsmath/amsgen.sty
179 −File: amsgen.sty 1999/11/30 v2.0 generic functions
180 −\@emptytoks=\toks23
181 −\ex@=\dimen159
182 −))
183 −(/usr/local/texlive/2026/texmf-dist/tex/latex/amsmath/amsbsy.sty
184 −Package: amsbsy 1999/11/29 v1.2d Bold Symbols
185 −\pmbraise@=\dimen160
186 −)
187 −(/usr/local/texlive/2026/texmf-dist/tex/latex/amsmath/amsopn.sty
188 −Package: amsopn 2022/04/08 v2.04 operator names
189 −)
190 −\inf@bad=\count291
191 −LaTeX Info: Redefining \frac on input line 233.
192 −\uproot@=\count292
193 −\leftroot@=\count293
194 −LaTeX Info: Redefining \overline on input line 398.
195 −LaTeX Info: Redefining \colon on input line 409.
196 −\classnum@=\count294
197 −\DOTSCASE@=\count295
198 −LaTeX Info: Redefining \ldots on input line 495.
199 −LaTeX Info: Redefining \dots on input line 498.
200 −LaTeX Info: Redefining \cdots on input line 619.
201 −\Mathstrutbox@=\box54
202 −\strutbox@=\box55
203 −LaTeX Info: Redefining \big on input line 721.
204 −LaTeX Info: Redefining \Big on input line 722.
205 −LaTeX Info: Redefining \bigg on input line 723.
206 −LaTeX Info: Redefining \Bigg on input line 724.
207 −\big@size=\dimen161
208 −LaTeX Font Info: Redeclaring font encoding OML on input line 742.
209 −LaTeX Font Info: Redeclaring font encoding OMS on input line 743.
210 −\macc@depth=\count296
211 −LaTeX Info: Redefining \bmod on input line 904.
212 −LaTeX Info: Redefining \pmod on input line 909.
213 −LaTeX Info: Redefining \smash on input line 939.
214 −LaTeX Info: Redefining \relbar on input line 969.
215 −LaTeX Info: Redefining \Relbar on input line 970.
216 −\c@MaxMatrixCols=\count297
217 −\dotsspace@=\muskip17
218 −\c@parentequation=\count298
219 −\dspbrk@lvl=\count299
220 −\tag@help=\toks24
221 −\row@=\count300
222 −\column@=\count301
223 −\maxfields@=\count302
224 −\andhelp@=\toks25
225 −\eqnshift@=\dimen162
226 −\alignsep@=\dimen163
227 −\tagshift@=\dimen164
228 −\tagwidth@=\dimen165
229 −\totwidth@=\dimen166
230 −\lineht@=\dimen167
231 −\@envbody=\toks26
232 −\multlinegap=\skip53
233 −\multlinetaggap=\skip54
234 −\mathdisplay@stack=\toks27
235 −LaTeX Info: Redefining \[ on input line 2950.
236 −LaTeX Info: Redefining \] on input line 2951.
237 −)
238 −(/usr/local/texlive/2026/texmf-dist/tex/latex/amsfonts/amssymb.sty
239 −Package: amssymb 2013/01/14 v3.01 AMS font symbols
240 −
241 −(/usr/local/texlive/2026/texmf-dist/tex/latex/amsfonts/amsfonts.sty
242 −Package: amsfonts 2013/01/14 v3.01 Basic AMSFonts support
243 −\symAMSa=\mathgroup6
244 −\symAMSb=\mathgroup7
245 −LaTeX Font Info: Overwriting math alphabet `\mathfrak' in version `bold'
246 −(Font) U/euf/m/n --> U/euf/b/n on input line 106.
247 −))
248 −(/usr/local/texlive/2026/texmf-dist/tex/latex/amscls/amsthm.sty
249 −Package: amsthm 2020/05/29 v2.20.6
250 −\thm@style=\toks28
251 −\thm@bodyfont=\toks29
252 −\thm@headfont=\toks30
253 −\thm@notefont=\toks31
254 −\thm@headpunct=\toks32
255 −\thm@preskip=\skip55
256 −\thm@postskip=\skip56
257 −\thm@headsep=\skip57
258 −\dth@everypar=\toks33
259 −)
260 −(/usr/local/texlive/2026/texmf-dist/tex/latex/booktabs/booktabs.sty
261 −Package: booktabs 2020/01/12 v1.61803398 Publication quality tables
262 −\heavyrulewidth=\dimen168
263 −\lightrulewidth=\dimen169
264 −\cmidrulewidth=\dimen170
265 −\belowrulesep=\dimen171
266 −\belowbottomsep=\dimen172
267 −\aboverulesep=\dimen173
268 −\abovetopsep=\dimen174
269 −\cmidrulesep=\dimen175
270 −\cmidrulekern=\dimen176
271 −\defaultaddspace=\dimen177
272 −\@cmidla=\count303
273 −\@cmidlb=\count304
274 −\@aboverulesep=\dimen178
275 −\@belowrulesep=\dimen179
276 −\@thisruleclass=\count305
277 −\@lastruleclass=\count306
278 −\@thisrulewidth=\dimen180
279 −)
280 −(/usr/local/texlive/2026/texmf-dist/tex/latex/tools/tabularx.sty
281 −Package: tabularx 2023/12/11 v2.12a `tabularx' package (DPC)
282 −
283 −(/usr/local/texlive/2026/texmf-dist/tex/latex/tools/array.sty
284 −Package: array 2025/09/25 v2.6n Tabular extension package (FMi)
285 −\col@sep=\dimen181
286 −\ar@mcellbox=\box56
287 −\extrarowheight=\dimen182
288 −\NC@list=\toks34
289 −\extratabsurround=\skip58
290 −\backup@length=\skip59
291 −\ar@cellbox=\box57
292 −)
293 −\TX@col@width=\dimen183
294 −\TX@old@table=\dimen184
295 −\TX@old@col=\dimen185
296 −\TX@target=\dimen186
297 −\TX@delta=\dimen187
298 −\TX@cols=\count307
299 −\TX@ftn=\toks35
300 −)
301 −(/usr/local/texlive/2026/texmf-dist/tex/latex/multirow/multirow.sty
302 −Package: multirow 2024/11/12 v2.9 Span multiple rows of a table
303 −\multirow@colwidth=\skip60
304 −\multirow@cntb=\count308
305 −\multirow@dima=\skip61
306 −\bigstrutjot=\dimen188
307 −)
308 −(/usr/local/texlive/2026/texmf-dist/tex/latex/threeparttable/threeparttable.sty
309 −Package: threeparttable 2003/06/13 v 3.0
310 −\@tempboxb=\box58
311 −) (/usr/local/texlive/2026/texmf-dist/tex/latex/siunitx/siunitx.sty
312 −Package: siunitx 2025-07-09 v3.4.14 A comprehensive (SI) units package
313 −\l__siunitx_number_uncert_offset_int=\count309
314 −\l__siunitx_number_exponent_fixed_int=\count310
315 −\l__siunitx_number_min_decimal_int=\count311
316 −\l__siunitx_number_min_integer_int=\count312
317 −\l__siunitx_number_round_precision_int=\count313
318 −\l__siunitx_number_lower_threshold_int=\count314
319 −\l__siunitx_number_upper_threshold_int=\count315
320 −\l__siunitx_number_group_first_int=\count316
321 −\l__siunitx_number_group_size_int=\count317
322 −\l__siunitx_number_group_minimum_int=\count318
323 −\l__siunitx_angle_tmp_dim=\dimen189
324 −\l__siunitx_angle_marker_box=\box59
325 −\l__siunitx_angle_unit_box=\box60
326 −\l__siunitx_compound_count_int=\count319
327 −
328 −(/usr/local/texlive/2026/texmf-dist/tex/latex/translations/translations.sty
329 −Package: translations 2022/02/05 v1.12 internationalization of LaTeX2e packages
330 − (CN)
331 −
332 −(/usr/local/texlive/2026/texmf-dist/tex/generic/pdftexcmds/pdftexcmds.sty
333 −Package: pdftexcmds 2020-06-27 v0.33 Utility functions of pdfTeX for LuaTeX (HO
334 −)
335 −
336 −(/usr/local/texlive/2026/texmf-dist/tex/generic/infwarerr/infwarerr.sty
337 −Package: infwarerr 2019/12/03 v1.5 Providing info/warning/error messages (HO)
338 −)
339 −(/usr/local/texlive/2026/texmf-dist/tex/generic/ltxcmds/ltxcmds.sty
340 −Package: ltxcmds 2023-12-04 v1.26 LaTeX kernel commands for general use (HO)
341 −)
342 −Package pdftexcmds Info: \pdf@primitive is available.
343 −Package pdftexcmds Info: \pdf@ifprimitive is available.
344 −Package pdftexcmds Info: \pdfdraftmode found.
345 −))
346 −\l__siunitx_table_tmp_box=\box61
347 −\l__siunitx_table_tmp_dim=\dimen190
348 −\l__siunitx_table_column_width_dim=\dimen191
349 −\l__siunitx_table_integer_box=\box62
350 −\l__siunitx_table_decimal_box=\box63
351 −\l__siunitx_table_uncert_box=\box64
352 −\l__siunitx_table_before_box=\box65
353 −\l__siunitx_table_after_box=\box66
354 −\l__siunitx_table_before_dim=\dimen192
355 −\l__siunitx_table_carry_dim=\dimen193
356 −\l__siunitx_unit_tmp_int=\count320
357 −\l__siunitx_unit_position_int=\count321
358 −\l__siunitx_unit_total_int=\count322
359 −)
360 −
361 −Package siunitx Info: Option "input-symbols" has been deprecated in this
362 −(siunitx) release.
363 −(siunitx)
364 −(siunitx) Use "input-digits" as a replacement.
365 −
366 −
367 −(/usr/local/texlive/2026/texmf-dist/tex/latex/graphics/graphicx.sty
368 −Package: graphicx 2024/12/31 v1.2e Enhanced LaTeX Graphics (DPC,SPQR)
369 −
370 −(/usr/local/texlive/2026/texmf-dist/tex/latex/graphics/graphics.sty
371 −Package: graphics 2024/08/06 v1.4g Standard LaTeX Graphics (DPC,SPQR)
372 −
373 −(/usr/local/texlive/2026/texmf-dist/tex/latex/graphics/trig.sty
374 −Package: trig 2023/12/02 v1.11 sin cos tan (DPC)
375 −)
376 −(/usr/local/texlive/2026/texmf-dist/tex/latex/graphics-cfg/graphics.cfg
377 −File: graphics.cfg 2016/06/04 v1.11 sample graphics configuration
378 −)
379 −Package graphics Info: Driver file: pdftex.def on input line 106.
380 −
381 −(/usr/local/texlive/2026/texmf-dist/tex/latex/graphics-def/pdftex.def
382 −File: pdftex.def 2025/09/29 v1.2d Graphics/color driver for pdftex
383 −))
384 −\Gin@req@height=\dimen194
385 −\Gin@req@width=\dimen195
386 −)
387 −(/usr/local/texlive/2026/texmf-dist/tex/latex/caption/subcaption.sty
388 −Package: subcaption 2023/07/28 v1.6b Sub-captions (AR)
389 −
390 −(/usr/local/texlive/2026/texmf-dist/tex/latex/caption/caption.sty
391 −Package: caption 2023/08/05 v3.6o Customizing captions (AR)
392 −
393 −(/usr/local/texlive/2026/texmf-dist/tex/latex/caption/caption3.sty
394 −Package: caption3 2023/07/31 v2.4d caption3 kernel (AR)
395 −\caption@tempdima=\dimen196
396 −\captionmargin=\dimen197
397 −\caption@leftmargin=\dimen198
398 −\caption@rightmargin=\dimen199
399 −\caption@width=\dimen256
400 −\caption@indent=\dimen257
401 −\caption@parindent=\dimen258
402 −\caption@hangindent=\dimen259
403 −Package caption Info: Standard document class detected.
404 −)
405 −\c@caption@flags=\count323
406 −\c@continuedfloat=\count324
407 −Package caption Info: threeparttable package is loaded.
408 −)
409 −Package caption Info: New subtype `subfigure' on input line 238.
410 −\c@subfigure=\count325
411 −Package caption Info: New subtype `subtable' on input line 238.
412 −\c@subtable=\count326
413 −)
414 −(/usr/local/texlive/2026/texmf-dist/tex/latex/float/float.sty
415 −Package: float 2001/11/08 v1.3d Float enhancements (AL)
416 −\c@float@type=\count327
417 −\float@exts=\toks36
418 −\float@box=\box67
419 −\@float@everytoks=\toks37
420 −\@floatcapt=\box68
421 −)
422 −(/usr/local/texlive/2026/texmf-dist/tex/latex/xcolor/xcolor.sty
423 −Package: xcolor 2024/09/29 v3.02 LaTeX color extensions (UK)
424 −
425 −(/usr/local/texlive/2026/texmf-dist/tex/latex/graphics-cfg/color.cfg
426 −File: color.cfg 2016/01/02 v1.6 sample color configuration
427 −)
428 −Package xcolor Info: Driver file: pdftex.def on input line 274.
429 −
430 −(/usr/local/texlive/2026/texmf-dist/tex/latex/graphics/mathcolor.ltx)
431 −Package xcolor Info: Model `cmy' substituted by `cmy0' on input line 1349.
432 −Package xcolor Info: Model `hsb' substituted by `rgb' on input line 1353.
433 −Package xcolor Info: Model `RGB' extended on input line 1365.
434 −Package xcolor Info: Model `HTML' substituted by `rgb' on input line 1367.
435 −Package xcolor Info: Model `Hsb' substituted by `hsb' on input line 1368.
436 −Package xcolor Info: Model `tHsb' substituted by `hsb' on input line 1369.
437 −Package xcolor Info: Model `HSB' substituted by `hsb' on input line 1370.
438 −Package xcolor Info: Model `Gray' substituted by `gray' on input line 1371.
439 −Package xcolor Info: Model `wave' substituted by `hsb' on input line 1372.
440 −)
441 −(/usr/local/texlive/2026/texmf-dist/tex/latex/graphics/dvipsnam.def
442 −File: dvipsnam.def 2016/06/17 v3.0m Driver-dependent file (DPC,SPQR)
443 −)
444 −(/usr/local/texlive/2026/texmf-dist/tex/latex/hyperref/hyperref.sty
445 −Package: hyperref 2026-01-29 v7.01p Hypertext links for LaTeX
446 −
447 −(/usr/local/texlive/2026/texmf-dist/tex/latex/kvsetkeys/kvsetkeys.sty
448 −Package: kvsetkeys 2022-10-05 v1.19 Key value parser (HO)
449 −)
450 −(/usr/local/texlive/2026/texmf-dist/tex/generic/kvdefinekeys/kvdefinekeys.sty
451 −Package: kvdefinekeys 2019-12-19 v1.6 Define keys (HO)
452 −)
453 −(/usr/local/texlive/2026/texmf-dist/tex/generic/pdfescape/pdfescape.sty
454 −Package: pdfescape 2019/12/09 v1.15 Implements pdfTeX's escape features (HO)
455 −)
456 −(/usr/local/texlive/2026/texmf-dist/tex/latex/hycolor/hycolor.sty
457 −Package: hycolor 2020-01-27 v1.10 Color options for hyperref/bookmark (HO)
458 −)
459 −(/usr/local/texlive/2026/texmf-dist/tex/latex/hyperref/nameref.sty
460 −Package: nameref 2026-01-29 v2.58 Cross-referencing by name of section
461 −
462 −(/usr/local/texlive/2026/texmf-dist/tex/latex/refcount/refcount.sty
463 −Package: refcount 2019/12/15 v3.6 Data extraction from label references (HO)
464 −)
465 −(/usr/local/texlive/2026/texmf-dist/tex/generic/gettitlestring/gettitlestring.s
466 −ty
467 −Package: gettitlestring 2019/12/15 v1.6 Cleanup title references (HO)
468 − (/usr/local/texlive/2026/texmf-dist/tex/latex/kvoptions/kvoptions.sty
469 −Package: kvoptions 2022-06-15 v3.15 Key value format for package options (HO)
470 −))
471 −\c@section@level=\count328
472 −)
473 −(/usr/local/texlive/2026/texmf-dist/tex/generic/stringenc/stringenc.sty
474 −Package: stringenc 2019/11/29 v1.12 Convert strings between diff. encodings (HO
475 −)
476 −)
477 −\@linkdim=\dimen260
478 −\Hy@linkcounter=\count329
479 −\Hy@pagecounter=\count330
480 −
481 −(/usr/local/texlive/2026/texmf-dist/tex/latex/hyperref/pd1enc.def
482 −File: pd1enc.def 2026-01-29 v7.01p Hyperref: PDFDocEncoding definition (HO)
483 −Now handling font encoding PD1 ...
484 −... no UTF-8 mapping file for font encoding PD1
485 −)
486 −(/usr/local/texlive/2026/texmf-dist/tex/generic/intcalc/intcalc.sty
487 −Package: intcalc 2019/12/15 v1.3 Expandable calculations with integers (HO)
488 −)
489 −\Hy@SavedSpaceFactor=\count331
490 −
491 −(/usr/local/texlive/2026/texmf-dist/tex/latex/hyperref/puenc.def
492 −File: puenc.def 2026-01-29 v7.01p Hyperref: PDF Unicode definition (HO)
493 −Now handling font encoding PU ...
494 −... no UTF-8 mapping file for font encoding PU
495 −)
496 −Package hyperref Info: Option `colorlinks' set `true' on input line 4072.
497 −Package hyperref Info: Option `breaklinks' set `true' on input line 4072.
498 −Package hyperref Info: Hyper figures OFF on input line 4201.
499 −Package hyperref Info: Link nesting OFF on input line 4206.
500 −Package hyperref Info: Hyper index ON on input line 4209.
501 −Package hyperref Info: Plain pages OFF on input line 4216.
502 −Package hyperref Info: Backreferencing OFF on input line 4221.
503 −Package hyperref Info: Implicit mode ON; LaTeX internals redefined.
504 −Package hyperref Info: Bookmarks ON on input line 4468.
505 −\c@Hy@tempcnt=\count332
506 −
507 −(/usr/local/texlive/2026/texmf-dist/tex/latex/url/url.sty
508 −\Urlmuskip=\muskip18
509 −Package: url 2013/09/16 ver 3.4 Verb mode for urls, etc.
510 −)
511 −LaTeX Info: Redefining \url on input line 4807.
512 −\XeTeXLinkMargin=\dimen261
513 −
514 −(/usr/local/texlive/2026/texmf-dist/tex/generic/bitset/bitset.sty
515 −Package: bitset 2019/12/09 v1.3 Handle bit-vector datatype (HO)
516 −
517 −(/usr/local/texlive/2026/texmf-dist/tex/generic/bigintcalc/bigintcalc.sty
518 −Package: bigintcalc 2019/12/15 v1.5 Expandable calculations on big integers (HO
519 −)
520 −))
521 −\Fld@menulength=\count333
522 −\Field@Width=\dimen262
523 −\Fld@charsize=\dimen263
524 −Package hyperref Info: Hyper figures OFF on input line 6084.
525 −Package hyperref Info: Link nesting OFF on input line 6089.
526 −Package hyperref Info: Hyper index ON on input line 6092.
527 −Package hyperref Info: backreferencing OFF on input line 6099.
528 −Package hyperref Info: Link coloring ON on input line 6102.
529 −Package hyperref Info: Link coloring with OCG OFF on input line 6109.
530 −Package hyperref Info: PDF/A mode OFF on input line 6114.
531 −\Hy@abspage=\count334
532 −\c@Item=\count335
533 −\c@Hfootnote=\count336
534 −)
535 −Package hyperref Info: Driver (autodetected): hpdftex.
536 −
537 −(/usr/local/texlive/2026/texmf-dist/tex/latex/hyperref/hpdftex.def
538 −File: hpdftex.def 2026-01-29 v7.01p Hyperref driver for pdfTeX
539 −\Fld@listcount=\count337
540 −\c@bookmark@seq@number=\count338
541 −
542 −(/usr/local/texlive/2026/texmf-dist/tex/latex/rerunfilecheck/rerunfilecheck.sty
543 −Package: rerunfilecheck 2025-06-21 v1.11 Rerun checks for auxiliary files (HO)
544 −
545 −(/usr/local/texlive/2026/texmf-dist/tex/generic/uniquecounter/uniquecounter.sty
546 −Package: uniquecounter 2019/12/15 v1.4 Provide unlimited unique counter (HO)
547 −)
548 −Package uniquecounter Info: New unique counter `rerunfilecheck' on input line 2
549 −84.
550 −)
551 −\Hy@SectionHShift=\skip62
552 −) (/usr/local/texlive/2026/texmf-dist/tex/latex/natbib/natbib.sty
553 −Package: natbib 2010/09/13 8.31b (PWD, AO)
554 −\bibhang=\skip63
555 −\bibsep=\skip64
556 −LaTeX Info: Redefining \cite on input line 694.
557 −\c@NAT@ctr=\count339
558 −)
559 −(/usr/local/texlive/2026/texmf-dist/tex/latex/fancyhdr/fancyhdr.sty
560 −Package: fancyhdr 2025/02/07 v5.2 Extensive control of page headers and footers
561 −
562 −\f@nch@headwidth=\skip65
563 −\f@nch@offset@elh=\skip66
564 −\f@nch@offset@erh=\skip67
565 −\f@nch@offset@olh=\skip68
566 −\f@nch@offset@orh=\skip69
567 −\f@nch@offset@elf=\skip70
568 −\f@nch@offset@erf=\skip71
569 −\f@nch@offset@olf=\skip72
570 −\f@nch@offset@orf=\skip73
571 −\f@nch@height=\skip74
572 −\f@nch@footalignment=\skip75
573 −\f@nch@widthL=\skip76
574 −\f@nch@widthC=\skip77
575 −\f@nch@widthR=\skip78
576 −\@temptokenb=\toks38
577 −)
578 −(/usr/local/texlive/2026/texmf-dist/tex/latex/titlesec/titlesec.sty
579 −Package: titlesec 2025/01/04 v2.17 Sectioning titles
580 −\ttl@box=\box69
581 −\beforetitleunit=\skip79
582 −\aftertitleunit=\skip80
583 −\ttl@plus=\dimen264
584 −\ttl@minus=\dimen265
585 −\ttl@toksa=\toks39
586 −\titlewidth=\dimen266
587 −\titlewidthlast=\dimen267
588 −\titlewidthfirst=\dimen268
589 −)
590 −(/usr/local/texlive/2026/texmf-dist/tex/latex/appendix/appendix.sty
591 −Package: appendix 2020/02/08 v1.2c extra appendix facilities
592 −\c@@pps=\count340
593 −\c@@ppsavesec=\count341
594 −\c@@ppsaveapp=\count342
595 −)
596 −(/usr/local/texlive/2026/texmf-dist/tex/latex/enumitem/enumitem.sty
597 −Package: enumitem 2025/02/06 v3.11 Customized lists
598 −\labelindent=\skip81
599 −\enit@outerparindent=\dimen269
600 −\enit@toks=\toks40
601 −\enit@inbox=\box70
602 −\enit@count@id=\count343
603 −\enitdp@description=\count344
604 −)
605 −LaTeX Font Info: Trying to load font information for T1+ptm on input line 12
606 −0.
607 −
608 −(/usr/local/texlive/2026/texmf-dist/tex/latex/psnfss/t1ptm.fd
609 −File: t1ptm.fd 2001/06/04 font definitions for T1/ptm.
610 −)
611 −(/usr/local/texlive/2026/texmf-dist/tex/latex/l3backend/l3backend-pdftex.def
612 −File: l3backend-pdftex.def 2025-10-09 L3 backend support: PDF output (pdfTeX)
613 −\l__color_backend_stack_int=\count345
614 −)
615 −(./main.aux)
616 −\openout1 = `main.aux'.
617 −
618 −LaTeX Font Info: Checking defaults for OML/cmm/m/it on input line 120.
619 −LaTeX Font Info: ... okay on input line 120.
620 −LaTeX Font Info: Checking defaults for OMS/cmsy/m/n on input line 120.
621 −LaTeX Font Info: ... okay on input line 120.
622 −LaTeX Font Info: Checking defaults for OT1/cmr/m/n on input line 120.
623 −LaTeX Font Info: ... okay on input line 120.
624 −LaTeX Font Info: Checking defaults for T1/cmr/m/n on input line 120.
625 −LaTeX Font Info: ... okay on input line 120.
626 −LaTeX Font Info: Checking defaults for TS1/cmr/m/n on input line 120.
627 −LaTeX Font Info: ... okay on input line 120.
628 −LaTeX Font Info: Checking defaults for OMX/cmex/m/n on input line 120.
629 −LaTeX Font Info: ... okay on input line 120.
630 −LaTeX Font Info: Checking defaults for U/cmr/m/n on input line 120.
631 −LaTeX Font Info: ... okay on input line 120.
632 −LaTeX Font Info: Checking defaults for PD1/pdf/m/n on input line 120.
633 −LaTeX Font Info: ... okay on input line 120.
634 −LaTeX Font Info: Checking defaults for PU/pdf/m/n on input line 120.
635 −LaTeX Font Info: ... okay on input line 120.
636 −
637 −*geometry* driver: auto-detecting
638 −*geometry* detected driver: pdftex
639 −*geometry* verbose mode - [ preamble ] result:
640 −* driver: pdftex
641 −* paper: letterpaper
642 −* layout: <same size as paper>
643 −* layoutoffset:(h,v)=(0.0pt,0.0pt)
644 −* modes:
645 −* h-part:(L,W,R)=(72.26999pt, 469.75502pt, 72.26999pt)
646 −* v-part:(T,H,B)=(72.26999pt, 650.43001pt, 72.26999pt)
647 −* \paperwidth=614.295pt
648 −* \paperheight=794.96999pt
649 −* \textwidth=469.75502pt
650 −* \textheight=650.43001pt
651 −* \oddsidemargin=0.0pt
652 −* \evensidemargin=0.0pt
653 −* \topmargin=-37.0pt
654 −* \headheight=12.0pt
655 −* \headsep=25.0pt
656 −* \topskip=12.0pt
657 −* \footskip=30.0pt
658 −* \marginparwidth=44.0pt
659 −* \marginparsep=10.0pt
660 −* \columnsep=10.0pt
661 −* \skip\footins=10.8pt plus 4.0pt minus 2.0pt
662 −* \hoffset=0.0pt
663 −* \voffset=0.0pt
664 −* \mag=1000
665 −* \@twocolumnfalse
666 −* \@twosidefalse
667 −* \@mparswitchfalse
668 −* \@reversemarginfalse
669 −* (1in=72.27pt=25.4mm, 1cm=28.453pt)
670 −
671 −LaTeX Info: Redefining \microtypecontext on input line 120.
672 −Package microtype Info: Applying patch `item' on input line 120.
673 −Package microtype Info: Applying patch `toc' on input line 120.
674 −Package microtype Info: Applying patch `eqnum' on input line 120.
675 −Package microtype Info: Applying patch `footnote' on input line 120.
676 −Package microtype Info: Applying patch `verbatim' on input line 120.
677 −LaTeX Info: Redefining \microtypesetup on input line 120.
678 −Package microtype Info: Generating PDF output.
679 −Package microtype Info: Character protrusion enabled (level 2).
680 −Package microtype Info: Using default protrusion set `alltext'.
681 −Package microtype Info: Automatic font expansion enabled (level 2),
682 −(microtype) stretch: 20, shrink: 20, step: 1, non-selected.
683 −Package microtype Info: Using default expansion set `alltext-nott'.
684 −Package microtype Info: Patching command `\showhyphens'.
685 −Package microtype Info: No adjustment of tracking.
686 −Package microtype Info: No adjustment of interword spacing.
687 −Package microtype Info: No adjustment of character kerning.
688 −(/usr/local/texlive/2026/texmf-dist/tex/latex/microtype/mt-ptm.cfg
689 −File: mt-ptm.cfg 2006/04/20 v1.7 microtype config. file: Times (RS)
690 −)
691 −(/usr/local/texlive/2026/texmf-dist/tex/latex/translations/translations-basic-d
692 −ictionary-english.trsl
693 −File: translations-basic-dictionary-english.trsl (english translation file `tra
694 −nslations-basic-dictionary')
695 −)
696 −Package translations Info: loading dictionary `translations-basic-dictionary' f
697 −or `english'. on input line 120.
698 −
699 −(/usr/local/texlive/2026/texmf-dist/tex/context/base/mkii/supp-pdf.mkii
700 −[Loading MPS to PDF converter (version 2006.09.02).]
701 −\scratchcounter=\count346
702 −\scratchdimen=\dimen270
703 −\scratchbox=\box71
704 −\nofMPsegments=\count347
705 −\nofMParguments=\count348
706 −\everyMPshowfont=\toks41
707 −\MPscratchCnt=\count349
708 −\MPscratchDim=\dimen271
709 −\MPnumerator=\count350
710 −\makeMPintoPDFobject=\count351
711 −\everyMPtoPDFconversion=\toks42
712 −) (/usr/local/texlive/2026/texmf-dist/tex/latex/epstopdf-pkg/epstopdf-base.sty
713 −Package: epstopdf-base 2020-01-24 v2.11 Base part for package epstopdf
714 −Package epstopdf-base Info: Redefining graphics rule for `.eps' on input line 4
715 −85.
716 −
717 −(/usr/local/texlive/2026/texmf-dist/tex/latex/latexconfig/epstopdf-sys.cfg
718 −File: epstopdf-sys.cfg 2010/07/13 v1.3 Configuration of (r)epstopdf for TeX Liv
719 −e
720 −))
721 −Package caption Info: Begin \AtBeginDocument code.
722 −Package caption Info: float package is loaded.
723 −Package caption Info: hyperref package is loaded.
724 −Package caption Info: End \AtBeginDocument code.
725 −Package hyperref Info: Link coloring ON on input line 120.
726 −
727 −(./main.out) (./main.out)
728 −\@outlinefile=\write3
729 −\openout3 = `main.out'.
730 −
731 − (./tables/numbers.tex) (./sections/titlepage.tex
732 −<uq_logo.jpg, id=156, 722.7pt x 361.35pt>
733 −File: uq_logo.jpg Graphic file (type jpg)
734 −<use uq_logo.jpg>
735 −Package pdftex.def Info: uq_logo.jpg used on input line 9.
736 −(pdftex.def) Requested size: 99.58464pt x 49.78915pt.
737 −
738 −[1
739 −
740 −{/usr/local/texlive/2026/texmf-var/fonts/map/pdftex/updmap/pdftex.map}{/usr/loc
741 −al/texlive/2026/texmf-dist/fonts/enc/dvips/base/8r.enc} <./uq_logo.jpg>]
742 −[2]) (./sections/introduction.tex
743 −LaTeX Font Info: Trying to load font information for OT1+ztmcm on input line
744 − 10.
745 −
746 −(/usr/local/texlive/2026/texmf-dist/tex/latex/psnfss/ot1ztmcm.fd
747 −File: ot1ztmcm.fd 2000/01/03 Fontinst v1.801 font definitions for OT1/ztmcm.
748 −)
749 −Package microtype Info: Loading generic protrusion settings for font family
750 −(microtype) `ztmcm' (encoding: OT1).
751 −(microtype) For optimal results, create family-specific settings.
752 −(microtype) See the microtype manual for details.
753 −LaTeX Font Info: Trying to load font information for OML+ztmcm on input line
754 − 10.
755 −
756 −(/usr/local/texlive/2026/texmf-dist/tex/latex/psnfss/omlztmcm.fd
757 −File: omlztmcm.fd 2000/01/03 Fontinst v1.801 font definitions for OML/ztmcm.
758 −)
759 −LaTeX Font Info: Trying to load font information for OMS+ztmcm on input line
760 − 10.
761 −
762 −(/usr/local/texlive/2026/texmf-dist/tex/latex/psnfss/omsztmcm.fd
763 −File: omsztmcm.fd 2000/01/03 Fontinst v1.801 font definitions for OMS/ztmcm.
764 −)
765 −LaTeX Font Info: Trying to load font information for OMX+ztmcm on input line
766 − 10.
767 −
768 −(/usr/local/texlive/2026/texmf-dist/tex/latex/psnfss/omxztmcm.fd
769 −File: omxztmcm.fd 2000/01/03 Fontinst v1.801 font definitions for OMX/ztmcm.
770 −)
771 −LaTeX Font Info: Trying to load font information for OT1+ptm on input line 1
772 −0.
773 −
774 −(/usr/local/texlive/2026/texmf-dist/tex/latex/psnfss/ot1ptm.fd
775 −File: ot1ptm.fd 2001/06/04 font definitions for OT1/ptm.
776 −)
777 −LaTeX Font Info: Font shape `OT1/ptm/bx/n' in size <12> not available
778 −(Font) Font shape `OT1/ptm/b/n' tried instead on input line 10.
779 −LaTeX Font Info: Font shape `OT1/ptm/bx/n' in size <9> not available
780 −(Font) Font shape `OT1/ptm/b/n' tried instead on input line 10.
781 −LaTeX Font Info: Font shape `OT1/ptm/bx/n' in size <7> not available
782 −(Font) Font shape `OT1/ptm/b/n' tried instead on input line 10.
783 −
784 −(/usr/local/texlive/2026/texmf-dist/tex/latex/microtype/mt-msa.cfg
785 −File: mt-msa.cfg 2006/02/04 v1.1 microtype config. file: AMS symbols (a) (RS)
786 −)
787 −(/usr/local/texlive/2026/texmf-dist/tex/latex/microtype/mt-msb.cfg
788 −File: mt-msb.cfg 2005/06/01 v1.0 microtype config. file: AMS symbols (b) (RS)
789 −) [3]
790 −[4]) (./sections/literature.tex [5] [6]) (./sections/data.tex [7] [8]
791 −(./tables/exclusions.tex) [9] (./tables/descriptives.tex)
792 −<figures/f7_indice.pdf, id=348, 440.99129pt x 221.13416pt>
793 −File: figures/f7_indice.pdf Graphic file (type pdf)
794 −<use figures/f7_indice.pdf>
795 −Package pdftex.def Info: figures/f7_indice.pdf used on input line 40.
796 −(pdftex.def) Requested size: 399.29463pt x 200.23361pt.
797 −LaTeX Font Info: Font shape `OT1/ptm/bx/n' in size <10.95> not available
798 −(Font) Font shape `OT1/ptm/b/n' tried instead on input line 41.
799 −LaTeX Font Info: Font shape `OT1/ptm/bx/n' in size <8> not available
800 −(Font) Font shape `OT1/ptm/b/n' tried instead on input line 41.
801 −LaTeX Font Info: Font shape `OT1/ptm/bx/n' in size <6> not available
802 −(Font) Font shape `OT1/ptm/b/n' tried instead on input line 41.
803 − [10 <./figures/f7_indice.pdf>] (./tables/density_dist.tex)) (./sections/method
804 −ology.tex [11]
805 −[12] [13] [14]) (./sections/results.tex
806 −<figures/f1_mdape_vs_k.pdf, id=438, 547.99101pt x 244.40283pt>
807 −File: figures/f1_mdape_vs_k.pdf Graphic file (type pdf)
808 −<use figures/f1_mdape_vs_k.pdf>
809 −Package pdftex.def Info: figures/f1_mdape_vs_k.pdf used on input line 15.
810 −(pdftex.def) Requested size: 469.75502pt x 209.51479pt.
811 − [15]
812 −<figures/f2_surface_d_T.pdf, id=447, 502.01927pt x 220.96164pt>
813 −File: figures/f2_surface_d_T.pdf Graphic file (type pdf)
814 −<use figures/f2_surface_d_T.pdf>
815 −Package pdftex.def Info: figures/f2_surface_d_T.pdf used on input line 22.
816 −(pdftex.def) Requested size: 422.77664pt x 186.08864pt.
817 − (./tables/surface_geo_hedo.tex)
818 −[16 <./figures/f1_mdape_vs_k.pdf> <./figures/f2_surface_d_T.pdf>]
819 −(./tables/h2.tex)
820 −LaTeX Font Info: Font shape `OT1/ptm/bx/n' in size <10> not available
821 −(Font) Font shape `OT1/ptm/b/n' tried instead on input line 49.
822 −LaTeX Font Info: Font shape `OT1/ptm/bx/n' in size <7.4> not available
823 −(Font) Font shape `OT1/ptm/b/n' tried instead on input line 49.
824 − [17] (./tables/adjustments.tex) [18]
825 −<figures/f3_ajustements.pdf, id=575, 443.50067pt x 221.07143pt>
826 −File: figures/f3_ajustements.pdf Graphic file (type pdf)
827 −<use figures/f3_ajustements.pdf>
828 −Package pdftex.def Info: figures/f3_ajustements.pdf used on input line 71.
829 −(pdftex.def) Requested size: 422.77664pt x 210.7554pt.
830 − (./tables/h3.tex)
831 −[19 <./figures/f3_ajustements.pdf>] (./tables/grid.tex) [20]
832 −(./tables/selection.tex) [21] (./tables/weighting.tex) (./tables/h4.tex)
833 −[22] (./tables/marginal.tex) [23] (./tables/competition.tex)
834 −<figures/f4_competition.pdf, id=660, 633.31883pt x 333.69041pt>
835 −File: figures/f4_competition.pdf Graphic file (type pdf)
836 −<use figures/f4_competition.pdf>
837 −Package pdftex.def Info: figures/f4_competition.pdf used on input line 163.
838 −(pdftex.def) Requested size: 446.26582pt x 235.13466pt.
839 − [24]
840 −(./tables/pairs.tex) [25 <./figures/f4_competition.pdf>] [26]
841 −(./tables/density.tex)
842 −<figures/f5_densite.pdf, id=743, 407.96793pt x 244.4342pt>
843 −File: figures/f5_densite.pdf Graphic file (type pdf)
844 −<use figures/f5_densite.pdf>
845 −Package pdftex.def Info: figures/f5_densite.pdf used on input line 198.
846 −(pdftex.def) Requested size: 399.29463pt x 239.25294pt.
847 − (./tables/h5.tex) [27 <./figures/f5_densite.pdf>]
848 −(./tables/intervals.tex) [28] (./tables/cond_rules.tex))
849 −(./sections/robustness.tex (./tables/stability.tex) [29])
850 −(./sections/conclusion.tex [30] [31]) [32] (./main.bbl [33] [34])
851 −(./appendix/appendix.tex (./tables/surface_cov.tex)
852 −(./tables/surface_geo_none.tex) (./tables/surface_sim_hedo.tex) [35]
853 −(./tables/e1_best.tex)
854 −Overfull \hbox (1.67334pt too wide) in paragraph at lines 1--36
855 − [][]
856 − []
857 −
858 −(./tables/regions.tex) [36] (./tables/strata_other.tex) [37]) [38] (./main.aux)
859 − ***********
860 −LaTeX2e <2025-11-01>
861 −L3 programming layer <2026-01-19>
862 − ***********
863 −Package rerunfilecheck Info: File `main.out' has not changed.
864 −(rerunfilecheck) Checksum: BF1DD9FE3C2A7A6E19AC40B70B222A94;7416.
865 − )
866 −Here is how much of TeX's memory you used:
867 − 22591 strings out of 467525
868 − 419598 string characters out of 5418982
869 − 869480 words of memory out of 5000000
870 − 50457 multiletter control sequences out of 15000+600000
871 − 746487 words of font info for 480 fonts, out of 8000000 for 9000
872 − 1141 hyphenation exceptions out of 8191
873 − 75i,12n,84p,2536b,1226s stack positions out of 10000i,1000n,20000p,200000b,200000s
874 −</usr/local/texlive/2026/texmf-dist/fonts/type1/public/amsfonts/cm/cmex10.pfb
875 −></usr/local/texlive/2026/texmf-dist/fonts/type1/public/amsfonts/cm/cmmi10.pfb>
876 −</usr/local/texlive/2026/texmf-dist/fonts/type1/public/amsfonts/cm/cmr10.pfb></
877 −usr/local/texlive/2026/texmf-dist/fonts/type1/public/amsfonts/cm/cmsy10.pfb></u
878 −sr/local/texlive/2026/texmf-dist/fonts/type1/public/amsfonts/symbols/msbm10.pfb
879 −></usr/local/texlive/2026/texmf-dist/fonts/type1/public/rsfs/rsfs10.pfb></usr/l
880 −ocal/texlive/2026/texmf-dist/fonts/type1/urw/symbol/usyr.pfb></usr/local/texliv
881 −e/2026/texmf-dist/fonts/type1/urw/symbol/usyr.pfb></usr/local/texlive/2026/texm
882 −f-dist/fonts/type1/urw/times/utmb8a.pfb></usr/local/texlive/2026/texmf-dist/fon
883 −ts/type1/urw/times/utmr8a.pfb></usr/local/texlive/2026/texmf-dist/fonts/type1/u
884 −rw/times/utmri8a.pfb>
885 −Output written on main.pdf (38 pages, 385249 bytes).
886 −PDF statistics:
887 − 954 PDF objects out of 1000 (max. 8388607)
888 − 605 compressed objects within 7 object streams
889 − 147 named destinations out of 1000 (max. 500000)
890 − 112980 words of extra memory for PDF output out of 128383 (max. 10000000)
891 −
deleted paper/main.out +0 −38
@@ -1,38 +0,0 @@
1 −\BOOKMARK [1][-]{section.1}{\376\377\000I\000n\000t\000r\000o\000d\000u\000c\000t\000i\000o\000n}{}% 1
2 −\BOOKMARK [1][-]{section.2}{\376\377\000R\000e\000l\000a\000t\000e\000d\000\040\000l\000i\000t\000e\000r\000a\000t\000u\000r\000e}{}% 2
3 −\BOOKMARK [2][-]{subsection.2.1}{\376\377\000T\000h\000e\000\040\000s\000a\000l\000e\000s\000\040\000c\000o\000m\000p\000a\000r\000i\000s\000o\000n\000\040\000a\000p\000p\000r\000o\000a\000c\000h\000\040\000a\000n\000d\000\040\000i\000t\000s\000\040\000c\000o\000n\000v\000e\000n\000t\000i\000o\000n\000s}{section.2}% 3
4 −\BOOKMARK [2][-]{subsection.2.2}{\376\377\000E\000s\000t\000i\000m\000a\000t\000i\000n\000g\000\040\000a\000d\000j\000u\000s\000t\000m\000e\000n\000t\000s\000:\000\040\000g\000r\000i\000d\000s\000,\000\040\000h\000e\000d\000o\000n\000i\000c\000s\000\040\000a\000n\000d\000\040\000l\000o\000c\000a\000l\000\040\000m\000o\000d\000e\000l\000s}{section.2}% 4
5 −\BOOKMARK [2][-]{subsection.2.3}{\376\377\000A\000u\000t\000o\000m\000a\000t\000e\000d\000\040\000v\000a\000l\000u\000a\000t\000i\000o\000n\000\040\000a\000n\000d\000\040\000m\000a\000c\000h\000i\000n\000e\000\040\000l\000e\000a\000r\000n\000i\000n\000g}{section.2}% 5
6 −\BOOKMARK [2][-]{subsection.2.4}{\376\377\000A\000p\000p\000r\000a\000i\000s\000e\000r\000\040\000b\000e\000h\000a\000v\000i\000o\000u\000r\000\040\000a\000n\000d\000\040\000t\000h\000e\000\040\000l\000i\000m\000i\000t\000s\000\040\000o\000f\000\040\000s\000i\000m\000u\000l\000a\000t\000i\000o\000n}{section.2}% 6
7 −\BOOKMARK [1][-]{section.3}{\376\377\000D\000a\000t\000a}{}% 7
8 −\BOOKMARK [2][-]{subsection.3.1}{\376\377\000T\000h\000e\000\040\000V\000r\000a\000i\000\040\000P\000r\000i\000x\000\040\000t\000r\000a\000n\000s\000a\000c\000t\000i\000o\000n\000\040\000d\000a\000t\000a\000b\000a\000s\000e}{section.3}% 8
9 −\BOOKMARK [2][-]{subsection.3.2}{\376\377\000S\000a\000m\000p\000l\000e\000\040\000c\000o\000n\000s\000t\000r\000u\000c\000t\000i\000o\000n}{section.3}% 9
10 −\BOOKMARK [2][-]{subsection.3.3}{\376\377\000C\000h\000r\000o\000n\000o\000l\000o\000g\000i\000c\000a\000l\000\040\000p\000a\000r\000t\000i\000t\000i\000o\000n\000\040\000a\000n\000d\000\040\000d\000e\000s\000c\000r\000i\000p\000t\000i\000v\000e\000\040\000s\000t\000a\000t\000i\000s\000t\000i\000c\000s}{section.3}% 10
11 −\BOOKMARK [2][-]{subsection.3.4}{\376\377\000M\000a\000r\000k\000e\000t\000\040\000d\000e\000n\000s\000i\000t\000y}{section.3}% 11
12 −\BOOKMARK [1][-]{section.4}{\376\377\000M\000e\000t\000h\000o\000d\000o\000l\000o\000g\000y}{}% 12
13 −\BOOKMARK [2][-]{subsection.4.1}{\376\377\000P\000o\000i\000n\000t\000-\000i\000n\000-\000t\000i\000m\000e\000\040\000b\000a\000c\000k\000t\000e\000s\000t\000i\000n\000g}{section.4}% 13
14 −\BOOKMARK [2][-]{subsection.4.2}{\376\377\000P\000o\000i\000n\000t\000-\000i\000n\000-\000t\000i\000m\000e\000\040\000p\000r\000i\000c\000e\000\040\000i\000n\000d\000e\000x}{section.4}% 14
15 −\BOOKMARK [2][-]{subsection.4.3}{\376\377\000C\000o\000n\000f\000i\000g\000u\000r\000a\000t\000i\000o\000n\000s\000\040\000o\000f\000\040\000t\000h\000e\000\040\000s\000a\000l\000e\000s\000\040\000c\000o\000m\000p\000a\000r\000i\000s\000o\000n\000\040\000a\000p\000p\000r\000o\000a\000c\000h}{section.4}% 15
16 −\BOOKMARK [2][-]{subsection.4.4}{\376\377\000B\000e\000n\000c\000h\000m\000a\000r\000k\000\040\000m\000o\000d\000e\000l\000s}{section.4}% 16
17 −\BOOKMARK [2][-]{subsection.4.5}{\376\377\000A\000c\000c\000u\000r\000a\000c\000y\000\040\000m\000e\000a\000s\000u\000r\000e\000s\000\040\000a\000n\000d\000\040\000i\000n\000f\000e\000r\000e\000n\000c\000e}{section.4}% 17
18 −\BOOKMARK [2][-]{subsection.4.6}{\376\377\000H\000y\000p\000o\000t\000h\000e\000s\000e\000s}{section.4}% 18
19 −\BOOKMARK [1][-]{section.5}{\376\377\000R\000e\000s\000u\000l\000t\000s}{}% 19
20 −\BOOKMARK [2][-]{subsection.5.1}{\376\377\000H\000o\000w\000\040\000m\000a\000n\000y\000\040\000c\000o\000m\000p\000a\000r\000a\000b\000l\000e\000s\000,\000\040\000h\000o\000w\000\040\000f\000a\000r\000,\000\040\000h\000o\000w\000\040\000f\000a\000r\000\040\000b\000a\000c\000k\000?}{section.5}% 20
21 −\BOOKMARK [2][-]{subsection.5.2}{\376\377\000W\000h\000i\000c\000h\000\040\000a\000d\000j\000u\000s\000t\000m\000e\000n\000t\000s\000\040\000a\000r\000e\000\040\000w\000a\000r\000r\000a\000n\000t\000e\000d\000?}{section.5}% 21
22 −\BOOKMARK [2][-]{subsection.5.3}{\376\377\000H\000o\000w\000\040\000s\000h\000o\000u\000l\000d\000\040\000c\000o\000m\000p\000a\000r\000a\000b\000l\000e\000s\000\040\000b\000e\000\040\000s\000e\000l\000e\000c\000t\000e\000d\000?\000\040\000\050\000H\0001\000\051}{section.5}% 22
23 −\BOOKMARK [2][-]{subsection.5.4}{\376\377\000W\000e\000i\000g\000h\000t\000i\000n\000g\000\040\000a\000n\000d\000\040\000r\000e\000c\000o\000n\000c\000i\000l\000i\000a\000t\000i\000o\000n\000\040\000\050\000H\0004\000\051}{section.5}% 23
24 −\BOOKMARK [2][-]{subsection.5.5}{\376\377\000T\000h\000e\000\040\000h\000o\000r\000s\000e\000\040\000r\000a\000c\000e}{section.5}% 24
25 −\BOOKMARK [2][-]{subsection.5.6}{\376\377\000W\000h\000e\000r\000e\000\040\000d\000o\000e\000s\000\040\000t\000h\000e\000\040\000m\000e\000t\000h\000o\000d\000\040\000h\000o\000l\000d\000,\000\040\000a\000n\000d\000\040\000w\000h\000e\000r\000e\000\040\000d\000o\000e\000s\000\040\000i\000t\000\040\000f\000a\000i\000l\000?}{section.5}% 25
26 −\BOOKMARK [2][-]{subsection.5.7}{\376\377\000V\000a\000l\000u\000e\000\040\000i\000n\000t\000e\000r\000v\000a\000l\000s}{section.5}% 26
27 −\BOOKMARK [2][-]{subsection.5.8}{\376\377\000T\000o\000w\000a\000r\000d\000s\000\040\000a\000n\000\040\000e\000m\000p\000i\000r\000i\000c\000a\000l\000\040\000c\000o\000m\000p\000a\000r\000a\000b\000l\000e\000-\000r\000e\000c\000o\000m\000m\000e\000n\000d\000a\000t\000i\000o\000n\000\040\000s\000y\000s\000t\000e\000m}{section.5}% 27
28 −\BOOKMARK [1][-]{section.6}{\376\377\000R\000o\000b\000u\000s\000t\000n\000e\000s\000s}{}% 28
29 −\BOOKMARK [2][-]{subsection.6.1}{\376\377\000S\000t\000a\000b\000i\000l\000i\000t\000y\000\040\000o\000v\000e\000r\000\040\000t\000i\000m\000e}{section.6}% 29
30 −\BOOKMARK [2][-]{subsection.6.2}{\376\377\000C\000o\000v\000e\000r\000a\000g\000e\000\040\000a\000n\000d\000\040\000t\000h\000e\000\040\000c\000o\000m\000m\000o\000n\000-\000s\000u\000p\000p\000o\000r\000t\000\040\000c\000o\000m\000p\000a\000r\000i\000s\000o\000n}{section.6}% 30
31 −\BOOKMARK [2][-]{subsection.6.3}{\376\377\000E\000x\000c\000l\000u\000s\000i\000o\000n\000\040\000r\000u\000l\000e\000s}{section.6}% 31
32 −\BOOKMARK [2][-]{subsection.6.4}{\376\377\000A\000l\000t\000e\000r\000n\000a\000t\000i\000v\000e\000\040\000a\000c\000c\000u\000r\000a\000c\000y\000\040\000m\000e\000a\000s\000u\000r\000e\000s}{section.6}% 32
33 −\BOOKMARK [2][-]{subsection.6.5}{\376\377\000I\000n\000d\000e\000x\000\040\000c\000o\000n\000s\000t\000r\000u\000c\000t\000i\000o\000n}{section.6}% 33
34 −\BOOKMARK [2][-]{subsection.6.6}{\376\377\000B\000e\000n\000c\000h\000m\000a\000r\000k\000\040\000s\000p\000e\000c\000i\000f\000i\000c\000a\000t\000i\000o\000n}{section.6}% 34
35 −\BOOKMARK [1][-]{section.7}{\376\377\000C\000o\000n\000c\000l\000u\000s\000i\000o\000n}{}% 35
36 −\BOOKMARK [1][-]{section*.27}{\376\377\000A\000p\000p\000e\000n\000d\000i\000c\000e\000s}{}% 36
37 −\BOOKMARK [1][-]{appendix.1.A}{\376\377\000A\000d\000d\000i\000t\000i\000o\000n\000a\000l\000\040\000t\000a\000b\000l\000e\000s}{}% 37
38 −\BOOKMARK [1][-]{appendix.1.B}{\376\377\000I\000m\000p\000l\000e\000m\000e\000n\000t\000a\000t\000i\000o\000n\000\040\000d\000e\000t\000a\000i\000l\000s}{}% 38
modified paper/main.pdf +0 −0

Binary file not shown.

deleted paper/main.synctex.gz +0 −0

Binary file not shown.

modified paper/main.tex +3 −2
@@ -53,6 +53,7 @@
53 53 urlcolor = NavyBlue,
54 54 breaklinks = true
55 55 ]{hyperref}
56 +\hypersetup{pdftitle={Does the Sales Comparison Approach Actually Work? A Large-Scale Point-in-Time Validation of Comparable Selection, Price Adjustments, and Value Reconciliation}, pdfauthor={Simon-Pierre Boucher}}
56 57
57 58 % --- Bibliography ---
58 59 \usepackage[authoryear,round,semicolon]{natbib}
@@ -90,7 +91,7 @@
90 91 % ============================================================================
91 92 % METADATA — Edit these fields for each working paper
92 93 % ============================================================================
93 −\newcommand{\WPnumber}{11}
94 +\newcommand{\WPnumber}{16}
94 95 \newcommand{\WPtitle}{Does the Sales Comparison Approach Actually Work? A Large-Scale Point-in-Time Validation of Comparable Selection, Price Adjustments, and Value Reconciliation}
95 96 \newcommand{\WPsubtitle}{Evidence from 391,000 Single-Family Transactions in Quebec, 2021--2026}
96 97 \newcommand{\WPdate}{October 2026}
@@ -98,7 +99,7 @@
98 99 \newcommand{\WPabstract}{%
99 100 The sales comparison approach is the dominant method of residential valuation, yet its operating rules---how many comparables to use, how far and how far back to search, which adjustments to apply, how to weight and reconcile---rest largely on professional convention. We subject these rules to a large-scale, point-in-time backtest on \nSalesClean{} arm's-length single-family transactions in Quebec (2021--2026) matched to the municipal assessment roll. For each of \nTargetsAll{} target sales in 2024--2026, the price is masked and \nConfigs{} configurations of the approach---together with hedonic, gradient-boosting, assessment-ratio and hybrid benchmarks---are applied using only information available on the valuation date; configurations are selected on 2024 and evaluated out of sample on 2025--2026. Adjustments dominate: the median absolute error of the five nearest sales falls from \mdapeMtwo{} with a time adjustment only to \mdapeMseven{} with a hedonic adjustment grid, and a stylized conventional grid captures most of that gain. Selecting physically similar rather than merely nearby comparables delivers a comparable gain without any adjustment. The optimal number of comparables is well above the conventional three---around twenty once a grid is applied, with no penalty up to fifty---and short time windows are dominated; the search radius matters far less than the number and similarity of the sales retained. Weighting and reconciliation rules matter little. The best transparent configuration chosen in 2024 reaches \mdapeMzero{} out of sample, against \mdapeMelevenB{} for a quarterly-retrained gradient-boosting model that uses the assessed value and \mdapeMtwelve{} for a hybrid; the cost of transparency is small and narrows in homogeneous submarkets. Accuracy is governed primarily by submarket liquidity; a wide-net rule that retains the most similar sales is self-adapting, and stratum-specific rules do not improve on it. The results turn a convention-based method into a set of empirically validated, reproducible rules.
100 101 }
101 −\newcommand{\WPkeywords}{Sales comparison approach, comparable selection, adjustment grid, automated valuation models, hedonic pricing, backtesting, residential real estate, Quebec}
102 +\newcommand{\WPkeywords}{sales comparison approach, comparable selection, adjustment grid, automated valuation models, hedonic pricing, backtesting, residential real estate, Quebec}
102 103 \newcommand{\WPjel}{R31, R32, C52, C53, G12}
103 104
104 105 % --- Author ---
105 106