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 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 316 print("\n=== Ménages par RMR (98-10-0231-01)"); print(mr.to_string(float_format=lambda v: f"{v:.4f}"))
317 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 321 print("\n=== IPLN (18-10-0205-01, déc. 2016 = 100)"); print(st.to_string(float_format=lambda v: f"{v:.4f}"))
319 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 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 2 """Figure ch03 — Modèle des quatre quadrants (DiPasquale–Wheaton) tracé à partir des paramètres pédagogiques du marché type
3 3 (analysis/python/cases/ch03_cycles.py, SYNTHÉTIQUE). Équilibre de référence (trait plein) et nouvel équilibre après une baisse
4 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 7 Sortie : assets/figures/ch03/ch03-fig-quatre-quadrants.pdf"""
6 8 import sys, pathlib
7 9 ROOT = pathlib.Path(__file__).resolve().parents[3]
8 10 sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases"))
9 11 import numpy as np, matplotlib.pyplot as plt
12 +from matplotlib.lines import Line2D
10 13 from traite.style import setup, palette, savefig, LARGEUR
11 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 23 def main():
15 24 setup()
16 25 p = cas.Parametres4Q()
17 − base = cas.equilibre_4q(p)
18 − i2 = 0.04
19 − nouv = cas.equilibre_4q(p, i=i2)
20 − ct = cas.court_terme_4q(p, i=i2)
21 − k = 1000.0 # affichage en milliers
26 + base = cas.equilibre_4q(p); i2 = 0.04
27 + nouv = cas.equilibre_4q(p, i=i2); ct = cas.court_terme_4q(p, i=i2)
22 28 c0, c1 = palette.cat[0], palette.cat[1]
23 − fig, ax = plt.subplots(figsize=(LARGEUR, LARGEUR * 0.92))
24 − # bornes
25 − Smax, Pmax, Rmax, Cmax = 150, 420, 25, 3.2
26 − ax.set_xlim(-Pmax, Smax); ax.set_ylim(-Cmax, Rmax)
29 + fig, ax = plt.subplots(figsize=(LARGEUR, LARGEUR * 0.9))
30 + XP, XS, YR, YC = -150, 150, 24, -13.5
31 + ax.set_xlim(XP - 2, XS + 2); ax.set_ylim(YC - 1, YR + 1)
27 32 ax.axhline(0, color=palette.ink, lw=0.9); ax.axvline(0, color=palette.ink, lw=0.9)
28 − ax.grid(False)
29 − for s in ("left", "bottom"):
30 − ax.spines[s].set_visible(False)
31 − ax.set_xticks([]); ax.set_yticks([])
32 − # --- NE : marché de l'espace, D(R) = S → R = (a − S)/b
33 − S = np.linspace(60, 150, 50) * k
34 − ax.plot(S / k, (p.a - S) / p.b / k, color=c0, lw=1.8)
35 − ax.plot(S / k, (1.0 * p.a - S) / p.b / k, color=c0, lw=0, label="_")
36 − # --- NW : marché des actifs, P = R / i (P à gauche, R en haut)
37 − R = np.linspace(0, Rmax, 50) * k
38 − ax.plot(-R / p.i / k, R / k, color=c0, lw=1.8)
39 − ax.plot(-R / i2 / k, R / k, color=c1, lw=1.8, ls="--")
40 − # --- SW : construction, C = (P − c0)/c1 (P à gauche, C en bas)
41 − P = np.linspace(p.c0, Pmax * k, 50)
42 − ax.plot(-P / k, -(P - p.c0) / p.c1 / k, color=c0, lw=1.8)
43 − # --- SE : ajustement du stock, S = C/δ (C en bas, S à droite)
44 − C = np.linspace(0, Cmax, 50) * k
45 − ax.plot(C / p.delta / k, -C / k, color=c0, lw=1.8)
33 + ax.grid(False); ax.set_xticks([]); ax.set_yticks([])
34 + for s in ax.spines.values():
35 + s.set_visible(False)
36 + # graduations manuelles
37 + for v in (50, 100, 150):
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)
39 + for v in (150, 300, 450):
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)
41 + for v in (6, 12, 18, 24):
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)
43 + for v in (1, 2, 3):
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)
45 + # --- NE : marché de l'espace R = (a − S)/b
46 + S = np.linspace(50_000, 150_000, 50)
47 + ax.plot(S * FS, (p.a - S) / p.b * FR, color=c0, lw=1.8)
48 + # --- NW : marché des actifs P = R/i
49 + R = np.linspace(0, YR / FR, 50)
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 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
49 − ax.plot([Sx, Sx], [0, Rx], color=color, lw=lw, ls=ls)
60 + Sx, Rx, Px, Cx = eq["S"] * FS, eq["R"] * FR, eq["P"] * FP, eq["C"] * FC
61 + ax.plot([Sx, Sx], [-Cx, Rx], color=color, lw=lw, ls=ls)
50 62 ax.plot([-Px, Sx], [Rx, Rx], color=color, lw=lw, ls=ls)
51 63 ax.plot([-Px, -Px], [Rx, -Cx], color=color, lw=lw, ls=ls)
52 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 68 rectangle(base, c0, "-")
56 69 rectangle(nouv, c1, "--")
57 − # court terme : stock fixe, prix bondit à R0/i2
58 − ax.plot([-ct["P"] / k, base["S"] / k], [base["R"] / k, base["R"] / k], color=c1, lw=1.0, ls=":")
59 − ax.plot([-ct["P"] / k, -ct["P"] / k], [base["R"] / k, -ct["C"] / k], 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=":")
61 − # points
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)
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)
64 − ax.plot(-ct["P"] / k, base["R"] / k, "o", color=c1, ms=4, mfc="white")
65 − # étiquettes d'axes et de quadrants
66 − ax.text(Smax, 0.6, "Stock $S$ (milliers de log.)", ha="right", va="bottom", fontsize=8)
67 − ax.text(-Pmax, 0.6, "Prix $P$ (k\\$/log.)", ha="left", va="bottom", fontsize=8)
68 − ax.text(1.5, Rmax, "Loyer $R$ (k\\$/log./an)", ha="left", va="top", fontsize=8)
69 − ax.text(1.5, -Cmax, "Construction $C$ (milliers de log./an)", ha="left", va="bottom", fontsize=8)
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)
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)
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)
70 + # court terme : stock fixe, P bondit à R0/i2, C bondit, mais S n'a pas encore bougé
71 + Pct, Cct = ct["P"] * FP, ct["C"] * FC
72 + ax.plot([-Pct, base["S"] * FS], [base["R"] * FR, base["R"] * FR], color=c1, lw=1.0, ls=":")
73 + ax.plot([-Pct, -Pct], [base["R"] * FR, -Cct], color=c1, lw=1.0, ls=":")
74 + ax.plot([-Pct, base["S"] * FS], [-Cct, -Cct], color=c1, lw=1.0, ls=":")
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")
76 + # titres des quadrants (coins) et noms d'axes
77 + ax.text(XS, YR, "Marché de l'espace\n$D(R) = S$", ha="right", va="top", fontsize=8, color=palette.ink2)
78 + ax.text(XP, YR, "Marché des actifs\n$P = R/i$", ha="left", va="top", fontsize=8, color=palette.ink2)
79 + ax.text(XP, YC + 4.0, "Construction\n$C = (P - c_0)/c_1$", ha="left", va="bottom", fontsize=8, color=palette.ink2)
80 + ax.text(XS, YC, "Ajustement du stock\n$C = \\delta S$", ha="right", va="bottom", fontsize=8, color=palette.ink2)
81 + ax.text(XS, 1.0, "Stock $S$\n(milliers de logements)", ha="right", va="bottom", fontsize=7.5)
82 + ax.text(XP, 1.0, "Prix $P$\n(k\\$ par logement)", ha="left", va="bottom", fontsize=7.5)
83 + ax.text(-2.5, YR, "Loyer $R$\n(k\\$/logement/an)", ha="right", va="top", fontsize=7.5)
84 + ax.text(-2.5, YC, "Construction $C$\n(milliers de logements/an)", ha="right", va="bottom", fontsize=7.5)
74 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)
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)
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)
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)
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")
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")
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")
82 − from matplotlib.lines import Line2D
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")
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")
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")
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")
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")
91 + ax.text(-Pct + 1.5, -Cct - 0.3, f"$C$ = {ct['C']/1000:.1f}", fontsize=7.5, color=c1, ha="left", 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")
83 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 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 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 19 s = df[df["geo"] == geo]
20 20 ls = "--" if geo == "Canada" else "-"; lw = 2.0 if geo == "Montréal" else 1.4
21 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 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 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 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 18 for i, (nom, bar) in enumerate(cas.BAREMES_2026.items()):
19 19 te = np.array([cas.droits_mutation(p, bar) / p for p in prix]) * 100
20 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)]:
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)
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 + dx, y, lab, fontsize=6.8, color=palette.ink2, ha=ha)
23 23 ax.set_xlabel("Base d'imposition (milliers de dollars)"); ax.set_ylabel("Taux effectif (% de la base)")
24 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 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 17 obl = b["obl5"].resample("W").mean()
18 18 ax.plot(obl.index, obl.values, color=palette.cat[1], lw=1.2, label="Obligation du Canada, 5 ans (moyenne hebdomadaire)")
19 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 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 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 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 32 a2.set_yticks(y2); a2.set_yticklabels(sub.index); a2.set_xlim(-0.8, 0.15)
33 33 a2.set_xlabel("Écart logarithmique avec Montréal"); a2.grid(axis="y", visible=False)
34 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 36 out = savefig(fig, "ch05/ch05-fig-valeur-logement-rmr.pdf"); print("OK", out)
37 37
38 38 if __name__ == "__main__":
modified analysis/python/figures/ch09_ratios.py +7 −6
@@ -33,7 +33,7 @@ def fig_distribution(et):
33 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 34 for ax in axes:
35 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 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 38 fontsize=8, color=palette.ink2, x=0.02, ha="left", y=1.02)
39 39 print("OK", savefig(fig, "ch09/ch09-fig-ratios-distribution.pdf"))
@@ -74,14 +74,15 @@ def fig_sensibilite(d, et, da):
74 74 s = cas.sensibilite_acheteur(d, et, da); tv = cas.taux_voisinage_gatineau()
75 75 fig, ax = plt.subplots(figsize=(LARGEUR, 2.9))
76 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 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])
80 − ax.axvline(da["valeur_role"] - da["seuil_valeur"], color=palette.ink2, ls=":", lw=0.9)
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 + 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 82 ax.set_xlabel("Valeur réelle conclue à la date de référence ($)"); ax.set_ylabel("Gain net sur trois exercices ($)")
82 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.legend(loc="upper right", fontsize=7.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)
85 + ax.legend(loc="lower left", fontsize=7.5)
85 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