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