SPB Git

Ch. 1 livré et validé (compilation isolée, tests, fusion des registres)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Oct 31, 2026) parent 8dfc6ca Browse files

52 changed files +3,540 −95

52 changed files
modified analysis/python/cases/ch04_marches.py +3 −0
@@ -315,6 +315,9 @@ def main(verbose: bool = True) -> dict:
315 plex_like = sp.loc[[5, 6], "qc_menages"].sum(); print(f" Duplex + appartements < 5 étages, Québec : {fmt(plex_like)} ménages = {plex_like/tot.loc['Québec','total']*100:.1f} %") 315 plex_like = sp.loc[[5, 6], "qc_menages"].sum(); print(f" Duplex + appartements < 5 étages, Québec : {fmt(plex_like)} ménages = {plex_like/tot.loc['Québec','total']*100:.1f} %")
316 print("\n=== Ménages par RMR (98-10-0231-01)"); print(mr.to_string(float_format=lambda v: f"{v:.4f}")) 316 print("\n=== Ménages par RMR (98-10-0231-01)"); print(mr.to_string(float_format=lambda v: f"{v:.4f}"))
317 print("\n=== Population des RMR au 1er juillet (17-10-0148-01)"); print(pop.to_string(float_format=lambda v: f"{v:.4f}")) 317 print("\n=== Population des RMR au 1er juillet (17-10-0148-01)"); print(pop.to_string(float_format=lambda v: f"{v:.4f}"))
318+ popqc = CAL["demographie_structure"]["population_quebec_2025"]["valeur"]
319+ print(f" Part des RMR dans la population du Québec 2025p ({fmt(popqc)}, ISQ) : {pop.loc['Ensemble des RMR du Québec', 2025]/popqc*100:.1f} % ; "
320+ f"six RMR du chapitre : {pop.loc[[k for k in RMR_148.values() if k not in ('Drummondville','Ensemble des RMR du Québec')], 2025].sum()/popqc*100:.1f} %")
318 print("\n=== IPLN (18-10-0205-01, déc. 2016 = 100)"); print(st.to_string(float_format=lambda v: f"{v:.4f}")) 321 print("\n=== IPLN (18-10-0205-01, déc. 2016 = 100)"); print(st.to_string(float_format=lambda v: f"{v:.4f}"))
319 print("\n=== SCHL — ELL octobre 2025 (fichier rmr-quebec-2025-fr.xlsx, tableaux 1.1.1/1.1.2/1.1.3/1.1.5)") 322 print("\n=== SCHL — ELL octobre 2025 (fichier rmr-quebec-2025-fr.xlsx, tableaux 1.1.1/1.1.2/1.1.3/1.1.5)")
320 cols = ["inocc_tous_2024", "inocc_tous_2025", "inocc_2_chambres_2025", "loyer_2_chambres_2024", "loyer_2_chambres_2025", "loyer_tous_2025", "univers_tous_2025", 323 cols = ["inocc_tous_2024", "inocc_tous_2025", "inocc_2_chambres_2025", "loyer_2_chambres_2024", "loyer_2_chambres_2025", "loyer_tous_2025", "univers_tous_2025",
added analysis/python/cases/ch17_ajustements.py +252 −0
@@ -0,0 +1,252 @@
1+#!/usr/bin/env python3
2+"""Chapitre 17 — ajustements qualitatifs et quantitatifs des cinq comparables du cas 1 (Gatineau, données synthétiques).
3+Reprend exactement la sélection du chapitre 16 (traite.cas1.selection) et la grille d'ajustements dérivée du modèle
4+hédonique de référence (traite.cas1.ajustements_hedoniques). Imprime tous les chiffres cités dans le chapitre.
5+Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch17_ajustements.py"""
6+from __future__ import annotations
7+import sys, pathlib, math
8+import numpy as np, pandas as pd
9+sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
10+from traite import cas1
11+from traite import comparables as cp
12+
13+ROOT = pathlib.Path(__file__).resolve().parents[3]
14+GROUPES = ["conditions_marche", "localisation", "superficie", "terrain", "age", "qualite", "garage", "sous_sol", "piscine", "type"]
15+LIBELLES = {"conditions_marche": "Conditions du marché", "localisation": "Localisation", "superficie": "Superficie habitable",
16+ "terrain": "Terrain", "age": "Âge", "qualite": "Qualité", "garage": "Garage", "sous_sol": "Sous-sol fini",
17+ "piscine": "Piscine", "type": "Type de bâtiment"}
18+COLS_GROUPES = {"conditions_marche": ["mois"], "localisation": ["dist_centre"] + [f"sect_{x}" for x in cas1.SECTEURS[1:]],
19+ "superficie": ["ln_S"], "terrain": ["ln_T"], "age": ["age", "age2_100"], "qualite": ["qualite"],
20+ "garage": ["garage"], "sous_sol": ["sous_sol"], "piscine": ["piscine"], "type": ["etages"]}
21+TAUX_GENERATION = 1.0906 ** 0.5 - 1 # 9,06 % sur deux ans (rôle 2027-2029 de Gatineau) → 4,43 %/an composé (loi du jeu)
22+
23+# ---------------------------------------------------------------- 1. base : modèle, comparables, grille hédonique
24+def base() -> dict:
25+ t = cas1.tout()
26+ m = t["modele"]; comps = t["selection"]["comparables"]; s = t["sujet"]
27+ adj = t["ajustements"].copy()
28+ res = pd.Series(m["residus"], index=t["ventes"].index).loc[comps.index].values
29+ adj["residu_log"] = res
30+ adj["residu_pct"] = np.exp(res) - 1
31+ g = m["croissance_mensuelle"]
32+ return {**t, "comps": comps, "adj": adj, "g_mensuel": g, "g_annuel": (1 + g) ** 12 - 1,
33+ "point_sans_duan": float(t["intervalle"]["point"]), "facteur_duan": float(np.mean(np.exp(m["residus"])))}
34+
35+# ---------------------------------------------------------------- 2. cascade d'un comparable et variantes de forme
36+def dlogs(b: dict, comp_index) -> dict:
37+ """Δln par groupe : β_k (x_s − x_c) pour chaque groupe d'ajustement."""
38+ m = b["modele"]; beta = pd.Series(m["beta"], index=m["X"].columns)
39+ Xs = cas1.matrice(cas1.sujet_df(b["sujet"])).iloc[0]
40+ Xc = cas1.matrice(b["comps"]).loc[comp_index]
41+ return {g: float(sum(beta[c] * (Xs[c] - Xc[c]) for c in cols)) for g, cols in COLS_GROUPES.items()}
42+
43+def cascade(b: dict, id_vente: str, ordre: list[str] | None = None) -> pd.DataFrame:
44+ """Cascade multiplicative : à chaque étape A_k = base × (exp(Δln_k) − 1), base ← base + A_k."""
45+ ordre = ordre or GROUPES
46+ idx = b["comps"].index[b["comps"]["id_vente"] == id_vente][0]
47+ d = dlogs(b, idx); base_ = float(b["comps"].loc[idx, "prix_vente"])
48+ rows = [{"etape": "Prix de vente", "dlog": 0.0, "pct": 0.0, "ajustement": 0.0, "base_apres": base_}]
49+ for g in ordre:
50+ a = base_ * (math.exp(d[g]) - 1); base_ += a
51+ rows.append({"etape": LIBELLES[g], "dlog": d[g], "pct": math.exp(d[g]) - 1, "ajustement": a, "base_apres": base_})
52+ rows.append({"etape": "Prix ajusté", "dlog": sum(d.values()), "pct": math.exp(sum(d.values())) - 1, "ajustement": base_ - rows[0]["base_apres"], "base_apres": base_})
53+ return pd.DataFrame(rows)
54+
55+def formes(b: dict) -> pd.DataFrame:
56+ """Trois façons d'agréger les mêmes effets : additif sur le prix brut, multiplicatif (cascade), mixte (temps en %, reste en $)."""
57+ out = []
58+ for idx, c in b["comps"].iterrows():
59+ d = dlogs(b, idx); P = float(c["prix_vente"])
60+ effets = {g: math.exp(v) - 1 for g, v in d.items()}
61+ additif = cp.prix_ajuste_additif(P, {g: P * e for g, e in effets.items()})
62+ multiplicatif = cp.prix_ajuste_multiplicatif(P, list(effets.values()))
63+ base_t = P * (1 + effets["conditions_marche"])
64+ mixte = cp.prix_ajuste_mixte(P, [effets["conditions_marche"]], {g: base_t * e for g, e in effets.items() if g != "conditions_marche"})["prix_ajuste"]
65+ out.append({"id_vente": c["id_vente"], "prix_vente": P, "additif": additif, "multiplicatif": multiplicatif, "mixte": mixte,
66+ "ecart_additif_pct": additif / multiplicatif - 1, "ecart_mixte_pct": mixte / multiplicatif - 1, "somme_dlog": sum(d.values())})
67+ return pd.DataFrame(out)
68+
69+# ---------------------------------------------------------------- 3. ajustement de temps
70+def temps(b: dict, id_vente: str = "G0404") -> dict:
71+ c = b["comps"][b["comps"]["id_vente"] == id_vente].iloc[0]
72+ P = float(c["prix_vente"]); mois = (pd.Timestamp(b["sujet"]["date_evaluation"]) - c["date_vente"]).days / 30.4375
73+ g = b["g_mensuel"]
74+ return {"id_vente": id_vente, "prix": P, "mois": mois,
75+ "coefficient": P * (math.exp(math.log(1 + g) * mois) - 1),
76+ "taux_compose_estime": cp.ajustement_temps_taux(P, b["g_annuel"], mois),
77+ "taux_compose_generation": cp.ajustement_temps_taux(P, TAUX_GENERATION, mois),
78+ "lineaire_simple": P * g * mois,
79+ "indices": cp.ajustement_temps(P, 100.0, 100.0 * (1 + b["g_annuel"]) ** (mois / 12)),
80+ "g_mensuel": g, "g_annuel": b["g_annuel"], "taux_generation": TAUX_GENERATION}
81+
82+# ---------------------------------------------------------------- 4. ventes appariées
83+CRITERES = {"superficie_pct": 0.05, "age_ans": 5, "mois_max": 12}
84+
85+def paires(b: dict, attribut: str, superficie_pct: float = 0.05, age_ans: float = 5, mois_max: float = 12,
86+ memes: tuple = ("secteur", "type", "qualite_1a5", "piscine")) -> pd.DataFrame:
87+ """Paires (avec, sans) construites dans les 600 ventes : même secteur, même type, même qualité, même piscine, autre attribut
88+ binaire identique, écart d'exactement une unité sur l'attribut étudié, superficie ±superficie_pct, âge ±age_ans, dates à
89+ moins de mois_max mois. La vente « sans » est ramenée à la date de la vente « avec » par le taux mensuel du modèle."""
90+ v = b["ventes"]; g = b["g_mensuel"]
91+ autres = [c for c in ["garage_places", "sous_sol_fini"] if c != attribut]
92+ rows = []
93+ for _, r in v.iterrows():
94+ m = v[v[attribut] == r[attribut] - 1]
95+ for c in list(memes) + autres:
96+ m = m[m[c] == r[c]]
97+ m = m[(abs(m["superficie_habitable_m2"] / r["superficie_habitable_m2"] - 1) <= superficie_pct) & (abs(m["age"] - r["age"]) <= age_ans)
98+ & (abs((m["date_vente"] - r["date_vente"]).dt.days) <= mois_max * 30.4375)]
99+ for _, q in m.iterrows():
100+ dm = (r["date_vente"] - q["date_vente"]).days / 30.4375
101+ q_t = q["prix_vente"] * (1 + g) ** dm
102+ rows.append({"avec": r["id_vente"], "sans": q["id_vente"], "prix_avec": r["prix_vente"], "prix_sans": q["prix_vente"], "prix_sans_temps": q_t,
103+ "delta_mois": dm, "diff_brute": r["prix_vente"] - q["prix_vente"], "diff_temps": r["prix_vente"] - q_t, "pct": r["prix_vente"] / q_t - 1,
104+ "superficie_avec": r["superficie_habitable_m2"], "superficie_sans": q["superficie_habitable_m2"], "secteur": r["secteur"]})
105+ return pd.DataFrame(rows)
106+
107+def resume_paires(p: pd.DataFrame) -> dict:
108+ r = cp.ventes_appariees(list(p["prix_avec"]), list(p["prix_sans_temps"]))
109+ r["erreur_type"] = r["ecart_type"] / math.sqrt(r["n"]) if r["n"] > 1 else float("nan")
110+ r["pct_moyen"] = float(p["pct"].mean()); r["pct_median"] = float(p["pct"].median())
111+ return r
112+
113+def variantes_paires(b: dict, attribut: str) -> pd.DataFrame:
114+ out = []
115+ for sp, aa, mm in [(0.05, 5, 6), (0.05, 5, 12), (0.05, 5, 18), (0.075, 5, 12), (0.05, 7, 12), (0.075, 7, 18)]:
116+ p = paires(b, attribut, sp, aa, mm)
117+ if len(p) > 1:
118+ r = resume_paires(p)
119+ out.append({"superficie_pct": sp, "age_ans": aa, "mois_max": mm, "n": r["n"], "moyenne": r["moyenne"], "mediane": r["mediane"], "ecart_type": r["ecart_type"], "erreur_type": r["erreur_type"], "pct_moyen": r["pct_moyen"]})
120+ else:
121+ out.append({"superficie_pct": sp, "age_ans": aa, "mois_max": mm, "n": len(p), "moyenne": np.nan, "mediane": np.nan, "ecart_type": np.nan, "erreur_type": np.nan, "pct_moyen": np.nan})
122+ return pd.DataFrame(out)
123+
124+# ---------------------------------------------------------------- 5. Halvorsen–Palmquist et Kennedy
125+def hp_kennedy(b: dict) -> pd.DataFrame:
126+ coef = b["modele"]["coef"]
127+ rows = []
128+ for k in ["garage", "sous_sol", "piscine", "etages", "sect_Aylmer", "sect_Gatineau", "sect_Buckingham", "sect_Masson-Angers"]:
129+ be, se = float(coef.loc[k, "coef"]), float(coef.loc[k, "se_hc1"])
130+ rows.append({"variable": k, "beta": be, "se": se, "naif_pct": be, "hp_pct": cp.ajustement_depuis_coefficient_log(be),
131+ "kennedy_pct": cp.ajustement_depuis_coefficient_log_sans_biais(be, se ** 2)})
132+ return pd.DataFrame(rows)
133+
134+# ---------------------------------------------------------------- 6. grille, brut/net, pondération, réconciliation
135+def comparables_ajustes(b: dict, adj: pd.DataFrame | None = None) -> list[cp.ComparableAjuste]:
136+ adj = b["adj"] if adj is None else adj
137+ return [cp.ComparableAjuste(r["id_vente"], float(r["prix_vente"]), {g: float(r[g]) for g in GROUPES}) for _, r in adj.iterrows()]
138+
139+def reconcilier(b: dict, adj: pd.DataFrame | None = None) -> dict:
140+ ca = comparables_ajustes(b, adj)
141+ w_brut = cp.poids_inverse_brut(ca)
142+ return {"comparables": ca, "grille": cp.grille(ca), "poids_inverse_brut": w_brut,
143+ "egal": cp.reconciliation(ca), "inverse_brut": cp.reconciliation(ca, w_brut)}
144+
145+def analyse_qualitative(b: dict) -> pd.DataFrame:
146+ """Analyse relative : pour chaque élément, le comparable est-il inférieur (ajustement > 0), égal ou supérieur (< 0) au sujet ?
147+ Le net > 0 signifie « comparable globalement inférieur » : la valeur du sujet devrait dépasser son prix."""
148+ rows = []
149+ for _, r in b["adj"].iterrows():
150+ row = {"id_vente": r["id_vente"], "prix_vente": r["prix_vente"]}
151+ for g in GROUPES:
152+ a = r[g]; row[g] = "inf." if a > 1 else ("sup." if a < -1 else "égal")
153+ row["net"] = r["prix_ajuste"] - r["prix_vente"]
154+ row["position"] = "inférieur" if row["net"] > 0 else "supérieur"
155+ rows.append(row)
156+ q = pd.DataFrame(rows)
157+ return q
158+
159+def encadrement(q: pd.DataFrame) -> dict:
160+ inf = q[q["position"] == "inférieur"]["prix_vente"]; sup = q[q["position"] == "supérieur"]["prix_vente"]
161+ return {"borne_basse": float(inf.max()) if len(inf) else float("nan"), "borne_haute": float(sup.min()) if len(sup) else float("nan"),
162+ "n_inferieurs": int(len(inf)), "n_superieurs": int(len(sup))}
163+
164+# ---------------------------------------------------------------- 7. sensibilité
165+def grille_avec_beta(b: dict, beta_mod: pd.Series) -> pd.DataFrame:
166+ m = dict(b["modele"]); m["beta"] = beta_mod.values
167+ return cas1.ajustements_hedoniques(m, b["comps"], b["sujet"])
168+
169+def sensibilite(b: dict) -> pd.DataFrame:
170+ """Valeur pondérée (poids inverse du brut) lorsque les coefficients d'un groupe varient de ±1,96 erreur-type, lorsque le taux
171+ de croissance change, ou lorsqu'un ajustement est omis."""
172+ m = b["modele"]; beta = pd.Series(m["beta"], index=m["X"].columns); se = m["coef"]["se_hc1"]
173+ ref = reconcilier(b)["inverse_brut"]["valeur_ponderee"]
174+ rows = [{"scenario": "Référence (grille hédonique)", "bas": ref, "haut": ref}]
175+ for g in ["conditions_marche", "superficie", "terrain", "age", "qualite", "garage", "sous_sol"]:
176+ vals = []
177+ for sgn in (-1, 1):
178+ bm = beta.copy()
179+ for c in COLS_GROUPES[g]: bm[c] = beta[c] + sgn * 1.96 * se[c]
180+ vals.append(reconcilier(b, grille_avec_beta(b, bm))["inverse_brut"]["valeur_ponderee"])
181+ rows.append({"scenario": f"{LIBELLES[g]} : coefficient ±1,96 é.-t.", "bas": min(vals), "haut": max(vals)})
182+ # taux de croissance imposé (3 % et 6 % par an)
183+ vals = []
184+ for taux in (0.03, 0.06):
185+ bm = beta.copy(); bm["mois"] = math.log(1 + taux) / 12
186+ vals.append(reconcilier(b, grille_avec_beta(b, bm))["inverse_brut"]["valeur_ponderee"])
187+ rows.append({"scenario": "Conditions du marché : taux de 3 % à 6 %/an", "bas": min(vals), "haut": max(vals)})
188+ # omission d'un ajustement
189+ for g in ["qualite", "age", "garage", "sous_sol", "conditions_marche"]:
190+ bm = beta.copy()
191+ for c in COLS_GROUPES[g]: bm[c] = 0.0
192+ v = reconcilier(b, grille_avec_beta(b, bm))["inverse_brut"]["valeur_ponderee"]
193+ rows.append({"scenario": f"Omission de l'ajustement « {LIBELLES[g].lower()} »", "bas": min(v, ref), "haut": max(v, ref), "valeur": v})
194+ # pondération
195+ rec = reconcilier(b)
196+ rows.append({"scenario": "Pondération : égale vs inverse du brut", "bas": min(rec["egal"]["valeur_ponderee"], ref), "haut": max(rec["egal"]["valeur_ponderee"], ref)})
197+ df = pd.DataFrame(rows); df["etendue"] = df["haut"] - df["bas"]
198+ return df
199+
200+# ---------------------------------------------------------------- 8. tout
201+def tout() -> dict:
202+ b = base()
203+ r = {"base": b, "cascade_G0404": cascade(b, "G0404"), "cascade_G0404_ordre_inverse": cascade(b, "G0404", list(reversed(GROUPES))),
204+ "formes": formes(b), "temps": temps(b), "hp_kennedy": hp_kennedy(b)}
205+ r["paires_garage"] = paires(b, "garage_places", **{"superficie_pct": CRITERES["superficie_pct"], "age_ans": CRITERES["age_ans"], "mois_max": CRITERES["mois_max"]})
206+ r["paires_sous_sol"] = paires(b, "sous_sol_fini", **{"superficie_pct": CRITERES["superficie_pct"], "age_ans": CRITERES["age_ans"], "mois_max": CRITERES["mois_max"]})
207+ r["resume_garage"] = resume_paires(r["paires_garage"]); r["resume_sous_sol"] = resume_paires(r["paires_sous_sol"])
208+ r["variantes_garage"] = variantes_paires(b, "garage_places"); r["variantes_sous_sol"] = variantes_paires(b, "sous_sol_fini")
209+ r["reconciliation"] = reconcilier(b)
210+ r["qualitatif"] = analyse_qualitative(b); r["encadrement"] = encadrement(r["qualitatif"])
211+ r["sensibilite"] = sensibilite(b)
212+ # identité : prix ajusté_j = prédiction ponctuelle (sans Duan) × exp(résidu_j)
213+ r["identite_residus"] = pd.DataFrame({"id_vente": b["adj"]["id_vente"], "prix_ajuste": b["adj"]["prix_ajuste"],
214+ "point_x_exp_res": b["point_sans_duan"] * np.exp(b["adj"]["residu_log"])})
215+ # prix implicites hédoniques du garage et du sous-sol, au prix médian des paires et au niveau du sujet
216+ coef = b["modele"]["coef"]
217+ for k, attr in (("garage", "garage"), ("sous_sol", "sous_sol")):
218+ eff = math.exp(coef.loc[k, "coef"]) - 1
219+ pm = float(r[f"paires_{attr}"]["prix_sans_temps"].median())
220+ r[f"implicite_{attr}"] = {"effet_pct": eff, "au_prix_median_paires": eff * pm, "prix_median_paires": pm, "au_sujet": eff * b["point_sans_duan"] / (1 + eff)}
221+ return r
222+
223+def main():
224+ pd.set_option("display.width", 220); pd.set_option("display.max_columns", 30)
225+ r = tout(); b = r["base"]; m = b["modele"]
226+ print("== Modèle hédonique de référence : n = %d, k = %d, R² = %.3f, R² ajusté = %.3f, sigma = %.4f" % (m["n"], m["k"], m["r2"], m["r2_ajuste"], m["sigma"]))
227+ print(m["coef"].round(5).to_string())
228+ print("== Croissance mensuelle %.4f %% ; taux annuel équivalent %.3f %% ; taux de génération %.3f %%" % (100 * b["g_mensuel"], 100 * b["g_annuel"], 100 * TAUX_GENERATION))
229+ print("== Grille hédonique (cascade) :\n", b["adj"].round(1).to_string(index=False))
230+ print("== Moyenne des prix ajustés %.0f ; médiane %.0f ; écart-type %.0f (prix bruts : moyenne %.0f, écart-type %.0f)" % (b["adj"].prix_ajuste.mean(), b["adj"].prix_ajuste.median(), b["adj"].prix_ajuste.std(), b["adj"].prix_vente.mean(), b["adj"].prix_vente.std()))
231+ print("== Prédiction hédonique (Duan) %.0f ; point sans Duan %.0f ; facteur de Duan %.4f ; IC 95 %% [%.0f ; %.0f]" % (b["prediction_hedonique"], b["point_sans_duan"], b["facteur_duan"], b["intervalle"]["bas"], b["intervalle"]["haut"]))
232+ print("== Résidus des cinq comparables (log) :", np.round(b["adj"].residu_log.values, 4), "moyenne log %.4f ; moyenne exp(e)−1 %.4f" % (b["adj"].residu_log.mean(), np.mean(np.exp(b["adj"].residu_log)) - 1))
233+ print("== Identité prix ajusté = point × exp(résidu) :\n", r["identite_residus"].round(1).to_string(index=False))
234+ print("== Cascade G0404 :\n", r["cascade_G0404"].round(4).to_string(index=False))
235+ print("== Cascade G0404, ordre inverse (même total, répartition différente) :\n", r["cascade_G0404_ordre_inverse"].round(1).to_string(index=False))
236+ print("== Formes additive / multiplicative / mixte :\n", r["formes"].round(4).to_string(index=False))
237+ t = r["temps"]; print("== Ajustement de temps G0404 (%.1f mois) : coefficient %.0f ; taux composé estimé %.0f ; taux de génération 4,43 %% %.0f ; linéaire %.0f ; indices %.0f" % (t["mois"], t["coefficient"], t["taux_compose_estime"], t["taux_compose_generation"], t["lineaire_simple"], t["indices"]))
238+ for attr in ("garage", "sous_sol"):
239+ rs = r[f"resume_{attr}"]; im = r[f"implicite_{attr}"]
240+ print("== Paires %s : n = %d, moyenne %.0f, médiane %.0f, écart-type %.0f, erreur-type %.0f, pct moyen %.2f %% ; prix implicite hédonique %.2f %% soit %.0f au prix médian des paires (%.0f) et %.0f au niveau du sujet" % (attr, rs["n"], rs["moyenne"], rs["mediane"], rs["ecart_type"], rs["erreur_type"], 100 * rs["pct_moyen"], 100 * im["effet_pct"], im["au_prix_median_paires"], im["prix_median_paires"], im["au_sujet"]))
241+ print(r[f"variantes_{attr}"].round(2).to_string(index=False))
242+ print("== Halvorsen–Palmquist / Kennedy :\n", r["hp_kennedy"].round(5).to_string(index=False))
243+ rec = r["reconciliation"]
244+ print("== Poids inverse du brut :", np.round(rec["poids_inverse_brut"], 3))
245+ print("== Réconciliation égale :", {k: (round(v, 1) if isinstance(v, float) else v) for k, v in rec["egal"].items() if k != "poids"})
246+ print("== Réconciliation inverse du brut :", {k: (round(v, 1) if isinstance(v, float) else v) for k, v in rec["inverse_brut"].items() if k != "poids"})
247+ print("== Analyse qualitative :\n", r["qualitatif"].to_string(index=False)); print("== Encadrement :", r["encadrement"])
248+ print("== Sensibilité :\n", r["sensibilite"].round(0).to_string(index=False))
249+ return r
250+
251+if __name__ == "__main__":
252+ main()
added analysis/python/cases/ch18_hedonique.py +375 −0
@@ -0,0 +1,375 @@
1+#!/usr/bin/env python3
2+"""Chapitre 18 — Modèles hédoniques : exemple intégral sur les 600 ventes du cas 1 (Gatineau, DONNÉES SYNTHÉTIQUES, graine 20261031).
3+
4+Le modèle de référence est `traite.cas1.hedonique` (semi-log, HC1) ; il n'est PAS redéfini ici (les ch. 17, 19, 20, 35, 37, 39, 45 s'y réfèrent).
5+Ce script ajoute : la comparaison des coefficients estimés aux paramètres de génération (data/synthetic/README.md), les variantes de
6+spécification (linéaire, log-log, Box–Cox, sans secteurs, indicatrices trimestrielles), les diagnostics (Breusch–Pagan, Jarque–Bera, VIF,
7+distances de Cook, I de Moran des résidus), les prix implicites au sujet (en $), la prédiction avec correction de Duan et son intervalle,
8+les statistiques de ratio (COD, PRD, PRB) du modèle et du rôle fictif, et l'analyse de sensibilité (spécification, sous-échantillons).
9+Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch18_hedonique.py"""
10+from __future__ import annotations
11+import sys, math, pathlib
12+import numpy as np, pandas as pd
13+sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
14+from traite import cas1
15+from traite import hedonique as h
16+from scipy import stats, optimize
17+
18+ROOT = pathlib.Path(__file__).resolve().parents[3]
19+GRAINE = 20261031
20+
21+# ----------------------------------------------------------------------------- paramètres de génération (README synthétique)
22+_g = (1 + 9.06 / 100) ** 0.5 - 1 # croissance annuelle composée du jeu (variation du rôle 2027-2029 sur deux ans)
23+VRAIS = {"ln_S": 0.55, "ln_T": 0.10, "age": -0.0055, "age2_100": 0.0030, "qualite": 0.06, "garage": 0.035, "sous_sol": 0.03,
24+ "piscine": 0.02, "etages": 0.02, "dist_centre": -0.006,
25+ "sect_Gatineau": math.log(464_900 / 475_000), "sect_Aylmer": math.log(530_000 / 475_000),
26+ "sect_Buckingham": math.log(395_000 / 475_000), "sect_Masson-Angers": math.log(395_000 / 475_000) - 0.02,
27+ "mois": math.log1p(_g) / 12, "sigma": 0.085}
28+ETIQUETTES = {"const": "Constante", "ln_S": "ln(superficie habitable / 140 m²)", "ln_T": "ln(terrain / 560 m²)", "age": "Âge (années)",
29+ "age2_100": "Âge² / 100", "qualite": "Qualité (écart à 3)", "garage": "Garage (places)", "sous_sol": "Sous-sol fini",
30+ "piscine": "Piscine", "etages": "À étages", "dist_centre": "Distance au centre au-delà de 3 km (km)",
31+ "sect_Gatineau": "Secteur Gatineau", "sect_Aylmer": "Secteur Aylmer", "sect_Buckingham": "Secteur Buckingham",
32+ "sect_Masson-Angers": "Secteur Masson-Angers", "mois": "Mois depuis le 1er juillet 2024"}
33+
34+# ----------------------------------------------------------------------------- utilitaires
35+def _duan(res: np.ndarray) -> float:
36+ return float(np.mean(np.exp(res)))
37+
38+def ratios(valeurs: np.ndarray, prix: np.ndarray) -> dict:
39+ return h.statistiques_ratio(valeurs, prix)
40+
41+def _rmse(a, b) -> float:
42+ a = np.asarray(a, float); b = np.asarray(b, float)
43+ return float(np.sqrt(np.mean((a - b) ** 2)))
44+
45+def trimestre(d: pd.Series) -> np.ndarray:
46+ d = pd.to_datetime(d)
47+ return ((d.dt.year - 2023) * 4 + (d.dt.month - 1) // 3).to_numpy()
48+
49+# ----------------------------------------------------------------------------- variantes de spécification
50+def matrice_lineaire(v: pd.DataFrame) -> pd.DataFrame:
51+ """Forme linéaire : mêmes variables, superficie et terrain en niveaux (m²)."""
52+ X = cas1.matrice(v).copy()
53+ X["S"] = v["superficie_habitable_m2"].astype(float); X["T"] = v["superficie_terrain_m2"].astype(float)
54+ X = X.drop(columns=["ln_S", "ln_T"])
55+ return X[["const", "S", "T"] + [c for c in X.columns if c not in ("const", "S", "T")]]
56+
57+def matrice_loglog(v: pd.DataFrame) -> pd.DataFrame:
58+ """Forme log-log : l'âge entre en logarithme (ln(1 + âge)) à la place du polynôme."""
59+ X = cas1.matrice(v).copy()
60+ X["ln_age"] = np.log1p(v["age"].astype(float))
61+ X = X.drop(columns=["age", "age2_100"])
62+ return X[["const", "ln_S", "ln_T", "ln_age"] + [c for c in X.columns if c not in ("const", "ln_S", "ln_T", "ln_age")]]
63+
64+def matrice_sans_secteurs(v: pd.DataFrame) -> pd.DataFrame:
65+ X = cas1.matrice(v)
66+ return X[[c for c in X.columns if not c.startswith("sect_")]]
67+
68+def matrice_trimestres(v: pd.DataFrame) -> pd.DataFrame:
69+ """Indicatrices trimestrielles (2023T1 = référence) à la place de la tendance linéaire `mois`."""
70+ X = cas1.matrice(v).drop(columns=["mois"])
71+ q = trimestre(v["date_vente"])
72+ for k in range(1, int(q.max()) + 1):
73+ X[f"trim_{k}"] = (q == k).astype(float)
74+ return X
75+
76+def boxcox_profil(P: np.ndarray, X: np.ndarray) -> dict:
77+ """Transformation de Box–Cox (1964) de la variable dépendante : y(λ) = (P^λ − 1)/λ, λ = 0 → ln P.
78+ Log-vraisemblance concentrée : ℓ(λ) = −(n/2) ln(SCR_λ / n) + (λ − 1) Σ ln P. IC à 95 % : ℓ(λ) ≥ ℓ_max − χ²₁(0,95)/2."""
79+ P = np.asarray(P, float); n = len(P); slog = np.log(P).sum()
80+ def y_l(l): return np.log(P) if abs(l) < 1e-8 else (P ** l - 1) / l
81+ def ll(l):
82+ y = y_l(l); b = np.linalg.lstsq(X, y, rcond=None)[0]; r = y - X @ b
83+ return -(n / 2) * np.log(r @ r / n) + (l - 1) * slog
84+ opt = optimize.minimize_scalar(lambda l: -ll(l), bounds=(-1.0, 1.5), method="bounded")
85+ lam = float(opt.x); llmax = ll(lam); seuil = llmax - stats.chi2.ppf(0.95, 1) / 2
86+ bas = optimize.brentq(lambda l: ll(l) - seuil, -1.0, lam); haut = optimize.brentq(lambda l: ll(l) - seuil, lam, 1.5)
87+ grille = np.linspace(-0.6, 1.2, 73)
88+ return {"lambda": lam, "ic95": (float(bas), float(haut)), "ll_max": float(llmax), "ll_0": float(ll(0.0)), "ll_1": float(ll(1.0)),
89+ "grille": grille, "ll_grille": np.array([ll(l) for l in grille]), "y": y_l(lam)}
90+
91+def _predire_lineaire(beta, X): return X @ beta
92+
93+def ajuster_variantes(v: pd.DataFrame, s: dict, ref: dict) -> dict:
94+ """Estime les variantes et renvoie, pour chacune : R², σ, RMSE en $, COD, prédiction du sujet."""
95+ P = v["prix_vente"].astype(float).to_numpy(); lnP = np.log(P)
96+ sd = cas1.sujet_df(s)
97+ out = {}
98+ # --- référence (semi-log)
99+ out["semi-log (référence)"] = {"r2": ref["r2"], "r2_ajuste": ref["r2_ajuste"], "sigma": ref["sigma"], "k": ref["k"],
100+ "ajuste": np.exp(ref["X"].values @ ref["beta"]) * _duan(ref["residus"]), "sujet": float(cas1.predire(ref, sd)[0])}
101+ # --- linéaire
102+ XL = matrice_lineaire(v); rL = h.mco(XL.values, P)
103+ XLs = matrice_lineaire(sd)[XL.columns].values
104+ out["linéaire"] = {"r2": rL["r2"], "r2_ajuste": rL["r2_ajuste"], "sigma": float(np.sqrt(rL["sigma2"])), "k": rL["k"],
105+ "ajuste": XL.values @ rL["beta"], "sujet": float((XLs @ rL["beta"])[0]), "mco": rL, "X": XL}
106+ # --- log-log
107+ XG = matrice_loglog(v); rG = h.mco(XG.values, lnP)
108+ XGs = matrice_loglog(sd)[XG.columns].values
109+ out["log-log"] = {"r2": rG["r2"], "r2_ajuste": rG["r2_ajuste"], "sigma": float(np.sqrt(rG["sigma2"])), "k": rG["k"],
110+ "ajuste": np.exp(XG.values @ rG["beta"]) * _duan(rG["residus"]), "sujet": float(np.exp(XGs @ rG["beta"])[0] * _duan(rG["residus"])),
111+ "mco": rG, "X": XG}
112+ # --- Box–Cox
113+ X0 = ref["X"].values; bc = boxcox_profil(P, X0)
114+ rB = h.mco(X0, bc["y"]); lam = bc["lambda"]
115+ def inv(yl): return np.exp(yl) if abs(lam) < 1e-8 else np.power(np.maximum(lam * yl + 1, 1e-9), 1 / lam)
116+ Xs0 = cas1.matrice(sd)[ref["X"].columns].values
117+ # retransformation par lissage de Duan généralisé : moyenne des inv(ŷ + e_i)
118+ aj_bc = np.array([np.mean(inv(yh + rB["residus"])) for yh in (X0 @ rB["beta"])])
119+ out["Box–Cox"] = {"r2": rB["r2"], "r2_ajuste": rB["r2_ajuste"], "sigma": float(np.sqrt(rB["sigma2"])), "k": rB["k"], "lambda": lam,
120+ "ic95": bc["ic95"], "ajuste": aj_bc, "sujet": float(np.mean(inv((Xs0 @ rB["beta"])[0] + rB["residus"]))), "mco": rB, "boxcox": bc}
121+ # --- sans secteurs
122+ XS = matrice_sans_secteurs(v); rS = h.mco(XS.values, lnP)
123+ XSs = matrice_sans_secteurs(sd)[XS.columns].values
124+ out["semi-log sans secteurs"] = {"r2": rS["r2"], "r2_ajuste": rS["r2_ajuste"], "sigma": float(np.sqrt(rS["sigma2"])), "k": rS["k"],
125+ "ajuste": np.exp(XS.values @ rS["beta"]) * _duan(rS["residus"]), "sujet": float(np.exp(XSs @ rS["beta"])[0] * _duan(rS["residus"])),
126+ "mco": rS, "X": XS, "coef": pd.Series(rS["beta"], index=XS.columns), "se": pd.Series(rS["se_hc1"], index=XS.columns)}
127+ # --- indicatrices trimestrielles
128+ XT = matrice_trimestres(v); rT = h.mco(XT.values, lnP)
129+ # sujet : date d'évaluation 2026-09-01 → trimestre 2026T3 (= dernier trimestre observé)
130+ sdT = cas1.matrice(sd).drop(columns=["mois"])
131+ for c in XT.columns:
132+ if c.startswith("trim_"): sdT[c] = 0.0
133+ sdT[f"trim_{int(trimestre(sd['date_vente']).max())}"] = 1.0
134+ XTs = sdT[XT.columns].values
135+ out["semi-log, indicatrices trimestrielles"] = {"r2": rT["r2"], "r2_ajuste": rT["r2_ajuste"], "sigma": float(np.sqrt(rT["sigma2"])), "k": rT["k"],
136+ "ajuste": np.exp(XT.values @ rT["beta"]) * _duan(rT["residus"]), "sujet": float(np.exp(XTs @ rT["beta"])[0] * _duan(rT["residus"])),
137+ "mco": rT, "X": XT}
138+ for nom, d in out.items():
139+ d["rmse"] = _rmse(d["ajuste"], P); d["ratios"] = ratios(d["ajuste"], P)
140+ return out
141+
142+# ----------------------------------------------------------------------------- diagnostics
143+def breusch_pagan(X: np.ndarray, res: np.ndarray) -> dict:
144+ """Test de Breusch–Pagan (1979), version studentisée de Koenker : LM = n·R² de la régression de e² sur X ; χ²(k − 1)."""
145+ e2 = res ** 2
146+ r = h.mco(X, e2, robuste=False)
147+ n, k = X.shape; LM = n * r["r2"]
148+ return {"LM": float(LM), "ddl": k - 1, "p": float(1 - stats.chi2.cdf(LM, k - 1))}
149+
150+def vif(X: pd.DataFrame) -> pd.Series:
151+ cols = [c for c in X.columns if c != "const"]
152+ out = {}
153+ for c in cols:
154+ autres = X[[k for k in X.columns if k != c]].values
155+ r = h.mco(autres, X[c].values, robuste=False)
156+ out[c] = 1.0 / max(1.0 - r["r2"], 1e-12)
157+ return pd.Series(out)
158+
159+def cook(X: np.ndarray, res: np.ndarray, sigma2: float) -> dict:
160+ n, k = X.shape
161+ H = X @ np.linalg.pinv(X.T @ X) @ X.T
162+ hii = np.diag(H)
163+ D = res ** 2 * hii / (k * sigma2 * (1 - hii) ** 2)
164+ return {"D": D, "h": hii, "seuil": 4.0 / n, "n_sup": int((D > 4.0 / n).sum())}
165+
166+def moran_residus(v: pd.DataFrame, res: np.ndarray, k: int = 8) -> dict:
167+ coords = np.column_stack([(v["lat"] - 45.428) * 111.0, (v["lon"] + 75.714) * 78.0])
168+ W = h.matrice_poids_knn(coords, k)
169+ return h.moran_i(res, W)
170+
171+def diagnostics(v: pd.DataFrame, ref: dict) -> dict:
172+ X = ref["X"]; res = ref["residus"]
173+ jb = stats.jarque_bera(res)
174+ return {"breusch_pagan": breusch_pagan(X.values, res), "jarque_bera": {"stat": float(jb.statistic), "p": float(jb.pvalue)},
175+ "skew": float(stats.skew(res)), "kurtosis": float(stats.kurtosis(res, fisher=False)),
176+ "vif": vif(X), "cook": cook(X.values, res, ref["sigma"] ** 2), "moran": moran_residus(v, res),
177+ "res_min": float(res.min()), "res_max": float(res.max()), "pct_abs_sup_15": float(np.mean(np.abs(res) > 0.15) * 100)}
178+
179+# ----------------------------------------------------------------------------- prix implicites au sujet
180+def prix_implicites_sujet(ref: dict, s: dict) -> pd.DataFrame:
181+ """Prix implicites en dollars au sujet, dérivés du semi-log autour de la prédiction corrigée P̂_s (Duan).
182+ Continues : ∂P/∂x = β P̂ (× dérivée de la transformation) ; indicatrices : P̂ [exp(β) − 1] (Halvorsen–Palmquist) et Kennedy."""
183+ beta = pd.Series(ref["beta"], index=ref["X"].columns); se = pd.Series(np.sqrt(np.diag(ref["V_hc1"])), index=ref["X"].columns)
184+ sd = cas1.sujet_df(s); Ps = float(cas1.predire(ref, sd)[0]); xs = cas1.matrice(sd).iloc[0]
185+ S = float(s["superficie_habitable_m2"]); T = float(s["superficie_terrain_m2"]); age = float(s["age"])
186+ rows = []
187+ def cont(nom, unite, b, scale, se_b):
188+ rows.append({"caracteristique": nom, "unite": unite, "type": "continue", "beta": b, "prix_implicite": b * Ps * scale, "se": abs(scale) * Ps * se_b})
189+ cont("Superficie habitable", "$/m²", beta["ln_S"], 1.0 / S, se["ln_S"])
190+ cont("Superficie du terrain", "$/m²", beta["ln_T"], 1.0 / T, se["ln_T"])
191+ d_age = beta["age"] + 2 * beta["age2_100"] * age / 100.0
192+ se_age = math.sqrt(ref["V_hc1"][list(beta.index).index("age"), list(beta.index).index("age")]
193+ + (2 * age / 100) ** 2 * ref["V_hc1"][list(beta.index).index("age2_100"), list(beta.index).index("age2_100")]
194+ + 2 * (2 * age / 100) * ref["V_hc1"][list(beta.index).index("age"), list(beta.index).index("age2_100")])
195+ rows.append({"caracteristique": "Âge (une année de plus, à 28 ans)", "unite": "$/an", "type": "continue", "beta": d_age, "prix_implicite": d_age * Ps, "se": Ps * se_age})
196+ cont("Conditions du marché (un mois)", "$/mois", beta["mois"], 1.0, se["mois"])
197+ rows.append({"caracteristique": "Distance au centre (sujet à 2,1 km : dans le plateau)", "unite": "$/km", "type": "continue", "beta": beta["dist_centre"], "prix_implicite": 0.0, "se": 0.0})
198+ def disc(nom, col, direction=+1):
199+ b = beta[col] * direction; v = se[col] ** 2
200+ hp = Ps * (math.exp(b) - 1); ken = Ps * (math.exp(b - v / 2) - 1)
201+ rows.append({"caracteristique": nom, "unite": "$", "type": "indicatrice", "beta": beta[col], "prix_implicite": hp, "kennedy": ken, "se": Ps * math.exp(b) * se[col]})
202+ rows.append({"caracteristique": "Qualité (un échelon de plus)", "unite": "$", "type": "indicatrice", "beta": beta["qualite"],
203+ "prix_implicite": Ps * (math.exp(beta["qualite"]) - 1), "kennedy": Ps * (math.exp(beta["qualite"] - se["qualite"] ** 2 / 2) - 1), "se": Ps * math.exp(beta["qualite"]) * se["qualite"]})
204+ disc("Garage (une place de plus)", "garage")
205+ disc("Sous-sol fini (contribution : fini vs non fini)", "sous_sol")
206+ disc("Piscine (ajout)", "piscine")
207+ disc("Cottage à étages vs plain-pied", "etages")
208+ disc("Secteur Aylmer vs Hull", "sect_Aylmer")
209+ disc("Secteur Gatineau vs Hull", "sect_Gatineau")
210+ df = pd.DataFrame(rows); df["P_sujet"] = Ps
211+ return df
212+
213+# ----------------------------------------------------------------------------- prédiction et intervalle
214+def prediction_sujet(ref: dict, s: dict) -> dict:
215+ sd = cas1.sujet_df(s); X = cas1.matrice(sd)[ref["X"].columns].values
216+ lnp = float((X @ ref["beta"])[0]); xVx = float((X @ ref["V_hc1"] @ X.T)[0, 0])
217+ duan = _duan(ref["residus"]); logn = math.exp(ref["sigma"] ** 2 / 2)
218+ ip = cas1.intervalle_prediction(ref, sd).iloc[0]
219+ ip80 = cas1.intervalle_prediction(ref, sd, 0.80).iloc[0]
220+ # prédiction à la date de référence du rôle fictif (2024-07-01) pour la comparaison avec la valeur au rôle
221+ sd_ref = cas1.sujet_df(s, "2024-07-01"); p_ref = float(cas1.predire(ref, sd_ref)[0])
222+ return {"ln_pred": lnp, "naif": math.exp(lnp), "duan": math.exp(lnp) * duan, "facteur_duan": duan, "facteur_lognormal": logn,
223+ "lognormal": math.exp(lnp) * logn, "xVx": xVx, "sigma2": ref["sigma"] ** 2, "sd_pred": math.sqrt(ref["sigma"] ** 2 + xVx),
224+ "ic95": (float(ip["bas"]), float(ip["haut"])), "ic80": (float(ip80["bas"]), float(ip80["haut"])),
225+ "pred_2024_07": p_ref, "ratio_role": float(s["valeur_role_2025"]) / p_ref}
226+
227+# ----------------------------------------------------------------------------- validation croisée et ratios
228+def validation_croisee(v: pd.DataFrame, nfolds: int = 10, graine: int = GRAINE) -> dict:
229+ rng = np.random.default_rng(graine)
230+ X = cas1.matrice(v).values; lnP = np.log(v["prix_vente"].astype(float).to_numpy()); P = np.exp(lnP)
231+ idx = rng.permutation(len(P)); folds = np.array_split(idx, nfolds)
232+ pred = np.empty_like(P)
233+ for f in folds:
234+ tr = np.setdiff1d(idx, f); r = h.mco(X[tr], lnP[tr])
235+ pred[f] = np.exp(X[f] @ r["beta"]) * _duan(r["residus"])
236+ return {"pred": pred, "rmse": _rmse(pred, P), "ratios": ratios(pred, P), "erreur_abs_pct_mediane": float(np.median(np.abs(pred / P - 1)) * 100)}
237+
238+def ratios_role_fictif(v: pd.DataFrame, ref: dict) -> dict:
239+ """Étude de ratios du rôle fictif : valeur au rôle / prix ramené à la date de référence (2024-07-01) par la tendance du modèle."""
240+ bm = ref["beta"][list(ref["X"].columns).index("mois")]
241+ prix_ref = v["prix_vente"].astype(float).to_numpy() * np.exp(-bm * ref["X"]["mois"].to_numpy())
242+ return {"ratios": ratios(v["valeur_role_2025"].to_numpy(float), prix_ref), "prix_ref": prix_ref,
243+ "r": v["valeur_role_2025"].to_numpy(float) / prix_ref}
244+
245+def indice_trimestriel(variantes: dict, ref: dict, v: pd.DataFrame) -> pd.DataFrame:
246+ rT = variantes["semi-log, indicatrices trimestrielles"]["mco"]; XT = variantes["semi-log, indicatrices trimestrielles"]["X"]
247+ cols = [c for c in XT.columns if c.startswith("trim_")]
248+ bq = np.array([rT["beta"][list(XT.columns).index(c)] for c in cols]); seq = np.array([rT["se_hc1"][list(XT.columns).index(c)] for c in cols])
249+ idx = h.indice_dummies_temps(bq)
250+ q = np.arange(len(cols) + 1)
251+ # tendance : mois au milieu de chaque trimestre depuis 2024-07-01, normalisée à 2023T1
252+ mid = pd.to_datetime([f"{2023 + k // 4}-{3 * (k % 4) + 2:02d}-15" for k in q])
253+ mois_mid = (mid - cas1.REF_DATE).days / 30.4375
254+ bm = ref["beta"][list(ref["X"].columns).index("mois")]
255+ tend = 100 * np.exp(bm * (mois_mid - mois_mid[0]))
256+ lab = [f"{2023 + k // 4}T{k % 4 + 1}" for k in q]
257+ return pd.DataFrame({"trimestre": lab, "indice_dummies": idx, "bas": np.concatenate([[100], 100 * np.exp(bq - 1.96 * seq)]),
258+ "haut": np.concatenate([[100], 100 * np.exp(bq + 1.96 * seq)]), "indice_tendance": tend,
259+ "n_ventes": [int((trimestre(v["date_vente"]) == k).sum()) for k in q]})
260+
261+# ----------------------------------------------------------------------------- sensibilité : sous-échantillons
262+def sensibilite_echantillon(v: pd.DataFrame, s: dict, ref: dict, diag: dict) -> pd.DataFrame:
263+ sd = cas1.sujet_df(s); Xs_full = cas1.matrice(sd)
264+ def fit(sub: pd.DataFrame, nom: str):
265+ X = cas1.matrice(sub); y = np.log(sub["prix_vente"].astype(float)).values
266+ garder = [c for c in X.columns if X[c].std() > 0 or c == "const"]
267+ X = X[garder]; r = h.mco(X.values, y)
268+ p = float(np.exp((Xs_full[garder].values @ r["beta"])[0]) * _duan(r["residus"]))
269+ bS = r["beta"][garder.index("ln_S")]
270+ return {"echantillon": nom, "n": len(sub), "r2": r["r2"], "sigma": float(np.sqrt(r["sigma2"])), "beta_ln_S": bS, "sujet": p}
271+ rows = [fit(v, "600 ventes (référence)")]
272+ rows.append(fit(v[v["secteur"] == "Hull"], "Secteur Hull seulement"))
273+ mois_av = (pd.Timestamp(s["date_evaluation"]) - v["date_vente"]).dt.days / 30.4375
274+ rows.append(fit(v[mois_av <= 18], "Ventes des 18 derniers mois"))
275+ rows.append(fit(v[v["type"] == "à étages"], "Cottages à étages seulement"))
276+ rows.append(fit(v[diag["cook"]["D"] <= diag["cook"]["seuil"]], "Sans les observations influentes (Cook > 4/n)"))
277+ rows.append(fit(v[(v["superficie_habitable_m2"].between(110, 230))], "Superficie de 110 à 230 m²"))
278+ return pd.DataFrame(rows)
279+
280+# ----------------------------------------------------------------------------- exercices (chiffres des solutions)
281+def exercices(ref: dict, pred: dict, vari: dict, idx: pd.DataFrame) -> dict:
282+ ex = {}
283+ # 18.1 : interprétation des coefficients
284+ ex["1"] = {"elasticite_S": 0.539, "garage_hp": math.exp(0.0324) - 1, "buck_hp": math.exp(-0.250) - 1, "buck_kennedy": math.exp(-0.250 - 0.072 ** 2 / 2) - 1,
285+ "mois_annuel": (1 + 0.0036) ** 12 - 1}
286+ # 18.2 : prix implicites à partir de coefficients arrondis
287+ P, S = 638_000.0, 165.0
288+ ex["2"] = {"pS_marginal": 0.539 * P / S, "pS_10m2_exact": P * ((175 / 165) ** 0.539 - 1), "pS_10m2_lineaire": 0.539 * P / S * 10,
289+ "age28": (-0.0051 + 2 * 0.0026 * 28 / 100) * P, "age60": (-0.0051 + 2 * 0.0026 * 60 / 100) * P, "age_min": 0.0051 / (2 * 0.0026 / 100),
290+ "piscine_hp": P * (math.exp(0.0263) - 1)}
291+ # 18.3 : Box–Cox
292+ ex["3"] = {"bc_1": (500_000 ** 1 - 1) / 1, "bc_05": (500_000 ** 0.5 - 1) / 0.5, "bc_0": math.log(500_000), "lambda_hat": vari["Box–Cox"]["lambda"], "ic": vari["Box–Cox"]["ic95"]}
293+ # 18.4 : Duan et intervalle avec chiffres arrondis
294+ lnp, sig, xVx, duan = 13.3627, 0.0830, round(pred["xVx"], 5), 1.0034
295+ sd = math.sqrt(sig ** 2 + xVx); z = 1.959964
296+ ex["4"] = {"naif": math.exp(lnp), "duan": math.exp(lnp) * duan, "lognormal": math.exp(lnp) * math.exp(sig ** 2 / 2), "sd": sd,
297+ "bas": math.exp(lnp - z * sd), "haut": math.exp(lnp + z * sd), "xVx": xVx, "part_xVx": xVx / (sig ** 2 + xVx),
298+ "demi_largeur_pct": (math.exp(z * sd) - 1) * 100}
299+ # 18.5 : étude de ratios (dix ventes fictives)
300+ prix = np.array([312_000, 358_500, 401_000, 445_000, 489_500, 532_000, 598_000, 655_000, 742_000, 895_000], float)
301+ val = np.array([298_000, 329_000, 371_000, 405_000, 452_000, 478_000, 539_000, 576_000, 645_000, 752_000], float)
302+ r5 = ratios(val, prix); ex["5"] = {"ratios": val / prix, **r5}
303+ # 18.6 : variables omises (coefficients sans secteurs)
304+ cS = vari["semi-log sans secteurs"]["coef"]; cR = pd.Series(ref["beta"], index=ref["X"].columns)
305+ ex["6"] = {"dist_ref": cR["dist_centre"], "dist_sans": cS["dist_centre"], "lnT_ref": cR["ln_T"], "lnT_sans": cS["ln_T"],
306+ "sigma_ref": ref["sigma"], "sigma_sans": vari["semi-log sans secteurs"]["sigma"], "sujet_ref": vari["semi-log (référence)"]["sujet"],
307+ "sujet_sans": vari["semi-log sans secteurs"]["sujet"], "r2_sans": vari["semi-log sans secteurs"]["r2"]}
308+ # 18.7 : Rosen — prix implicite marginal et enchère
309+ ex["7"] = {"p_S": 0.539 * 638_000 / 165, "p_S_120": 0.539 * 638_000 * (120 / 165) ** 0.539 / 120, "p_S_220": 0.539 * 638_000 * (220 / 165) ** 0.539 / 220}
310+ # 18.8 : indice trimestriel vs tendance
311+ bm = ref["beta"][list(ref["X"].columns).index("mois")]
312+ ecart = (idx["indice_dummies"] / idx["indice_tendance"] - 1) * 100
313+ ex["8"] = {"g_mensuel": math.exp(bm) - 1, "g_annuel": math.exp(12 * bm) - 1, "ecart_abs_moyen_pct": float(ecart.abs().mean()), "ecart_max_pct": float(ecart.abs().max()),
314+ "G0404_tendance": 506_500 * (math.exp(bm * 15.2) - 1), "indice_fin": float(idx["indice_dummies"].iloc[-1]), "tendance_fin": float(idx["indice_tendance"].iloc[-1])}
315+ return ex
316+
317+# ----------------------------------------------------------------------------- principal
318+def main(verbose: bool = True) -> dict:
319+ d = cas1.charger(); v = d["ventes"]; s = d["sujet"]
320+ ref = cas1.hedonique(v)
321+ coef = ref["coef"].copy(); coef["vrai"] = [VRAIS.get(c, np.nan) for c in coef.index]
322+ coef["ecart_t"] = (coef["coef"] - coef["vrai"]) / coef["se_hc1"]
323+ coef["ic_bas"] = coef["coef"] - 1.96 * coef["se_hc1"]; coef["ic_haut"] = coef["coef"] + 1.96 * coef["se_hc1"]
324+ coef["couvre"] = (coef["ic_bas"] <= coef["vrai"]) & (coef["vrai"] <= coef["ic_haut"])
325+ coef["etiquette"] = [ETIQUETTES[c] for c in coef.index]
326+ diag = diagnostics(v, ref)
327+ vari = ajuster_variantes(v, s, ref)
328+ pi = prix_implicites_sujet(ref, s)
329+ pred = prediction_sujet(ref, s)
330+ cv = validation_croisee(v)
331+ role = ratios_role_fictif(v, ref)
332+ idx = indice_trimestriel(vari, ref, v)
333+ sens = sensibilite_echantillon(v, s, ref, diag)
334+ ex = exercices(ref, pred, vari, idx)
335+ # comparables du ch. 16 : résidus du modèle
336+ sel = cas1.selection(); comps = sel["comparables"]
337+ res_comps = pd.Series(ref["residus"], index=v.index).loc[comps.index]
338+ P = v["prix_vente"].astype(float).to_numpy()
339+ out = {"ventes": v, "sujet": s, "modele": ref, "coef": coef, "diag": diag, "variantes": vari, "prix_implicites": pi, "prediction": pred,
340+ "cv": cv, "role": role, "indice": idx, "sensibilite": sens, "exercices": ex, "comparables": comps, "residus_comparables": res_comps,
341+ "ratios_modele": ratios(vari["semi-log (référence)"]["ajuste"], P)}
342+ if verbose:
343+ pd.set_option("display.width", 220); pd.set_option("display.max_columns", 30)
344+ print("== Modèle de référence (traite.cas1.hedonique) : n =", ref["n"], "k =", ref["k"], f"R² = {ref['r2']:.4f} R²aj = {ref['r2_ajuste']:.4f} σ = {ref['sigma']:.4f}")
345+ print(coef[["coef", "se_hc1", "t", "effet_pct", "vrai", "ecart_t", "couvre"]].round(4).to_string())
346+ print(f" couverture des IC 95 % : {int(coef['couvre'].sum())}/{int(coef['vrai'].notna().sum())} paramètres de génération couverts")
347+ print(f"== Croissance mensuelle {ref['croissance_mensuelle']*100:.3f} % ; annuelle {((1+ref['croissance_mensuelle'])**12-1)*100:.2f} % ; vraie {_g*100:.2f} %")
348+ bp = diag["breusch_pagan"]; print(f"== Breusch–Pagan (Koenker) LM = {bp['LM']:.2f}, ddl = {bp['ddl']}, p = {bp['p']:.3f}")
349+ print(f"== Jarque–Bera = {diag['jarque_bera']['stat']:.2f} (p = {diag['jarque_bera']['p']:.3f}) ; asymétrie {diag['skew']:.3f} ; aplatissement {diag['kurtosis']:.3f}")
350+ print(f"== Résidus : min {diag['res_min']:.3f}, max {diag['res_max']:.3f}, |e| > 0,15 : {diag['pct_abs_sup_15']:.1f} %")
351+ print("== VIF :\n", diag["vif"].round(2).to_string())
352+ ck = diag["cook"]; top = np.argsort(-ck["D"])[:3]
353+ print(f"== Cook : seuil 4/n = {ck['seuil']:.4f} ; {ck['n_sup']} observations au-dessus ; max {ck['D'].max():.4f} ; 3 plus grandes :", list(v['id_vente'].iloc[top]), np.round(ck['D'][top], 4))
354+ m = diag["moran"]; print(f"== Moran (résidus, 8 voisins) I = {m['I']:.4f}, z = {m['z']:.2f}, p = {m['p']:.3f}")
355+ print("== Variantes :")
356+ for nom, dd in vari.items():
357+ extra = f" λ = {dd['lambda']:.3f} IC [{dd['ic95'][0]:.3f}; {dd['ic95'][1]:.3f}]" if "lambda" in dd else ""
358+ print(f" {nom:42s} k={dd['k']:2d} R²={dd['r2']:.4f} R²aj={dd['r2_ajuste']:.4f} σ={dd['sigma']:.4f} RMSE={dd['rmse']:,.0f} $ COD={dd['ratios']['COD']:.2f} PRD={dd['ratios']['PRD']:.3f} PRB={dd['ratios']['PRB']:.3f} sujet={dd['sujet']:,.0f} ${extra}")
359+ cS = vari["semi-log sans secteurs"]
360+ print(f" sans secteurs : dist_centre {cS['coef']['dist_centre']:.4f} (réf {ref['beta'][list(ref['X'].columns).index('dist_centre')]:.4f}) ; ln_T {cS['coef']['ln_T']:.4f} (réf {ref['beta'][list(ref['X'].columns).index('ln_T')]:.4f})")
361+ print("== Prix implicites au sujet (P̂ = %.0f $) :" % pi["P_sujet"].iloc[0])
362+ print(pi[["caracteristique", "unite", "beta", "prix_implicite", "kennedy", "se"]].round(4).to_string(index=False))
363+ print("== Prédiction du sujet :", {k: (round(x, 5) if isinstance(x, float) and abs(x) < 10 else (tuple(round(y) for y in x) if isinstance(x, tuple) else round(x))) for k, x in pred.items()})
364+ print(f"== Ratios du modèle (in-sample) : COD {out['ratios_modele']['COD']:.2f}, PRD {out['ratios_modele']['PRD']:.4f}, PRB {out['ratios_modele']['PRB']:.4f}, médiane {out['ratios_modele']['ratio_median']:.4f}")
365+ print(f"== Validation croisée 10 plis : RMSE {cv['rmse']:,.0f} $, COD {cv['ratios']['COD']:.2f}, PRD {cv['ratios']['PRD']:.4f}, PRB {cv['ratios']['PRB']:.4f}, erreur absolue médiane {cv['erreur_abs_pct_mediane']:.2f} %")
366+ rr = role["ratios"]; print(f"== Rôle fictif vs prix ramenés au 2024-07-01 : médiane {rr['ratio_median']:.4f}, COD {rr['COD']:.2f}, PRD {rr['PRD']:.4f}, PRB {rr['PRB']:.4f}")
367+ print("== Indice trimestriel :\n", idx.round(2).to_string(index=False))
368+ print("== Sensibilité à l'échantillon :\n", sens.round(4).to_string(index=False))
369+ print("== Résidus des 5 comparables du ch. 16 :", dict(zip(comps["id_vente"], res_comps.round(4))), "moyenne", round(res_comps.mean(), 4))
370+ print("== Exercices :")
371+ for k, e in ex.items(): print(" ", k, {kk: (np.round(vv, 4) if not isinstance(vv, tuple) else tuple(round(x, 4) for x in vv)) for kk, vv in e.items()})
372+ return out
373+
374+if __name__ == "__main__":
375+ main()
added analysis/python/figures/ch03_cout_usage.py +43 −0
@@ -0,0 +1,43 @@
1+#!/usr/bin/env python3
2+"""Figure ch03 — Coût d'usage du logement au Québec, 2001-2026, données RÉELLES (Banque du Canada, Valet : V80691335 taux
3+hypothécaire affiché 5 ans, BD.CDN.5YR.DQ.YLD obligation 5 ans, V39079 taux directeur ; Statistique Canada 18-10-0004-01 IPC Québec,
4+18-10-0205-01 IPLN Québec), lues dans data/raw (téléchargées le 2026-10-31). Paramètres déclarés : τ = 0,825 % (taxes Ville de
5+Québec 2026 + scolaire 2026-2027), δ = 2,5 % (entretien, dépréciation, prime de risque : hypothèse pédagogique).
6+Panneau A : taux nominaux et inflation ; B : taux de coût d'usage u sous deux hypothèses d'anticipation ; C : ratio prix/loyer implicite 1/u.
7+Sortie : assets/figures/ch03/ch03-fig-cout-usage.pdf"""
8+import sys, pathlib
9+ROOT = pathlib.Path(__file__).resolve().parents[3]
10+sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases"))
11+import matplotlib.pyplot as plt, matplotlib.dates as mdates
12+from traite.style import setup, palette, savefig, LARGEUR
13+import ch03_cycles as cas
14+
15+
16+def main():
17+ setup()
18+ df = cas.serie_cout_usage()
19+ fig, (a, b, c) = plt.subplots(3, 1, figsize=(LARGEUR, 6.0), sharex=True)
20+ a.plot(df.index, df["hyp5"], color=palette.cat[0], label="taux hypothécaire affiché 5 ans (V80691335)")
21+ a.plot(df.index, df["gdc5"], color=palette.cat[1], label="obligation du Canada 5 ans")
22+ a.plot(df.index, df["directeur"], color=palette.cat[2], lw=1.2, label="taux directeur (depuis 2009)")
23+ a.plot(df.index, df["pi"], color=palette.cat[3], lw=1.2, ls="--", label="inflation IPC Québec, 12 mois")
24+ a.set_ylabel("% par an"); a.legend(fontsize=7, ncol=2, loc="upper center"); a.set_title("A. Taux nominaux et inflation", loc="left"); a.set_ylim(-1, 12.5)
25+ b.plot(df.index, df["u_ipc"], color=palette.cat[0], label="$u$ avec $g$ = inflation IPC (12 mois)")
26+ b.plot(df.index, df["u_ipln"], color=palette.cat[1], label="$u$ avec $g$ = croissance IPLN Québec (5 ans)")
27+ b.axhline(0, color=palette.ink2, lw=0.7)
28+ b.set_ylabel("% du prix par an"); b.legend(fontsize=7, loc="upper right"); b.set_title("B. Taux de coût d'usage $u = i + \\tau + \\delta - g$", loc="left")
29+ c.plot(df.index, df["pr_ipc"], color=palette.cat[0], label="$P/R$ = 1/$u$, anticipation IPC")
30+ c.plot(df.index, df["pr_ipln"].clip(upper=40), color=palette.cat[1], label="$P/R$ = 1/$u$, anticipation IPLN")
31+ c.set_ylabel("multiple du loyer annuel"); c.set_ylim(5, 40); c.legend(fontsize=7, loc="upper left"); c.set_title("C. Ratio prix/loyer implicite", loc="left")
32+ last = df.index[-1]
33+ c.annotate(f"{last:%Y-%m} : {df['pr_ipc'].iloc[-1]:.1f}", xy=(last, df["pr_ipc"].iloc[-1]), xytext=(-60, -14), textcoords="offset points", fontsize=7.5,
34+ arrowprops=dict(arrowstyle="-", color=palette.ink2, lw=0.6))
35+ for ax in (a, b, c):
36+ ax.axvspan(mdates.date2num(__import__("datetime").date(2022, 3, 1)), mdates.date2num(__import__("datetime").date(2023, 7, 31)), color=palette.grid, alpha=0.7, lw=0)
37+ c.xaxis.set_major_locator(mdates.YearLocator(3)); c.xaxis.set_major_formatter(mdates.DateFormatter("%Y"))
38+ fig.tight_layout(h_pad=1.0)
39+ out = savefig(fig, "ch03/ch03-fig-cout-usage.pdf"); print("OK", out)
40+
41+
42+if __name__ == "__main__":
43+ main()
added analysis/python/figures/ch03_cycle.py +68 −0
@@ -0,0 +1,68 @@
1+#!/usr/bin/env python3
2+"""Figures ch03 — Simulation d'un cycle stock-flux (SYNTHÉTIQUE, analysis/python/cases/ch03_cycles.py, paramètres pédagogiques).
3+1. ch03-fig-cycle-simule.pdf : quatre panneaux (demande et stock ; inoccupation ; loyer ; mises en chantier) après un choc de demande.
4+2. ch03-fig-cycle-sensibilite.pdf : trajectoires du loyer selon le délai de livraison L et selon la réactivité de l'offre κ."""
5+import sys, pathlib
6+ROOT = pathlib.Path(__file__).resolve().parents[3]
7+sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases"))
8+from dataclasses import replace
9+import matplotlib.pyplot as plt
10+from traite.style import setup, palette, savefig, LARGEUR
11+import ch03_cycles as cas
12+
13+
14+def fig_cycle(pc):
15+ df = cas.simuler_cycle(pc); ss = cas.etat_stationnaire_cycle(pc); ss0 = cas.etat_stationnaire_cycle(pc, apres_choc=False)
16+ x = df["annee"]
17+ fig, axs = plt.subplots(2, 2, figsize=(LARGEUR, 4.6), sharex=True)
18+ c0, c1, c2 = palette.cat[0], palette.cat[1], palette.cat[2]
19+ a = axs[0, 0]
20+ a.plot(x, df["S"] / 1000, color=c0, label="stock $S_t$")
21+ a.plot(x, df["D"] / 1000, color=c1, label="demande occupée $D_t$")
22+ a.axhline(ss["S"] / 1000, color=c0, lw=0.7, ls=":")
23+ a.set_ylabel("milliers de logements"); a.legend(loc="lower right", fontsize=7.5); a.set_title("A. Stock et demande", loc="left")
24+ a = axs[0, 1]
25+ a.plot(x, 100 * df["v"], color=c0)
26+ a.axhline(100 * pc.v_nat, color=palette.ink2, lw=0.8, ls="--"); a.text(x.iloc[-1], 100 * pc.v_nat + 0.15, "$v^*$", ha="right", fontsize=8, color=palette.ink2)
27+ a.set_ylabel("%"); a.set_title("B. Taux d'inoccupation $v_t$", loc="left"); a.set_ylim(0, None)
28+ a = axs[1, 0]
29+ a.plot(x, df["R_mois"], color=c0)
30+ a.axhline(ss["R"] / 12, color=palette.ink2, lw=0.8, ls="--"); a.text(x.iloc[-1], ss["R"] / 12 + 8, "nouveau stationnaire", ha="right", fontsize=7.5, color=palette.ink2)
31+ a.set_ylabel("$/mois"); a.set_title("C. Loyer $R_t$", loc="left"); a.set_xlabel("années depuis le début")
32+ a = axs[1, 1]
33+ a.plot(x, df["C_annuel"], color=c0)
34+ a.axhline(ss["C"], color=palette.ink2, lw=0.8, ls="--"); a.text(x.iloc[-1], ss["C"] + 80, "$\\delta S^*$", ha="right", fontsize=8, color=palette.ink2)
35+ a.set_ylabel("logements / an (rythme annuel)"); a.set_title("D. Mises en chantier $C_t$", loc="left"); a.set_xlabel("années depuis le début")
36+ for ax in axs.flat:
37+ ax.axvspan(pc.t_choc / 4, (pc.t_choc + pc.n_choc) / 4, color=palette.grid, alpha=0.6, lw=0)
38+ axs[0, 0].text((pc.t_choc + pc.n_choc / 2) / 4, axs[0, 0].get_ylim()[1] * 0.995, "choc de\ndemande", ha="center", va="top", fontsize=7, color=palette.ink2)
39+ fig.tight_layout(h_pad=1.0, w_pad=1.2)
40+ return savefig(fig, "ch03/ch03-fig-cycle-simule.pdf")
41+
42+
43+def fig_sensibilite(pc):
44+ fig, axs = plt.subplots(1, 2, figsize=(LARGEUR, 2.7), sharey=True)
45+ ss = cas.etat_stationnaire_cycle(pc)
46+ for k, L in enumerate((2, 6, 12)):
47+ p = replace(pc, L=L); d = cas.simuler_cycle(p); s = cas.etat_stationnaire_cycle(p)
48+ axs[0].plot(d["annee"], 100 * (d["R"] / s["R"] - 1), color=palette.cat[k], label=f"$L$ = {L} trimestres")
49+ axs[0].set_title("A. Délai de livraison ($\\kappa$ = 0,48)", loc="left")
50+ for k, m in enumerate((1.0, 0.5, 0.25)):
51+ p = replace(pc, mult_c1=m); d = cas.simuler_cycle(p); s = cas.etat_stationnaire_cycle(p)
52+ axs[1].plot(d["annee"], 100 * (d["R"] / s["R"] - 1), color=palette.cat[k], label=f"$\\kappa$ = {p.kappa:.2f}")
53+ axs[1].set_title("B. Réactivité de l'offre ($L$ = 6)", loc="left")
54+ for ax in axs:
55+ ax.axhline(0, color=palette.ink2, lw=0.8, ls="--"); ax.set_xlabel("années depuis le début"); ax.legend(fontsize=7.5)
56+ axs[0].set_ylabel("loyer, écart au stationnaire final (%)")
57+ fig.tight_layout(w_pad=1.2)
58+ return savefig(fig, "ch03/ch03-fig-cycle-sensibilite.pdf")
59+
60+
61+def main():
62+ setup()
63+ pc = cas.ParametresCycle()
64+ print("OK", fig_cycle(pc)); print("OK", fig_sensibilite(pc))
65+
66+
67+if __name__ == "__main__":
68+ main()
added analysis/python/figures/ch03_mises_chantier.py +49 −0
@@ -0,0 +1,49 @@
1+#!/usr/bin/env python3
2+"""Figure ch03 — Mises en chantier au Québec, données RÉELLES : Statistique Canada (SCHL), tableau 34-10-0135-01,
3+toutes les régions, trimestriel non désaisonnalisé, lu dans data/raw/statcan_34100135/34100135.csv (téléchargé le 2026-10-31).
4+Panneau A : totaux annuels 1990-2025 (2026 : trois trimestres, hachuré) et somme mobile sur quatre trimestres ; la zone grisée
5+marque le resserrement monétaire (mars 2022 → juillet 2023, série V39079). Panneau B : composition par type d'unité.
6+Sortie : assets/figures/ch03/ch03-fig-mises-chantier-quebec.pdf"""
7+import sys, pathlib
8+ROOT = pathlib.Path(__file__).resolve().parents[3]
9+sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases"))
10+import pandas as pd, matplotlib.pyplot as plt
11+from traite.style import setup, palette, savefig, LARGEUR, fmt_milliers
12+import ch03_cycles as cas
13+
14+
15+def main():
16+ setup()
17+ df = cas.mises_en_chantier_quebec()
18+ ann = cas.mises_en_chantier_annuelles(df)
19+ tot = df[df["type"] == "Total d'unités"].set_index("date")["unites"]
20+ mobile = tot.rolling(4).sum()
21+ fig, (a, b) = plt.subplots(2, 1, figsize=(LARGEUR, 4.9), height_ratios=[3, 2], sharex=False)
22+ yrs = ann.loc[1990:2026]
23+ complet = yrs[yrs["trimestres"] == 4]; partiel = yrs[yrs["trimestres"] < 4]
24+ a.bar(complet.index, complet["total"] / 1000, width=0.8, color=palette.cat[0], alpha=0.75, label="total annuel")
25+ a.bar(partiel.index, partiel["total"] / 1000, width=0.8, color=palette.cat[0], alpha=0.35, hatch="///", edgecolor=palette.cat[0], label=f"{int(partiel.index[0])} (3 trimestres)")
26+ mob = mobile[mobile.index >= "1990-10-01"]
27+ a.plot(mob.index.year + (mob.index.month - 1) / 12 + 0.25, mob / 1000, color=palette.cat[1], lw=1.4, label="somme mobile sur 4 trimestres")
28+ a.axvspan(2022 + 2 / 12, 2023 + 6.5 / 12, color=palette.grid, alpha=0.8, lw=0)
29+ a.text(2023.75, 74, "resserrement monétaire\n(mars 2022 – juillet 2023)", ha="left", va="top", fontsize=7, color=palette.ink2)
30+ for y, va in ((2021, "bottom"), (2023, "bottom"), (2025, "bottom")):
31+ a.annotate(f"{fmt_milliers(ann.loc[y, 'total'])}", xy=(y, ann.loc[y, "total"] / 1000), xytext=(0, 3), textcoords="offset points", ha="center", va=va, fontsize=7.5)
32+ a.set_ylabel("milliers de logements"); a.set_xlim(1989.3, 2026.9); a.set_ylim(0, 75)
33+ a.legend(loc="upper left", fontsize=7.5, ncol=1); a.set_title("A. Logements mis en chantier, Québec (toutes les régions)", loc="left")
34+ # B. composition
35+ comp = ann.loc[1990:2025]
36+ b.stackplot(comp.index, 100 * comp["part_individuelles"], 100 * comp["jumeles"] / comp["total"] + 100 * comp["bande"] / comp["total"],
37+ 100 * comp["part_appartements"], colors=[palette.cat[0], palette.cat[2], palette.cat[1]], alpha=0.85,
38+ labels=["maisons individuelles", "jumelés et en bande", "appartements et autres"])
39+ b.set_ylim(0, 100); b.set_ylabel("% des unités"); b.set_xlim(1989.3, 2026.9)
40+ b.legend(loc="upper center", bbox_to_anchor=(0.5, -0.16), fontsize=7.5, ncol=3, frameon=False)
41+ b.set_title("B. Composition par type d'unité (années complètes)", loc="left")
42+ for y, ha in ((2005, "center"), (2025, "right")):
43+ b.annotate(f"{100*ann.loc[y,'part_individuelles']:.0f} %", xy=(y + (0.3 if ha == "right" else 0), 100 * ann.loc[y, "part_individuelles"] / 2), ha=ha, va="center", fontsize=7.5, color="white")
44+ fig.tight_layout(h_pad=1.4)
45+ out = savefig(fig, "ch03/ch03-fig-mises-chantier-quebec.pdf"); print("OK", out)
46+
47+
48+if __name__ == "__main__":
49+ main()
modified analysis/python/figures/ch03_quadrants.py +67 −57
@@ -2,88 +2,98 @@
2 """Figure ch03 — Modèle des quatre quadrants (DiPasquale–Wheaton) tracé à partir des paramètres pédagogiques du marché type 2 """Figure ch03 — Modèle des quatre quadrants (DiPasquale–Wheaton) tracé à partir des paramètres pédagogiques du marché type
3 (analysis/python/cases/ch03_cycles.py, SYNTHÉTIQUE). Équilibre de référence (trait plein) et nouvel équilibre après une baisse 3 (analysis/python/cases/ch03_cycles.py, SYNTHÉTIQUE). Équilibre de référence (trait plein) et nouvel équilibre après une baisse
4 du taux de capitalisation de 5 % à 4 % (tirets), avec la réaction de court terme (stock fixe) en pointillé. 4 du taux de capitalisation de 5 % à 4 % (tirets), avec la réaction de court terme (stock fixe) en pointillé.
5+Les quatre demi-axes ont des échelles propres (S en milliers de logements, P en k$/logement, R en k$/logement/an,
6+C en milliers de logements/an) ; les graduations sont dessinées explicitement.
5 Sortie : assets/figures/ch03/ch03-fig-quatre-quadrants.pdf""" 7 Sortie : assets/figures/ch03/ch03-fig-quatre-quadrants.pdf"""
6 import sys, pathlib 8 import sys, pathlib
7 ROOT = pathlib.Path(__file__).resolve().parents[3] 9 ROOT = pathlib.Path(__file__).resolve().parents[3]
8 sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases")) 10 sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases"))
9 import numpy as np, matplotlib.pyplot as plt 11 import numpy as np, matplotlib.pyplot as plt
12+from matplotlib.lines import Line2D
10 from traite.style import setup, palette, savefig, LARGEUR 13 from traite.style import setup, palette, savefig, LARGEUR
11 import ch03_cycles as cas 14 import ch03_cycles as cas
12 15
16+# facteurs d'affichage : unités de données → unités du tracé
17+FS = 1.0 / 1000.0 # S : logements → milliers (axe x positif, 0..150)
18+FP = 1.0 / 3000.0 # P : $ → k$/3 (axe x négatif ; 420 k$ → 140 unités)
19+FR = 1.0 / 1000.0 # R : $/an → k$ (axe y positif, 0..24)
20+FC = 4.0 / 1000.0 # C : logements/an → milliers × 4 (axe y négatif ; 3 000/an → 12 unités)
21+
13 22
14 def main(): 23 def main():
15 setup() 24 setup()
16 p = cas.Parametres4Q() 25 p = cas.Parametres4Q()
17− base = cas.equilibre_4q(p) 26+ base = cas.equilibre_4q(p); i2 = 0.04
18− i2 = 0.04 27+ nouv = cas.equilibre_4q(p, i=i2); ct = cas.court_terme_4q(p, i=i2)
19− nouv = cas.equilibre_4q(p, i=i2)
20− ct = cas.court_terme_4q(p, i=i2)
21− k = 1000.0 # affichage en milliers
22 c0, c1 = palette.cat[0], palette.cat[1] 28 c0, c1 = palette.cat[0], palette.cat[1]
23− fig, ax = plt.subplots(figsize=(LARGEUR, LARGEUR * 0.92)) 29+ fig, ax = plt.subplots(figsize=(LARGEUR, LARGEUR * 0.9))
24− # bornes 30+ XP, XS, YR, YC = -150, 150, 24, -13.5
25− Smax, Pmax, Rmax, Cmax = 150, 420, 25, 3.2 31+ ax.set_xlim(XP - 2, XS + 2); ax.set_ylim(YC - 1, YR + 1)
26− ax.set_xlim(-Pmax, Smax); ax.set_ylim(-Cmax, Rmax)
27 ax.axhline(0, color=palette.ink, lw=0.9); ax.axvline(0, color=palette.ink, lw=0.9) 32 ax.axhline(0, color=palette.ink, lw=0.9); ax.axvline(0, color=palette.ink, lw=0.9)
28− ax.grid(False) 33+ ax.grid(False); ax.set_xticks([]); ax.set_yticks([])
29− for s in ("left", "bottom"): 34+ for s in ax.spines.values():
30− ax.spines[s].set_visible(False) 35+ s.set_visible(False)
31− ax.set_xticks([]); ax.set_yticks([]) 36+ # graduations manuelles
32− # --- NE : marché de l'espace, D(R) = S → R = (a − S)/b 37+ for v in (50, 100, 150):
33− S = np.linspace(60, 150, 50) * k 38+ ax.plot([v, v], [0, -0.35], color=palette.ink, lw=0.7); ax.text(v, -0.6, f"{v}", ha="center", va="top", fontsize=7, color=palette.ink2)
34− ax.plot(S / k, (p.a - S) / p.b / k, color=c0, lw=1.8) 39+ for v in (150, 300, 450):
35− ax.plot(S / k, (1.0 * p.a - S) / p.b / k, color=c0, lw=0, label="_") 40+ ax.plot([-v / 3, -v / 3], [0, -0.35], color=palette.ink, lw=0.7); ax.text(-v / 3, -0.6, f"{v}", ha="center", va="top", fontsize=7, color=palette.ink2)
36− # --- NW : marché des actifs, P = R / i (P à gauche, R en haut) 41+ for v in (6, 12, 18, 24):
37− R = np.linspace(0, Rmax, 50) * k 42+ ax.plot([0, -1.5], [v, v], color=palette.ink, lw=0.7); ax.text(-2.5, v, f"{v}", ha="right", va="center", fontsize=7, color=palette.ink2)
38− ax.plot(-R / p.i / k, R / k, color=c0, lw=1.8) 43+ for v in (1, 2, 3):
39− ax.plot(-R / i2 / k, R / k, color=c1, lw=1.8, ls="--") 44+ ax.plot([0, -1.5], [-4 * v, -4 * v], color=palette.ink, lw=0.7); ax.text(-2.5, -4 * v, f"{v}", ha="right", va="center", fontsize=7, color=palette.ink2)
40− # --- SW : construction, C = (P − c0)/c1 (P à gauche, C en bas) 45+ # --- NE : marché de l'espace R = (a − S)/b
41− P = np.linspace(p.c0, Pmax * k, 50) 46+ S = np.linspace(50_000, 150_000, 50)
42− ax.plot(-P / k, -(P - p.c0) / p.c1 / k, color=c0, lw=1.8) 47+ ax.plot(S * FS, (p.a - S) / p.b * FR, color=c0, lw=1.8)
43− # --- SE : ajustement du stock, S = C/δ (C en bas, S à droite) 48+ # --- NW : marché des actifs P = R/i
44− C = np.linspace(0, Cmax, 50) * k 49+ R = np.linspace(0, YR / FR, 50)
45− ax.plot(C / p.delta / k, -C / k, color=c0, lw=1.8) 50+ ax.plot(-R / p.i * FP, R * FR, color=c0, lw=1.8)
51+ ax.plot(-R / i2 * FP, R * FR, color=c1, lw=1.8, ls="--")
52+ # --- SW : construction C = (P − c0)/c1
53+ P = np.linspace(p.c0, 450_000, 50)
54+ ax.plot(-P * FP, -(P - p.c0) / p.c1 * FC, color=c0, lw=1.8)
55+ # --- SE : stock stationnaire S = C/δ
56+ C = np.linspace(0, 1_500, 50)
57+ ax.plot(C / p.delta * FS, -C * FC, color=c0, lw=1.8)
46 58
47 def rectangle(eq, color, ls, lw=1.0): 59 def rectangle(eq, color, ls, lw=1.0):
48− Sx, Rx, Px, Cx = eq["S"] / k, eq["R"] / k, eq["P"] / k, eq["C"] / k 60+ Sx, Rx, Px, Cx = eq["S"] * FS, eq["R"] * FR, eq["P"] * FP, eq["C"] * FC
49− ax.plot([Sx, Sx], [0, Rx], color=color, lw=lw, ls=ls) 61+ ax.plot([Sx, Sx], [-Cx, Rx], color=color, lw=lw, ls=ls)
50 ax.plot([-Px, Sx], [Rx, Rx], color=color, lw=lw, ls=ls) 62 ax.plot([-Px, Sx], [Rx, Rx], color=color, lw=lw, ls=ls)
51 ax.plot([-Px, -Px], [Rx, -Cx], color=color, lw=lw, ls=ls) 63 ax.plot([-Px, -Px], [Rx, -Cx], color=color, lw=lw, ls=ls)
52 ax.plot([-Px, Sx], [-Cx, -Cx], color=color, lw=lw, ls=ls) 64 ax.plot([-Px, Sx], [-Cx, -Cx], color=color, lw=lw, ls=ls)
53− ax.plot([Sx, Sx], [-Cx, 0], color=color, lw=lw, ls=ls) 65+ for x, y in ((Sx, Rx), (-Px, Rx), (-Px, -Cx), (Sx, -Cx)):
66+ ax.plot(x, y, "o", color=color, ms=3.8)
54 67
55 rectangle(base, c0, "-") 68 rectangle(base, c0, "-")
56 rectangle(nouv, c1, "--") 69 rectangle(nouv, c1, "--")
57− # court terme : stock fixe, prix bondit à R0/i2 70+ # court terme : stock fixe, P bondit à R0/i2, C bondit, mais S n'a pas encore bougé
58− ax.plot([-ct["P"] / k, base["S"] / k], [base["R"] / k, base["R"] / k], color=c1, lw=1.0, ls=":") 71+ Pct, Cct = ct["P"] * FP, ct["C"] * FC
59− ax.plot([-ct["P"] / k, -ct["P"] / k], [base["R"] / k, -ct["C"] / k], color=c1, lw=1.0, ls=":") 72+ ax.plot([-Pct, base["S"] * FS], [base["R"] * FR, base["R"] * FR], color=c1, lw=1.0, ls=":")
60− ax.plot([-ct["P"] / k, base["S"] / k], [-ct["C"] / k, -ct["C"] / k], color=c1, lw=1.0, ls=":") 73+ ax.plot([-Pct, -Pct], [base["R"] * FR, -Cct], color=c1, lw=1.0, ls=":")
61− # points 74+ ax.plot([-Pct, base["S"] * FS], [-Cct, -Cct], color=c1, lw=1.0, ls=":")
62− ax.plot(base["S"] / k, base["R"] / k, "o", color=c0, ms=4); ax.plot(nouv["S"] / k, nouv["R"] / k, "o", color=c1, ms=4) 75+ ax.plot(-Pct, base["R"] * FR, "o", color=c1, ms=3.8, mfc="white"); ax.plot(-Pct, -Cct, "o", color=c1, ms=3.8, mfc="white")
63− ax.plot(-base["P"] / k, base["R"] / k, "o", color=c0, ms=4); ax.plot(-nouv["P"] / k, nouv["R"] / k, "o", color=c1, ms=4) 76+ # titres des quadrants (coins) et noms d'axes
64− ax.plot(-ct["P"] / k, base["R"] / k, "o", color=c1, ms=4, mfc="white") 77+ ax.text(XS, YR, "Marché de l'espace\n$D(R) = S$", ha="right", va="top", fontsize=8, color=palette.ink2)
65− # étiquettes d'axes et de quadrants 78+ ax.text(XP, YR, "Marché des actifs\n$P = R/i$", ha="left", va="top", fontsize=8, color=palette.ink2)
66− ax.text(Smax, 0.6, "Stock $S$ (milliers de log.)", ha="right", va="bottom", fontsize=8) 79+ ax.text(XP, YC + 4.0, "Construction\n$C = (P - c_0)/c_1$", ha="left", va="bottom", fontsize=8, color=palette.ink2)
67− ax.text(-Pmax, 0.6, "Prix $P$ (k\\$/log.)", ha="left", va="bottom", fontsize=8) 80+ ax.text(XS, YC, "Ajustement du stock\n$C = \\delta S$", ha="right", va="bottom", fontsize=8, color=palette.ink2)
68− ax.text(1.5, Rmax, "Loyer $R$ (k\\$/log./an)", ha="left", va="top", fontsize=8) 81+ ax.text(XS, 1.0, "Stock $S$\n(milliers de logements)", ha="right", va="bottom", fontsize=7.5)
69− ax.text(1.5, -Cmax, "Construction $C$ (milliers de log./an)", ha="left", va="bottom", fontsize=8) 82+ ax.text(XP, 1.0, "Prix $P$\n(k\\$ par logement)", ha="left", va="bottom", fontsize=7.5)
70− ax.text(Smax * 0.98, Rmax * 0.93, "Marché de l'espace\n$D(R) = S$", ha="right", va="top", fontsize=8, color=palette.ink2) 83+ ax.text(-2.5, YR, "Loyer $R$\n(k\\$/logement/an)", ha="right", va="top", fontsize=7.5)
71− ax.text(-Pmax * 0.98, Rmax * 0.93, "Marché des actifs\n$P = R/i$", ha="left", va="top", fontsize=8, color=palette.ink2) 84+ ax.text(-2.5, YC, "Construction $C$\n(milliers de logements/an)", ha="right", va="bottom", fontsize=7.5)
72− ax.text(-Pmax * 0.98, -Cmax * 0.93, "Construction\n$C = (P - c_0)/c_1$", ha="left", va="bottom", fontsize=8, color=palette.ink2)
73− ax.text(Smax * 0.98, -Cmax * 0.93, "Ajustement du stock\n$\\Delta S = C - \\delta S = 0$", ha="right", va="bottom", fontsize=8, color=palette.ink2)
74 # annotations numériques 85 # annotations numériques
75− ax.annotate(f"$S^*$ = {base['S']/k:.0f}", xy=(base["S"] / k, 0), xytext=(base["S"] / k + 2, 1.2), fontsize=7.5, color=c0) 86+ ax.text(base["S"] * FS + 1.5, base["R"] * FR + 0.4, f"$S^*$ = {base['S']/1000:.0f} ; $R^*$ = {base['R']/1000:.1f}", fontsize=7.5, color=c0, ha="left", va="bottom")
76− ax.annotate(f"{nouv['S']/k:.1f}", xy=(nouv["S"] / k, 0), xytext=(nouv["S"] / k + 2, 3.4), fontsize=7.5, color=c1) 87+ ax.text(nouv["S"] * FS + 1.5, nouv["R"] * FR - 0.4, f"{nouv['S']/1000:.1f} ; {nouv['R']/1000:.1f}", fontsize=7.5, color=c1, ha="left", va="top")
77− ax.annotate(f"$R^*$ = {base['R']/k:.1f}", xy=(0, base["R"] / k), xytext=(3, base["R"] / k + 0.6), fontsize=7.5, color=c0) 88+ ax.text(-base["P"] * FP + 1.5, base["R"] * FR + 0.4, f"$P^*$ = {base['P']/1000:.0f}", fontsize=7.5, color=c0, ha="left", va="bottom")
78− ax.annotate(f"{nouv['R']/k:.1f}", xy=(0, nouv["R"] / k), xytext=(3, nouv["R"] / k - 1.9), fontsize=7.5, color=c1) 89+ ax.text(-nouv["P"] * FP - 1.5, nouv["R"] * FR - 0.4, f"{nouv['P']/1000:.1f}", fontsize=7.5, color=c1, ha="right", va="top")
79− ax.annotate(f"$P^*$ = {base['P']/k:.0f}", xy=(-base["P"] / k, 0), xytext=(-base["P"] / k - 6, 1.2), fontsize=7.5, color=c0, ha="right") 90+ ax.text(-Pct, base["R"] * FR + 1.6, f"court terme :\n$P$ = {ct['P']/1000:.0f}", fontsize=7.5, color=c1, ha="center", va="bottom")
80− ax.annotate(f"court terme : {ct['P']/k:.0f}", xy=(-ct["P"] / k, 0), xytext=(-ct["P"] / k - 4, -0.9), fontsize=7.5, color=c1, ha="right", va="top") 91+ ax.text(-Pct + 1.5, -Cct - 0.3, f"$C$ = {ct['C']/1000:.1f}", fontsize=7.5, color=c1, ha="left", va="top")
81− ax.annotate(f"$C^*$ = {base['C']/k:.1f}", xy=(0, -base["C"] / k), xytext=(3, -base["C"] / k - 0.1), fontsize=7.5, color=c0, va="top") 92+ ax.text(base["S"] * FS + 1.5, -base["C"] * FC - 0.3, f"$C^*$ = {base['C']/1000:.1f}", fontsize=7.5, color=c0, ha="left", va="top")
82− from matplotlib.lines import Line2D
83 h = [Line2D([], [], color=c0, lw=1.8, label="équilibre de référence ($i$ = 5 %)"), 93 h = [Line2D([], [], color=c0, lw=1.8, label="équilibre de référence ($i$ = 5 %)"),
84− Line2D([], [], color=c1, lw=1.8, ls="--", label="nouvel équilibre de long terme ($i$ = 4 %)"), 94+ Line2D([], [], color=c1, lw=1.8, ls="--", label="long terme après la baisse à $i$ = 4 %"),
85 Line2D([], [], color=c1, lw=1.0, ls=":", label="court terme (stock fixe)")] 95 Line2D([], [], color=c1, lw=1.0, ls=":", label="court terme (stock fixe)")]
86− ax.legend(handles=h, loc="lower center", bbox_to_anchor=(0.5, -0.06), ncol=3, fontsize=7.5, frameon=False) 96+ ax.legend(handles=h, loc="upper center", bbox_to_anchor=(0.5, -0.01), ncol=3, fontsize=7.5, frameon=False)
87 out = savefig(fig, "ch03/ch03-fig-quatre-quadrants.pdf"); print("OK", out) 97 out = savefig(fig, "ch03/ch03-fig-quatre-quadrants.pdf"); print("OK", out)
88 98
89 99
modified analysis/python/figures/ch04_ipln.py +1 −1
@@ -19,7 +19,7 @@ def main():
19 s = df[df["geo"] == geo] 19 s = df[df["geo"] == geo]
20 ls = "--" if geo == "Canada" else "-"; lw = 2.0 if geo == "Montréal" else 1.4 20 ls = "--" if geo == "Canada" else "-"; lw = 2.0 if geo == "Montréal" else 1.4
21 ax.plot(s["date"], s["indice"], color=palette.cat[i], ls=ls, lw=lw, label=geo.replace(" (partie du Québec)", " (partie QC)")) 21 ax.plot(s["date"], s["indice"], color=palette.cat[i], ls=ls, lw=lw, label=geo.replace(" (partie du Québec)", " (partie QC)"))
22− dy = {"Canada": 2.3, "Trois-Rivières": -2.0}.get(geo, 0.0) 22+ dy = {"Canada": 2.6, "Trois-Rivières": 0.4, "Gatineau (partie du Québec)": -2.2}.get(geo, 0.0)
23 ax.text(s["date"].iloc[-1] + pd.Timedelta(days=60), s["indice"].iloc[-1] + dy, f"{s['indice'].iloc[-1]:.1f}".replace(".", ","), fontsize=7, va="center", color=palette.ink) 23 ax.text(s["date"].iloc[-1] + pd.Timedelta(days=60), s["indice"].iloc[-1] + dy, f"{s['indice'].iloc[-1]:.1f}".replace(".", ","), fontsize=7, va="center", color=palette.ink)
24 ax.axhline(100, color=palette.ink2, lw=0.6, ls=":"); ax.text(pd.Timestamp("2010-02-01"), 101.5, "déc. 2016 = 100", fontsize=7.5, color=palette.ink2) 24 ax.axhline(100, color=palette.ink2, lw=0.6, ls=":"); ax.text(pd.Timestamp("2010-02-01"), 101.5, "déc. 2016 = 100", fontsize=7.5, color=palette.ink2)
25 ax.axvspan(pd.Timestamp("2020-03-01"), pd.Timestamp("2022-12-31"), color=palette.grid, alpha=0.5, lw=0) 25 ax.axvspan(pd.Timestamp("2020-03-01"), pd.Timestamp("2022-12-31"), color=palette.grid, alpha=0.5, lw=0)
modified analysis/python/figures/ch04_mutations.py +2 −2
@@ -18,8 +18,8 @@ def main():
18 for i, (nom, bar) in enumerate(cas.BAREMES_2026.items()): 18 for i, (nom, bar) in enumerate(cas.BAREMES_2026.items()):
19 te = np.array([cas.droits_mutation(p, bar) / p for p in prix]) * 100 19 te = np.array([cas.droits_mutation(p, bar) / p for p in prix]) * 100
20 ax.plot(prix / 1000, te, color=palette.cat[i], lw=1.6 if i else 1.2, ls="--" if i == 0 else "-", label=nom) 20 ax.plot(prix / 1000, te, color=palette.cat[i], lw=1.6 if i else 1.2, ls="--" if i == 0 else "-", label=nom)
21− for p, lab, y in [(500000, "500 000 $ : médiane unifamiliale,\nprovince, T3 2026", 0.12), (860500, "860 500 $ : médiane plex,\nRMR Montréal, T3 2026", 0.5)]: 21+ for p, lab, y, ha, dx in [(500000, "500 000 $ : médiane unifamiliale,\nprovince, T3 2026", 0.1, "right", -25), (860500, "860 500 $ : médiane plex,\nRMR Montréal, T3 2026", 0.5, "left", 25)]:
22− ax.axvline(p / 1000, color=palette.ink2, lw=0.6, ls=":"); ax.text(p / 1000 + 25, y, lab, fontsize=6.8, color=palette.ink2) 22+ ax.axvline(p / 1000, color=palette.ink2, lw=0.6, ls=":"); ax.text(p / 1000 + dx, y, lab, fontsize=6.8, color=palette.ink2, ha=ha)
23 ax.set_xlabel("Base d'imposition (milliers de dollars)"); ax.set_ylabel("Taux effectif (% de la base)") 23 ax.set_xlabel("Base d'imposition (milliers de dollars)"); ax.set_ylabel("Taux effectif (% de la base)")
24 ax.xaxis.set_major_formatter(FuncFormatter(lambda v, p: f"{v:,.0f}".replace(",", " "))); ax.yaxis.set_major_formatter(FuncFormatter(lambda v, p: f"{v:.1f}".replace(".", ","))) 24 ax.xaxis.set_major_formatter(FuncFormatter(lambda v, p: f"{v:,.0f}".replace(",", " "))); ax.yaxis.set_major_formatter(FuncFormatter(lambda v, p: f"{v:.1f}".replace(".", ",")))
25 ax.set_ylim(0, 3.2); ax.legend(loc="upper left", fontsize=7.5) 25 ax.set_ylim(0, 3.2); ax.legend(loc="upper left", fontsize=7.5)
modified analysis/python/figures/ch04_taux.py +1 −1
@@ -17,7 +17,7 @@ def main():
17 obl = b["obl5"].resample("W").mean() 17 obl = b["obl5"].resample("W").mean()
18 ax.plot(obl.index, obl.values, color=palette.cat[1], lw=1.2, label="Obligation du Canada, 5 ans (moyenne hebdomadaire)") 18 ax.plot(obl.index, obl.values, color=palette.cat[1], lw=1.2, label="Obligation du Canada, 5 ans (moyenne hebdomadaire)")
19 ax.step(b["directeur"].index, b["directeur"].values, color=palette.cat[2], lw=1.4, where="post", label="Taux directeur (cible du financement à un jour, dès 2009)") 19 ax.step(b["directeur"].index, b["directeur"].values, color=palette.cat[2], lw=1.4, where="post", label="Taux directeur (cible du financement à un jour, dès 2009)")
20− for t, lab, y in [("2008-09-15", "crise\nfinancière", 9.3), ("2020-03-11", "pandémie", 9.3), ("2022-03-02", "resserrement\n2022-2023", 9.3)]: 20+ for t, lab, y in [("2008-09-15", "crise\nfinancière", 9.3), ("2020-03-11", "pandémie", 9.3), ("2022-03-02", "resserrement\n2022-2023", 8.4)]:
21 ax.axvline(pd.Timestamp(t), color=palette.ink2, lw=0.6, ls=":"); ax.text(pd.Timestamp(t) + pd.Timedelta(days=40), y, lab, fontsize=7, color=palette.ink2, va="top") 21 ax.axvline(pd.Timestamp(t), color=palette.ink2, lw=0.6, ls=":"); ax.text(pd.Timestamp(t) + pd.Timedelta(days=40), y, lab, fontsize=7, color=palette.ink2, va="top")
22 ax.set_ylabel("Taux (% annuel)"); ax.set_ylim(0, 9.6); ax.set_xlim(pd.Timestamp("2000-01-01"), pd.Timestamp("2027-01-01")) 22 ax.set_ylabel("Taux (% annuel)"); ax.set_ylim(0, 9.6); ax.set_xlim(pd.Timestamp("2000-01-01"), pd.Timestamp("2027-01-01"))
23 ax.xaxis.set_major_locator(mdates.YearLocator(2)); ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y")) 23 ax.xaxis.set_major_locator(mdates.YearLocator(2)); ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y"))
modified analysis/python/figures/ch05_rmr.py +1 −1
@@ -32,7 +32,7 @@ def main():
32 a2.set_yticks(y2); a2.set_yticklabels(sub.index); a2.set_xlim(-0.8, 0.15) 32 a2.set_yticks(y2); a2.set_yticklabels(sub.index); a2.set_xlim(-0.8, 0.15)
33 a2.set_xlabel("Écart logarithmique avec Montréal"); a2.grid(axis="y", visible=False) 33 a2.set_xlabel("Écart logarithmique avec Montréal"); a2.grid(axis="y", visible=False)
34 a2.set_title("B. Décomposition de l'écart (losange = total)", loc="left", fontsize=8.5) 34 a2.set_title("B. Décomposition de l'écart (losange = total)", loc="left", fontsize=8.5)
35− a2.legend(fontsize=6.8, loc="upper left") 35+ a2.legend(fontsize=6.8, loc="upper center", bbox_to_anchor=(0.45, -0.2), ncol=2)
36 out = savefig(fig, "ch05/ch05-fig-valeur-logement-rmr.pdf"); print("OK", out) 36 out = savefig(fig, "ch05/ch05-fig-valeur-logement-rmr.pdf"); print("OK", out)
37 37
38 if __name__ == "__main__": 38 if __name__ == "__main__":
modified analysis/python/figures/ch09_ratios.py +7 −6
@@ -33,7 +33,7 @@ def fig_distribution(et):
33 ax.text(ep["borne_haute"] + 0.01, ax.get_ylim()[1] * 0.55, f"médiane ± 2σ\n{ep['n_retires']} ventes\nretirées", fontsize=7.5, color=palette.cat[3]) 33 ax.text(ep["borne_haute"] + 0.01, ax.get_ylim()[1] * 0.55, f"médiane ± 2σ\n{ep['n_retires']} ventes\nretirées", fontsize=7.5, color=palette.cat[3])
34 for ax in axes: 34 for ax in axes:
35 ax.axvspan(0.90, 1.10, color=palette.grid, alpha=0.35, lw=0, zorder=0) 35 ax.axvspan(0.90, 1.10, color=palette.grid, alpha=0.35, lw=0, zorder=0)
36− axes[0].text(0.905, axes[0].get_ylim()[1] * 0.05, "niveau IAAO 0,90–1,10", fontsize=7, color=palette.ink2) 36+ axes[0].text(1.13, axes[0].get_ylim()[1] * 0.55, "zone grise :\nniveau IAAO\n0,90–1,10", fontsize=7, color=palette.ink2, ha="center", va="center")
37 fig.suptitle(f"COD {et['brut']['COD']:.1f} → {et['aj']['COD']:.1f} ; PRD {et['brut']['PRD']:.3f} → {et['aj']['PRD']:.3f} ; proportion médiane après épuration {et['PM']:.3f}".replace(".", ","), 37 fig.suptitle(f"COD {et['brut']['COD']:.1f} → {et['aj']['COD']:.1f} ; PRD {et['brut']['PRD']:.3f} → {et['aj']['PRD']:.3f} ; proportion médiane après épuration {et['PM']:.3f}".replace(".", ","),
38 fontsize=8, color=palette.ink2, x=0.02, ha="left", y=1.02) 38 fontsize=8, color=palette.ink2, x=0.02, ha="left", y=1.02)
39 print("OK", savefig(fig, "ch09/ch09-fig-ratios-distribution.pdf")) 39 print("OK", savefig(fig, "ch09/ch09-fig-ratios-distribution.pdf"))
@@ -74,14 +74,15 @@ def fig_sensibilite(d, et, da):
74 s = cas.sensibilite_acheteur(d, et, da); tv = cas.taux_voisinage_gatineau() 74 s = cas.sensibilite_acheteur(d, et, da); tv = cas.taux_voisinage_gatineau()
75 fig, ax = plt.subplots(figsize=(LARGEUR, 2.9)) 75 fig, ax = plt.subplots(figsize=(LARGEUR, 2.9))
76 for col, lab, c in [("gain_net_min", f"taux de voisinage minimal ({tv['min']:.3f} $/1 000 $)", palette.cat[2]), ("gain_net_mediane", f"taux médian ({tv['mediane']:.3f} $/1 000 $)", palette.cat[0]), ("gain_net_max", f"taux maximal ({tv['max']:.3f} $/1 000 $)", palette.cat[1])]: 76 for col, lab, c in [("gain_net_min", f"taux de voisinage minimal ({tv['min']:.3f} $/1 000 $)", palette.cat[2]), ("gain_net_mediane", f"taux médian ({tv['mediane']:.3f} $/1 000 $)", palette.cat[0]), ("gain_net_max", f"taux maximal ({tv['max']:.3f} $/1 000 $)", palette.cat[1])]:
77− ax.plot(s["valeur_reelle"], s[col], marker="o", ms=3, color=c, label=lab.replace(".", ",")) 77+ ax.plot(s["valeur_reelle"], s[col], marker="o", ms=3, color=c, label=lab.replace(".", ",").replace("$", "\\$"))
78 ax.axhline(0, color=palette.ink2, lw=0.8) 78 ax.axhline(0, color=palette.ink2, lw=0.8)
79− ax.axvline(da["prix_ref"], color=palette.cat[3], ls="--", lw=0.9); ax.text(da["prix_ref"] + 1500, ax.get_ylim()[1] * 0.92, "prix rajusté\nde la vente", fontsize=7.5, color=palette.cat[3]) 79+ ax.axvline(da["prix_ref"], color=palette.cat[3], ls="--", lw=0.9); ax.text(da["prix_ref"] + 1500, ax.get_ylim()[1] * 0.97, "prix rajusté\nde la vente", fontsize=7.5, color=palette.cat[3], va="top")
80− ax.axvline(da["valeur_role"] - da["seuil_valeur"], color=palette.ink2, ls=":", lw=0.9) 80+ seuil = da["valeur_role"] - da["seuil_valeur"]
81+ ax.axvline(seuil, color=palette.ink2, ls=":", lw=0.9); ax.text(seuil - 1500, ax.get_ylim()[1] * 0.97, "seuil de\nrentabilité", fontsize=7.5, color=palette.ink2, ha="right", va="top")
81 ax.set_xlabel("Valeur réelle conclue à la date de référence ($)"); ax.set_ylabel("Gain net sur trois exercices ($)") 82 ax.set_xlabel("Valeur réelle conclue à la date de référence ($)"); ax.set_ylabel("Gain net sur trois exercices ($)")
82 ax.xaxis.set_major_formatter(FuncFormatter(fmt_dollars)); ax.yaxis.set_major_formatter(FuncFormatter(fmt_dollars)) 83 ax.xaxis.set_major_formatter(FuncFormatter(fmt_dollars)); ax.yaxis.set_major_formatter(FuncFormatter(fmt_dollars))
83− ax.set_title(f"Valeur inscrite {fmt_dollars(da['valeur_role'])} ; frais de révision {da['frais_revision']:.0f} $ ; facteur comparatif {et['F']:.4f}".replace(".", ","), loc="left", fontsize=8.5) 84+ ax.set_title(f"Valeur inscrite {fmt_dollars(da['valeur_role'])} ; frais de révision {da['frais_revision']:.0f} $ ; facteur comparatif {et['F']:.4f}".replace(".", ",").replace("$", "\\$"), loc="left", fontsize=8.5)
84− ax.legend(loc="upper right", fontsize=7.5) 85+ ax.legend(loc="lower left", fontsize=7.5)
85 print("OK", savefig(fig, "ch09/ch09-fig-sensibilite-contestation.pdf")) 86 print("OK", savefig(fig, "ch09/ch09-fig-sensibilite-contestation.pdf"))
86 87
87 88
added analysis/python/figures/ch17_figures.py +104 −0
@@ -0,0 +1,104 @@
1+#!/usr/bin/env python3
2+"""Chapitre 17 — figures : cascade d'ajustements (G0404), dispersion des paires, prix bruts vs ajustés, tornade de sensibilité.
3+Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/figures/ch17_figures.py"""
4+from __future__ import annotations
5+import sys, pathlib
6+import numpy as np
7+import matplotlib.pyplot as plt
8+from matplotlib.ticker import FuncFormatter
9+sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
10+sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "cases"))
11+from traite.style import setup, palette, savefig, LARGEUR, fmt_dollars
12+import ch17_ajustements as c17
13+
14+def fig_cascade(r):
15+ c = r["cascade_G0404"]
16+ fig, ax = plt.subplots(figsize=(LARGEUR, 3.7))
17+ n = len(c); x = np.arange(n)
18+ bases = c["base_apres"].values; adj = c["ajustement"].values
19+ for i in range(n):
20+ if i == 0 or i == n - 1:
21+ ax.bar(x[i], bases[i], color=palette.cat[0] if i == 0 else palette.brand, width=0.7)
22+ ax.text(x[i], bases[i] + 900, fmt_dollars(bases[i]), ha="center", va="bottom", fontsize=7.5)
23+ elif adj[i] == 0:
24+ ax.plot([x[i] - 0.35, x[i] + 0.35], [bases[i], bases[i]], color=palette.ink2, lw=1.2)
25+ ax.text(x[i], bases[i] + 900, "0", ha="center", va="bottom", fontsize=7, color=palette.ink2)
26+ else:
27+ bottom = min(bases[i - 1], bases[i])
28+ couleur = palette.cat[2] if adj[i] > 0 else palette.cat[1]
29+ ax.bar(x[i], abs(adj[i]), bottom=bottom, color=couleur, width=0.7)
30+ lab = ("+" if adj[i] > 0 else "\u2212") + f"{abs(adj[i]):,.0f}".replace(",", " ")
31+ if adj[i] > 0:
32+ ax.text(x[i], bases[i] + 900, lab, ha="center", va="bottom", fontsize=7.5)
33+ else:
34+ ax.text(x[i], bases[i] - 900, lab, ha="center", va="top", fontsize=7.5)
35+ if 0 < i < n:
36+ ax.plot([x[i - 1] + 0.35, x[i] - 0.35], [bases[i - 1], bases[i - 1]], color=palette.ink2, lw=0.6)
37+ ax.set_ylim(495000, 558000); ax.set_xticks(x)
38+ ax.set_xticklabels(c["etape"], rotation=35, ha="right", fontsize=7.5)
39+ ax.yaxis.set_major_formatter(FuncFormatter(fmt_dollars)); ax.set_ylabel("Base après l'étape")
40+ savefig(fig, "ch17/ch17-fig-cascade-g0404.pdf")
41+
42+def fig_paires(r):
43+ fig, axes = plt.subplots(1, 2, figsize=(LARGEUR, 3.3), sharey=True)
44+ for ax, attr, titre in zip(axes, ("garage", "sous_sol"), ("Garage (une place de plus)", "Sous-sol fini")):
45+ p = r[f"paires_{attr}"].sort_values("diff_temps").reset_index(drop=True)
46+ rs = r[f"resume_{attr}"]; im = r[f"implicite_{attr}"]
47+ y = p["diff_temps"].values; x = np.arange(len(y))
48+ ax.axhline(0, color=palette.ink2, lw=0.6)
49+ ax.axhspan(rs["moyenne"] - 1.96 * rs["erreur_type"], rs["moyenne"] + 1.96 * rs["erreur_type"], color=palette.cat[0], alpha=0.12, lw=0)
50+ ax.axhline(rs["moyenne"], color=palette.cat[0], lw=1.4, label="moyenne des paires (± 1,96 erreur-type)")
51+ ax.axhline(rs["mediane"], color=palette.cat[0], lw=1.0, ls=":", label="médiane des paires")
52+ ax.axhline(im["au_prix_median_paires"], color=palette.cat[1], lw=1.4, ls="--", label="prix implicite hédonique")
53+ ax.scatter(x, y, s=22, color=palette.ink, zorder=3)
54+ ax.set_title(f"{titre} — n = {rs['n']} paires", fontsize=9)
55+ ax.set_xticks([]); ax.set_xlabel("Paires classées par écart croissant")
56+ ax.yaxis.set_major_formatter(FuncFormatter(fmt_dollars))
57+ ax.set_ylim(-160000, 160000)
58+ axes[0].set_ylabel("Écart de prix « avec » − « sans » (date alignée)")
59+ axes[0].legend(loc="upper left", fontsize=7)
60+ savefig(fig, "ch17/ch17-fig-paires.pdf")
61+
62+def fig_bruts_ajustes(r):
63+ b = r["base"]; a = b["adj"]; rec = r["reconciliation"]
64+ fig, ax = plt.subplots(figsize=(LARGEUR, 3.2))
65+ y = np.arange(len(a))[::-1]
66+ for yi, (_, row) in zip(y, a.iterrows()):
67+ ax.plot([row["prix_vente"], row["prix_ajuste"]], [yi, yi], color=palette.grid, lw=2.2, zorder=1)
68+ ax.scatter(a["prix_vente"], y, s=42, facecolor="white", edgecolor=palette.cat[0], linewidth=1.6, zorder=3, label="prix de vente")
69+ ax.scatter(a["prix_ajuste"], y, s=42, color=palette.cat[0], zorder=4, label="prix ajusté (grille hédonique)")
70+ w = rec["poids_inverse_brut"]
71+ for yi, (_, row), wi in zip(y, a.iterrows(), w):
72+ ax.text(max(row["prix_vente"], row["prix_ajuste"]) + 4000, yi, f"brut {row['ajust_brut_pct']*100:.0f} % · poids {wi:.2f}".replace(".", ","), va="center", fontsize=7.2, color=palette.ink2)
73+ vp = rec["inverse_brut"]["valeur_ponderee"]; pred = b["prediction_hedonique"]
74+ ax.axvline(vp, color=palette.brand, lw=1.3, ls="-")
75+ ax.text(vp, len(a) - 0.45, "valeur pondérée\n" + fmt_dollars(vp), ha="center", va="bottom", fontsize=7.3, color=palette.brand)
76+ ax.axvline(pred, color=palette.cat[1], lw=1.3, ls="--")
77+ ax.text(pred, len(a) - 0.45, "prédiction hédonique\n" + fmt_dollars(pred), ha="center", va="bottom", fontsize=7.3, color=palette.cat[1])
78+ ax.set_yticks(y); ax.set_yticklabels(a["id_vente"]); ax.set_ylim(-0.7, len(a) + 0.6)
79+ ax.set_xlim(490000, 720000); ax.xaxis.set_major_formatter(FuncFormatter(fmt_dollars))
80+ ax.legend(loc="upper left", fontsize=7.3)
81+ savefig(fig, "ch17/ch17-fig-bruts-ajustes.pdf")
82+
83+def fig_sensibilite(r):
84+ s = r["sensibilite"]; ref = float(s.iloc[0]["bas"])
85+ s = s.iloc[1:].copy(); s = s.sort_values("etendue")
86+ fig, ax = plt.subplots(figsize=(LARGEUR, 3.6))
87+ y = np.arange(len(s))
88+ ax.barh(y, s["haut"] - s["bas"], left=s["bas"], color=palette.cat[0], alpha=0.85, height=0.62)
89+ ax.axvline(ref, color=palette.ink, lw=1.0)
90+ for yi, (_, row) in zip(y, s.iterrows()):
91+ ax.text(row["bas"] - 1500, yi, fmt_dollars(row["bas"]), ha="right", va="center", fontsize=6.8, color=palette.ink2)
92+ ax.text(row["haut"] + 1500, yi, fmt_dollars(row["haut"]), ha="left", va="center", fontsize=6.8, color=palette.ink2)
93+ ax.set_yticks(y); ax.set_yticklabels(s["scenario"], fontsize=7.4)
94+ ax.set_xlim(530000, 615000); ax.xaxis.set_major_formatter(FuncFormatter(fmt_dollars))
95+ ax.set_xlabel("Valeur pondérée (poids inverse de l'ajustement brut) ; trait vertical : référence " + fmt_dollars(ref))
96+ savefig(fig, "ch17/ch17-fig-sensibilite.pdf")
97+
98+def main():
99+ setup(); r = c17.tout()
100+ fig_cascade(r); fig_paires(r); fig_bruts_ajustes(r); fig_sensibilite(r)
101+ print("figures écrites dans assets/figures/ch17/")
102+
103+if __name__ == "__main__":
104+ main()
added analysis/python/tests/test_ch03.py +218 −0
@@ -0,0 +1,218 @@
1+"""Tests du chapitre 3 — Offre, demande et cycles.
2+Tolérances : identités algébriques 1e-9 (relatif) ; valeurs citées dans le texte : 0,5 unité sur les logements et les dollars arrondis,
3+0,05 point de pourcentage sur les taux ; valeurs réelles lues dans data/raw : exactes (sommes d'entiers) ou 0,01."""
4+import sys, pathlib, math
5+import numpy as np, pytest
6+sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "cases"))
7+import ch03_cycles as cas
8+from dataclasses import replace
9+
10+
11+@pytest.fixture(scope="module")
12+def p4q():
13+ return cas.Parametres4Q()
14+
15+
16+@pytest.fixture(scope="module")
17+def pc():
18+ return cas.ParametresCycle()
19+
20+
21+class TestQuatreQuadrants:
22+ def test_parametres_derives(self, p4q):
23+ assert p4q.b == pytest.approx(3.4722, abs=5e-4)
24+ assert p4q.a == pytest.approx(150_000, abs=1e-6)
25+ assert p4q.P0 == pytest.approx(288_000, abs=1e-6)
26+ assert p4q.C0 == pytest.approx(1_000, abs=1e-9)
27+ assert p4q.c1 == pytest.approx(48.0, abs=1e-9)
28+
29+ def test_equilibre_ferme_egal_iteratif(self, p4q):
30+ eq, it = cas.equilibre_4q(p4q), cas.equilibre_4q_iteratif(p4q)
31+ for k in ("S", "R", "P", "C"):
32+ assert eq[k] == pytest.approx(it[k], rel=1e-6)
33+ assert eq["S"] == pytest.approx(100_000, abs=1e-6) and eq["R"] == pytest.approx(14_400, abs=1e-6)
34+ assert eq["C"] == pytest.approx(p4q.delta * eq["S"], rel=1e-12) # ΔS = 0
35+ assert eq["P"] == pytest.approx(eq["R"] / p4q.i, rel=1e-12) # P = R/i
36+ assert eq["C"] == pytest.approx((eq["P"] - p4q.c0) / p4q.c1, rel=1e-9) # C = g(P)
37+
38+ def test_baisse_du_taux(self, p4q):
39+ lt = cas.equilibre_4q(p4q, i=0.04); ct = cas.court_terme_4q(p4q, i=0.04)
40+ assert ct["P"] == pytest.approx(360_000, abs=0.5)
41+ assert lt["S"] == pytest.approx(109_375, abs=0.5)
42+ assert lt["R"] == pytest.approx(11_700, abs=0.5)
43+ assert lt["P"] == pytest.approx(292_500, abs=0.5)
44+ assert lt["C"] == pytest.approx(1_093.75, abs=0.01)
45+
46+ def test_statique_comparative(self, p4q):
47+ sc = cas.statique_comparative(p4q)
48+ assert sc.loc["Demande a : +10 %", "R_ct"] == pytest.approx(18_720, abs=0.5)
49+ assert sc.loc["Demande a : +10 %", "S_lt"] == pytest.approx(113_846.15, abs=0.5)
50+ assert sc.loc["Demande a : +10 %", "R_lt"] == pytest.approx(14_732.31, abs=0.5)
51+ assert sc.loc["Demande a : +10 %", "dR_ct_pct"] == pytest.approx(30.0, abs=0.05)
52+ assert sc.loc["Demande a : +10 %", "dR_lt_pct"] == pytest.approx(2.31, abs=0.05)
53+ assert sc.loc["Coût c0 : +10 %", "S_lt"] == pytest.approx(96_153.85, abs=0.5)
54+ assert sc.loc["Coût c0 : +10 %", "dP_lt_pct"] == pytest.approx(7.69, abs=0.05)
55+ assert sc.loc["Taux i : 5 % → 4 %", "dP_lt_pct"] == pytest.approx(1.56, abs=0.05)
56+ assert sc.loc["Taux i : 5 % → 4 %", "dR_lt_pct"] == pytest.approx(-18.75, abs=0.05)
57+
58+ def test_elasticites(self, p4q):
59+ el = cas.elasticites_4q(p4q)
60+ assert el["eps_C_P"] == pytest.approx(6.0, rel=1e-9)
61+ assert el["kappa"] == pytest.approx(0.12, rel=1e-9)
62+ assert el["eps_S_a"] == pytest.approx(1.385, abs=5e-3)
63+
64+
65+class TestDynamique:
66+ def test_etat_initial_stationnaire(self, pc):
67+ df = cas.simuler_cycle(replace(pc, choc_demande=0.0))
68+ assert np.allclose(df["S"], pc.p4q.S0, rtol=1e-9) and np.allclose(df["R"], pc.p4q.R0, rtol=1e-9)
69+ assert np.allclose(df["v"], pc.v_nat, atol=1e-12)
70+ assert pc.c0 == pytest.approx(276_000, abs=1e-6) and pc.kappa == pytest.approx(0.48, rel=1e-9)
71+
72+ def test_stationnaire_apres_choc(self, pc):
73+ ss = cas.etat_stationnaire_cycle(pc)
74+ assert ss["S"] == pytest.approx(108_902, abs=1.0)
75+ assert ss["R"] == pytest.approx(14_453, abs=1.0)
76+ assert ss["C"] == pytest.approx(pc.p4q.delta * ss["S"], rel=1e-12)
77+ df = cas.simuler_cycle(replace(pc, T=600))
78+ assert df["S"].iloc[-1] == pytest.approx(ss["S"], rel=2e-3)
79+ assert df["v"].iloc[-1] == pytest.approx(pc.v_nat, abs=2e-3)
80+
81+ def test_mesures_du_cycle_cite(self, pc):
82+ r = cas.resume_cycle(cas.simuler_cycle(pc), pc)
83+ assert r["R_pointe_pct"] == pytest.approx(15.0, abs=0.1)
84+ assert r["t_pointe"] == 13
85+ assert r["R_creux_pct"] == pytest.approx(-7.0, abs=0.1)
86+ assert r["t_creux"] == 35
87+ assert r["v_max_pct"] == pytest.approx(5.0, abs=0.05)
88+ assert r["v_min_pct"] == pytest.approx(1.17, abs=0.05)
89+ assert r["C_max_annuel"] == pytest.approx(4_711, abs=1.0)
90+ assert r["periode_trim"] == 47
91+ assert r["amortissement"] == pytest.approx(0.17, abs=0.01)
92+
93+ def test_sensibilite(self, pc):
94+ s = cas.sensibilite_cycle(pc, delais=(2, 6, 12), mult_c1=(0.25, 1.0), lams=(0.75,)).set_index(["L", "mult_c1"])
95+ assert s.loc[(12, 0.25), "R_creux_pct"] == pytest.approx(-18.06, abs=0.1)
96+ assert s.loc[(2, 0.25), "R_creux_pct"] == pytest.approx(-1.21, abs=0.1)
97+ assert s.loc[(6, 1.0), "R_creux_pct"] > 0 # offre peu réactive : pas de sous-ajustement
98+ assert math.isnan(s.loc[(6, 1.0), "periode_trim"]) # pas de second cycle
99+ # le délai allonge la période et creuse le creux
100+ assert s.loc[(12, 0.25), "periode_trim"] > s.loc[(6, 0.25), "periode_trim"] > s.loc[(2, 0.25), "periode_trim"]
101+
102+ def test_condition_oscillation(self, pc):
103+ d = pc.p4q.delta
104+ for k in (0.05, 0.12, 0.2449, 0.2451, 0.3, 0.48, 0.6, 1.2):
105+ a = cas.condition_oscillation_L1(k, d); b = cas.racines_caracteristiques(k, d, L=1)
106+ assert a["oscille"] == b["oscille"]
107+ assert a["module"] == pytest.approx(b["module"], rel=1e-9)
108+ if a["oscille"]:
109+ assert a["periode"] == pytest.approx(b["periode"], rel=1e-9)
110+ assert a["module"] == pytest.approx(math.sqrt(k), rel=1e-12)
111+ assert cas.condition_oscillation_L1(0.12, d)["seuil"] == pytest.approx(0.2450, abs=1e-4)
112+ assert not cas.condition_oscillation_L1(0.12, d)["oscille"]
113+ c = cas.condition_oscillation_L1(0.48, d)
114+ assert c["oscille"] and c["stable"] and c["periode"] == pytest.approx(8.1, abs=0.05) and c["module"] == pytest.approx(0.693, abs=5e-4)
115+ assert not cas.condition_oscillation_L1(1.2, d)["stable"]
116+ assert cas.racines_caracteristiques(0.48, d, L=3)["module"] > 1.0 # délai de 3 ans : instable
117+
118+
119+class TestDonneesReelles:
120+ def test_mises_en_chantier_quebec(self):
121+ ann = cas.mises_en_chantier_annuelles()
122+ assert (ann.loc[1990:2025, "trimestres"] == 4).all() and ann.loc[2026, "trimestres"] == 3
123+ attendu = {1990: 48_070, 2000: 24_695, 2005: 50_910, 2015: 37_926, 2020: 54_066, 2021: 67_810, 2022: 57_107,
124+ 2023: 38_912, 2024: 48_713, 2025: 59_864, 2026: 42_159}
125+ for y, v in attendu.items():
126+ assert ann.loc[y, "total"] == v
127+ assert ann.loc[2005, "individuelles"] == 23_930 and ann.loc[2025, "individuelles"] == 8_477
128+ assert ann.loc[2005, "collectifs"] == 26_980 and ann.loc[2025, "collectifs"] == 51_387
129+ assert ann.loc[2025, "part_individuelles"] == pytest.approx(0.1416, abs=5e-4)
130+ assert ann.loc[2005, "part_individuelles"] == pytest.approx(0.470, abs=5e-4)
131+ # cohérence interne : les cinq types somment au total
132+ tot = ann[["individuelles", "jumeles", "bande", "appartements"]].sum(axis=1)
133+ assert np.allclose(tot.loc[1990:2026], ann.loc[1990:2026, "total"])
134+ assert np.allclose(ann["jumeles"] + ann["bande"] + ann["appartements"], ann["collectifs"])
135+ # variation 2021 → 2023 et 2023 → 2025
136+ assert (ann.loc[2023, "total"] / ann.loc[2021, "total"] - 1) == pytest.approx(-0.426, abs=5e-4)
137+ assert (ann.loc[2025, "total"] / ann.loc[2023, "total"] - 1) == pytest.approx(0.538, abs=5e-4)
138+
139+ def test_montreal_incomplet(self):
140+ m = cas.mises_en_chantier_montreal_annuelles()
141+ assert m.loc[2024, "mois"] == 12 and m.loc[2024, "total"] == 17_570
142+ assert m.loc[2025, "mois"] == 9 and m.loc[2025, "total"] == 21_138 # juillet-septembre 2025 absents du fichier
143+ q = cas.mises_en_chantier_montreal_annuelles("Québec")
144+ assert q.loc[2025, "mois"] == 12 and q.loc[2025, "total"] == 54_220
145+
146+ def test_taux_directeur(self):
147+ ch = cas.changements_taux_directeur()
148+ assert len(ch) == 23
149+ assert ch.loc["2022-03-03", "apres"] == 0.50 and ch.loc["2023-07-13", "apres"] == 5.00
150+ assert ch.loc["2025-10-30", "apres"] == 2.25 and ch.loc["2026-10-29", "apres"] == 2.50
151+ assert ch.loc["2022-07-14", "variation_pb"] == 100
152+ assert int(ch.loc["2022-03-03":"2023-07-13", "variation_pb"].sum()) == 475
153+ assert int(ch.loc["2024-06-06":"2025-10-30", "variation_pb"].sum()) == -275
154+
155+ def test_taux_effectifs_a4(self):
156+ a4 = cas.taux_effectifs_a4()
157+ assert a4["serie_assure"] == "V122667780" and a4["serie_non_assure"] == "V122667786"
158+ assert a4["assure_fixe_5ans_plus"] == pytest.approx(4.12) and a4["non_assure_fixe_5ans_plus"] == pytest.approx(4.58)
159+
160+ def test_ipc_ipln(self):
161+ ipc = cas.ipc_quebec(); ipln = cas.ipln_quebec()
162+ assert ipc.index[-1].strftime("%Y-%m") == "2026-09" and ipc.iloc[-1] == 167.7 and ipc.iloc[-13] == 162.6
163+ assert (ipc.iloc[-1] / ipc.iloc[-13] - 1) * 100 == pytest.approx(3.14, abs=0.01)
164+ assert ipln.iloc[-1] == 150.0 and (ipln.iloc[-1] / ipln.iloc[-13] - 1) * 100 == pytest.approx(1.28, abs=0.01)
165+ loyer = cas.ipc_quebec("Loyer")
166+ assert loyer.iloc[-1] == 174.2 and (loyer.iloc[-1] / loyer.iloc[-13] - 1) * 100 == pytest.approx(3.3, abs=0.05)
167+
168+ def test_serie_cout_usage(self):
169+ cu = cas.serie_cout_usage()
170+ assert cu.index[0].strftime("%Y-%m") == "2001-01" and cu.index[-1].strftime("%Y-%m") == "2026-09"
171+ t = cu.loc[cas.DATES_TABLEAU_COUT_USAGE]
172+ assert t["hyp5"].round(2).tolist() == [7.79, 7.49, 5.29, 4.94, 7.04, 6.09]
173+ assert t["pi"].round(2).tolist() == [2.76, 2.02, 2.84, 0.30, 4.16, 3.14]
174+ assert t["pr_ipc"].round(1).tolist() == [12.0, 11.4, 17.3, 12.6, 16.1, 15.9]
175+ assert np.allclose(cu["u_ipc"], cu["hyp5"] + 100 * (cas.TAUX_TAXE_QUEBEC_2026 + cas.DELTA_ENTRETIEN_RISQUE) - cu["pi"])
176+ assert np.allclose(cu["pr_ipc"] * cu["u_ipc"], 100.0)
177+ # plancher du coût d'usage sous l'hypothèse IPLN en 2021-2022 (anticipations extrapolatives)
178+ assert cu["u_ipln"].min() < 1.5 and cu["u_ipln"].idxmin().year in (2021, 2022)
179+
180+
181+class TestCoutUsageExemple:
182+ def test_formules(self):
183+ assert cas.taux_cout_usage(0.06, 0.01, 0.02, 0.03) == pytest.approx(0.06)
184+ assert cas.cout_usage(400_000, 0.06, 0.01, 0.02, 0.03) == pytest.approx(24_000)
185+ assert cas.ratio_prix_loyer(0.06, 0.01, 0.02, 0.03) == pytest.approx(1 / 0.06)
186+ assert cas.ratio_prix_loyer(0.02, 0.0, 0.0, 0.03) == math.inf
187+
188+ def test_exemple_cite(self):
189+ ex = cas.exemple_cout_usage()
190+ assert cas.TAUX_TAXE_QUEBEC_2026 == pytest.approx(0.0082539, abs=1e-7)
191+ assert ex["u_affiche_ipc"] == pytest.approx(0.06415, abs=5e-5)
192+ assert ex["cout_annuel_affiche_ipc"] == pytest.approx(29_735, abs=0.5)
193+ assert ex["cout_mensuel_affiche_ipc"] == pytest.approx(2_478, abs=0.5)
194+ assert ex["pr_affiche_ipc"] == pytest.approx(15.59, abs=0.005)
195+ assert ex["u_effectif_ipc"] == pytest.approx(0.04805, abs=5e-5)
196+ assert ex["cout_annuel_effectif_ipc"] == pytest.approx(22_273, abs=0.5)
197+ assert ex["cout_mensuel_effectif_ipc"] == pytest.approx(1_856, abs=0.5)
198+ assert ex["pr_effectif_ipc"] == pytest.approx(20.81, abs=0.005)
199+ assert ex["u_effectif_ipln"] == pytest.approx(0.06605, abs=5e-5)
200+ assert ex["cout_mensuel_effectif_ipln"] == pytest.approx(2_551, abs=0.5)
201+ assert ex["pr_effectif_ipln"] == pytest.approx(15.14, abs=0.005)
202+ assert ex["var_pr_plus100pb_pct"] == pytest.approx(-17.2, abs=0.05)
203+ assert ex["semi_elasticite"] == pytest.approx(-20.8, abs=0.05)
204+ assert ex["prix_implicite_loyer_3ch_effectif_ipc"] == pytest.approx(368_336, abs=0.5)
205+ # identité : coût annuel / 12 = coût mensuel ; prix implicite × u = loyer annuel
206+ assert ex["cout_annuel_effectif_ipc"] / 12 == pytest.approx(ex["cout_mensuel_effectif_ipc"], rel=1e-12)
207+ assert ex["prix_implicite_loyer_3ch_effectif_ipc"] * ex["u_effectif_ipc"] == pytest.approx(1475 * 12, rel=1e-12)
208+
209+
210+class TestIndicateurs:
211+ def test_mois_inventaire(self):
212+ assert cas.mois_inventaire(2_204, 2_161) == pytest.approx(3.06, abs=0.005)
213+ ind = cas.indicateurs_apciq()
214+ attendu = {"Province": 6.08, "RMR Montréal": 6.59, "RMR Québec": 3.06, "RMR Gatineau": 6.48,
215+ "RMR Sherbrooke": 4.35, "RMR Trois-Rivières": 3.60, "RMR Saguenay": 4.03}
216+ for k, v in attendu.items():
217+ assert ind.loc[k, "mois_inventaire"] == pytest.approx(v, abs=0.005)
218+ assert ind.loc["RMR Montréal", "ventes_mensuelles"] == pytest.approx(3_088.33, abs=0.01)
added analysis/python/tests/test_ch04.py +227 −0
@@ -0,0 +1,227 @@
1+"""Tests du chapitre 4 — marchés résidentiels québécois : lecture des données réelles (Recensement 2021, 17-10-0148-01, IPLN 18-10-0205-01,
2+SCHL ELL octobre 2025, Banque du Canada), identités (parts, dissimilarité, barèmes progressifs, paiement hypothécaire) et valeurs citées dans le
3+texte. Tolérances : valeurs d'indices et de taux exactes (lecture des fichiers bruts) ; 0,5 $ sur les montants arrondis ; 1e-9 sur les identités."""
4+import sys, pathlib, math
5+import numpy as np, pytest
6+sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "cases"))
7+import ch04_marches as cas
8+from traite import finance as fin
9+
10+ROOT = pathlib.Path(__file__).resolve().parents[3]
11+
12+
13+@pytest.fixture(scope="module")
14+def res():
15+ return cas.main(verbose=False)
16+
17+
18+class TestRecensement:
19+ def test_totaux(self, res):
20+ t = res["types"]; tot = t[t["type_id"] == 1].set_index("geo")
21+ assert tot.loc["Québec", "total"] == 3749035 and tot.loc["Québec", "proprietaire"] == 2245600 and tot.loc["Québec", "locataire"] == 1497005
22+ assert tot.loc["Canada", "total"] == 14978945 and tot.loc["Canada", "proprietaire"] == 9955970
23+ assert tot.loc["Québec", "taux_propriete"] == pytest.approx(0.599, abs=5e-4)
24+ assert tot.loc["Canada", "taux_propriete"] == pytest.approx(0.665, abs=5e-4)
25+
26+ def test_types_cites(self, res):
27+ sp = res["parc"]
28+ assert sp.loc[2, "qc_menages"] == 1672095 and sp.loc[6, "qc_menages"] == 1242980 and sp.loc[5, "qc_menages"] == 269415
29+ assert sp.loc[3, "qc_menages"] == 225795 and sp.loc[9, "qc_menages"] == 200495 and sp.loc[8, "qc_menages"] == 99205
30+ assert sp.loc[2, "qc_part"] == pytest.approx(0.446, abs=5e-4) and sp.loc[6, "qc_part"] == pytest.approx(0.3315, abs=5e-4)
31+ assert sp.loc[5, "qc_part"] == pytest.approx(0.0719, abs=5e-4) and sp.loc[2, "ca_part"] == pytest.approx(0.5256, abs=5e-4)
32+ assert sp.loc[6, "ca_part"] == pytest.approx(0.1828, abs=5e-4)
33+ # les huit types détaillés épuisent le total (hiérarchie 2, 3, 4 = {5,6,7,8,9}, 10)
34+ # arrondi aléatoire de Statistique Canada (multiples de 5) : la somme des huit types diffère du total d'au plus 10 ménages
35+ assert abs(sp["qc_menages"].sum() - 3749035) <= 10 and abs(sp["ca_menages"].sum() - 14978945) <= 10
36+ assert sp["qc_part"].sum() == pytest.approx(1.0, abs=1e-5) and sp["ca_part"].sum() == pytest.approx(1.0, abs=1e-5)
37+
38+ def test_taux_propriete_par_type(self, res):
39+ sp = res["parc"]
40+ assert sp.loc[2, "qc_taux_propriete"] == pytest.approx(0.922, abs=5e-4)
41+ assert sp.loc[5, "qc_taux_propriete"] == pytest.approx(0.429, abs=5e-4)
42+ assert sp.loc[6, "qc_taux_propriete"] == pytest.approx(0.210, abs=5e-4)
43+ assert sp.loc[3, "qc_taux_propriete"] == pytest.approx(0.2865, abs=5e-4)
44+ # propriétaires + locataires ≤ total : le solde est le mode « logement fourni par le gouvernement local, la Première Nation ou la bande »
45+ ecart = sp["qc_menages"] - sp["qc_proprietaires"] - sp["qc_locataires"]
46+ assert (ecart >= -10).all() and ecart.sum() == pytest.approx(6430, abs=40)
47+
48+ def test_dissimilarite(self, res):
49+ sp = res["parc"]
50+ D = cas.indice_dissimilarite(sp["qc_part"].values, sp["ca_part"].values)
51+ assert D == pytest.approx(0.1715, abs=5e-4) and 0 <= D <= 1
52+ assert cas.indice_dissimilarite(sp["qc_part"].values, sp["qc_part"].values) == 0.0
53+ plex_like = sp.loc[[5, 6], "qc_menages"].sum()
54+ assert plex_like == 1512395 and plex_like / 3749035 == pytest.approx(0.4034, abs=5e-4)
55+
56+ def test_menages_rmr(self, res):
57+ mr = res["menages_rmr"]
58+ assert mr.loc["RMR Montréal", "total"] == 1835705 and mr.loc["RMR Montréal", "locataire"] == 837470
59+ assert mr.loc["RMR Montréal", "part_locataires"] == pytest.approx(0.456, abs=5e-4)
60+ assert mr.loc["RMR Québec", "total"] == 387955 and mr.loc["RMR Québec", "part_locataires"] == pytest.approx(0.417, abs=5e-4)
61+ assert mr.loc["RMR Ottawa-Gatineau (ensemble)", "part_locataires"] == pytest.approx(0.3456, abs=5e-4)
62+ assert mr.loc["Québec (province)", "part_locataires"] == pytest.approx(0.3993, abs=5e-4)
63+
64+
65+class TestPopulation:
66+ def test_valeurs_2025(self, res):
67+ pop = res["population"]
68+ attendu = {"Montréal": 4597837, "Québec": 903607, "Ottawa-Gatineau (partie du Québec)": 374226, "Sherbrooke": 243911,
69+ "Trois-Rivières": 174316, "Saguenay": 170192, "Drummondville": 109151, "Ensemble des RMR du Québec": 6573240}
70+ for k, v in attendu.items():
71+ assert pop.loc[k, 2025] == v
72+
73+ def test_croissance(self, res):
74+ pop = res["population"]
75+ assert pop.loc["Montréal", "tcac_2020_2025"] == pytest.approx((4597837 / 4356853) ** 0.2 - 1, rel=1e-12)
76+ assert pop.loc["Montréal", "tcac_2020_2025"] == pytest.approx(0.0108, abs=5e-4)
77+ assert pop.loc["Québec", "tcac_2020_2025"] == pytest.approx(0.0146, abs=5e-4)
78+ assert pop.loc["Saguenay", "tcac_2020_2025"] == pytest.approx(0.0096, abs=5e-4)
79+ assert pop.loc["Montréal", "var_2024_2025"] == pytest.approx(0.0055, abs=5e-4)
80+ assert pop.loc[2021 if False else "Montréal", 2021] < pop.loc["Montréal", 2020] # recul de 2021 (pandémie)
81+
82+
83+class TestIPLN:
84+ def test_niveaux_septembre_2026(self, res):
85+ st = res["ipln_stats"]
86+ assert st.loc["Montréal", "total"] == 159.5 and st.loc["Québec", "total"] == 144.2 and st.loc["Gatineau (partie du Québec)", "total"] == 116.6
87+ assert st.loc["Québec (province)", "total"] == 150.0 and st.loc["Canada", "total"] == 120.2
88+ assert st.loc["Sherbrooke", "total"] == 112.1 and st.loc["Trois-Rivières", "total"] == 119.4
89+ assert (st["dernier_mois"] == "2026-09").all()
90+
91+ def test_variations(self, res):
92+ st = res["ipln_stats"]
93+ assert st.loc["Montréal", "var_12_mois"] == pytest.approx(0.0121, abs=5e-4)
94+ assert st.loc["Québec", "var_12_mois"] == pytest.approx(0.0191, abs=5e-4)
95+ assert st.loc["Gatineau (partie du Québec)", "var_12_mois"] == pytest.approx(0.0034, abs=5e-4)
96+ assert st.loc["Canada", "var_12_mois"] == pytest.approx(-0.0204, abs=5e-4)
97+ assert st.loc["Montréal", "var_2020_01_2022_12"] == pytest.approx(0.3557, abs=5e-4)
98+ assert st.loc["Gatineau (partie du Québec)", "var_2020_01_2022_12"] == pytest.approx(0.0923, abs=5e-4)
99+ assert st.loc["Montréal", "var_depuis_2016_12"] == pytest.approx(0.595, abs=1e-6)
100+ assert st.loc["Montréal", "var_2000_2016"] == pytest.approx(0.7606, abs=5e-4)
101+ assert st.loc["Canada", "sommet"] == "2022-08" and st.loc["Canada", "ecart_sommet"] == pytest.approx(-0.0468, abs=5e-4)
102+
103+ def test_composantes(self, res):
104+ st = res["ipln_stats"]
105+ assert st.loc["Montréal", "maison"] == 169.9 and st.loc["Montréal", "terrain"] == 132.6
106+ assert st.loc["Québec (province)", "maison"] == 158.3 and st.loc["Québec (province)", "terrain"] == 128.0
107+ # base déc. 2016 = 100 pour toutes les séries
108+ ip = res["ipln"]; base = ip[ip["date"] == "2016-12-01"]
109+ assert np.allclose(base["indice"].values, 100.0)
110+
111+
112+class TestSCHL:
113+ def test_valeurs_citees(self, res):
114+ sc = res["schl"]
115+ assert sc.loc["Montréal", "inocc_tous_2025"] == 2.9 and sc.loc["Montréal", "inocc_tous_2024"] == 2.1
116+ assert sc.loc["Montréal", "loyer_2_chambres_2025"] == 1346 and sc.loc["Montréal", "loyer_2_chambres_2024"] == 1176
117+ assert sc.loc["Québec", "inocc_tous_2025"] == 2.4 and sc.loc["Québec", "loyer_2_chambres_2025"] == 1277
118+ assert sc.loc["Gatineau (partie du Québec)", "inocc_tous_2025"] == 3.8 and sc.loc["Gatineau (partie du Québec)", "loyer_2_chambres_2025"] == 1460
119+ assert sc.loc["Sherbrooke", "inocc_tous_2025"] == 2.7 and sc.loc["Trois-Rivières", "inocc_tous_2025"] == 2.7 and sc.loc["Saguenay", "inocc_tous_2025"] == 1.3
120+ assert sc.loc["Saguenay", "loyer_2_chambres_2025"] == 985 and sc.loc["Sherbrooke", "loyer_2_chambres_2025"] == 1062 and sc.loc["Trois-Rivières", "loyer_2_chambres_2025"] == 1016
121+ assert sc.loc["Montréal", "univers_tous_2025"] == 667266 and sc.loc["Québec", "univers_tous_2025"] == 118693
122+
123+ def test_variation_brute_vs_estimative(self, res):
124+ sc = res["schl"]
125+ assert sc.loc["Montréal", "var_brute_2_chambres"] == pytest.approx(1346 / 1176 - 1, rel=1e-12)
126+ assert sc.loc["Montréal", "var_brute_2_chambres"] == pytest.approx(0.1446, abs=5e-4)
127+ assert sc.loc["Montréal", "varest_2_chambres_2025"] == 7.2 and sc.loc["Montréal", "varest_tous_2025"] == 7.4
128+ assert sc.loc["Saguenay", "varest_tous_2025"] == 12.5 and sc.loc["Gatineau (partie du Québec)", "varest_tous_2025"] == 4.5
129+ # effet de composition dans les deux sens : brute > estimative à Montréal (14,5 vs 7,2), brute < estimative à Sherbrooke (6,0 vs 9,8)
130+ assert sc.loc["Montréal", "var_brute_2_chambres"] * 100 > sc.loc["Montréal", "varest_2_chambres_2025"]
131+ assert sc.loc["Sherbrooke", "var_brute_2_chambres"] == pytest.approx(0.0599, abs=5e-4)
132+ assert sc.loc["Sherbrooke", "var_brute_2_chambres"] * 100 < sc.loc["Sherbrooke", "varest_2_chambres_2025"]
133+
134+
135+class TestAPCIQ:
136+ def test_mois_inventaire(self, res):
137+ ap = res["apciq"]
138+ assert ap.loc["Province", "mois_inventaire"] == pytest.approx(42408 / (20935 / 3), rel=1e-12)
139+ assert ap.loc["Province", "mois_inventaire"] == pytest.approx(6.08, abs=5e-3)
140+ assert ap.loc["Montréal", "mois_inventaire"] == pytest.approx(6.59, abs=5e-3)
141+ assert ap.loc["Québec", "mois_inventaire"] == pytest.approx(3.06, abs=5e-3)
142+ assert ap.loc["Gatineau", "mois_inventaire"] == pytest.approx(6.48, abs=5e-3)
143+ assert ap.loc["Sherbrooke", "mois_inventaire"] == pytest.approx(4.35, abs=5e-3)
144+ assert ap.loc["Trois-Rivières", "mois_inventaire"] == pytest.approx(3.60, abs=5e-3)
145+ assert ap.loc["Saguenay", "mois_inventaire"] == pytest.approx(4.03, abs=5e-3)
146+
147+ def test_rapports_et_mrb(self, res):
148+ ap = res["apciq"]
149+ assert ap.loc["Montréal", "plex_sur_unifamiliale"] == pytest.approx(1.333, abs=5e-4)
150+ assert ap.loc["Saguenay", "plex_sur_unifamiliale"] == pytest.approx(1.022, abs=5e-4)
151+ assert ap.loc["Province", "copro_sur_unifamiliale"] == pytest.approx(0.80, abs=1e-9)
152+ assert res["mrb"]["Montréal"] == pytest.approx(860500 / (36 * 1346), rel=1e-12)
153+ assert res["mrb"]["Montréal"] == pytest.approx(17.76, abs=5e-3) and res["mrb"]["Québec"] == pytest.approx(13.05, abs=5e-3)
154+ assert res["mrb"]["Saguenay"] == pytest.approx(10.53, abs=5e-3)
155+ assert cas.ratio_prix_loyer(435000, 1826) == pytest.approx(19.85, abs=5e-3)
156+
157+
158+class TestBanqueDuCanada:
159+ def test_dernieres_valeurs(self, res):
160+ s = res["bdc"]["stats"]
161+ assert s["directeur_dernier"] == ("2026-10-29", 2.5) and s["hyp5_dernier"] == ("2026-10-28", 6.19)
162+ assert s["obl5_dernier"] == ("2026-10-29", 3.58) and s["obl10_dernier"] == ("2026-10-29", 3.84)
163+ assert s["directeur_min"] == ("2009-04-21", 0.25) and s["directeur_2022_03"] == 0.25 and s["directeur_2023_07_12"] == 5.0
164+ assert s["hyp5_max_2000"] == ("2000-05-24", 8.75) and s["hyp5_min"] == ("2015-04-08", 4.64)
165+
166+ def test_cycle_2022_2026(self, res):
167+ chg = dict(res["bdc"]["stats"]["changements_2022_2026"])
168+ assert chg["2022-03-03"] == 0.5 and chg["2023-07-13"] == 5.0 and chg["2024-06-06"] == 4.75 and chg["2025-10-30"] == 2.25 and chg["2026-10-29"] == 2.5
169+ assert len([d for d in chg if d.startswith("2022") or d.startswith("2023")]) == 10 # dix hausses mars 2022 → juillet 2023
170+
171+
172+class TestMutations:
173+ def test_bareme_provincial(self):
174+ bar = cas.BAREMES_2026["Province (barème légal)"]
175+ assert cas.droits_mutation(500000, bar) == pytest.approx(0.005 * 62900 + 0.01 * (315000 - 62900) + 0.015 * (500000 - 315000), abs=1e-9)
176+ assert cas.droits_mutation(500000, bar) == pytest.approx(5610.5, abs=1e-9)
177+ assert cas.droits_mutation(62900, bar) == pytest.approx(314.5, abs=1e-9)
178+ assert cas.droits_mutation(0, bar) == 0.0
179+
180+ def test_baremes_municipaux(self, res):
181+ m = res["mutations"].set_index(["bareme", "prix"])["droit"]
182+ assert m[("Montréal", 500000)] == pytest.approx(5610.5, abs=1e-9) # identique au barème légal sous 552 300 $
183+ assert m[("Montréal", 860500)] == pytest.approx(12559.0, abs=1e-9)
184+ assert m[("Gatineau (dès le 25 février 2026)", 860500)] == pytest.approx(16425.5, abs=1e-9)
185+ assert m[("Québec (ville)", 860500)] == pytest.approx(15175.5, abs=1e-9)
186+ assert m[("Montréal", 1500000)] == pytest.approx(27325.5, abs=1e-9)
187+ assert m[("Gatineau (dès le 25 février 2026)", 1500000)] == pytest.approx(35610.5, abs=1e-9)
188+
189+ def test_continuite_et_progressivite(self):
190+ for bar in cas.BAREMES_2026.values():
191+ for borne, _ in bar:
192+ if borne is None: continue
193+ assert cas.droits_mutation(borne - 1e-6, bar) == pytest.approx(cas.droits_mutation(borne + 1e-6, bar), abs=1e-6)
194+ te = [cas.droits_mutation(p, bar) / p for p in (100000, 300000, 500000, 1000000, 3000000)]
195+ assert all(te[i] <= te[i + 1] + 1e-12 for i in range(len(te) - 1))
196+ assert te[-1] < bar[-1][1] # le taux effectif reste sous le taux marginal maximal
197+
198+
199+class TestTauxEtPrix:
200+ def test_paiement_reference(self, res):
201+ h = fin.Hypotheque(400000, 0.0412, 300)
202+ assert res["paiement_ref"] == pytest.approx(h.paiement, rel=1e-12) and res["paiement_ref"] == pytest.approx(2130.18, abs=0.5)
203+ assert h.taux_periodique == pytest.approx((1 + 0.0412 / 2) ** (1 / 6) - 1, rel=1e-12)
204+
205+ def test_prix_soutenable(self, res):
206+ s = res["sensibilite"].set_index("taux")
207+ assert s.loc[0.0412, "prix_soutenable"] == pytest.approx(500000, abs=1e-6)
208+ assert s.loc[0.0619, "prix_soutenable"] == pytest.approx(408931, abs=0.5) and s.loc[0.0619, "variation_prix"] == pytest.approx(-0.1821, abs=5e-4)
209+ assert s.loc[0.03, "prix_soutenable"] == pytest.approx(562653, abs=0.5)
210+ assert s.loc[0.07, "variation_prix"] == pytest.approx(-0.2397, abs=5e-4)
211+ assert s.loc[0.0619, "paiement_pour_prix_ref"] == pytest.approx(2604.57, abs=0.5)
212+ # inversion : le prix soutenable au taux t redonne le paiement de référence
213+ for t, row in s.iterrows():
214+ assert fin.Hypotheque(0.8 * row["prix_soutenable"], t, 300).paiement == pytest.approx(res["paiement_ref"], rel=1e-10)
215+
216+
217+class TestFiguresEtFichiers:
218+ def test_figures_presentes(self):
219+ for slug in ("parc-types", "ipln-rmr", "schl-rmr", "carte-rmr", "taux-2000-2026", "mutations"):
220+ assert (ROOT / f"assets/figures/ch04/ch04-fig-{slug}.pdf").exists(), slug
221+
222+ def test_coordonnees_carte(self):
223+ sys.path.insert(0, str(ROOT / "analysis/python/figures"))
224+ import ch04_carte as carte
225+ assert set(carte.CENTRES) == {"Montréal", "Québec", "Ottawa-Gatineau (partie du Québec)", "Sherbrooke", "Trois-Rivières", "Saguenay"}
226+ for lat, lon in carte.CENTRES.values():
227+ assert 45.0 < lat < 49.0 and -76.0 < lon < -70.5
modified analysis/python/tests/test_ch09.py +4 −4
Large diff (31 lines) is not loaded by default to keep the page fast.Load diff
added analysis/python/tests/test_ch17.py +152 −0
Large diff (152 lines) is not loaded by default to keep the page fast.Load diff
modified analysis/python/traite/hedonique.py +253 −0
Large diff (256 lines) is not loaded by default to keep the page fast.Load diff
added assets/figures/ch03/ch03-fig-cout-usage.pdf +0 −0

Binary file not shown.

added assets/figures/ch03/ch03-fig-cycle-sensibilite.pdf +0 −0

Binary file not shown.

added assets/figures/ch03/ch03-fig-cycle-simule.pdf +0 −0

Binary file not shown.

added assets/figures/ch03/ch03-fig-mises-chantier-quebec.pdf +0 −0

Binary file not shown.

added assets/figures/ch03/ch03-fig-quatre-quadrants.pdf +0 −0

Binary file not shown.

modified assets/figures/ch04/ch04-fig-ipln-rmr.pdf +0 −0

Binary file not shown.

modified assets/figures/ch04/ch04-fig-mutations.pdf +0 −0

Binary file not shown.

modified assets/figures/ch04/ch04-fig-taux-2000-2026.pdf +0 −0

Binary file not shown.

modified assets/figures/ch05/ch05-fig-valeur-logement-rmr.pdf +0 −0

Binary file not shown.

modified assets/figures/ch09/ch09-fig-prb.pdf +0 −0

Binary file not shown.

modified assets/figures/ch09/ch09-fig-ratios-distribution.pdf +0 −0

Binary file not shown.

modified assets/figures/ch09/ch09-fig-ratios-temps.pdf +0 −0

Binary file not shown.

modified assets/figures/ch09/ch09-fig-sensibilite-contestation.pdf +0 −0

Binary file not shown.

added assets/figures/ch17/ch17-fig-bruts-ajustes.pdf +0 −0

Binary file not shown.

added assets/figures/ch17/ch17-fig-cascade-g0404.pdf +0 −0

Binary file not shown.

added assets/figures/ch17/ch17-fig-paires.pdf +0 −0

Binary file not shown.

added assets/figures/ch17/ch17-fig-sensibilite.pdf +0 −0

Binary file not shown.

modified manuscript/chapters/ch01.tex +493 −1
Large diff (495 lines) is not loaded by default to keep the page fast.Load diff
modified manuscript/chapters/ch02.tex +543 −1
Large diff (545 lines) is not loaded by default to keep the page fast.Load diff
modified manuscript/chapters/ch04.tex +215 −1
Large diff (217 lines) is not loaded by default to keep the page fast.Load diff
modified manuscript/chapters/ch10.tex +207 −20
Large diff (294 lines) is not loaded by default to keep the page fast.Load diff
added references/lib-ch17.bib +41 −0
Large diff (41 lines) is not loaded by default to keep the page fast.Load diff
modified references/library.bib +39 −0
Large diff (45 lines) is not loaded by default to keep the page fast.Load diff
modified solutions/ch01.tex +29 −0
Large diff (30 lines) is not loaded by default to keep the page fast.Load diff
modified solutions/ch10.tex +53 −0
Large diff (54 lines) is not loaded by default to keep the page fast.Load diff
added tools/valider_chapitre.sh +12 −0
Large diff (12 lines) is not loaded by default to keep the page fast.Load diff
modified verification/calculations.csv +14 −0
Large diff (17 lines) is not loaded by default to keep the page fast.Load diff
added verification/chapters/ch01-calculations.csv +15 −0
Large diff (15 lines) is not loaded by default to keep the page fast.Load diff
added verification/chapters/ch01-claims.csv +15 −0
Large diff (15 lines) is not loaded by default to keep the page fast.Load diff
added verification/chapters/ch01-figures.csv +7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added verification/chapters/ch01-registry.csv +1 −0
Large diff (1 lines) is not loaded by default to keep the page fast.Load diff
added verification/chapters/ch01.md +9 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
modified verification/claims.csv +14 −0
Large diff (17 lines) is not loaded by default to keep the page fast.Load diff
modified verification/figures.csv +6 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme