Ch. 34 livré et validé (compilation isolée, tests, fusion des registres)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
70 changed files +3,828 −128
added
analysis/python/cases/ch37_ml.py
+411 −0
@@ -0,0 +1,411 @@ | |||||
| 1 | +#!/usr/bin/env python3 | ||||
| 2 | +"""Chapitre 37 — Apprentissage automatique et explicabilité : exemple intégral sur les 600 ventes du cas 1 | ||||
| 3 | +(Gatineau, DONNÉES SYNTHÉTIQUES, graine 20261031). | ||||
| 4 | + | ||||
| 5 | +Le modèle hédonique de référence est `traite.cas1.hedonique` (semi-log, MCO, HC1) ; il n'est PAS redéfini ici. Ce script lui | ||||
| 6 | +oppose un arbre de régression, une forêt aléatoire (Breiman 2001), un gradient boosting (Friedman 2001 ; scikit-learn) et XGBoost | ||||
| 7 | +(Chen et Guestrin 2016), sous trois schémas de validation : plis aléatoires (les MÊMES dix plis que `ch18_hedonique.validation_croisee`, | ||||
| 8 | +graine 20261031), blocs spatiaux stratifiés par secteur (k-moyennes sur les coordonnées), et partition temporelle (ventes de 2026 | ||||
| 9 | +prédites à partir de 2023-2025). Il ajoute la validation « un secteur exclu » (extrapolation hors support), un scénario où la loi de | ||||
| 10 | +génération est perturbée par une interaction et une rupture déclarées (le boosting y gagne), les valeurs SHAP (TreeSHAP) du boosting, | ||||
| 11 | +la dépendance partielle, l'importance par permutation, la prédiction du sujet par chaque modèle et les chiffres des exercices. | ||||
| 12 | +Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch37_ml.py""" | ||||
| 13 | +from __future__ import annotations | ||||
| 14 | +import sys, math, pathlib, warnings | ||||
| 15 | +import numpy as np, pandas as pd | ||||
| 16 | +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) | ||||
| 17 | +from traite import cas1 | ||||
| 18 | +from traite import hedonique as h | ||||
| 19 | + | ||||
| 20 | +warnings.filterwarnings("ignore", category=FutureWarning) | ||||
| 21 | +ROOT = pathlib.Path(__file__).resolve().parents[3] | ||||
| 22 | +GRAINE = 20261031 | ||||
| 23 | +N_ARBRES_FORET = 500 | ||||
| 24 | + | ||||
| 25 | +# ----------------------------------------------------------------------------- paramètres de génération (README synthétique) | ||||
| 26 | +_g = (1 + 9.06 / 100) ** 0.5 - 1 | ||||
| 27 | +VRAIS = {"ln_S": 0.55, "ln_T": 0.10, "age": -0.0055, "age2": 0.00003, "qualite": 0.06, "garage": 0.035, "sous_sol": 0.03, "piscine": 0.02, | ||||
| 28 | + "etages": 0.02, "dist_centre": -0.006, "sigma": 0.085, "mois": math.log1p(_g) / 12, | ||||
| 29 | + "sect": {"Hull": 0.0, "Gatineau": math.log(464_900 / 475_000), "Aylmer": math.log(530_000 / 475_000), | ||||
| 30 | + "Buckingham": math.log(395_000 / 475_000), "Masson-Angers": math.log(395_000 / 475_000) - 0.02}} | ||||
| 31 | +# scénario non linéaire déclaré (section 37.6) : prime « patrimoine rénové » et « mur de la mi-vie » | ||||
| 32 | +SCENARIO = {"prime_qualite_age": 0.12, "qualite_min": 4, "age_min_prime": 40, "mur": -0.10, "age_mur": (25, 45)} | ||||
| 33 | + | ||||
| 34 | +# ----------------------------------------------------------------------------- régresseurs | ||||
| 35 | +VARIABLES_ML = ["superficie", "terrain", "age", "qualite", "garage", "sous_sol", "piscine", "etages", "dist_centre", "lat", "lon", "mois"] + \ | ||||
| 36 | + [f"sect_{s}" for s in cas1.SECTEURS] | ||||
| 37 | +ETIQUETTES = {"superficie": "Superficie habitable (m²)", "terrain": "Terrain (m²)", "age": "Âge (années)", "qualite": "Qualité (1-5)", | ||||
| 38 | + "garage": "Garage (places)", "sous_sol": "Sous-sol fini", "piscine": "Piscine", "etages": "À étages", "dist_centre": "Distance au centre (km)", | ||||
| 39 | + "lat": "Latitude", "lon": "Longitude", "mois": "Mois depuis 2024-07", "sect_Hull": "Secteur Hull", "sect_Gatineau": "Secteur Gatineau", | ||||
| 40 | + "sect_Aylmer": "Secteur Aylmer", "sect_Buckingham": "Secteur Buckingham", "sect_Masson-Angers": "Secteur Masson-Angers"} | ||||
| 41 | + | ||||
| 42 | +def matrice_ml(df: pd.DataFrame, s: dict | None = None) -> pd.DataFrame: | ||||
| 43 | + """Régresseurs bruts pour les méthodes à base d'arbres : aucune transformation (les arbres sont invariants aux transformations | ||||
| 44 | + monotones), secteur en indicatrices complètes, coordonnées brutes, temps en mois depuis la date de référence du jeu.""" | ||||
| 45 | + X = pd.DataFrame(index=df.index) | ||||
| 46 | + X["superficie"] = df["superficie_habitable_m2"].astype(float); X["terrain"] = df["superficie_terrain_m2"].astype(float) | ||||
| 47 | + X["age"] = df["age"].astype(float); X["qualite"] = df["qualite_1a5"].astype(float); X["garage"] = df["garage_places"].astype(float) | ||||
| 48 | + X["sous_sol"] = df["sous_sol_fini"].astype(float); X["piscine"] = df["piscine"].astype(float) | ||||
| 49 | + X["etages"] = (df["type"] == "à étages").astype(float); X["dist_centre"] = df["dist_centre_km"].astype(float) | ||||
| 50 | + X["lat"] = df["lat"].astype(float); X["lon"] = df["lon"].astype(float) | ||||
| 51 | + d = df["date_vente"] if "date_vente" in df else pd.Series([pd.Timestamp(s["date_evaluation"])] * len(df), index=df.index) | ||||
| 52 | + X["mois"] = ((pd.to_datetime(d) - cas1.REF_DATE).dt.days / 30.4375).astype(float) | ||||
| 53 | + for sct in cas1.SECTEURS: | ||||
| 54 | + X[f"sect_{sct}"] = (df["secteur"] == sct).astype(float) | ||||
| 55 | + return X[VARIABLES_ML] | ||||
| 56 | + | ||||
| 57 | +# ----------------------------------------------------------------------------- modèles | ||||
| 58 | +class Hedonique: | ||||
| 59 | + """Enveloppe du modèle hédonique de référence (traite.hedonique.mco sur traite.cas1.matrice) avec l'interface fit/predict.""" | ||||
| 60 | + def __init__(self, colonnes_exclues=()): self.excl = list(colonnes_exclues) | ||||
| 61 | + def fit(self, X: pd.DataFrame, y): | ||||
| 62 | + self.cols = [c for c in X.columns if c not in self.excl] | ||||
| 63 | + self.r = h.mco(X[self.cols].values, np.asarray(y, float)); self.beta = self.r["beta"]; return self | ||||
| 64 | + def predict(self, X: pd.DataFrame): return X[self.cols].values @ self.beta | ||||
| 65 | + | ||||
| 66 | +def _foret(**k): | ||||
| 67 | + from sklearn.ensemble import RandomForestRegressor | ||||
| 68 | + return RandomForestRegressor(**{**dict(n_estimators=N_ARBRES_FORET, min_samples_leaf=2, max_features=0.5, random_state=GRAINE, n_jobs=-1), **k}) | ||||
| 69 | + | ||||
| 70 | +def _boosting(**k): | ||||
| 71 | + from sklearn.ensemble import GradientBoostingRegressor | ||||
| 72 | + return GradientBoostingRegressor(**{**dict(n_estimators=1200, learning_rate=0.03, max_depth=2, subsample=0.8, min_samples_leaf=5, random_state=GRAINE), **k}) | ||||
| 73 | + | ||||
| 74 | +def _xgboost(**k): | ||||
| 75 | + import xgboost as xgb | ||||
| 76 | + return xgb.XGBRegressor(**{**dict(n_estimators=1200, learning_rate=0.03, max_depth=2, subsample=0.8, colsample_bytree=0.8, min_child_weight=5, | ||||
| 77 | + reg_lambda=1.0, random_state=GRAINE, n_jobs=4, verbosity=0), **k}) | ||||
| 78 | + | ||||
| 79 | +def _arbre(**k): | ||||
| 80 | + from sklearn.tree import DecisionTreeRegressor | ||||
| 81 | + return DecisionTreeRegressor(**{**dict(max_depth=3, min_samples_leaf=5, random_state=GRAINE), **k}) | ||||
| 82 | + | ||||
| 83 | +HYPERPARAMETRES = {"Arbre (profondeur 3)": "profondeur 3, feuilles ≥ 5", "Forêt aléatoire": f"{N_ARBRES_FORET} arbres, feuilles ≥ 2, 50 % des variables par division, bootstrap", | ||||
| 84 | + "Gradient boosting": "1 200 arbres de profondeur 2, ν = 0,03, sous-échantillon 80 %, feuilles ≥ 5", | ||||
| 85 | + "XGBoost": "1 200 arbres de profondeur 2, ν = 0,03, sous-échantillon 80 %, 80 % des colonnes, poids min. 5, λ = 1"} | ||||
| 86 | +MODELES = {"Hédonique (référence)": ("hedo", Hedonique), "Arbre (profondeur 3)": ("ml", _arbre), "Forêt aléatoire": ("ml", _foret), | ||||
| 87 | + "Gradient boosting": ("ml", _boosting), "XGBoost": ("ml", _xgboost)} | ||||
| 88 | + | ||||
| 89 | +# ----------------------------------------------------------------------------- plis | ||||
| 90 | +def plis_aleatoires(n: int, nfolds: int = 10, graine: int = GRAINE) -> list[np.ndarray]: | ||||
| 91 | + """Les mêmes dix plis que ch18_hedonique.validation_croisee (même graine, même permutation, même découpage).""" | ||||
| 92 | + rng = np.random.default_rng(graine); idx = rng.permutation(n); return list(np.array_split(idx, nfolds)) | ||||
| 93 | + | ||||
| 94 | +def plis_spatiaux(v: pd.DataFrame, par_secteur: int = 2, graine: int = GRAINE) -> tuple[list[np.ndarray], np.ndarray]: | ||||
| 95 | + """Blocs spatiaux stratifiés : dans chaque secteur, k-moyennes (k = 2) sur les coordonnées en km → 10 blocs contigus. | ||||
| 96 | + Chaque secteur reste représenté à l'estimation (par l'autre moitié), mais le voisinage immédiat d'une vente testée ne l'est jamais.""" | ||||
| 97 | + from sklearn.cluster import KMeans | ||||
| 98 | + km = h.coordonnees_km(v["lat"].to_numpy(), v["lon"].to_numpy()); bloc = np.zeros(len(v), int); b = 0 | ||||
| 99 | + for sct in cas1.SECTEURS: | ||||
| 100 | + ii = np.where(v["secteur"].to_numpy() == sct)[0] | ||||
| 101 | + lab = KMeans(par_secteur, n_init=10, random_state=graine).fit_predict(km[ii]); bloc[ii] = b + lab; b += par_secteur | ||||
| 102 | + return [np.where(bloc == j)[0] for j in range(b)], bloc | ||||
| 103 | + | ||||
| 104 | +def plis_secteurs(v: pd.DataFrame) -> list[np.ndarray]: | ||||
| 105 | + """Un secteur exclu à la fois (extrapolation hors support spatial).""" | ||||
| 106 | + return [np.where(v["secteur"].to_numpy() == sct)[0] for sct in cas1.SECTEURS] | ||||
| 107 | + | ||||
| 108 | +def partition_temporelle(v: pd.DataFrame, date: str = "2026-01-01") -> tuple[np.ndarray, np.ndarray]: | ||||
| 109 | + test = np.where(v["date_vente"] >= pd.Timestamp(date))[0]; return np.setdiff1d(np.arange(len(v)), test), test | ||||
| 110 | + | ||||
| 111 | +# ----------------------------------------------------------------------------- métriques | ||||
| 112 | +def metriques(pred_log: np.ndarray, y: np.ndarray, facteur: float = 1.0) -> dict: | ||||
| 113 | + """RMSE du logarithme, R² hors échantillon, biais moyen (log), RMSE en dollars, MdAPE, statistiques de ratio IAAO | ||||
| 114 | + (traite.hedonique.statistiques_ratio). `facteur` : correction de retransformation (Duan) appliquée aux prédictions en niveau.""" | ||||
| 115 | + m = ~np.isnan(pred_log); e = pred_log[m] - y[m]; P = np.exp(y[m]); V = np.exp(pred_log[m]) * facteur | ||||
| 116 | + r = h.statistiques_ratio(V, P) | ||||
| 117 | + return {"n": int(m.sum()), "rmse_log": float(np.sqrt(np.mean(e ** 2))), "biais_log": float(e.mean()), | ||||
| 118 | + "r2_hors": float(1 - np.sum(e ** 2) / np.sum((y[m] - y[m].mean()) ** 2)), "rmse_dollars": float(np.sqrt(np.mean((V - P) ** 2))), | ||||
| 119 | + "mdape": float(np.median(np.abs(V / P - 1)) * 100), "COD": r["COD"], "PRD": r["PRD"], "PRB": r["PRB"], "PRB_se": r["PRB_se"], | ||||
| 120 | + "ratio_median": r["ratio_median"]} | ||||
| 121 | + | ||||
| 122 | +def valider(constructeur, genre: str, Xh: pd.DataFrame, Xm: pd.DataFrame, y: np.ndarray, plis: list[np.ndarray]) -> np.ndarray: | ||||
| 123 | + """Prédictions hors échantillon (chaque pli prédit par un modèle estimé sans lui).""" | ||||
| 124 | + X = Xh if genre == "hedo" else Xm; n = len(y); pred = np.full(n, np.nan); tous = np.arange(n) | ||||
| 125 | + for f in plis: | ||||
| 126 | + tr = np.setdiff1d(tous, f); m = constructeur(); m.fit(X.iloc[tr], y[tr]); pred[f] = np.asarray(m.predict(X.iloc[f]), float) | ||||
| 127 | + return pred | ||||
| 128 | + | ||||
| 129 | +def facteur_duan(pred_log: np.ndarray, y: np.ndarray) -> float: | ||||
| 130 | + """Facteur de retransformation estimé sur les résidus hors échantillon (honnête pour les modèles qui surajustent en échantillon).""" | ||||
| 131 | + m = ~np.isnan(pred_log); return float(np.mean(np.exp(y[m] - pred_log[m]))) | ||||
| 132 | + | ||||
| 133 | +def comparaison(v: pd.DataFrame, Xh: pd.DataFrame, Xm: pd.DataFrame, y: np.ndarray) -> dict: | ||||
| 134 | + """Trois schémas de validation pour les cinq modèles. Retourne les prédictions hors échantillon et les métriques.""" | ||||
| 135 | + n = len(y); pa = plis_aleatoires(n); ps, bloc = plis_spatiaux(v); tr_t, te_t = partition_temporelle(v) | ||||
| 136 | + out = {"plis": {"aleatoire": pa, "spatial": ps, "bloc_spatial": bloc, "temporel": (tr_t, te_t)}, "pred": {}, "metriques": {}, "duan": {}} | ||||
| 137 | + for nom, (genre, ctor) in MODELES.items(): | ||||
| 138 | + p_a = valider(ctor, genre, Xh, Xm, y, pa); F = facteur_duan(p_a, y) | ||||
| 139 | + p_s = valider(ctor, genre, Xh, Xm, y, ps) | ||||
| 140 | + p_t = valider(ctor, genre, Xh, Xm, y, [te_t]) # un seul pli de test : les ventes de 2026 | ||||
| 141 | + out["pred"][nom] = {"aleatoire": p_a, "spatial": p_s, "temporel": p_t}; out["duan"][nom] = F | ||||
| 142 | + out["metriques"][nom] = {"aleatoire": metriques(p_a, y, F), "spatial": metriques(p_s, y, F), "temporel": metriques(p_t, y, F)} | ||||
| 143 | + # biais temporel par mois de 2026 pour le boosting et l'hédonique | ||||
| 144 | + mois = Xm["mois"].to_numpy() | ||||
| 145 | + out["biais_mensuel_2026"] = {nom: pd.Series(out["pred"][nom]["temporel"][te_t] - y[te_t]).groupby(np.floor(mois[te_t])).mean().to_dict() | ||||
| 146 | + for nom in ("Hédonique (référence)", "Gradient boosting", "Forêt aléatoire")} | ||||
| 147 | + return out | ||||
| 148 | + | ||||
| 149 | +def boosting_detendance(v: pd.DataFrame, Xh: pd.DataFrame, Xm: pd.DataFrame, y: np.ndarray) -> dict: | ||||
| 150 | + """Remède à l'extrapolation temporelle : boosting estimé sur ln P − β̂_mois·mois (tendance de l'hédonique estimée sur l'entraînement), | ||||
| 151 | + sans la variable mois, puis tendance rajoutée. Variante « feuilles ≥ 1 » pour montrer l'effet de la feuille extrême.""" | ||||
| 152 | + tr, te = partition_temporelle(v); n = len(y); cols = list(Xh.columns) | ||||
| 153 | + hd = Hedonique().fit(Xh.iloc[tr], y[tr]); bm = hd.beta[cols.index("mois")]; mois = Xm["mois"].to_numpy() | ||||
| 154 | + Xs = Xm.drop(columns=["mois"]) | ||||
| 155 | + g = _boosting().fit(Xs.iloc[tr], y[tr] - bm * mois[tr]); p = np.full(n, np.nan); p[te] = g.predict(Xs.iloc[te]) + bm * mois[te] | ||||
| 156 | + g1 = _boosting(min_samples_leaf=1).fit(Xm.iloc[tr], y[tr]); p1 = np.full(n, np.nan); p1[te] = g1.predict(Xm.iloc[te]) | ||||
| 157 | + return {"beta_mois_entrainement": float(bm), "pred_detendance": p, "metriques_detendance": metriques(p, y), "pred_feuille1": p1, | ||||
| 158 | + "metriques_feuille1": metriques(p1, y), "n_entrainement_apres_mois_17": int((mois[tr] > 17).sum())} | ||||
| 159 | + | ||||
| 160 | +def validation_secteurs(v: pd.DataFrame, Xh: pd.DataFrame, Xm: pd.DataFrame, y: np.ndarray) -> dict: | ||||
| 161 | + """Un secteur exclu à la fois. L'hédonique n'a aucune information sur un secteur absent : son indicatrice est retirée et le secteur | ||||
| 162 | + est prédit au niveau du secteur de référence (Hull) ; si Hull est exclu, Gatineau devient la référence. Ce choix est un JUGEMENT | ||||
| 163 | + de l'analyste, explicite ; les arbres font implicitement le leur à partir des coordonnées et de la distance au centre.""" | ||||
| 164 | + n = len(y); plis = plis_secteurs(v); out = {"pred": {}, "metriques": {}, "biais_secteur": {}} | ||||
| 165 | + for nom, (genre, ctor) in MODELES.items(): | ||||
| 166 | + pred = np.full(n, np.nan) | ||||
| 167 | + for sct, f in zip(cas1.SECTEURS, plis): | ||||
| 168 | + tr = np.setdiff1d(np.arange(n), f) | ||||
| 169 | + if genre == "hedo": | ||||
| 170 | + drop = f"sect_{sct}" if sct != "Hull" else "sect_Gatineau"; m = Hedonique([drop]).fit(Xh.iloc[tr], y[tr]); pred[f] = m.predict(Xh.iloc[f]) | ||||
| 171 | + else: | ||||
| 172 | + m = ctor(); m.fit(Xm.iloc[tr], y[tr]); pred[f] = m.predict(Xm.iloc[f]) | ||||
| 173 | + out["pred"][nom] = pred; out["metriques"][nom] = metriques(pred, y) | ||||
| 174 | + out["biais_secteur"][nom] = {sct: float(np.mean(pred[f] - y[f])) for sct, f in zip(cas1.SECTEURS, plis)} | ||||
| 175 | + return out | ||||
| 176 | + | ||||
| 177 | +# ----------------------------------------------------------------------------- scénario non linéaire déclaré | ||||
| 178 | +def perturbation_scenario(v: pd.DataFrame) -> np.ndarray: | ||||
| 179 | + """ln P† = ln P + 0,12·1[qualité ≥ 4 et âge ≥ 40] − 0,10·1[25 ≤ âge ≤ 45] (hypothèse pédagogique déclarée).""" | ||||
| 180 | + q = v["qualite_1a5"].to_numpy(float); a = v["age"].to_numpy(float); S = SCENARIO | ||||
| 181 | + return S["prime_qualite_age"] * ((q >= S["qualite_min"]) & (a >= S["age_min_prime"])) + S["mur"] * ((a >= S["age_mur"][0]) & (a <= S["age_mur"][1])) | ||||
| 182 | + | ||||
| 183 | +def scenario_nonlineaire(v: pd.DataFrame, Xh: pd.DataFrame, Xm: pd.DataFrame, y: np.ndarray) -> dict: | ||||
| 184 | + """Mêmes plis aléatoires ; hédonique de référence (mal spécifié désormais), forêt, boosting, XGBoost, et hédonique respécifié | ||||
| 185 | + avec les deux indicatrices (ce que l'analyste ferait après avoir lu la dépendance partielle de l'âge et les interactions SHAP).""" | ||||
| 186 | + delta = perturbation_scenario(v); y2 = y + delta; pa = plis_aleatoires(len(y)) | ||||
| 187 | + q = v["qualite_1a5"].to_numpy(float); a = v["age"].to_numpy(float); S = SCENARIO | ||||
| 188 | + Xh2 = Xh.copy(); Xh2["prime"] = ((q >= S["qualite_min"]) & (a >= S["age_min_prime"])).astype(float); Xh2["mur"] = ((a >= S["age_mur"][0]) & (a <= S["age_mur"][1])).astype(float) | ||||
| 189 | + out = {"delta": delta, "y": y2, "n_prime": int(Xh2["prime"].sum()), "n_mur": int(Xh2["mur"].sum()), "pred": {}, "metriques": {}, "Xh2": Xh2} | ||||
| 190 | + for nom, (genre, ctor) in MODELES.items(): | ||||
| 191 | + if nom == "Arbre (profondeur 3)": continue | ||||
| 192 | + p = valider(ctor, genre, Xh, Xm, y2, pa); out["pred"][nom] = p; out["metriques"][nom] = metriques(p, y2, facteur_duan(p, y2)) | ||||
| 193 | + p = valider(Hedonique, "hedo", Xh2, Xm, y2, pa); out["pred"]["Hédonique respécifié"] = p; out["metriques"]["Hédonique respécifié"] = metriques(p, y2, facteur_duan(p, y2)) | ||||
| 194 | + r = Hedonique().fit(Xh2, y2); cols = list(Xh2.columns) | ||||
| 195 | + out["coef_respecifie"] = {"prime": float(r.beta[cols.index("prime")]), "mur": float(r.beta[cols.index("mur")]), "se_prime": float(r.r["se_hc1"][cols.index("prime")]), | ||||
| 196 | + "se_mur": float(r.r["se_hc1"][cols.index("mur")]), "sigma": float(np.sqrt(r.r["sigma2"])), "r2": float(r.r["r2"])} | ||||
| 197 | + out["gbm_scenario"] = _boosting().fit(Xm, y2) | ||||
| 198 | + out["hedo_scenario"] = Hedonique().fit(Xh, y2) | ||||
| 199 | + return out | ||||
| 200 | + | ||||
| 201 | +# ----------------------------------------------------------------------------- explicabilité | ||||
| 202 | +def shap_boosting(gbm, Xm: pd.DataFrame, xs: pd.DataFrame) -> dict: | ||||
| 203 | + """TreeSHAP (Lundberg et al. 2020) du gradient boosting : valeurs SHAP des 600 ventes, du sujet, valeur de base, importance | ||||
| 204 | + moyenne |φ|, interactions SHAP moyennes. Vérifie l'axiome d'efficacité (somme des φ + base = prédiction).""" | ||||
| 205 | + import shap | ||||
| 206 | + ex = shap.TreeExplainer(gbm); sv = np.asarray(ex.shap_values(Xm)); base = float(np.ravel(ex.expected_value)[0]) | ||||
| 207 | + svs = np.asarray(ex.shap_values(xs))[0] | ||||
| 208 | + si = np.asarray(ex.shap_interaction_values(Xm)); inter = np.abs(si).mean(0).copy(); np.fill_diagonal(inter, 0.0) | ||||
| 209 | + inter_df = pd.DataFrame(inter, index=Xm.columns, columns=Xm.columns) | ||||
| 210 | + paires = inter_df.where(np.triu(np.ones_like(inter, bool), 1)).stack().sort_values(ascending=False) | ||||
| 211 | + return {"valeurs": sv, "base": base, "sujet": pd.Series(svs, index=Xm.columns), "importance": pd.Series(np.abs(sv).mean(0), index=Xm.columns).sort_values(ascending=False), | ||||
| 212 | + "interactions": paires, "efficacite_max_err": float(np.abs(sv.sum(1) + base - gbm.predict(Xm)).max()), | ||||
| 213 | + "efficacite_sujet_err": float(abs(svs.sum() + base - gbm.predict(xs)[0]))} | ||||
| 214 | + | ||||
| 215 | +def importance_permutation(Xh: pd.DataFrame, Xm: pd.DataFrame, y: np.ndarray, plis: list[np.ndarray], repetitions: int = 10, graine: int = GRAINE) -> pd.DataFrame: | ||||
| 216 | + """Importance par permutation (Breiman 2001) mesurée HORS échantillon : pour chaque pli, la variable j est permutée dans les | ||||
| 217 | + ventes testées et l'accroissement de l'erreur quadratique (log) est moyenné. Boosting et forêt.""" | ||||
| 218 | + rng = np.random.default_rng(graine); n = len(y); res = {} | ||||
| 219 | + for nom, ctor in (("Gradient boosting", _boosting), ("Forêt aléatoire", _foret)): | ||||
| 220 | + gain = np.zeros(Xm.shape[1]); base_err = 0.0 | ||||
| 221 | + for f in plis: | ||||
| 222 | + tr = np.setdiff1d(np.arange(n), f); m = ctor(); m.fit(Xm.iloc[tr], y[tr]); Xt = Xm.iloc[f].to_numpy(); e0 = np.mean((m.predict(Xt) - y[f]) ** 2); base_err += e0 * len(f) / n | ||||
| 223 | + for j in range(Xm.shape[1]): | ||||
| 224 | + acc = 0.0 | ||||
| 225 | + for _ in range(repetitions): | ||||
| 226 | + Xp = Xt.copy(); Xp[:, j] = rng.permutation(Xp[:, j]); acc += np.mean((m.predict(Xp) - y[f]) ** 2) | ||||
| 227 | + gain[j] += (acc / repetitions - e0) * len(f) / n | ||||
| 228 | + res[nom] = pd.Series(gain, index=Xm.columns); res[nom + " (EQM de base)"] = base_err | ||||
| 229 | + return res | ||||
| 230 | + | ||||
| 231 | +def dependance_partielle(modele, X: pd.DataFrame, variable: str, grille: np.ndarray) -> np.ndarray: | ||||
| 232 | + """PDP (Friedman 2001) : moyenne des prédictions lorsque la variable est fixée à chaque valeur de la grille, les autres variables | ||||
| 233 | + gardant leurs valeurs observées. Retourne la courbe en logarithme du prix.""" | ||||
| 234 | + out = np.empty(len(grille)) | ||||
| 235 | + for i, g in enumerate(grille): | ||||
| 236 | + Xg = X.copy(); Xg[variable] = g; out[i] = float(np.mean(modele.predict(Xg))) | ||||
| 237 | + return out | ||||
| 238 | + | ||||
| 239 | +def pdp_vraie(v: pd.DataFrame, variable: str, grille: np.ndarray, scenario: bool = False) -> np.ndarray: | ||||
| 240 | + """Dépendance partielle de la LOI DE GÉNÉRATION (connue) : utile pour juger les modèles sur données synthétiques.""" | ||||
| 241 | + a0 = v["age"].to_numpy(float); S0 = v["superficie_habitable_m2"].to_numpy(float); q = v["qualite_1a5"].to_numpy(float); out = np.empty(len(grille)) | ||||
| 242 | + for i, g in enumerate(grille): | ||||
| 243 | + a = np.full_like(a0, g) if variable == "age" else a0; S = np.full_like(S0, g) if variable == "superficie" else S0 | ||||
| 244 | + f = VRAIS["ln_S"] * np.log(S / 140.0) + VRAIS["age"] * a + VRAIS["age2"] * a ** 2 | ||||
| 245 | + if scenario: | ||||
| 246 | + f = f + SCENARIO["prime_qualite_age"] * ((q >= SCENARIO["qualite_min"]) & (a >= SCENARIO["age_min_prime"])) + SCENARIO["mur"] * ((a >= SCENARIO["age_mur"][0]) & (a <= SCENARIO["age_mur"][1])) | ||||
| 247 | + out[i] = float(np.mean(f)) | ||||
| 248 | + return out | ||||
| 249 | + | ||||
| 250 | +def courbes_apprentissage(Xm: pd.DataFrame, y: np.ndarray, plis: list[np.ndarray], profondeurs=(1, 2, 4)) -> dict: | ||||
| 251 | + """Erreur quadratique (log) d'entraînement et de validation croisée selon le nombre d'arbres du boosting (staged_predict), pour | ||||
| 252 | + plusieurs profondeurs : surapprentissage et arrêt précoce.""" | ||||
| 253 | + n = len(y); out = {} | ||||
| 254 | + for d in profondeurs: | ||||
| 255 | + tr_err = np.zeros(1200); te_err = np.zeros(1200) | ||||
| 256 | + for f in plis: | ||||
| 257 | + tr = np.setdiff1d(np.arange(n), f); m = _boosting(max_depth=d).fit(Xm.iloc[tr], y[tr]) | ||||
| 258 | + for k, (pt, pv) in enumerate(zip(m.staged_predict(Xm.iloc[tr]), m.staged_predict(Xm.iloc[f]))): | ||||
| 259 | + tr_err[k] += np.mean((pt - y[tr]) ** 2) / len(plis); te_err[k] += np.mean((pv - y[f]) ** 2) * len(f) / n | ||||
| 260 | + out[d] = {"entrainement": np.sqrt(tr_err), "validation": np.sqrt(te_err), "optimum": int(np.argmin(te_err)) + 1, "rmse_min": float(np.sqrt(te_err.min()))} | ||||
| 261 | + return out | ||||
| 262 | + | ||||
| 263 | +def sensibilite_hyperparametres(Xm: pd.DataFrame, y: np.ndarray, plis: list[np.ndarray]) -> pd.DataFrame: | ||||
| 264 | + """Grille d'hyperparamètres du boosting (profondeur, taux d'apprentissage, nombre d'arbres, taille minimale des feuilles) et de la | ||||
| 265 | + forêt (feuilles, variables par division) : RMSE log et COD en validation croisée aléatoire.""" | ||||
| 266 | + lignes = [] | ||||
| 267 | + grille_b = [dict(max_depth=1, n_estimators=1200), dict(max_depth=2, n_estimators=300), dict(max_depth=2, n_estimators=1200), dict(max_depth=2, n_estimators=3000), | ||||
| 268 | + dict(max_depth=3, n_estimators=1200), dict(max_depth=4, n_estimators=1200), dict(max_depth=2, n_estimators=1200, learning_rate=0.1), | ||||
| 269 | + dict(max_depth=2, n_estimators=1200, min_samples_leaf=1), dict(max_depth=2, n_estimators=1200, subsample=1.0)] | ||||
| 270 | + for k in grille_b: | ||||
| 271 | + p = valider(lambda: _boosting(**k), "ml", None, Xm, y, plis); m = metriques(p, y, facteur_duan(p, y)) | ||||
| 272 | + lignes.append({"modele": "Gradient boosting", "profondeur": k.get("max_depth", 2), "arbres": k.get("n_estimators", 1200), "nu": k.get("learning_rate", 0.03), | ||||
| 273 | + "feuille_min": k.get("min_samples_leaf", 5), "sous_ech": k.get("subsample", 0.8), "variables": "toutes", **m}) | ||||
| 274 | + for k in [dict(min_samples_leaf=1, max_features=1.0), dict(min_samples_leaf=2, max_features=0.5), dict(min_samples_leaf=5, max_features=0.33), dict(min_samples_leaf=2, max_features=0.5, n_estimators=100)]: | ||||
| 275 | + p = valider(lambda: _foret(**k), "ml", None, Xm, y, plis); m = metriques(p, y, facteur_duan(p, y)) | ||||
| 276 | + lignes.append({"modele": "Forêt aléatoire", "profondeur": "max", "arbres": k.get("n_estimators", N_ARBRES_FORET), "nu": np.nan, "feuille_min": k["min_samples_leaf"], | ||||
| 277 | + "sous_ech": "bootstrap", "variables": f"{int(round(k['max_features'] * 100))} %", **m}) | ||||
| 278 | + return pd.DataFrame(lignes) | ||||
| 279 | + | ||||
| 280 | +# ----------------------------------------------------------------------------- sujet | ||||
| 281 | +def prediction_sujet(modeles_ajustes: dict, duan: dict, Xh_s: pd.DataFrame, Xm_s: pd.DataFrame, ref: dict, s: dict) -> pd.DataFrame: | ||||
| 282 | + """Prédiction du sujet par chaque modèle estimé sur les 600 ventes : logarithme, niveau avec correction de retransformation | ||||
| 283 | + (facteur hors échantillon ; pour l'hédonique, traite.cas1.predire donne la valeur de référence 637 953 $).""" | ||||
| 284 | + rows = [] | ||||
| 285 | + for nom, m in modeles_ajustes.items(): | ||||
| 286 | + lp = float(m.predict(Xh_s if nom.startswith("Hédonique") else Xm_s)[0]); F = duan[nom] | ||||
| 287 | + rows.append({"modele": nom, "ln_pred": lp, "naif": math.exp(lp), "facteur": F, "prediction": math.exp(lp) * F}) | ||||
| 288 | + df = pd.DataFrame(rows).set_index("modele") | ||||
| 289 | + df.loc["Hédonique (référence)", "prediction_cas1"] = float(cas1.predire(ref, cas1.sujet_df(s))[0]) | ||||
| 290 | + return df | ||||
| 291 | + | ||||
| 292 | +# ----------------------------------------------------------------------------- exercices | ||||
| 293 | +def exercices(v: pd.DataFrame, comp: dict, secteurs: dict, scen: dict, sh: dict) -> dict: | ||||
| 294 | + e = {} | ||||
| 295 | + # 37.1 : division d'un nœud sur huit ventes (ln P, superficie) — réduction de la somme des carrés | ||||
| 296 | + sub = v.sort_values("id_vente").iloc[:8]; lnP = np.log(sub["prix_vente"].to_numpy(float)); S = sub["superficie_habitable_m2"].to_numpy(float) | ||||
| 297 | + ordre = np.argsort(S); S, lnP = S[ordre], lnP[ordre]; sse0 = float(np.sum((lnP - lnP.mean()) ** 2)); best = None; essais = [] | ||||
| 298 | + for k in range(1, 8): | ||||
| 299 | + g, d = lnP[:k], lnP[k:]; sse = float(np.sum((g - g.mean()) ** 2) + np.sum((d - d.mean()) ** 2)); seuil = float((S[k - 1] + S[k]) / 2) | ||||
| 300 | + essais.append({"seuil": seuil, "sse": sse, "gain": sse0 - sse}) | ||||
| 301 | + if best is None or sse < best["sse"]: best = {"seuil": seuil, "sse": sse, "gain": sse0 - sse, "k": k, "c_gauche": float(g.mean()), "c_droite": float(d.mean())} | ||||
| 302 | + e["1"] = {"ids": list(sub.sort_values("superficie_habitable_m2")["id_vente"]), "S": S.tolist(), "lnP": lnP.tolist(), "prix": sub.sort_values("superficie_habitable_m2")["prix_vente"].tolist(), | ||||
| 303 | + "sse0": sse0, "essais": essais, "best": best} | ||||
| 304 | + # 37.2 : variance d'une moyenne d'arbres corrélés | ||||
| 305 | + s2, rho = 0.12 ** 2, 0.30; e["2"] = {"sigma": 0.12, "rho": rho, **{f"B{B}": rho * s2 + (1 - rho) * s2 / B for B in (1, 10, 100, 500)}, "limite": rho * s2, "ecart_type_limite": math.sqrt(rho * s2), | ||||
| 306 | + "B_pour_1pct": (1 - rho) / 0.01 / rho if rho else np.inf} | ||||
| 307 | + # 37.3 : boosting à la main | ||||
| 308 | + nu = 0.1; F = 12.80; yv = 13.00; traj = [F] | ||||
| 309 | + for _ in range(3): F = F + nu * (yv - F); traj.append(F) | ||||
| 310 | + e["3"] = {"nu": nu, "F": traj, "residus": [yv - t for t in traj], "m_1pct": math.log(0.01) / math.log(1 - nu), "F_nu1": yv} | ||||
| 311 | + # 37.4 : valeurs de Shapley d'un jeu à trois joueurs (S, Q, L) ; v(S) en milliers de dollars au-dessus de la base | ||||
| 312 | + vS = {(): 0.0, ("S",): 60.0, ("Q",): 30.0, ("L",): 20.0, ("S", "Q"): 100.0, ("S", "L"): 85.0, ("Q", "L"): 45.0, ("S", "Q", "L"): 130.0} | ||||
| 313 | + import itertools | ||||
| 314 | + joueurs = ["S", "Q", "L"]; phi = {} | ||||
| 315 | + for j in joueurs: | ||||
| 316 | + tot = 0.0 | ||||
| 317 | + for perm in itertools.permutations(joueurs): | ||||
| 318 | + avant = tuple(sorted(perm[:perm.index(j)])); apres = tuple(sorted(avant + (j,))) | ||||
| 319 | + tot += vS[apres] - vS[avant] | ||||
| 320 | + phi[j] = tot / 6 | ||||
| 321 | + e["4"] = {"v": {"+".join(k) if k else "∅": val for k, val in vS.items()}, "phi": phi, "somme": sum(phi.values()), "total": vS[("S", "Q", "L")]} | ||||
| 322 | + # 37.5 : dix prédictions d'un AVM fictif | ||||
| 323 | + prix = np.array([312000, 358500, 401000, 445000, 489500, 532000, 598000, 655000, 742000, 895000], float) | ||||
| 324 | + pred = np.array([335000, 349000, 388000, 468000, 471000, 561000, 575000, 702000, 718000, 839000], float) | ||||
| 325 | + r = h.statistiques_ratio(pred, prix); el = np.log(pred / prix) | ||||
| 326 | + e["5"] = {"prix": prix.tolist(), "pred": pred.tolist(), "ratios": (pred / prix).tolist(), "rmse_log": float(np.sqrt(np.mean(el ** 2))), "mdape": float(np.median(np.abs(pred / prix - 1)) * 100), | ||||
| 327 | + "mape": float(np.mean(np.abs(pred / prix - 1)) * 100), **{k: float(val) for k, val in r.items()}} | ||||
| 328 | + # 37.6 : validation par secteur vs aléatoire (chiffres du script) | ||||
| 329 | + e["6"] = {nom: {"aleatoire": comp["metriques"][nom]["aleatoire"]["COD"], "spatial": comp["metriques"][nom]["spatial"]["COD"], "secteur": secteurs["metriques"][nom]["COD"]} | ||||
| 330 | + for nom in ("Hédonique (référence)", "Forêt aléatoire", "Gradient boosting")} | ||||
| 331 | + e["6"]["biais_secteur"] = secteurs["biais_secteur"] | ||||
| 332 | + # 37.7 : scénario — respécification | ||||
| 333 | + e["7"] = {nom: scen["metriques"][nom]["COD"] for nom in scen["metriques"]}; e["7"]["coef"] = scen["coef_respecifie"]; e["7"]["interactions_top"] = sh["scenario_interactions"].head(3).to_dict() | ||||
| 334 | + return e | ||||
| 335 | + | ||||
| 336 | +# ----------------------------------------------------------------------------- orchestration | ||||
| 337 | +def main(verbose: bool = True, complet: bool = True) -> dict: | ||||
| 338 | + d = cas1.charger(); v = d["ventes"].reset_index(drop=True); s = d["sujet"] | ||||
| 339 | + ref = cas1.hedonique(v); Xh = cas1.matrice(v); Xm = matrice_ml(v); y = np.log(v["prix_vente"].astype(float).to_numpy()) | ||||
| 340 | + sd = cas1.sujet_df(s); Xh_s = cas1.matrice(sd)[Xh.columns]; Xm_s = matrice_ml(sd, s) | ||||
| 341 | + comp = comparaison(v, Xh, Xm, y) | ||||
| 342 | + det = boosting_detendance(v, Xh, Xm, y) | ||||
| 343 | + sect = validation_secteurs(v, Xh, Xm, y) | ||||
| 344 | + scen = scenario_nonlineaire(v, Xh, Xm, y) | ||||
| 345 | + ajustes = {nom: (ctor().fit(Xh if genre == "hedo" else Xm, y)) for nom, (genre, ctor) in MODELES.items()} | ||||
| 346 | + sujet = prediction_sujet(ajustes, comp["duan"], Xh_s, Xm_s, ref, s) | ||||
| 347 | + sh = shap_boosting(ajustes["Gradient boosting"], Xm, Xm_s) | ||||
| 348 | + sh_sc = shap_boosting(scen["gbm_scenario"], Xm, Xm_s); sh["scenario_interactions"] = sh_sc["interactions"]; sh["scenario_importance"] = sh_sc["importance"] | ||||
| 349 | + pa = comp["plis"]["aleatoire"] | ||||
| 350 | + perm = importance_permutation(Xh, Xm, y, pa) if complet else None | ||||
| 351 | + grille_S = np.linspace(80, 260, 37); grille_A = np.linspace(1, 95, 48) | ||||
| 352 | + pdp = {"superficie": {"grille": grille_S, "Gradient boosting": dependance_partielle(ajustes["Gradient boosting"], Xm, "superficie", grille_S), | ||||
| 353 | + "Forêt aléatoire": dependance_partielle(ajustes["Forêt aléatoire"], Xm, "superficie", grille_S), | ||||
| 354 | + "Hédonique (référence)": dependance_partielle(ajustes["Hédonique (référence)"], Xh, "ln_S", np.log(grille_S / 140.0)), "vraie": pdp_vraie(v, "superficie", grille_S)}, | ||||
| 355 | + "age": {"grille": grille_A, "Gradient boosting": dependance_partielle(ajustes["Gradient boosting"], Xm, "age", grille_A), | ||||
| 356 | + "Forêt aléatoire": dependance_partielle(ajustes["Forêt aléatoire"], Xm, "age", grille_A), "vraie": pdp_vraie(v, "age", grille_A)}} | ||||
| 357 | + # hédonique : l'âge entre par deux colonnes (age, age2_100) → PDP par substitution conjointe | ||||
| 358 | + def pdp_hedo_age(modele, Xh_, grille): | ||||
| 359 | + out = np.empty(len(grille)) | ||||
| 360 | + for i, g in enumerate(grille): | ||||
| 361 | + Xg = Xh_.copy(); Xg["age"] = g; Xg["age2_100"] = g ** 2 / 100.0; out[i] = float(np.mean(modele.predict(Xg))) | ||||
| 362 | + return out | ||||
| 363 | + pdp["age"]["Hédonique (référence)"] = pdp_hedo_age(ajustes["Hédonique (référence)"], Xh, grille_A) | ||||
| 364 | + pdp["age_scenario"] = {"grille": grille_A, "Gradient boosting": dependance_partielle(scen["gbm_scenario"], Xm, "age", grille_A), | ||||
| 365 | + "Hédonique (référence)": pdp_hedo_age(scen["hedo_scenario"], Xh, grille_A), "vraie": pdp_vraie(v, "age", grille_A, scenario=True)} | ||||
| 366 | + courbes = courbes_apprentissage(Xm, y, pa) if complet else None | ||||
| 367 | + sens = sensibilite_hyperparametres(Xm, y, pa) if complet else None | ||||
| 368 | + ex = exercices(v, comp, sect, scen, sh) | ||||
| 369 | + out = {"ventes": v, "sujet": s, "modele_ref": ref, "Xh": Xh, "Xm": Xm, "y": y, "Xh_s": Xh_s, "Xm_s": Xm_s, "comparaison": comp, "detendance": det, "secteurs": sect, | ||||
| 370 | + "scenario": scen, "ajustes": ajustes, "sujet_pred": sujet, "shap": sh, "permutation": perm, "pdp": pdp, "courbes": courbes, "sensibilite": sens, "exercices": ex} | ||||
| 371 | + if verbose: | ||||
| 372 | + pd.set_option("display.width", 220); pd.set_option("display.max_columns", 30) | ||||
| 373 | + print("== Modèle hédonique de référence : R² %.3f, σ %.4f, n %d, k %d" % (ref["r2"], ref["sigma"], ref["n"], ref["k"])) | ||||
| 374 | + for schema in ("aleatoire", "spatial", "temporel"): | ||||
| 375 | + print(f"== Validation {schema} :") | ||||
| 376 | + for nom, m in comp["metriques"].items(): | ||||
| 377 | + mm = m[schema]; print(f" {nom:24s} n={mm['n']:3d} RMSElog={mm['rmse_log']:.4f} R²hors={mm['r2_hors']:.3f} biais={mm['biais_log']:+.4f} RMSE$={mm['rmse_dollars']:,.0f} MdAPE={mm['mdape']:.2f} COD={mm['COD']:.2f} PRD={mm['PRD']:.3f} PRB={mm['PRB']:.3f} méd={mm['ratio_median']:.3f} Duan={comp['duan'][nom]:.4f}") | ||||
| 378 | + print(" biais mensuel 2026 :", {k: {int(kk): round(vv, 3) for kk, vv in vals.items()} for k, vals in comp["biais_mensuel_2026"].items()}) | ||||
| 379 | + md = det["metriques_detendance"]; m1 = det["metriques_feuille1"] | ||||
| 380 | + print(f"== Boosting détendancé (test 2026) : biais={md['biais_log']:+.4f} RMSElog={md['rmse_log']:.4f} COD={md['COD']:.2f} MdAPE={md['mdape']:.2f} ; β_mois entraînement {det['beta_mois_entrainement']:.5f} ; n(mois>17)={det['n_entrainement_apres_mois_17']}") | ||||
| 381 | + print(f" Boosting feuilles ≥ 1 (test 2026) : biais={m1['biais_log']:+.4f} RMSElog={m1['rmse_log']:.4f} COD={m1['COD']:.2f}") | ||||
| 382 | + print("== Un secteur exclu :") | ||||
| 383 | + for nom, m in sect["metriques"].items(): | ||||
| 384 | + print(f" {nom:24s} RMSElog={m['rmse_log']:.4f} MdAPE={m['mdape']:.2f} COD={m['COD']:.2f} biais/secteur=", {k: round(vv, 3) for k, vv in sect["biais_secteur"][nom].items()}) | ||||
| 385 | + print(f"== Scénario non linéaire (n prime={scen['n_prime']}, n mur={scen['n_mur']}) :") | ||||
| 386 | + for nom, m in scen["metriques"].items(): | ||||
| 387 | + print(f" {nom:24s} RMSElog={m['rmse_log']:.4f} MdAPE={m['mdape']:.2f} COD={m['COD']:.2f} PRB={m['PRB']:.3f}") | ||||
| 388 | + print(" coefficients respécifiés :", {k: round(vv, 4) for k, vv in scen["coef_respecifie"].items()}) | ||||
| 389 | + print("== Sujet :"); print(sujet.round(4).to_string()) | ||||
| 390 | + print(f"== SHAP (boosting) : base {sh['base']:.4f} (exp {math.exp(sh['base']):,.0f} $) ; efficacité max err {sh['efficacite_max_err']:.2e}") | ||||
| 391 | + print(" importance moyenne |φ| :", sh["importance"].round(4).to_dict()) | ||||
| 392 | + print(" sujet φ :", sh["sujet"].sort_values().round(4).to_dict()) | ||||
| 393 | + print(" interactions (base) :", sh["interactions"].head(4).round(4).to_dict()); print(" interactions (scénario) :", sh["scenario_interactions"].head(4).round(4).to_dict()) | ||||
| 394 | + if perm is not None: | ||||
| 395 | + print("== Importance par permutation (ΔEQM log, hors échantillon) :") | ||||
| 396 | + for nom in ("Gradient boosting", "Forêt aléatoire"): print(f" {nom} (EQM base {perm[nom + ' (EQM de base)']:.5f}) :", perm[nom].sort_values(ascending=False).round(5).head(8).to_dict()) | ||||
| 397 | + if courbes is not None: | ||||
| 398 | + print("== Courbes d'apprentissage :", {d_: (c["optimum"], round(c["rmse_min"], 4), round(float(c["entrainement"][-1]), 4)) for d_, c in courbes.items()}) | ||||
| 399 | + if sens is not None: | ||||
| 400 | + print("== Sensibilité aux hyperparamètres :"); print(sens[["modele", "profondeur", "arbres", "nu", "feuille_min", "sous_ech", "variables", "rmse_log", "mdape", "COD", "PRB"]].round(4).to_string(index=False)) | ||||
| 401 | + print("== Exercices :") | ||||
| 402 | + print(" 37.1", {k: (round(vv, 4) if isinstance(vv, float) else vv) for k, vv in ex["1"]["best"].items()}, "SSE0", round(ex["1"]["sse0"], 4)); print(" ventes", ex["1"]["ids"], ex["1"]["S"], [round(x, 4) for x in ex["1"]["lnP"]]) | ||||
| 403 | + print(" 37.2", {k: (round(vv, 6) if isinstance(vv, float) else vv) for k, vv in ex["2"].items()}) | ||||
| 404 | + print(" 37.3", {k: (np.round(vv, 4).tolist() if isinstance(vv, list) else round(vv, 4)) for k, vv in ex["3"].items()}) | ||||
| 405 | + print(" 37.4", {k: (round(vv, 3) if isinstance(vv, float) else vv) for k, vv in ex["4"].items()}) | ||||
| 406 | + print(" 37.5", {k: (round(vv, 4) if isinstance(vv, float) else vv) for k, vv in ex["5"].items() if k not in ("prix", "pred", "ratios")}, [round(r_, 3) for r_ in ex["5"]["ratios"]]) | ||||
| 407 | + print(" 37.6", ex["6"]); print(" 37.7", ex["7"]) | ||||
| 408 | + return out | ||||
| 409 | + | ||||
| 410 | +if __name__ == "__main__": | ||||
| 411 | + main() | ||||
modified
analysis/python/cases/ch39_bayes.py
+60 −29
@@ -35,18 +35,25 @@ LN_MEDIANE_HULL = math.log(475_000) # médiane APCIQ T3 2026 secteur H | |||||
| 35 | # 1. Régression linéaire bayésienne conjuguée (normale–inverse-gamma) | 35 | # 1. Régression linéaire bayésienne conjuguée (normale–inverse-gamma) | ||
| 36 | # ============================================================================================================================= | 36 | # ============================================================================================================================= | ||
| 37 | 37 | ||||
| 38 | +def prior_reference(colonnes) -> dict: | ||||
| 39 | + """A priori de référence (impropre, Jeffreys) : p(β, σ²) ∝ 1/σ². Cas limite de la NIG avec V0⁻¹ = 0, a0 = −k/2, b0 = 0 : la | ||||
| 40 | + postérieure est NIG(β̂, (X'X)⁻¹, (n−k)/2, SCR/2) et la prédictive coïncide avec l'intervalle de prédiction fréquentiste classique.""" | ||||
| 41 | + k = len(colonnes) | ||||
| 42 | + return {"m0": np.zeros(k), "V0inv": np.zeros((k, k)), "a0": -k / 2, "b0": 0.0, "nom": "référence (impropre)"} | ||||
| 43 | + | ||||
| 38 | def prior_faible(colonnes, v0: float = 1e4, a0: float = 1e-3, b0: float = 1e-3) -> dict: | 44 | def prior_faible(colonnes, v0: float = 1e4, a0: float = 1e-3, b0: float = 1e-3) -> dict: | ||
| 39 | − """A priori faiblement informatif : β | σ² ~ N(m0, σ² V0), σ² ~ IG(a0, b0). m0 = ln(médiane APCIQ de Hull) pour la constante, | 45 | + """A priori faiblement informatif PROPRE : β | σ² ~ N(m0, σ² V0), σ² ~ IG(a0, b0). m0 = ln(médiane APCIQ de Hull) pour la constante, | ||
| 40 | − 0 ailleurs ; V0 = v0·I (écart-type a priori ≈ 100 σ ≈ 8 en logarithme : pratiquement plat).""" | 46 | + 0 ailleurs ; V0 = v0·I (écart-type a priori ≈ 100 σ ≈ 8 en logarithme : pratiquement plat). Prix d'un a priori propre et vague : | ||
| 47 | + a_n = a0 + n/2 au lieu de (n−k)/2, donc E[σ²] ≈ SCR/(n−2) légèrement inférieur à s² = SCR/(n−k).""" | ||||
| 41 | k = len(colonnes); m0 = np.zeros(k) | 48 | k = len(colonnes); m0 = np.zeros(k) | ||
| 42 | if "const" in colonnes: m0[list(colonnes).index("const")] = LN_MEDIANE_HULL | 49 | if "const" in colonnes: m0[list(colonnes).index("const")] = LN_MEDIANE_HULL | ||
| 43 | − return {"m0": m0, "V0": np.eye(k) * v0, "a0": a0, "b0": b0, "nom": "faiblement informatif"} | 50 | + return {"m0": m0, "V0inv": np.eye(k) / v0, "a0": a0, "b0": b0, "nom": "faiblement informatif (propre)"} | ||
| 44 | 51 | ||||
| 45 | def nig_posterieure(X: np.ndarray, y: np.ndarray, prior: dict) -> dict: | 52 | def nig_posterieure(X: np.ndarray, y: np.ndarray, prior: dict) -> dict: | ||
| 46 | − """Postérieure NIG : V_n = (V0⁻¹ + X'X)⁻¹ ; m_n = V_n (V0⁻¹ m0 + X'y) ; a_n = a0 + n/2 ; | 53 | + """Postérieure NIG : V_n = (V0⁻¹ + X'X)⁻¹ ; m_n = V_n (V0⁻¹ m0 + X'y) ; a_n = a0 + n/2 ; (prior : m0, V0inv, a0, b0) | ||
| 47 | b_n = b0 + ½ (y'y + m0' V0⁻¹ m0 − m_n' V_n⁻¹ m_n). Marginale de β : Student-t à 2a_n ddl, échelle² = (b_n/a_n) diag(V_n).""" | 54 | b_n = b0 + ½ (y'y + m0' V0⁻¹ m0 − m_n' V_n⁻¹ m_n). Marginale de β : Student-t à 2a_n ddl, échelle² = (b_n/a_n) diag(V_n).""" | ||
| 48 | X = np.asarray(X, float); y = np.asarray(y, float); n, k = X.shape | 55 | X = np.asarray(X, float); y = np.asarray(y, float); n, k = X.shape | ||
| 49 | − V0inv = np.linalg.inv(prior["V0"]); m0 = prior["m0"] | 56 | + V0inv = prior["V0inv"]; m0 = prior["m0"] | ||
| 50 | Vn_inv = V0inv + X.T @ X; Vn = np.linalg.inv(Vn_inv) | 57 | Vn_inv = V0inv + X.T @ X; Vn = np.linalg.inv(Vn_inv) | ||
| 51 | mn = Vn @ (V0inv @ m0 + X.T @ y) | 58 | mn = Vn @ (V0inv @ m0 + X.T @ y) | ||
| 52 | an = prior["a0"] + n / 2 | 59 | an = prior["a0"] + n / 2 | ||
@@ -88,7 +95,7 @@ def prior_marche_voisin(post_voisin: dict, colonnes_voisin, colonnes_cible, kapp | |||||
| 88 | m0[pos] = post_voisin["m_n"][idx] | 95 | m0[pos] = post_voisin["m_n"][idx] | ||
| 89 | V0[np.ix_(pos, pos)] = kappa * post_voisin["V_n"][np.ix_(idx, idx)] | 96 | V0[np.ix_(pos, pos)] = kappa * post_voisin["V_n"][np.ix_(idx, idx)] | ||
| 90 | ic = cc.index("const"); m0[ic] = LN_MEDIANE_HULL; V0[ic, ic] = v0_const | 97 | ic = cc.index("const"); m0[ic] = LN_MEDIANE_HULL; V0[ic, ic] = v0_const | ||
| 91 | − return {"m0": m0, "V0": V0, "a0": post_voisin["a_n"], "b0": post_voisin["b_n"], "nom": f"marché voisin, κ = {kappa:g}"} | 98 | + return {"m0": m0, "V0inv": np.linalg.inv(V0), "a0": post_voisin["a_n"], "b0": post_voisin["b_n"], "nom": f"marché voisin, κ = {kappa:g}"} | ||
| 92 | 99 | ||||
| 93 | def loo_exact_nig(X: np.ndarray, y: np.ndarray, prior: dict) -> dict: | 100 | def loo_exact_nig(X: np.ndarray, y: np.ndarray, prior: dict) -> dict: | ||
| 94 | """LOO exact du modèle conjugué : Σ_i log p(y_i | y_{−i}) par n réestimations en forme fermée (prédictive Student-t).""" | 101 | """LOO exact du modèle conjugué : Σ_i log p(y_i | y_{−i}) par n réestimations en forme fermée (prédictive Student-t).""" | ||
@@ -115,17 +122,21 @@ def matrice_hier(df: pd.DataFrame, moyennes: pd.Series | None = None): | |||||
| 115 | 122 | ||||
| 116 | def gibbs_hierarchique(y: np.ndarray, X: np.ndarray, z: np.ndarray, J: int = 5, n_iter: int = 3000, brulage: int = 1000, | 123 | def gibbs_hierarchique(y: np.ndarray, X: np.ndarray, z: np.ndarray, J: int = 5, n_iter: int = 3000, brulage: int = 1000, | ||
| 117 | chaines: int = 4, graine: int = GRAINE, prior_tau: str = "uniforme", tau_fixe: float | None = None, | 124 | chaines: int = 4, graine: int = GRAINE, prior_tau: str = "uniforme", tau_fixe: float | None = None, | ||
| 118 | − ig_a: float = 1e-3, ig_b: float = 1e-3) -> dict: | 125 | + ig_a: float = 1e-3, ig_b: float = 1e-3, bloc: bool = True) -> dict: | ||
| 119 | """ln P_i = x_i'β + α_{s(i)} + ε_i, ε ~ N(0, σ²) ; α_s | α0, τ² ~ N(α0, τ²) ; p(β) ∝ 1, p(α0) ∝ 1, p(σ²) ∝ 1/σ² ; | 126 | """ln P_i = x_i'β + α_{s(i)} + ε_i, ε ~ N(0, σ²) ; α_s | α0, τ² ~ N(α0, τ²) ; p(β) ∝ 1, p(α0) ∝ 1, p(σ²) ∝ 1/σ² ; | ||
| 120 | − τ : p(τ) ∝ 1 (uniforme sur τ, BDA3 §5.7 ; « uniforme ») ou τ² ~ IG(ig_a, ig_b) (« ig ») ou τ fixé (« fixe », bayésien empirique). | 127 | + τ : p(τ) ∝ 1 (uniforme sur τ, BDA3 §5.7 ; « uniforme ») ou τ² ~ IG(ig_a, ig_b) (« ig ») ou τ fixé (bayésien empirique). | ||
| 121 | Conditionnelles complètes (toutes conjuguées) : | 128 | Conditionnelles complètes (toutes conjuguées) : | ||
| 122 | β | · ~ N((X'X)⁻¹X'(y − Zα), σ²(X'X)⁻¹) | 129 | β | · ~ N((X'X)⁻¹X'(y − Zα), σ²(X'X)⁻¹) | ||
| 123 | α_s | · ~ N([n_s r̄_s/σ² + α0/τ²] / [n_s/σ² + 1/τ²], 1/[n_s/σ² + 1/τ²]), r̄_s = moyenne de (y − Xβ) dans le secteur s | 130 | α_s | · ~ N([n_s r̄_s/σ² + α0/τ²] / [n_s/σ² + 1/τ²], 1/[n_s/σ² + 1/τ²]), r̄_s = moyenne de (y − Xβ) dans le secteur s | ||
| 124 | α0 | · ~ N(moyenne des α_s, τ²/J) | 131 | α0 | · ~ N(moyenne des α_s, τ²/J) | ||
| 125 | σ² | · ~ IG(n/2, ½ Σ (y − Xβ − Zα)²) | 132 | σ² | · ~ IG(n/2, ½ Σ (y − Xβ − Zα)²) | ||
| 126 | τ² | · ~ IG((J−1)/2, ½ Σ (α_s − α0)²) [uniforme sur τ] ou IG(a + J/2, b + ½ Σ (α_s − α0)²) [IG] | 133 | τ² | · ~ IG((J−1)/2, ½ Σ (α_s − α0)²) [uniforme sur τ] ou IG(a + J/2, b + ½ Σ (α_s − α0)²) [IG] | ||
| 134 | + bloc=True : (β, α) sont tirés ENSEMBLE de leur conditionnelle jointe normale (Gibbs par blocs), ce qui supprime la lenteur de | ||||
| 135 | + mélange due à la colinéarité entre la distance au centre et les niveaux des secteurs périphériques ; bloc=False : un à la fois. | ||||
| 127 | Retourne les tirages après brûlage, de forme (chaînes, n_iter − brulage, ·).""" | 136 | Retourne les tirages après brûlage, de forme (chaînes, n_iter − brulage, ·).""" | ||
| 128 | y = np.asarray(y, float); X = np.asarray(X, float); n, k = X.shape | 137 | y = np.asarray(y, float); X = np.asarray(X, float); n, k = X.shape | ||
| 138 | + Z = np.zeros((n, J)); Z[np.arange(n), z] = 1.0 | ||||
| 139 | + W = np.hstack([X, Z]); WtW = W.T @ W; Wty = W.T @ y | ||||
| 129 | XtX_inv = np.linalg.inv(X.T @ X); L_beta = np.linalg.cholesky(XtX_inv) | 140 | XtX_inv = np.linalg.inv(X.T @ X); L_beta = np.linalg.cholesky(XtX_inv) | ||
| 130 | ns = np.bincount(z, minlength=J).astype(float) | 141 | ns = np.bincount(z, minlength=J).astype(float) | ||
| 131 | garder = n_iter - brulage | 142 | garder = n_iter - brulage | ||
@@ -133,21 +144,31 @@ def gibbs_hierarchique(y: np.ndarray, X: np.ndarray, z: np.ndarray, J: int = 5, | |||||
| 133 | "sigma2": np.empty((chaines, garder)), "tau2": np.empty((chaines, garder))} | 144 | "sigma2": np.empty((chaines, garder)), "tau2": np.empty((chaines, garder))} | ||
| 134 | for c in range(chaines): | 145 | for c in range(chaines): | ||
| 135 | rng = np.random.default_rng([graine, c, 39]) | 146 | rng = np.random.default_rng([graine, c, 39]) | ||
| 136 | − # valeurs initiales dispersées (BDA3 : points de départ surdispersés pour que R̂ ait un sens) | 147 | + # valeurs initiales surdispersées (BDA3 : points de départ dispersés pour que R̂ ait un sens) | ||
| 137 | beta = XtX_inv @ X.T @ y + rng.normal(0, 0.05, k) | 148 | beta = XtX_inv @ X.T @ y + rng.normal(0, 0.05, k) | ||
| 138 | alpha = np.full(J, float(np.mean(y))) + rng.normal(0, 0.3, J); alpha0 = float(np.mean(alpha)) | 149 | alpha = np.full(J, float(np.mean(y))) + rng.normal(0, 0.3, J); alpha0 = float(np.mean(alpha)) | ||
| 139 | sigma2 = float(np.exp(rng.normal(math.log(0.01), 0.5))); tau2 = float(np.exp(rng.normal(math.log(0.02), 1.0))) | 150 | sigma2 = float(np.exp(rng.normal(math.log(0.01), 0.5))); tau2 = float(np.exp(rng.normal(math.log(0.02), 1.0))) | ||
| 140 | if tau_fixe is not None: tau2 = tau_fixe ** 2 | 151 | if tau_fixe is not None: tau2 = tau_fixe ** 2 | ||
| 141 | for it in range(n_iter): | 152 | for it in range(n_iter): | ||
| 142 | − # β | α, σ² | 153 | + if bloc: | ||
| 143 | − r = y - alpha[z] | 154 | + # (β, α) | α0, σ², τ² : précision = W'W/σ² + diag(0_k, 1/τ² 1_J) ; moyenne = Préc⁻¹ (W'y/σ² + (0, α0/τ² 1_J)) | ||
| 144 | − beta = XtX_inv @ X.T @ r + math.sqrt(sigma2) * (L_beta @ rng.standard_normal(k)) | 155 | + prec = WtW / sigma2; prec[k:, k:] += np.eye(J) / tau2 | ||
| 145 | − # α_s | β, α0, σ², τ² | 156 | + b = Wty / sigma2; b[k:] += alpha0 / tau2 | ||
| 146 | − e = y - X @ beta | 157 | + Lp = np.linalg.cholesky(prec) | ||
| 147 | − somme = np.bincount(z, weights=e, minlength=J) | 158 | + moy = np.linalg.solve(Lp.T, np.linalg.solve(Lp, b)) | ||
| 148 | − prec = ns / sigma2 + 1.0 / tau2 | 159 | + theta = moy + np.linalg.solve(Lp.T, rng.standard_normal(k + J)) | ||
| 149 | − moy = (somme / sigma2 + alpha0 / tau2) / prec | 160 | + beta, alpha = theta[:k], theta[k:] | ||
| 150 | − alpha = moy + rng.standard_normal(J) / np.sqrt(prec) | 161 | + e = y - X @ beta | ||
| 162 | + else: | ||||
| 163 | + # β | α, σ² | ||||
| 164 | + r = y - alpha[z] | ||||
| 165 | + beta = XtX_inv @ X.T @ r + math.sqrt(sigma2) * (L_beta @ rng.standard_normal(k)) | ||||
| 166 | + # α_s | β, α0, σ², τ² | ||||
| 167 | + e = y - X @ beta | ||||
| 168 | + somme = np.bincount(z, weights=e, minlength=J) | ||||
| 169 | + prec_a = ns / sigma2 + 1.0 / tau2 | ||||
| 170 | + moy_a = (somme / sigma2 + alpha0 / tau2) / prec_a | ||||
| 171 | + alpha = moy_a + rng.standard_normal(J) / np.sqrt(prec_a) | ||||
| 151 | # α0 | α, τ² | 172 | # α0 | α, τ² | ||
| 152 | alpha0 = float(alpha.mean() + rng.standard_normal() * math.sqrt(tau2 / J)) | 173 | alpha0 = float(alpha.mean() + rng.standard_normal() * math.sqrt(tau2 / J)) | ||
| 153 | # σ² | β, α | 174 | # σ² | β, α | ||
@@ -164,7 +185,7 @@ def gibbs_hierarchique(y: np.ndarray, X: np.ndarray, z: np.ndarray, J: int = 5, | |||||
| 164 | out["beta"][c, j] = beta; out["alpha"][c, j] = alpha; out["alpha0"][c, j] = alpha0 | 185 | out["beta"][c, j] = beta; out["alpha"][c, j] = alpha; out["alpha0"][c, j] = alpha0 | ||
| 165 | out["sigma2"][c, j] = sigma2; out["tau2"][c, j] = tau2 | 186 | out["sigma2"][c, j] = sigma2; out["tau2"][c, j] = tau2 | ||
| 166 | out["meta"] = {"n": n, "k": k, "J": J, "ns": ns, "n_iter": n_iter, "brulage": brulage, "chaines": chaines, "prior_tau": prior_tau, | 187 | out["meta"] = {"n": n, "k": k, "J": J, "ns": ns, "n_iter": n_iter, "brulage": brulage, "chaines": chaines, "prior_tau": prior_tau, | ||
| 167 | − "tau_fixe": tau_fixe} | 188 | + "tau_fixe": tau_fixe, "bloc": bloc} | ||
| 168 | return out | 189 | return out | ||
| 169 | 190 | ||||
| 170 | def facteur_retrecissement(n_s: float, sigma2: float, tau2: float) -> float: | 191 | def facteur_retrecissement(n_s: float, sigma2: float, tau2: float) -> float: | ||
@@ -284,12 +305,17 @@ def sous_echantillons(v: pd.DataFrame, s: dict) -> dict: | |||||
| 284 | return {"tout": v, "hull": v[v["secteur"] == "Hull"], "voisin": v[v["secteur"] != "Hull"], "micro": micro} | 305 | return {"tout": v, "hull": v[v["secteur"] == "Hull"], "voisin": v[v["secteur"] != "Hull"], "micro": micro} | ||
| 285 | 306 | ||||
| 286 | def colonnes_actives(X: pd.DataFrame) -> list: | 307 | def colonnes_actives(X: pd.DataFrame) -> list: | ||
| 287 | − return [c for c in X.columns if c == "const" or X[c].std() > 0] | 308 | + """Colonnes non constantes ; si le secteur de référence (Hull) est absent du sous-échantillon, les indicatrices restantes somment | ||
| 309 | + à 1 et l'une d'elles (la première) est retirée pour éviter la colinéarité avec la constante.""" | ||||
| 310 | + cols = [c for c in X.columns if c == "const" or X[c].std() > 0] | ||||
| 311 | + sect = [c for c in cols if c.startswith("sect_")] | ||||
| 312 | + if sect and np.allclose(X[sect].sum(axis=1), 1.0): cols.remove(sect[0]) | ||||
| 313 | + return cols | ||||
| 288 | 314 | ||||
| 289 | def ajuster_nig(df: pd.DataFrame, prior_fn=None, colonnes=None) -> dict: | 315 | def ajuster_nig(df: pd.DataFrame, prior_fn=None, colonnes=None) -> dict: | ||
| 290 | X = cas1.matrice(df); cols = colonnes or colonnes_actives(X); X = X[cols] | 316 | X = cas1.matrice(df); cols = colonnes or colonnes_actives(X); X = X[cols] | ||
| 291 | y = np.log(df["prix_vente"].astype(float)).values | 317 | y = np.log(df["prix_vente"].astype(float)).values | ||
| 292 | − prior = prior_fn(cols) if prior_fn else prior_faible(cols) | 318 | + prior = prior_fn(cols) if prior_fn else prior_reference(cols) | ||
| 293 | p = nig_posterieure(X.values, y, prior); p["colonnes"] = cols | 319 | p = nig_posterieure(X.values, y, prior); p["colonnes"] = cols | ||
| 294 | return p | 320 | return p | ||
| 295 | 321 | ||||
@@ -304,7 +330,8 @@ def exemple_conjugue(v: pd.DataFrame, s: dict, ref: dict) -> dict: | |||||
| 304 | """Étapes 1-2 : postérieure NIG sur 600 ventes (a priori faible) ; petit échantillon (40 ventes à 3 km / 18 mois) avec a priori | 330 | """Étapes 1-2 : postérieure NIG sur 600 ventes (a priori faible) ; petit échantillon (40 ventes à 3 km / 18 mois) avec a priori | ||
| 305 | faible vs informatif emprunté aux autres secteurs (κ = 1, 4, 16) ; prédictive du sujet.""" | 331 | faible vs informatif emprunté aux autres secteurs (κ = 1, 4, 16) ; prédictive du sujet.""" | ||
| 306 | se = sous_echantillons(v, s) | 332 | se = sous_echantillons(v, s) | ||
| 307 | − p600 = ajuster_nig(se["tout"]); cols = p600["colonnes"] | 333 | + p600 = ajuster_nig(se["tout"]); cols = p600["colonnes"] # a priori de référence | ||
| 334 | + p600_propre = ajuster_nig(se["tout"], prior_fn=prior_faible, colonnes=cols) # a priori propre vague (comparaison) | ||||
| 308 | xs = x_sujet(s, cols) | 335 | xs = x_sujet(s, cols) | ||
| 309 | tab = pd.DataFrame({"mco": ref["coef"]["coef"].reindex(cols), "se_hc1": ref["coef"]["se_hc1"].reindex(cols), | 336 | tab = pd.DataFrame({"mco": ref["coef"]["coef"].reindex(cols), "se_hc1": ref["coef"]["se_hc1"].reindex(cols), | ||
| 310 | "post_moy": p600["m_n"], "post_sd": p600["sd"], "ic_bas": p600["ic95_bas"], "ic_haut": p600["ic95_haut"], | 337 | "post_moy": p600["m_n"], "post_sd": p600["sd"], "ic_bas": p600["ic95_bas"], "ic_haut": p600["ic95_haut"], | ||
@@ -314,7 +341,7 @@ def exemple_conjugue(v: pd.DataFrame, s: dict, ref: dict) -> dict: | |||||
| 314 | # cohérence séquentielle : 2023-2024 puis 2025-2026 | 341 | # cohérence séquentielle : 2023-2024 puis 2025-2026 | ||
| 315 | anc = v[v["date_vente"] < "2025-01-01"]; rec = v[v["date_vente"] >= "2025-01-01"] | 342 | anc = v[v["date_vente"] < "2025-01-01"]; rec = v[v["date_vente"] >= "2025-01-01"] | ||
| 316 | p_anc = ajuster_nig(anc, colonnes=cols) | 343 | p_anc = ajuster_nig(anc, colonnes=cols) | ||
| 317 | − prior_seq = {"m0": p_anc["m_n"], "V0": p_anc["V_n"], "a0": p_anc["a_n"], "b0": p_anc["b_n"], "nom": "séquentiel"} | 344 | + prior_seq = {"m0": p_anc["m_n"], "V0inv": np.linalg.inv(p_anc["V_n"]), "a0": p_anc["a_n"], "b0": p_anc["b_n"], "nom": "séquentiel"} | ||
| 318 | p_seq = ajuster_nig(rec, prior_fn=lambda c: prior_seq, colonnes=cols) | 345 | p_seq = ajuster_nig(rec, prior_fn=lambda c: prior_seq, colonnes=cols) | ||
| 319 | # petit échantillon : 40 ventes autour du sujet ; a priori emprunté aux autres secteurs | 346 | # petit échantillon : 40 ventes autour du sujet ; a priori emprunté aux autres secteurs | ||
| 320 | p_vois = ajuster_nig(se["voisin"]) | 347 | p_vois = ajuster_nig(se["voisin"]) | ||
@@ -326,7 +353,7 @@ def exemple_conjugue(v: pd.DataFrame, s: dict, ref: dict) -> dict: | |||||
| 326 | petits[nom] = {"post": p, "pred": pr, "largeur95_pct": (pr["haut95"] - pr["bas95"]) / pr["mediane"] * 100} | 353 | petits[nom] = {"post": p, "pred": pr, "largeur95_pct": (pr["haut95"] - pr["bas95"]) / pr["mediane"] * 100} | ||
| 327 | # Hull seul (186), a priori faible | 354 | # Hull seul (186), a priori faible | ||
| 328 | p_hull = ajuster_nig(se["hull"]); pred_hull = predictive_t(p_hull, x_sujet(s, p_hull["colonnes"])) | 355 | p_hull = ajuster_nig(se["hull"]); pred_hull = predictive_t(p_hull, x_sujet(s, p_hull["colonnes"])) | ||
| 329 | − return {"p600": p600, "cols": cols, "xs": xs, "tab600": tab, "pred600": pred600, "p_anc": p_anc, "p_rec_seq": p_seq, "n_anc": len(anc), | 356 | + return {"p600": p600, "p600_propre": p600_propre, "pred600_propre": predictive_t(p600_propre, xs), "cols": cols, "xs": xs, "tab600": tab, "pred600": pred600, "p_anc": p_anc, "p_rec_seq": p_seq, "n_anc": len(anc), | ||
| 330 | "n_rec": len(rec), "p_vois": p_vois, "micro": micro, "cols_micro": cols_m, "petits": petits, "p_hull": p_hull, | 357 | "n_rec": len(rec), "p_vois": p_vois, "micro": micro, "cols_micro": cols_m, "petits": petits, "p_hull": p_hull, | ||
| 331 | "pred_hull": pred_hull, "n_micro": len(micro)} | 358 | "pred_hull": pred_hull, "n_micro": len(micro)} | ||
| 332 | 359 | ||||
@@ -334,8 +361,10 @@ def exemple_hierarchique(v: pd.DataFrame, s: dict, ref: dict, n_iter: int = 3000 | |||||
| 334 | """Étapes 3-4 : Gibbs sur les 600 ventes ; effets relatifs à Hull vs effets fixes (ch. 18) ; prédictive du sujet ; | 361 | """Étapes 3-4 : Gibbs sur les 600 ventes ; effets relatifs à Hull vs effets fixes (ch. 18) ; prédictive du sujet ; | ||
| 335 | expérience Masson-Angers réduit à 5, 10, 20 ventes ; sensibilité à l'a priori sur τ.""" | 362 | expérience Masson-Angers réduit à 5, 10, 20 ventes ; sensibilité à l'a priori sur τ.""" | ||
| 336 | Xc, moy, z = matrice_hier(v); y = np.log(v["prix_vente"].astype(float)).values; cols = list(Xc.columns) | 363 | Xc, moy, z = matrice_hier(v); y = np.log(v["prix_vente"].astype(float)).values; cols = list(Xc.columns) | ||
| 337 | − g = gibbs_hierarchique(y, Xc.values, z, n_iter=n_iter, brulage=brulage) | 364 | + g = gibbs_hierarchique(y, Xc.values, z, n_iter=n_iter, brulage=brulage) # Gibbs par blocs (résultats du chapitre) | ||
| 338 | − res = resume_gibbs(g, cols); rel = effets_relatifs(g) | 365 | + g_naif = gibbs_hierarchique(y, Xc.values, z, n_iter=n_iter, brulage=brulage, bloc=False) # un paramètre à la fois (diagnostics) | ||
| 366 | + res = resume_gibbs(g, cols); rel = effets_relatifs(g); res_naif = resume_gibbs(g_naif, cols) | ||||
| 367 | + diag = pd.DataFrame({"rhat_bloc": res["rhat"], "ess_bloc": res["ess"], "rhat_naif": res_naif["rhat"], "ess_naif": res_naif["ess"]}) | ||||
| 339 | fixes = ref["coef"].loc[[f"sect_{x}" for x in SECTEURS[1:]], ["coef", "se_hc1"]]; fixes.index = SECTEURS[1:] | 368 | fixes = ref["coef"].loc[[f"sect_{x}" for x in SECTEURS[1:]], ["coef", "se_hc1"]]; fixes.index = SECTEURS[1:] | ||
| 340 | rel["fixe"] = fixes["coef"]; rel["fixe_se"] = fixes["se_hc1"]; rel["vrai"] = [VRAIS[f"sect_{x}"] for x in rel.index] | 369 | rel["fixe"] = fixes["coef"]; rel["fixe_se"] = fixes["se_hc1"]; rel["vrai"] = [VRAIS[f"sect_{x}"] for x in rel.index] | ||
| 341 | # prédictive du sujet (Hull) : ln P* = (x_s − x̄)'β + α_Hull + ε | 370 | # prédictive du sujet (Hull) : ln P* = (x_s − x̄)'β + α_Hull + ε | ||
@@ -396,7 +425,7 @@ def exemple_hierarchique(v: pd.DataFrame, s: dict, ref: dict, n_iter: int = 3000 | |||||
| 396 | "ma_600": r600["moyenne"], "ma_600_sd": r600["sd"], "tau_med_5": float(np.median(np.sqrt(g5["tau2"]))), | 425 | "ma_600": r600["moyenne"], "ma_600_sd": r600["sd"], "tau_med_5": float(np.median(np.sqrt(g5["tau2"]))), | ||
| 397 | "ma_5": r5["moyenne"], "ma_5_sd": r5["sd"], "ma_5_bas": r5["q025"], "ma_5_haut": r5["q975"]}) | 426 | "ma_5": r5["moyenne"], "ma_5_sd": r5["sd"], "ma_5_bas": r5["q025"], "ma_5_haut": r5["q975"]}) | ||
| 398 | sensibilite = pd.DataFrame(sens_rows).set_index("prior") | 427 | sensibilite = pd.DataFrame(sens_rows).set_index("prior") | ||
| 399 | − return {"g": g, "cols": cols, "moyennes": moy, "z": z, "y": y, "Xc": Xc, "resume": res, "relatifs": rel, "pred": pred, "B": B, | 428 | + return {"g": g, "g_naif": g_naif, "diag": diag, "cols": cols, "moyennes": moy, "z": z, "y": y, "Xc": Xc, "resume": res, "relatifs": rel, "pred": pred, "B": B, | ||
| 400 | "sigma2_med": s2m, "tau2_med": t2m, "experience": experience, "dist_ma": dist_ma, "sensibilite": sensibilite, "tau_eb": tau_eb, | 429 | "sigma2_med": s2m, "tau2_med": t2m, "experience": experience, "dist_ma": dist_ma, "sensibilite": sensibilite, "tau_eb": tau_eb, | ||
| 401 | "perm_ma": perm} | 430 | "perm_ma": perm} | ||
| 402 | 431 | ||||
@@ -405,7 +434,7 @@ def criteres(v: pd.DataFrame, hier: dict, ex: dict) -> pd.DataFrame: | |||||
| 405 | y = hier["y"] | 434 | y = hier["y"] | ||
| 406 | # pooling complet : matrice de référence sans indicatrices de secteur | 435 | # pooling complet : matrice de référence sans indicatrices de secteur | ||
| 407 | X = cas1.matrice(v); cols_pool = [c for c in X.columns if not c.startswith("sect_")] | 436 | X = cas1.matrice(v); cols_pool = [c for c in X.columns if not c.startswith("sect_")] | ||
| 408 | − p_pool = nig_posterieure(X[cols_pool].values, y, prior_faible(cols_pool)); p_pool["colonnes"] = cols_pool | 437 | + p_pool = nig_posterieure(X[cols_pool].values, y, prior_reference(cols_pool)); p_pool["colonnes"] = cols_pool | ||
| 409 | p_fixe = ex["p600"] | 438 | p_fixe = ex["p600"] | ||
| 410 | lignes = [] | 439 | lignes = [] | ||
| 411 | for nom, lp, loo in [("pooling complet (sans secteur)", log_vrais_pointwise_nig(p_pool), loo_exact_nig(p_pool["X"], y, p_pool["prior"])), | 440 | for nom, lp, loo in [("pooling complet (sans secteur)", log_vrais_pointwise_nig(p_pool), loo_exact_nig(p_pool["X"], y, p_pool["prior"])), | ||
@@ -487,6 +516,7 @@ def imprimer(r: dict) -> None: | |||||
| 487 | print(t.round(4).to_string()) | 516 | print(t.round(4).to_string()) | ||
| 488 | print(f"couverture des vrais paramètres (IC 95 %) : {int(t['couvre'].sum())}/{int(t['vrai'].notna().sum())}") | 517 | print(f"couverture des vrais paramètres (IC 95 %) : {int(t['couvre'].sum())}/{int(t['vrai'].notna().sum())}") | ||
| 489 | p = ex["p600"]; print(f"a_n = {p['a_n']:.1f} ; b_n = {p['b_n']:.4f} ; ν = {p['nu']:.0f} ; E[σ] ≈ {p['sigma_moy']:.4f} ; MCO σ = {r['modele']['sigma']:.4f}") | 518 | p = ex["p600"]; print(f"a_n = {p['a_n']:.1f} ; b_n = {p['b_n']:.4f} ; ν = {p['nu']:.0f} ; E[σ] ≈ {p['sigma_moy']:.4f} ; MCO σ = {r['modele']['sigma']:.4f}") | ||
| 519 | + pp = ex["p600_propre"]; print(f"a priori propre vague : a_n = {pp['a_n']:.3f} ; ν = {pp['nu']:.0f} ; E[σ] ≈ {pp['sigma_moy']:.4f} ; max|m_n − β̂| = {np.abs(pp['m_n'] - r['modele']['beta']).max():.2e} ; sujet 95 % [{_d(ex['pred600_propre']['bas95'])} ; {_d(ex['pred600_propre']['haut95'])}]") | ||||
| 490 | pr = ex["pred600"] | 520 | pr = ex["pred600"] | ||
| 491 | print(f"prédictive du sujet : loc {pr['loc']:.4f}, échelle {pr['echelle']:.5f}, médiane {_d(pr['mediane'])}, part coefficients {pr['part_coefficients']*100:.2f} %") | 521 | print(f"prédictive du sujet : loc {pr['loc']:.4f}, échelle {pr['echelle']:.5f}, médiane {_d(pr['mediane'])}, part coefficients {pr['part_coefficients']*100:.2f} %") | ||
| 492 | print(f" crédibilité 90 % : [{_d(pr['bas90'])} ; {_d(pr['haut90'])}] 95 % : [{_d(pr['bas95'])} ; {_d(pr['haut95'])}] (ch. 18 : [{_d(IC18['bas95'])} ; {_d(IC18['haut95'])}])") | 522 | print(f" crédibilité 90 % : [{_d(pr['bas90'])} ; {_d(pr['haut90'])}] 95 % : [{_d(pr['bas95'])} ; {_d(pr['haut95'])}] (ch. 18 : [{_d(IC18['bas95'])} ; {_d(IC18['haut95'])}])") | ||
@@ -497,8 +527,9 @@ def imprimer(r: dict) -> None: | |||||
| 497 | print(f" {nom:9s}: ln_S {po['m_n'][i]:.3f} ({po['sd'][i]:.3f}) ; qualité {po['m_n'][iq]:.3f} ({po['sd'][iq]:.3f}) ; garage {po['m_n'][ig]:.3f} ({po['sd'][ig]:.3f}) ; " | 527 | print(f" {nom:9s}: ln_S {po['m_n'][i]:.3f} ({po['sd'][i]:.3f}) ; qualité {po['m_n'][iq]:.3f} ({po['sd'][iq]:.3f}) ; garage {po['m_n'][ig]:.3f} ({po['sd'][ig]:.3f}) ; " | ||
| 498 | f"E[σ] {po['sigma_moy']:.4f} ; sujet médiane {_d(pe['pred']['mediane'])} [{_d(pe['pred']['bas95'])} ; {_d(pe['pred']['haut95'])}] largeur {pe['largeur95_pct']:.1f} %") | 528 | f"E[σ] {po['sigma_moy']:.4f} ; sujet médiane {_d(pe['pred']['mediane'])} [{_d(pe['pred']['bas95'])} ; {_d(pe['pred']['haut95'])}] largeur {pe['largeur95_pct']:.1f} %") | ||
| 499 | ph = ex["pred_hull"]; print(f" Hull seul ({ex['p_hull']['n']}) : médiane {_d(ph['mediane'])} [{_d(ph['bas95'])} ; {_d(ph['haut95'])}]") | 529 | ph = ex["pred_hull"]; print(f" Hull seul ({ex['p_hull']['n']}) : médiane {_d(ph['mediane'])} [{_d(ph['bas95'])} ; {_d(ph['haut95'])}]") | ||
| 500 | − print("\n=== modèle hiérarchique (Gibbs) : résumé ===") | 530 | + print("\n=== modèle hiérarchique (Gibbs par blocs) : résumé ===") | ||
| 501 | print(hi["resume"].round(4).to_string()) | 531 | print(hi["resume"].round(4).to_string()) | ||
| 532 | + print("\ndiagnostics : Gibbs par blocs vs un paramètre à la fois"); print(hi["diag"].round(3).to_string()) | ||||
| 502 | print("\neffets relatifs à Hull (hiérarchique vs effets fixes ch. 18 vs vrai) :"); print(hi["relatifs"].round(4).to_string()) | 533 | print("\neffets relatifs à Hull (hiérarchique vs effets fixes ch. 18 vs vrai) :"); print(hi["relatifs"].round(4).to_string()) | ||
| 503 | print(f"σ² médian {hi['sigma2_med']:.5f} (σ {math.sqrt(hi['sigma2_med']):.4f}) ; τ² médian {hi['tau2_med']:.5f} (τ {math.sqrt(hi['tau2_med']):.4f})") | 534 | print(f"σ² médian {hi['sigma2_med']:.5f} (σ {math.sqrt(hi['sigma2_med']):.4f}) ; τ² médian {hi['tau2_med']:.5f} (τ {math.sqrt(hi['tau2_med']):.4f})") | ||
| 504 | print("facteurs de rétrécissement B_s :", {k: round(v_, 4) for k, v_ in hi["B"].items()}) | 535 | print("facteurs de rétrécissement B_s :", {k: round(v_, 4) for k, v_ in hi["B"].items()}) | ||
modified
analysis/python/cases/ch40_montecarlo.py
+20 −9
@@ -196,7 +196,7 @@ def comparer_lhs(r: dict, n: int = 1000, k: int = 100) -> pd.DataFrame: | |||||
| 196 | rows = [] | 196 | rows = [] | ||
| 197 | for s in ["moyenne", "mediane", "q05", "q95", "ecart_type", "p_rcd1_120", "p_V_2M"]: | 197 | for s in ["moyenne", "mediane", "q05", "q95", "ecart_type", "p_rcd1_120", "p_V_2M"]: | ||
| 198 | rows.append({"statistique": s, "moy_simple": simple[s].mean(), "moy_lhs": lhs[s].mean(), "se_simple": simple[s].std(ddof=1), "se_lhs": lhs[s].std(ddof=1), | 198 | rows.append({"statistique": s, "moy_simple": simple[s].mean(), "moy_lhs": lhs[s].mean(), "se_simple": simple[s].std(ddof=1), "se_lhs": lhs[s].std(ddof=1), | ||
| 199 | − "ratio_variance": simple[s].var(ddof=1) / lhs[s].var(ddof=1)}) | 199 | + "ratio_variance": (simple[s].var(ddof=1) / lhs[s].var(ddof=1)) if lhs[s].var(ddof=1) > 0 else float("inf")}) | ||
| 200 | return pd.DataFrame(rows) | 200 | return pd.DataFrame(rows) | ||
| 201 | 201 | ||||
| 202 | # ----------------------------------------------------------------------------- 40.5 mesures de risque, vérification analytique, stress tests | 202 | # ----------------------------------------------------------------------------- 40.5 mesures de risque, vérification analytique, stress tests | ||
@@ -272,17 +272,17 @@ NOMS_SOBOL = ["inoccupation_pertes", "croissance_loyers_marche", "rotation_annue | |||||
| 272 | def _completer(x: dict) -> dict: | 272 | def _completer(x: dict) -> dict: | ||
| 273 | x = dict(x); x["taux_terminal"] = np.maximum(x["taux_actualisation"] - x["ecart_terminal"], R_T_PLANCHER); return x | 273 | x = dict(x); x["taux_terminal"] = np.maximum(x["taux_actualisation"] - x["ecart_terminal"], R_T_PLANCHER); return x | ||
| 274 | 274 | ||||
| 275 | −def sobol(r: dict, n: int = N_MC, graine: int = GRAINE) -> pd.DataFrame: | 275 | +def sobol(r: dict, n: int = 2 * N_MC, graine: int = GRAINE) -> pd.DataFrame: | ||
| 276 | """Indices de Sobol de premier ordre S_i et totaux S_Ti par échantillons appariés : deux matrices indépendantes A et B (n tirages | 276 | """Indices de Sobol de premier ordre S_i et totaux S_Ti par échantillons appariés : deux matrices indépendantes A et B (n tirages | ||
| 277 | chacune), puis A_B^(i) = A où la seule colonne i vient de B. S_i ≈ (1/n) Σ f(B)·[f(A_B^(i)) − f(A)] / Var ; S_Ti ≈ (1/2n) Σ [f(A) − f(A_B^(i))]² / Var. | 277 | chacune), puis A_B^(i) = A où la seule colonne i vient de B. S_i ≈ (1/n) Σ f(B)·[f(A_B^(i)) − f(A)] / Var ; S_Ti ≈ (1/2n) Σ [f(A) − f(A_B^(i))]² / Var. | ||
| 278 | Les intrants de base (r et delta, non r et R_T) sont indépendants, condition de validité de la décomposition. Le facteur CAPEX est traité en groupe (3 facteurs).""" | 278 | Les intrants de base (r et delta, non r et R_T) sont indépendants, condition de validité de la décomposition. Le facteur CAPEX est traité en groupe (3 facteurs).""" | ||
| 279 | nb = len(r["hyp"]["capex"]) | 279 | nb = len(r["hyp"]["capex"]) | ||
| 280 | A = tirer(n, graine, nb); B = tirer(n, graine + 1, nb) | 280 | A = tirer(n, graine, nb); B = tirer(n, graine + 1, nb) | ||
| 281 | fA = evaluer(r, A)["valeur"]; fB = evaluer(r, B)["valeur"] | 281 | fA = evaluer(r, A)["valeur"]; fB = evaluer(r, B)["valeur"] | ||
| 282 | − var = np.concatenate([fA, fB]).var(ddof=1); rows = [] | 282 | + tous = np.concatenate([fA, fB]); var = tous.var(ddof=1); mu = tous.mean(); rows = [] | ||
| 283 | for nom in NOMS_SOBOL: | 283 | for nom in NOMS_SOBOL: | ||
| 284 | AB = dict(A); AB[nom] = B[nom]; fAB = evaluer(r, _completer(AB))["valeur"] | 284 | AB = dict(A); AB[nom] = B[nom]; fAB = evaluer(r, _completer(AB))["valeur"] | ||
| 285 | − S1 = float(np.mean(fB * (fAB - fA)) / var); ST = float(0.5 * np.mean((fA - fAB) ** 2) / var) | 285 | + S1 = float(np.mean((fB - mu) * (fAB - fA)) / var); ST = float(0.5 * np.mean((fA - fAB) ** 2) / var) # centrage : même espérance, variance réduite | ||
| 286 | rows.append({"intrant": nom, "S1": S1, "ST": ST}) | 286 | rows.append({"intrant": nom, "S1": S1, "ST": ST}) | ||
| 287 | d = pd.DataFrame(rows); d.attrs["somme_S1"] = float(d["S1"].sum()); d.attrs["variance"] = float(var); d.attrs["n_evaluations"] = n * (2 + len(NOMS_SOBOL)) | 287 | d = pd.DataFrame(rows); d.attrs["somme_S1"] = float(d["S1"].sum()); d.attrs["variance"] = float(var); d.attrs["n_evaluations"] = n * (2 + len(NOMS_SOBOL)) | ||
| 288 | return d | 288 | return d | ||
@@ -309,7 +309,7 @@ def regression_standardisee(mc: dict) -> dict: | |||||
| 309 | 309 | ||||
| 310 | def simuler_portefeuille(pf: pd.DataFrame, n: int = N_MC, graine: int = GRAINE, sigma_R: float = 0.005, rho_rmr: float = 0.60, rho_prov: float = 0.30, | 310 | def simuler_portefeuille(pf: pd.DataFrame, n: int = N_MC, graine: int = GRAINE, sigma_R: float = 0.005, rho_rmr: float = 0.60, rho_prov: float = 0.30, | ||
| 311 | croissance: pd.DataFrame | None = None, sigma_g_defaut: float = 0.03, choc_taux_renouvellement: float = 0.02, horizon_renouvellement: int = 2028, | 311 | croissance: pd.DataFrame | None = None, sigma_g_defaut: float = 0.03, choc_taux_renouvellement: float = 0.02, horizon_renouvellement: int = 2028, | ||
| 312 | − amortissement_mois: int = 300, seuil_rcd: float = 1.20) -> dict: | 312 | + amortissement_mois: int = 480, seuil_rcd: float = 1.20) -> dict: | ||
| 313 | """Simulation à un an de la valeur et de la couverture de la dette d'un portefeuille d'immeubles à revenus avec corrélation régionale. | 313 | """Simulation à un an de la valeur et de la couverture de la dette d'un portefeuille d'immeubles à revenus avec corrélation régionale. | ||
| 314 | 314 | ||||
| 315 | Modèle (hypothèses pédagogiques déclarées au ch. 40, §40.8) : pour l'immeuble i de la RMR k, | 315 | Modèle (hypothèses pédagogiques déclarées au ch. 40, §40.8) : pour l'immeuble i de la RMR k, | ||
@@ -339,6 +339,8 @@ def simuler_portefeuille(pf: pd.DataFrame, n: int = N_MC, graine: int = GRAINE, | |||||
| 339 | sd = np.array([fin.Hypotheque(float(d), float(t) + (choc_taux_renouvellement if int(ech) <= horizon_renouvellement else 0.0), amortissement_mois).service_dette_annuel | 339 | sd = np.array([fin.Hypotheque(float(d), float(t) + (choc_taux_renouvellement if int(ech) <= horizon_renouvellement else 0.0), amortissement_mois).service_dette_annuel | ||
| 340 | for d, t, ech in zip(pf["dette"], pf["taux_dette"], pf["echeance_dette"])]) | 340 | for d, t, ech in zip(pf["dette"], pf["taux_dette"], pf["echeance_dette"])]) | ||
| 341 | rcd = rne / sd | 341 | rcd = rne / sd | ||
| 342 | + sd0 = np.array([fin.Hypotheque(float(d), float(t), amortissement_mois).service_dette_annuel for d, t in zip(pf["dette"], pf["taux_dette"])]) | ||||
| 343 | + rcd_det = pf["rne_2026"].values / sd0 | ||||
| 342 | VP = V.sum(1); V0 = float(pf["valeur_2026"].sum()); VP_det = float((pf["rne_2026"].values * np.exp(g) / pf["taux_global"].values).sum()) | 344 | VP = V.sum(1); V0 = float(pf["valeur_2026"].sum()); VP_det = float((pf["rne_2026"].values * np.exp(g) / pf["taux_global"].values).sum()) | ||
| 343 | q = lambda a, p: float(np.quantile(a, p)) | 345 | q = lambda a, p: float(np.quantile(a, p)) | ||
| 344 | stats_ = {"n": n, "N_immeubles": N, "valeur_2026": V0, "valeur_deterministe": VP_det, "mediane": q(VP, 0.5), "moyenne": float(VP.mean()), "ecart_type": float(VP.std(ddof=1)), | 346 | stats_ = {"n": n, "N_immeubles": N, "valeur_2026": V0, "valeur_deterministe": VP_det, "mediane": q(VP, 0.5), "moyenne": float(VP.mean()), "ecart_type": float(VP.std(ddof=1)), | ||
@@ -346,7 +348,10 @@ def simuler_portefeuille(pf: pd.DataFrame, n: int = N_MC, graine: int = GRAINE, | |||||
| 346 | "somme_ecarts_types": float(V.std(0, ddof=1).sum()), "nb_rcd_sous_seuil_moyen": float((rcd < seuil_rcd).sum(1).mean()), "nb_rcd_sous_1_moyen": float((rcd < 1.0).sum(1).mean()), | 348 | "somme_ecarts_types": float(V.std(0, ddof=1).sum()), "nb_rcd_sous_seuil_moyen": float((rcd < seuil_rcd).sum(1).mean()), "nb_rcd_sous_1_moyen": float((rcd < 1.0).sum(1).mean()), | ||
| 347 | "P_au_moins_un_rcd_sous_1": float(((rcd < 1.0).any(1)).mean()), "P_au_moins_un_rcd_sous_seuil": float(((rcd < seuil_rcd).any(1)).mean()), | 349 | "P_au_moins_un_rcd_sous_1": float(((rcd < 1.0).any(1)).mean()), "P_au_moins_un_rcd_sous_seuil": float(((rcd < seuil_rcd).any(1)).mean()), | ||
| 348 | "rcd_portefeuille_mediane": q(rne.sum(1) / sd.sum(), 0.5), "P_rcd_portefeuille_sous_seuil": float((rne.sum(1) / sd.sum() < seuil_rcd).mean()), | 350 | "rcd_portefeuille_mediane": q(rne.sum(1) / sd.sum(), 0.5), "P_rcd_portefeuille_sous_seuil": float((rne.sum(1) / sd.sum() < seuil_rcd).mean()), | ||
| 349 | − "nb_renouvellements_stress": int((pf["echeance_dette"] <= horizon_renouvellement).sum())} | 351 | + "nb_renouvellements_stress": int((pf["echeance_dette"] <= horizon_renouvellement).sum()), | ||
| 352 | + "nb_rcd_det_sous_seuil": int((rcd_det < seuil_rcd).sum()), "nb_rcd_det_sous_1": int((rcd_det < 1.0).sum()), "rcd_portefeuille_det": float(pf["rne_2026"].sum() / sd0.sum()), | ||||
| 353 | + "nb_rcd_sous_seuil_q95": float(np.quantile((rcd < seuil_rcd).sum(1), 0.95)), "nb_rcd_sous_1_q95": float(np.quantile((rcd < 1.0).sum(1), 0.95)), | ||||
| 354 | + "P_plus_de_10_sous_1": float(((rcd < 1.0).sum(1) > 10).mean()), "rpv_portefeuille": float(pf["dette"].sum() / V0)} | ||||
| 350 | stats_["ratio_diversification"] = stats_["ecart_type"] / stats_["somme_ecarts_types"] | 355 | stats_["ratio_diversification"] = stats_["ecart_type"] / stats_["somme_ecarts_types"] | ||
| 351 | return {"valeurs": V, "valeur_portefeuille": VP, "rne": rne, "rcd": rcd, "service_dette": sd, "stats": stats_, | 356 | return {"valeurs": V, "valeur_portefeuille": VP, "rne": rne, "rcd": rcd, "service_dette": sd, "stats": stats_, | ||
| 352 | "par_rmr": pd.DataFrame({"rmr": rmrs.cat.categories, "ecart_type_valeur": [V[:, k == j].sum(1).std(ddof=1) for j in range(K)], "valeur_2026": [pf.loc[k == j, "valeur_2026"].sum() for j in range(K)]})} | 357 | "par_rmr": pd.DataFrame({"rmr": rmrs.cat.categories, "ecart_type_valeur": [V[:, k == j].sum(1).std(ddof=1) for j in range(K)], "valeur_2026": [pf.loc[k == j, "valeur_2026"].sum() for j in range(K)]})} | ||
@@ -356,7 +361,7 @@ def comparer_structures_correlation(pf: pd.DataFrame, croissance: pd.DataFrame, | |||||
| 356 | rows = [] | 361 | rows = [] | ||
| 357 | for nom, rp, rr in [("Indépendance", 0.0, 0.0), ("Régionale (0,30 ; 0,60)", 0.30, 0.60), ("Parfaite", 1.0, 1.0)]: | 362 | for nom, rp, rr in [("Indépendance", 0.0, 0.0), ("Régionale (0,30 ; 0,60)", 0.30, 0.60), ("Parfaite", 1.0, 1.0)]: | ||
| 358 | s = simuler_portefeuille(pf, n, GRAINE, rho_rmr=rr, rho_prov=rp, croissance=croissance)["stats"] | 363 | s = simuler_portefeuille(pf, n, GRAINE, rho_rmr=rr, rho_prov=rp, croissance=croissance)["stats"] | ||
| 359 | − rows.append({"structure": nom, "rho_prov": rp, "rho_rmr": rr, **{k: s[k] for k in ["mediane", "ecart_type", "q05", "VaR05", "CVaR05", "ratio_diversification", "nb_rcd_sous_seuil_moyen", "nb_rcd_sous_1_moyen", "P_au_moins_un_rcd_sous_1", "P_rcd_portefeuille_sous_seuil"]}}) | 364 | + rows.append({"structure": nom, "rho_prov": rp, "rho_rmr": rr, **{k: s[k] for k in ["mediane", "ecart_type", "q05", "VaR05", "CVaR05", "ratio_diversification", "nb_rcd_sous_seuil_moyen", "nb_rcd_sous_seuil_q95", "nb_rcd_sous_1_moyen", "nb_rcd_sous_1_q95", "P_plus_de_10_sous_1", "rcd_portefeuille_mediane"]}}) | ||
| 360 | return pd.DataFrame(rows) | 365 | return pd.DataFrame(rows) | ||
| 361 | 366 | ||||
| 362 | def variance_somme_correlee(sigmas: np.ndarray, rho: float) -> float: | 367 | def variance_somme_correlee(sigmas: np.ndarray, rho: float) -> float: | ||
@@ -400,8 +405,13 @@ def tout(rapide: bool = False) -> dict: | |||||
| 400 | D = {**r, "mc": mc, "tornade": torn, "lois": tableau_lois(x), "corr_rRT": correlation_r_RT(x), "erreurs": erreurs_theoriques(mc), | 405 | D = {**r, "mc": mc, "tornade": torn, "lois": tableau_lois(x), "corr_rRT": correlation_r_RT(x), "erreurs": erreurs_theoriques(mc), | ||
| 401 | "risque": mesures_risque(mc, r["dcf"]["valeur"]), "rcd_analytique": rcd1_analytique(r), "stress": stress_deterministes(r, mc), "inverse": stress_inverse(r), | 406 | "risque": mesures_risque(mc, r["dcf"]["valeur"]), "rcd_analytique": rcd1_analytique(r), "stress": stress_deterministes(r, mc), "inverse": stress_inverse(r), | ||
| 402 | "sobol": sobol(r), "rangs": correlations_rang(mc), "reg": regression_standardisee(mc), "pert_rotation": pert(0.08, 0.15, 0.25)} | 407 | "sobol": sobol(r), "rangs": correlations_rang(mc), "reg": regression_standardisee(mc), "pert_rotation": pert(0.08, 0.15, 0.25)} | ||
| 403 | − k_rep = 20 if rapide else 100; k_se = 10 if rapide else 40 | 408 | + k_rep = 20 if rapide else 100; k_se = 10 if rapide else 40; k_med = 40 if rapide else 300 | ||
| 404 | − D["repetitions"] = repetitions(r, N_MC, k_rep, graine0=1) | 409 | + D["repetitions"] = repetitions(r, N_MC, k_med, graine0=1) | ||
| 410 | + rep = D["repetitions"]; D["graine_traite"] = {"mediane": mc["stats"]["mediane"], "moyenne_des_medianes": float(rep["mediane"].mean()), "se_mediane_empirique": float(rep["mediane"].std(ddof=1)), | ||||
| 411 | + "z_mediane": float((mc["stats"]["mediane"] - rep["mediane"].mean()) / rep["mediane"].std(ddof=1)), | ||||
| 412 | + "z_q95": float((mc["stats"]["q95"] - rep["q95"].mean()) / rep["q95"].std(ddof=1)), "z_q05": float((mc["stats"]["q05"] - rep["q05"].mean()) / rep["q05"].std(ddof=1)), | ||||
| 413 | + "rang_mediane": int((rep["mediane"] < mc["stats"]["mediane"]).sum()), "k": len(rep), | ||||
| 414 | + "moyenne_tiree_r": float(x["taux_actualisation"].mean()), "moyenne_tiree_gm": float(x["croissance_loyers_marche"].mean())} | ||||
| 405 | D["convergence"] = convergence(r, N_max=20_000 if rapide else 100_000) | 415 | D["convergence"] = convergence(r, N_max=20_000 if rapide else 100_000) | ||
| 406 | D["se_N"] = se_empirique_selon_N(r, k=k_se) | 416 | D["se_N"] = se_empirique_selon_N(r, k=k_se) | ||
| 407 | D["lhs"] = comparer_lhs(r, n=1000, k=k_rep) | 417 | D["lhs"] = comparer_lhs(r, n=1000, k=k_rep) | ||
@@ -429,6 +439,7 @@ def imprimer(D: dict) -> None: | |||||
| 429 | rep = D["repetitions"] | 439 | rep = D["repetitions"] | ||
| 430 | print(f"\nRépétitions ({len(rep)} graines, N = 10 000) : écart-type empirique — moyenne {rep['moyenne'].std(ddof=1):,.0f} | médiane {rep['mediane'].std(ddof=1):,.0f} | q05 {rep['q05'].std(ddof=1):,.0f} | q95 {rep['q95'].std(ddof=1):,.0f} | P(RCD1<1,20) {rep['p_rcd1_120'].std(ddof=1):.4f}") | 440 | print(f"\nRépétitions ({len(rep)} graines, N = 10 000) : écart-type empirique — moyenne {rep['moyenne'].std(ddof=1):,.0f} | médiane {rep['mediane'].std(ddof=1):,.0f} | q05 {rep['q05'].std(ddof=1):,.0f} | q95 {rep['q95'].std(ddof=1):,.0f} | P(RCD1<1,20) {rep['p_rcd1_120'].std(ddof=1):.4f}") | ||
| 431 | print(f" moyenne des médianes {rep['mediane'].mean():,.0f} ; min {rep['mediane'].min():,.0f} ; max {rep['mediane'].max():,.0f} ; P(RCD1<1,20) moyenne {rep['p_rcd1_120'].mean():.4f}") | 441 | print(f" moyenne des médianes {rep['mediane'].mean():,.0f} ; min {rep['mediane'].min():,.0f} ; max {rep['mediane'].max():,.0f} ; P(RCD1<1,20) moyenne {rep['p_rcd1_120'].mean():.4f}") | ||
| 442 | + print(" graine du traité (20261031) :", {k: (round(v, 4) if isinstance(v, float) else v) for k, v in D["graine_traite"].items()}) | ||||
| 432 | print("\nConvergence (une suite, graine 20261031) :\n", D["convergence"].to_string(index=False)) | 443 | print("\nConvergence (une suite, graine 20261031) :\n", D["convergence"].to_string(index=False)) | ||
| 433 | print(f"\nÉcart-type empirique selon N (pente log-log médiane = {D['se_N'].attrs['pente_log_mediane']:.3f}) :\n", D["se_N"].to_string(index=False)) | 444 | print(f"\nÉcart-type empirique selon N (pente log-log médiane = {D['se_N'].attrs['pente_log_mediane']:.3f}) :\n", D["se_N"].to_string(index=False)) | ||
| 434 | print("\n=== 40.4 Hypercube latin vs simple (N = 1 000, répétitions) :\n", D["lhs"].to_string(index=False)) | 445 | print("\n=== 40.4 Hypercube latin vs simple (N = 1 000, répétitions) :\n", D["lhs"].to_string(index=False)) | ||
added
analysis/python/cases/ch42_copro.py
+326 −0
@@ -0,0 +1,326 @@ | |||||
| 1 | +#!/usr/bin/env python3 | ||||
| 2 | +"""Chapitre 42 — Copropriétés et conversions : exemples chiffrés (DONNÉES SYNTHÉTIQUES, graine 20261031). | ||||
| 3 | + | ||||
| 4 | +Réutilise l'immeuble fictif de huit fractions du chapitre 7 (analysis/python/cases/ch07_servitudes_copro.py : quotes-parts, | ||||
| 5 | +répartition des charges, cotisation de seuil, déficit de capitalisation) et y ajoute quatre blocs, tous générés ici | ||||
| 6 | +(aucun fichier externe ; aucune statistique de marché n'est reproduite) : | ||||
| 7 | + A. Évaluation de la fraction F4 par comparaison : deux ventes dans l'immeuble, trois ventes dans d'autres immeubles fictifs ; | ||||
| 8 | + ajustements de date, d'étage, d'orientation, de stationnement, de superficie, de charges courantes (capitalisation) et de | ||||
| 9 | + situation financière du syndicat (déficit du fonds de prévoyance, cotisation spéciale) ; réconciliation (traite.comparables). | ||||
| 10 | + B. Effet d'une cotisation spéciale égale au déficit de capitalisation, répartie selon les quotes-parts (figure). | ||||
| 11 | + C. Copropriété locative : revenu net d'exploitation et taux implicite d'une fraction louée (traite.finance). | ||||
| 12 | + D. Conversion d'un immeuble locatif de huit logements en copropriété divise : valeur en bloc (capitalisation directe) contre | ||||
| 13 | + somme des fractions nette des coûts, des délais et des locataires (rotation, préemption, vente occupée) ; sensibilités. | ||||
| 14 | +Toutes les fonctions financières viennent de analysis/python/traite/ (finance.py, comparables.py) ; rien n'est réécrit. | ||||
| 15 | +Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch42_copro.py | ||||
| 16 | +""" | ||||
| 17 | +from __future__ import annotations | ||||
| 18 | +import sys, math, pathlib | ||||
| 19 | +import numpy as np | ||||
| 20 | +HERE = pathlib.Path(__file__).resolve() | ||||
| 21 | +sys.path.insert(0, str(HERE.parents[1])); sys.path.insert(0, str(HERE.parent)) | ||||
| 22 | +from traite import comparables as cp | ||||
| 23 | +from traite import finance as fin | ||||
| 24 | +import ch07_servitudes_copro as c7 | ||||
| 25 | + | ||||
| 26 | +SEED = 20261031 | ||||
| 27 | +DATE_EVAL = "2026-09-01" | ||||
| 28 | + | ||||
| 29 | +# ----------------------------------------------------------------------------- marché fictif : paramètres déclarés | ||||
| 30 | +# Prix implicites « du marché fictif » (hypothèses pédagogiques ; l'évaluateur les tient d'une analyse de ventes appariées / hédonique). | ||||
| 31 | +PRIX_M2_REF = 3_650.0 # $/m² pour une fraction de 98 m², 2e étage, orientation est/ouest, sans stationnement, immeuble bien capitalisé | ||||
| 32 | +ELASTICITE_S = 0.60 # élasticité prix-superficie (< 1) ; prix marginal au point de référence = 0,60 × 3 650 = 2 190 $/m² | ||||
| 33 | +S_REF = 98.0 | ||||
| 34 | +COEF_ETAGE_MARCHE = {"RDC": -0.04, "2e": 0.00, "3e": 0.02} # proportion du prix (immeubles sans ascenseur) | ||||
| 35 | +COEF_ORIENT_MARCHE = {"nord": -0.03, "est": 0.00, "ouest": 0.00, "sud": 0.03} | ||||
| 36 | +STATIONNEMENT = 28_000.0 # $ ; case intérieure à usage restreint | ||||
| 37 | +G_MARCHE = 0.02 # croissance annuelle des prix des copropriétés sur le marché fictif (ajustement de conditions du marché) | ||||
| 38 | +R_CHARGES = 0.08 # taux de capitalisation des écarts de charges courantes (hypothèse pédagogique ; à extraire du marché) | ||||
| 39 | +KAPPA = 1.0 # coefficient de capitalisation du passif du syndicat (1 = dollar pour dollar, convention du ch. 7) | ||||
| 40 | +CHARGES_REF_M2 = 40_000.0 / 710.0 # 56,34 $/m²/an : charges courantes de l'immeuble fictif (hors fonds), référence du marché | ||||
| 41 | +SIGMA_BRUIT = 0.02 # bruit de transaction (log) dans la génération des prix des comparables | ||||
| 42 | + | ||||
| 43 | +# Situation financière des syndicats fictifs : déficit de capitalisation (ch. 7) ou cotisation spéciale votée non payée | ||||
| 44 | +QP = c7.quotes_parts() | ||||
| 45 | +INC = c7.incidence_valeur(QP) # déficit de l'immeuble fictif du ch. 7 : 150 908 $ | ||||
| 46 | +DEFICIT_IMMEUBLE = float(INC["deficit"]) | ||||
| 47 | +Q_F4 = float(QP["q"][3]); Q_F2 = float(QP["q"][1]); Q_F7 = float(QP["q"][6]) | ||||
| 48 | + | ||||
| 49 | +SUJET = dict(nom="F4 (sujet)", immeuble="A (fictif, 8 fractions)", S=98.0, etage="2e", orient="sud", stat=True, | ||||
| 50 | + charges_m2=CHARGES_REF_M2, passif=Q_F4 * DEFICIT_IMMEUBLE, mois=0.0) | ||||
| 51 | + | ||||
| 52 | +COMPARABLES = [ # (nom, immeuble, S, étage, orientation, stationnement, charges courantes $/m²/an, passif de la fraction ($), ancienneté (mois), date) | ||||
| 53 | + dict(nom="C1 = F2", immeuble="A (même immeuble)", S=98.0, etage="RDC", orient="sud", stat=True, charges_m2=CHARGES_REF_M2, passif=Q_F2 * DEFICIT_IMMEUBLE, mois=6.0, date="2026-03-02"), | ||||
| 54 | + dict(nom="C2 = F7", immeuble="A (même immeuble)", S=85.0, etage="2e", orient="est", stat=True, charges_m2=CHARGES_REF_M2, passif=Q_F7 * DEFICIT_IMMEUBLE, mois=10.0, date="2025-11-03"), | ||||
| 55 | + dict(nom="C3", immeuble="B (24 fractions, ascenseur)", S=96.0, etage="2e", orient="sud", stat=True, charges_m2=78.0, passif=0.0, mois=3.0, date="2026-06-01"), | ||||
| 56 | + dict(nom="C4", immeuble="C (12 fractions)", S=100.0, etage="3e", orient="ouest", stat=False, charges_m2=52.0, passif=0.09 * 60_000.0, mois=4.0, date="2026-05-04"), | ||||
| 57 | + dict(nom="C5", immeuble="D (8 fractions)", S=92.0, etage="RDC", orient="sud", stat=True, charges_m2=58.0, passif=0.13 * 240_000.0, mois=8.0, date="2026-01-05"), | ||||
| 58 | +] | ||||
| 59 | + | ||||
| 60 | + | ||||
| 61 | +def valeur_modele(u: dict) -> float: | ||||
| 62 | + """Valeur « vraie » d'une fraction dans le marché fictif à la date d'évaluation (hypothèse de génération). | ||||
| 63 | + V = base(S) × (1 + c_étage + c_orient) + stationnement − κ × passif − (c_m² − c_ref) × S / R_c.""" | ||||
| 64 | + base = PRIX_M2_REF * S_REF * (u["S"] / S_REF) ** ELASTICITE_S | ||||
| 65 | + v = base * (1.0 + COEF_ETAGE_MARCHE[u["etage"]] + COEF_ORIENT_MARCHE[u["orient"]]) | ||||
| 66 | + v += STATIONNEMENT if u["stat"] else 0.0 | ||||
| 67 | + v -= KAPPA * u["passif"] | ||||
| 68 | + v -= (u["charges_m2"] - CHARGES_REF_M2) * u["S"] / R_CHARGES | ||||
| 69 | + return v | ||||
| 70 | + | ||||
| 71 | + | ||||
| 72 | +def generer_prix(comparables=COMPARABLES, seed: int = SEED) -> list[dict]: | ||||
| 73 | + """Prix observés = valeur du modèle déflatée à la date de vente (croissance g) × bruit, arrondis à 500 $.""" | ||||
| 74 | + rng = np.random.default_rng(seed) | ||||
| 75 | + out = [] | ||||
| 76 | + for c in comparables: | ||||
| 77 | + v = valeur_modele(c) | ||||
| 78 | + p = v * (1.0 + G_MARCHE) ** (-c["mois"] / 12.0) * math.exp(rng.normal(0.0, SIGMA_BRUIT)) | ||||
| 79 | + d = dict(c); d["prix"] = float(np.round(p / 500.0) * 500.0); d["valeur_modele"] = v | ||||
| 80 | + out.append(d) | ||||
| 81 | + return out | ||||
| 82 | + | ||||
| 83 | + | ||||
| 84 | +def prix_marginal_m2(S: float = S_REF) -> float: | ||||
| 85 | + """Prix implicite marginal d'un m² au voisinage de la superficie du sujet : dV/dS = ε × base(S) / S.""" | ||||
| 86 | + base = PRIX_M2_REF * S_REF * (S / S_REF) ** ELASTICITE_S | ||||
| 87 | + return ELASTICITE_S * base / S | ||||
| 88 | + | ||||
| 89 | + | ||||
| 90 | +def ajuster(comp: dict, sujet: dict = SUJET, r_charges: float = R_CHARGES, kappa: float = KAPPA, g: float = G_MARCHE) -> cp.ComparableAjuste: | ||||
| 91 | + """Grille : ajustement de conditions du marché (%, séquentiel) puis ajustements de propriété en dollars, signe du point de vue du sujet.""" | ||||
| 92 | + p_t = comp["prix"] * (1.0 + g) ** (comp["mois"] / 12.0) # prix ramené à la date d'évaluation | ||||
| 93 | + A = {"marché": p_t - comp["prix"]} | ||||
| 94 | + A["étage"] = p_t * (COEF_ETAGE_MARCHE[sujet["etage"]] - COEF_ETAGE_MARCHE[comp["etage"]]) | ||||
| 95 | + A["orientation"] = p_t * (COEF_ORIENT_MARCHE[sujet["orient"]] - COEF_ORIENT_MARCHE[comp["orient"]]) | ||||
| 96 | + A["stationnement"] = STATIONNEMENT * (int(sujet["stat"]) - int(comp["stat"])) | ||||
| 97 | + A["superficie"] = prix_marginal_m2() * (sujet["S"] - comp["S"]) | ||||
| 98 | + A["charges"] = (comp["charges_m2"] - sujet["charges_m2"]) * sujet["S"] / r_charges | ||||
| 99 | + A["syndicat"] = kappa * (comp["passif"] - sujet["passif"]) | ||||
| 100 | + return cp.ComparableAjuste(comp["nom"], comp["prix"], A) | ||||
| 101 | + | ||||
| 102 | + | ||||
| 103 | +def grille_fraction(r_charges: float = R_CHARGES, kappa: float = KAPPA, g: float = G_MARCHE, poids: str = "inverse_brut") -> dict: | ||||
| 104 | + comps = generer_prix() | ||||
| 105 | + ajustes = [ajuster(c, SUJET, r_charges, kappa, g) for c in comps] | ||||
| 106 | + w = cp.poids_inverse_brut(ajustes) if poids == "inverse_brut" else None | ||||
| 107 | + rec = cp.reconciliation(ajustes, w) | ||||
| 108 | + return {"comparables": comps, "ajustes": ajustes, "reconciliation": rec, "poids": rec["poids"], | ||||
| 109 | + "valeur_modele_sujet": valeur_modele(SUJET), "prix_marginal_m2": prix_marginal_m2()} | ||||
| 110 | + | ||||
| 111 | + | ||||
| 112 | +def sensibilite_fraction(r_charges_list=(0.05, 0.06, 0.08, 0.10, 0.12), kappas=(0.0, 0.5, 0.75, 1.0)) -> dict: | ||||
| 113 | + grille = {k: [grille_fraction(r, k)["reconciliation"]["valeur_ponderee"] for r in r_charges_list] for k in kappas} | ||||
| 114 | + return {"r_charges": list(r_charges_list), "kappas": list(kappas), "grille": grille} | ||||
| 115 | + | ||||
| 116 | + | ||||
| 117 | +# ----------------------------------------------------------------------------- B. cotisation spéciale | ||||
| 118 | +def cotisation_speciale(montant: float | None = None, qp: dict = QP) -> dict: | ||||
| 119 | + """Répartition d'une cotisation spéciale (par défaut égale au déficit de capitalisation du ch. 7) selon les quotes-parts ; | ||||
| 120 | + comparaison avec les charges annuelles normalisées (cotisation de seuil) de chaque fraction.""" | ||||
| 121 | + montant = DEFICIT_IMMEUBLE if montant is None else montant | ||||
| 122 | + A_star = c7.cotisation_seuil() | ||||
| 123 | + rep = c7.repartition_charges(A_star, qp) | ||||
| 124 | + part = qp["q"] * montant | ||||
| 125 | + return {"montant": montant, "par_fraction": part, "charges_annuelles": rep["par_fraction_total"], | ||||
| 126 | + "ratio_annees": part / rep["par_fraction_total"], "A_star": A_star, "rep": rep} | ||||
| 127 | + | ||||
| 128 | + | ||||
| 129 | +# ----------------------------------------------------------------------------- C. copropriété locative (F4 louée) | ||||
| 130 | +LOYER_F4 = 1_700.0 # $/mois ; hypothèse pédagogique dans l'ordre de grandeur des loyers SCHL (oct. 2025) des copropriétés offertes en location, 2 ch. | ||||
| 131 | +INOCC_COPRO = 0.021 # SCHL, RMR de Montréal, copropriétés offertes en location, octobre 2025 (tableau 4.1.1) | ||||
| 132 | +TAXES_F4 = 3_300.0 # $/an ; ordre de grandeur du compte de taxes moyen d'une copropriété à Montréal en 2026 (La Presse, 2026-10-19, source secondaire) | ||||
| 133 | +ASSURANCE_COPRO = 600.0 # $/an ; hypothèse (BAC 2022 : 544 $ pour un copropriétaire, partiellement vérifié) | ||||
| 134 | +GESTION = 0.05 # part du revenu brut effectif (forfait usuel) | ||||
| 135 | +TAUX_PLEX = 0.0525 # taux global de référence des petits immeubles locatifs du marché fictif (dans la fourchette CBRE Québec Low Rise B T3 2026 : 5,00-6,00 %) | ||||
| 136 | + | ||||
| 137 | + | ||||
| 138 | +def copro_locative(valeur: float, loyer: float = LOYER_F4, inocc: float = INOCC_COPRO, taxes: float = TAXES_F4, | ||||
| 139 | + assurance: float = ASSURANCE_COPRO, gestion: float = GESTION, frais_copro: float | None = None) -> dict: | ||||
| 140 | + """RNE d'une fraction louée (convention de l'ouvrage : la cotisation au fonds de prévoyance normalisée tient lieu de réserve).""" | ||||
| 141 | + if frais_copro is None: | ||||
| 142 | + frais_copro = float(c7.repartition_charges(c7.cotisation_seuil(), QP)["par_fraction_total"][3]) # F4 : 10 218 $ | ||||
| 143 | + rbp = loyer * 12.0 | ||||
| 144 | + rbe = fin.revenu_brut_effectif(rbp, inocc) | ||||
| 145 | + de = taxes + assurance + frais_copro + gestion * rbe | ||||
| 146 | + noi = fin.rne(rbe, de) | ||||
| 147 | + fonds = float(c7.repartition_charges(c7.cotisation_seuil(), QP)["par_fraction_fonds"][3]) # part « fonds de prévoyance » des frais (3 082 $) | ||||
| 148 | + return {"rbp": rbp, "rbe": rbe, "depenses": de, "frais_copro": frais_copro, "noi": noi, "noi_sans_reserve": noi + fonds, "fonds": fonds, | ||||
| 149 | + "taux_implicite_sans_reserve": fin.taux_global_extrait(noi + fonds, valeur), | ||||
| 150 | + "taux_implicite": fin.taux_global_extrait(noi, valeur), "ratio_depenses": fin.ratio_depenses(de, rbe), | ||||
| 151 | + "valeur_investissement_5_25": fin.capitalisation_directe(noi, TAUX_PLEX), | ||||
| 152 | + "mrb": fin.multiplicateur_revenu_brut(valeur, rbp)} | ||||
| 153 | + | ||||
| 154 | + | ||||
| 155 | +# ----------------------------------------------------------------------------- D. conversion locatif → copropriété divise | ||||
| 156 | +# L'immeuble fictif de huit fractions, vu AVANT conversion comme un immeuble locatif de 8 logements (hypothèses déclarées). | ||||
| 157 | +LOYERS_LOG = {"F1": 1_250.0, "F2": 1_650.0, "F3": 1_300.0, "F4": 1_700.0, "F5": 1_450.0, "F6": 1_850.0, "F7": 1_500.0, "F8": 1_525.0} # $/mois, loyers contractuels | ||||
| 158 | +INOCC_LOCATIF = 0.03 # inoccupation + mauvaises créances (hypothèse ; SCHL RMR 2025 : 2,4 % Québec, 2,9 % Montréal, 3,8 % Gatineau) | ||||
| 159 | +RATIO_DEPENSES_BLOC = 0.42 # dépenses d'exploitation + réserve / RBE (hypothèse pédagogique pour un immeuble de 8 logements des années 1990) | ||||
| 160 | +LOCATAIRES = {"F1": True, "F2": False, "F3": True, "F4": False, "F5": True, "F6": True, "F7": True, "F8": False} # occupé par un locataire (bail en cours) ? | ||||
| 161 | +PREEMPTION = {"F6"} # locataire(s) qui acceptent l'offre de vente (art. 54.7) à la valeur libre | ||||
| 162 | +ROTATION = 0.12 # part des logements occupés libérés chaque année (départ volontaire ; aucune éviction : moratoire jusqu'au 6 juin 2027) | ||||
| 163 | +DECOTE_OCCUPE = 0.20 # décote d'une fraction vendue occupée (bail maintenu, art. 1937 C.c.Q.) à un investisseur — hypothèse | ||||
| 164 | +R_PROMOTEUR = 0.10 # taux d'actualisation exigé par le convertisseur | ||||
| 165 | +FRAIS_VENTE = 0.04 # courtage et frais de vente (proportion du prix) — hypothèse | ||||
| 166 | +COUTS_CONVERSION = { # dollars de 2026, hypothèses pédagogiques | ||||
| 167 | + "Cadastre vertical et certificats de localisation (arpenteur-géomètre)": 28_000.0, | ||||
| 168 | + "Déclaration de copropriété, publication et honoraires juridiques": 22_000.0, | ||||
| 169 | + "Rapport d'expert et circulaire d'information (art. 54.6 Loi TAL)": 9_000.0, | ||||
| 170 | + "Frais de demande (TAL, municipalité), budget prévisionnel, valeur relative": 6_000.0, | ||||
| 171 | + "Individualisation des services et mise aux normes (8 logements)": 120_000.0, | ||||
| 172 | + "Rafraîchissement des parties communes": 35_000.0, | ||||
| 173 | + "Dotation initiale du fonds de prévoyance (solde d'ouverture requis, ch. 7)": 85_000.0, | ||||
| 174 | +} | ||||
| 175 | +HORIZON_CONV = 6 # années de projection des ventes | ||||
| 176 | +PORTAGE_PCT = 0.015 # coûts de portage annuels (taxes, assurance, intérêts nets des loyers résiduels) en proportion de la valeur des fractions invendues — hypothèse | ||||
| 177 | + | ||||
| 178 | + | ||||
| 179 | +def unites_fictives() -> list[dict]: | ||||
| 180 | + """Les huit fractions de l'immeuble du ch. 7 comme unités du marché fictif (charges de référence, passif nul après dotation du fonds).""" | ||||
| 181 | + out = [] | ||||
| 182 | + for i, (n, S, e, o, st) in enumerate(c7.FRACTIONS): | ||||
| 183 | + out.append(dict(nom=n, S=S, etage=e, orient=o, stat=st, charges_m2=CHARGES_REF_M2, passif=0.0, loyer=LOYERS_LOG[n], | ||||
| 184 | + locataire=LOCATAIRES[n], preemption=n in PREEMPTION, q=float(QP["q"][i]))) | ||||
| 185 | + return out | ||||
| 186 | + | ||||
| 187 | + | ||||
| 188 | +def valeur_bloc(taux: float = TAUX_PLEX, inocc: float = INOCC_LOCATIF, ratio_dep: float = RATIO_DEPENSES_BLOC) -> dict: | ||||
| 189 | + u = unites_fictives() | ||||
| 190 | + rbp = 12.0 * sum(x["loyer"] for x in u) | ||||
| 191 | + rbe = fin.revenu_brut_effectif(rbp, inocc) | ||||
| 192 | + de = ratio_dep * rbe | ||||
| 193 | + noi = fin.rne(rbe, de) | ||||
| 194 | + return {"rbp": rbp, "rbe": rbe, "depenses": de, "noi": noi, "taux": taux, "valeur": fin.capitalisation_directe(noi, taux), | ||||
| 195 | + "mrb": fin.multiplicateur_revenu_brut(fin.capitalisation_directe(noi, taux), rbp), "n": len(u)} | ||||
| 196 | + | ||||
| 197 | + | ||||
| 198 | +def calendrier_ventes(rotation: float = ROTATION, decote_occ: float = DECOTE_OCCUPE, vendre_occupe: bool = False, | ||||
| 199 | + horizon: int = HORIZON_CONV, unites=None) -> dict: | ||||
| 200 | + """Produit brut attendu par année (fin d'année), avant frais de vente. | ||||
| 201 | + An 1 : fractions libres et locataires qui exercent la préemption, à la valeur libre. Fractions occupées : si `vendre_occupe`, | ||||
| 202 | + vendues à l'an 1 avec décote ; sinon vendues libres l'année où le locataire part (probabilité ρ par an), le reliquat à l'horizon | ||||
| 203 | + étant vendu occupé avec décote. Valeurs libres = modèle du marché fictif (passif nul après dotation du fonds).""" | ||||
| 204 | + u = unites or unites_fictives() | ||||
| 205 | + produit = np.zeros(horizon + 1); invendu = np.zeros(horizon + 1) | ||||
| 206 | + valeurs = {x["nom"]: valeur_modele(x) for x in u} | ||||
| 207 | + libres_an1 = [x for x in u if (not x["locataire"]) or x["preemption"]] | ||||
| 208 | + occupes = [x for x in u if x["locataire"] and not x["preemption"]] | ||||
| 209 | + produit[1] += sum(valeurs[x["nom"]] for x in libres_an1) | ||||
| 210 | + V_occ = sum(valeurs[x["nom"]] for x in occupes) | ||||
| 211 | + if vendre_occupe: | ||||
| 212 | + produit[1] += V_occ * (1.0 - decote_occ) | ||||
| 213 | + else: | ||||
| 214 | + reste = 1.0 | ||||
| 215 | + for t in range(1, horizon + 1): | ||||
| 216 | + if t < horizon: | ||||
| 217 | + produit[t] += V_occ * reste * rotation # fractions libérées pendant l'année t, vendues libres en fin d'année | ||||
| 218 | + reste *= (1.0 - rotation) | ||||
| 219 | + else: | ||||
| 220 | + produit[t] += V_occ * reste * (1.0 - decote_occ) # reliquat vendu occupé à l'horizon | ||||
| 221 | + reste = 0.0 | ||||
| 222 | + invendu[t] = V_occ * reste | ||||
| 223 | + # valeur libre des fractions invendues au début de chaque année (assiette des coûts de portage) | ||||
| 224 | + base_portage = np.zeros(horizon + 1) | ||||
| 225 | + total = sum(valeurs.values()) | ||||
| 226 | + base_portage[1] = total | ||||
| 227 | + if not vendre_occupe: | ||||
| 228 | + reste = 1.0 | ||||
| 229 | + for t in range(2, horizon + 1): | ||||
| 230 | + reste *= (1.0 - rotation) | ||||
| 231 | + base_portage[t] = V_occ * reste | ||||
| 232 | + return {"produit": produit, "valeurs": valeurs, "somme_libre": total, "V_occ": V_occ, "n_libres_an1": len(libres_an1), | ||||
| 233 | + "n_occupes": len(occupes), "base_portage": base_portage} | ||||
| 234 | + | ||||
| 235 | + | ||||
| 236 | +def faisabilite(rotation: float = ROTATION, decote_occ: float = DECOTE_OCCUPE, r: float = R_PROMOTEUR, vendre_occupe: bool = False, | ||||
| 237 | + frais_vente: float = FRAIS_VENTE, couts=COUTS_CONVERSION, portage: float = PORTAGE_PCT, horizon: int = HORIZON_CONV) -> dict: | ||||
| 238 | + """Valeur de conversion V_conv = Σ_t [produit_t (1 − f) − portage_t] / (1+r)^t − coûts de conversion (t = 0) ; prime = V_conv − V_bloc.""" | ||||
| 239 | + cal = calendrier_ventes(rotation, decote_occ, vendre_occupe, horizon) | ||||
| 240 | + flux = np.zeros(horizon + 1) | ||||
| 241 | + flux[0] = -sum(couts.values()) | ||||
| 242 | + for t in range(1, horizon + 1): | ||||
| 243 | + flux[t] = cal["produit"][t] * (1.0 - frais_vente) - portage * cal["base_portage"][t] | ||||
| 244 | + v_conv = fin.van(list(flux), r) | ||||
| 245 | + bloc = valeur_bloc() | ||||
| 246 | + return {"flux": flux, "v_conv": v_conv, "v_bloc": bloc["valeur"], "prime": v_conv - bloc["valeur"], | ||||
| 247 | + "somme_libre": cal["somme_libre"], "couts": sum(couts.values()), "frais_vente_total": float(sum(cal["produit"]) * frais_vente), | ||||
| 248 | + "portage_total": float(sum(portage * cal["base_portage"][1:])), "produit": cal["produit"], "calendrier": cal, "bloc": bloc, | ||||
| 249 | + "marge_sur_bloc": (v_conv - bloc["valeur"]) / bloc["valeur"], | ||||
| 250 | + "somme_nette_non_actualisee": float(sum(cal["produit"]) * (1 - frais_vente) - sum(couts.values()) - sum(portage * cal["base_portage"][1:]))} | ||||
| 251 | + | ||||
| 252 | + | ||||
| 253 | +def sensibilite_conversion(rotations=(0.05, 0.08, 0.12, 0.20, 0.30), decotes=(0.10, 0.20, 0.30), r: float = R_PROMOTEUR) -> dict: | ||||
| 254 | + grille = {d: [faisabilite(rho, d, r)["v_conv"] for rho in rotations] for d in decotes} | ||||
| 255 | + occ = {d: faisabilite(ROTATION, d, r, vendre_occupe=True)["v_conv"] for d in decotes} | ||||
| 256 | + return {"rotations": list(rotations), "decotes": list(decotes), "grille": grille, "vente_occupee_an1": occ, "v_bloc": valeur_bloc()["valeur"]} | ||||
| 257 | + | ||||
| 258 | + | ||||
| 259 | +def seuil_prix(r: float = R_PROMOTEUR) -> float: | ||||
| 260 | + """Facteur multiplicatif uniforme des valeurs libres pour lequel V_conv = V_bloc (point mort de la conversion).""" | ||||
| 261 | + global PRIX_M2_REF | ||||
| 262 | + p0 = PRIX_M2_REF | ||||
| 263 | + lo, hi = 0.3 * p0, 1.5 * p0 | ||||
| 264 | + for _ in range(100): | ||||
| 265 | + mid = 0.5 * (lo + hi); PRIX_M2_REF = mid | ||||
| 266 | + if faisabilite(r=r)["prime"] > 0: hi = mid | ||||
| 267 | + else: lo = mid | ||||
| 268 | + PRIX_M2_REF = p0 | ||||
| 269 | + return hi / p0 | ||||
| 270 | + | ||||
| 271 | + | ||||
| 272 | +# ----------------------------------------------------------------------------- impression | ||||
| 273 | +def fmt(x): return f"{x:,.0f}".replace(",", " ") | ||||
| 274 | + | ||||
| 275 | + | ||||
| 276 | +def main() -> dict: | ||||
| 277 | + out = {} | ||||
| 278 | + g = grille_fraction(); out["grille"] = g | ||||
| 279 | + print(f"== A. Fraction F4 (sujet) — marché fictif, graine {SEED}, date d'évaluation {DATE_EVAL}") | ||||
| 280 | + print(f" passif du syndicat A (déficit {fmt(DEFICIT_IMMEUBLE)} $) : part de F4 = q4 {Q_F4:.4f} × déficit = {fmt(SUJET['passif'])} $ ; prix marginal {g['prix_marginal_m2']:,.0f} $/m²") | ||||
| 281 | + print(f" valeur du modèle pour F4 (contrôle de génération) : {fmt(g['valeur_modele_sujet'])} $") | ||||
| 282 | + for c, a, w in zip(g["comparables"], g["ajustes"], g["poids"]): | ||||
| 283 | + print(f" {c['nom']:8s} {c['immeuble']:28s} S {c['S']:5.0f} {c['etage']:3s} {c['orient']:5s} stat {int(c['stat'])} charges {c['charges_m2']:5.1f} $/m² passif {fmt(c['passif']):>7} $ " | ||||
| 284 | + f"{c['mois']:4.0f} mois prix {fmt(c['prix']):>8} $ (modèle {fmt(c['valeur_modele'])})") | ||||
| 285 | + print(" ajustements : " + ", ".join(f"{k} {v:+,.0f}" for k, v in a.ajustements.items()).replace(",", " ").replace(" $", "$")) | ||||
| 286 | + print(f" prix ajusté {fmt(a.prix_ajuste)} $ ; brut {a.brut_pct:.1%} ; net {a.net_pct:+.1%} ; poids {w:.3f}") | ||||
| 287 | + rec = g["reconciliation"] | ||||
| 288 | + print(f" réconciliation : pondérée {fmt(rec['valeur_ponderee'])} $ ; moyenne {fmt(rec['moyenne'])} ; médiane {fmt(rec['mediane'])} ; min {fmt(rec['min'])} ; max {fmt(rec['max'])} ; étendue {rec['etendue_pct']:.1%}") | ||||
| 289 | + sf = sensibilite_fraction(); out["sens_fraction"] = sf | ||||
| 290 | + print(" sensibilité (valeur pondérée) : lignes κ, colonnes R_c " + " ".join(f"{r:.0%}" for r in sf["r_charges"])) | ||||
| 291 | + for k, row in sf["grille"].items(): | ||||
| 292 | + print(f" κ={k:.2f} " + " ".join(f"{v:9,.0f}" for v in row).replace(",", " ")) | ||||
| 293 | + cs = cotisation_speciale(); out["cotisation"] = cs | ||||
| 294 | + print(f"\n== B. Cotisation spéciale de {fmt(cs['montant'])} $ (déficit du ch. 7) répartie selon q_i ; charges annuelles normalisées (A* = {fmt(cs['A_star'])} $)") | ||||
| 295 | + for n, p, ch, r in zip(QP["noms"], cs["par_fraction"], cs["charges_annuelles"], cs["ratio_annees"]): | ||||
| 296 | + print(f" {n} : cotisation {fmt(p):>7} $ ; charges {fmt(ch):>6} $/an ; = {r:.2f} années de charges") | ||||
| 297 | + v_f4 = rec["valeur_ponderee"] | ||||
| 298 | + cl = copro_locative(v_f4); out["locative"] = cl | ||||
| 299 | + print(f"\n== C. F4 louée {fmt(LOYER_F4)} $/mois : RBP {fmt(cl['rbp'])} ; RBE {fmt(cl['rbe'])} ; dépenses {fmt(cl['depenses'])} (frais copro {fmt(cl['frais_copro'])}) ; RNE {fmt(cl['noi'])} $") | ||||
| 300 | + print(f" RNE hors cotisation au fonds ({fmt(cl['fonds'])} $) = {fmt(cl['noi_sans_reserve'])} $ → taux {cl['taux_implicite_sans_reserve']:.2%}") | ||||
| 301 | + print(f" taux implicite sur {fmt(v_f4)} $ = {cl['taux_implicite']:.2%} ; ratio de dépenses {cl['ratio_depenses']:.1%} ; MRB {cl['mrb']:.1f} ; valeur d'investissement à {TAUX_PLEX:.2%} = {fmt(cl['valeur_investissement_5_25'])} $") | ||||
| 302 | + fb = faisabilite(); out["faisabilite"] = fb | ||||
| 303 | + b = fb["bloc"] | ||||
| 304 | + print(f"\n== D. Conversion — immeuble locatif de 8 logements : RBP {fmt(b['rbp'])} ; RBE {fmt(b['rbe'])} ; DE {fmt(b['depenses'])} ; RNE {fmt(b['noi'])} ; R {b['taux']:.2%} → V_bloc {fmt(b['valeur'])} $ ({fmt(b['valeur']/8)} $/log., MRB {b['mrb']:.1f})") | ||||
| 305 | + cal = fb["calendrier"] | ||||
| 306 | + print(" valeurs libres des fractions : " + ", ".join(f"{k} {fmt(v)}" for k, v in cal["valeurs"].items()) + f" ; somme {fmt(cal['somme_libre'])} $") | ||||
| 307 | + print(f" libres ou préemption à l'an 1 : {cal['n_libres_an1']} fractions ; occupées : {cal['n_occupes']} (valeur libre {fmt(cal['V_occ'])} $) ; ρ = {ROTATION:.0%}, décote occupée {DECOTE_OCCUPE:.0%}, r = {R_PROMOTEUR:.0%}") | ||||
| 308 | + print(" produit brut par année : " + ", ".join(f"an {t} {fmt(p)}" for t, p in enumerate(fb["produit"]) if t > 0)) | ||||
| 309 | + print(" flux : " + ", ".join(f"t{t} {f:,.0f}".replace(",", " ") for t, f in enumerate(fb["flux"]))) | ||||
| 310 | + print(f" coûts de conversion {fmt(fb['couts'])} ; frais de vente {fmt(fb['frais_vente_total'])} ; portage {fmt(fb['portage_total'])} ; somme nette non actualisée {fmt(fb['somme_nette_non_actualisee'])}") | ||||
| 311 | + print(f" V_conv = {fmt(fb['v_conv'])} $ ; V_bloc = {fmt(fb['v_bloc'])} $ ; prime {fmt(fb['prime'])} $ ({fb['marge_sur_bloc']:+.1%})") | ||||
| 312 | + fo = faisabilite(vendre_occupe=True); out["faisabilite_occ"] = fo | ||||
| 313 | + print(f" variante : tout vendre à l'an 1 (occupées avec décote) → V_conv {fmt(fo['v_conv'])} $ ; prime {fmt(fo['prime'])} $") | ||||
| 314 | + sc = sensibilite_conversion(); out["sens_conv"] = sc | ||||
| 315 | + print(" sensibilité V_conv : lignes décote occupée, colonnes ρ " + " ".join(f"{r:.0%}" for r in sc["rotations"]) + " | vente occupée an 1") | ||||
| 316 | + for d, row in sc["grille"].items(): | ||||
| 317 | + print(f" δ={d:.0%} " + " ".join(f"{v:10,.0f}" for v in row).replace(",", " ") + f" | {sc['vente_occupee_an1'][d]:10,.0f}".replace(",", " ")) | ||||
| 318 | + sp = seuil_prix(); out["seuil_prix"] = sp | ||||
| 319 | + print(f" point mort : les valeurs libres peuvent baisser jusqu'à {sp:.1%} de leur niveau (−{1-sp:.1%}) avant que V_conv < V_bloc") | ||||
| 320 | + for r in (0.08, 0.12): | ||||
| 321 | + print(f" r = {r:.0%} : V_conv {fmt(faisabilite(r=r)['v_conv'])} $") | ||||
| 322 | + return out | ||||
| 323 | + | ||||
| 324 | + | ||||
| 325 | +if __name__ == "__main__": | ||||
| 326 | + main() | ||||
added
analysis/python/cases/ch43_risques.py
+386 −0
@@ -0,0 +1,386 @@ | |||||
| 1 | +#!/usr/bin/env python3 | ||||
| 2 | +"""Chapitre 43 — Risques environnementaux et sinistres : DONNÉES SYNTHÉTIQUES et calculs de l'exemple intégral. | ||||
| 3 | + | ||||
| 4 | +Quatre blocs, tous reproductibles (graine 20261031) : | ||||
| 5 | + | ||||
| 6 | +1. Cas 4 (portefeuille, `traite.cas4`) : lecture du coefficient `inondable` et `sol` de `effets_regionaux` | ||||
| 7 | + (prime de risque dans le log du taux global ; loi déclarée du générateur : +5 % du taux en zone inondable, | ||||
| 8 | + +3 % si contamination), part de la valeur du portefeuille située en zone inondable. | ||||
| 9 | +2. Cas 4, doubles différences : on injecte sur les séries trimestrielles de RNE des trois immeubles | ||||
| 10 | + `zone_inondable == 1` un choc d'inondation déclaré au printemps 2019 (chute transitoire puis perte | ||||
| 11 | + permanente de 5 % du RNE), puis on l'estime par DiD 2 × 2 (naïf et par strate de RMR), par étude | ||||
| 12 | + d'événement à effets fixes immeuble et RMR × trimestre (`traite.panel`) et par inférence de | ||||
| 13 | + randomisation exacte (toutes les affectations de 3 immeubles traités parmi 48). | ||||
| 14 | +3. Municipalité riveraine fictive « Rivière-Fictive » : 625 lots dont 175 en zone inondable (classes très | ||||
| 15 | + élevée / élevée / modérée / faible) le long d'une rivière et 75 lots riverains d'un lac non inondable, ventes | ||||
| 16 | + 2016-2026 avec décote déclarée par classe, stigmate après une inondation le 1er mai 2019 et aménité « vue sur | ||||
| 17 | + l'eau » (rivière et lac) qui brouille la mesure si on l'omet. Quatre méthodes de mesure : | ||||
| 18 | + ventes appariées, hédonique à indicatrice (avec et sans contrôle de l'aménité), hédonique par classe avec | ||||
| 19 | + interaction classe × après, doubles différences (cellules 2 × 2 et régression). | ||||
| 20 | +4. Modèles de valeur : terrain contaminé (coût de réhabilitation + délai + stigmate), immeuble sinistré | ||||
| 21 | + (remise en état + perte d'usage + stigmate résiduel ; rôle de l'assurance), désuétude vs coût de correction. | ||||
| 22 | + | ||||
| 23 | +Écrit data/synthetic/ch43_riverain_ventes.csv et ch43_riverain_lots.csv s'ils n'existent pas. | ||||
| 24 | +Usage : python3 analysis/python/cases/ch43_risques.py | ||||
| 25 | +""" | ||||
| 26 | +from __future__ import annotations | ||||
| 27 | +import sys, pathlib, math, itertools | ||||
| 28 | +from datetime import date, timedelta | ||||
| 29 | +import numpy as np | ||||
| 30 | +import pandas as pd | ||||
| 31 | + | ||||
| 32 | +ROOT = pathlib.Path(__file__).resolve().parents[3] | ||||
| 33 | +sys.path.insert(0, str(ROOT / "analysis/python")) | ||||
| 34 | +from traite import cas4, comparables as cp, hedonique as h, panel # noqa: E402 | ||||
| 35 | + | ||||
| 36 | +SEED = 20261031 | ||||
| 37 | +D = ROOT / "data/synthetic" | ||||
| 38 | + | ||||
| 39 | +# =============================================================================== 1. cas 4 : prime de risque dans le taux | ||||
| 40 | + | ||||
| 41 | +def prime_taux_cas4() -> dict: | ||||
| 42 | + """Coefficients `inondable` et `sol` de cas4.effets_regionaux : effet en % du taux, IC à 95 % (HC1), | ||||
| 43 | + comparaison à la loi déclarée (+5 % / +3 %) et part de la valeur en zone inondable.""" | ||||
| 44 | + d = cas4.charger(); pf = d["portefeuille"] | ||||
| 45 | + eff = cas4.effets_regionaux(pf); som = cas4.sommaire(pf) | ||||
| 46 | + out = {"n": len(pf), "n_inondable": int(pf["zone_inondable"].sum()), "n_sol": int(pf["contamination_sol"].sum()), | ||||
| 47 | + "part_valeur_inondable": som["total"]["part_zone_inondable"], "valeur_totale": som["total"]["valeur"]} | ||||
| 48 | + for v, declare in [("inondable", 0.05), ("sol", 0.03)]: | ||||
| 49 | + b, se = float(eff.loc[v, "coef"]), float(eff.loc[v, "se_hc1"]) | ||||
| 50 | + out[v] = {"coef": b, "se": se, "effet_pct": math.exp(b) - 1, "ic_bas_pct": math.exp(b - 1.96 * se) - 1, | ||||
| 51 | + "ic_haut_pct": math.exp(b + 1.96 * se) - 1, "declare_pct": declare, "declare_log": math.log(1 + declare), | ||||
| 52 | + "t_vs_declare": (b - math.log(1 + declare)) / se, | ||||
| 53 | + # correction de Kennedy (variance non négligeable) : exp(b − V/2) − 1 | ||||
| 54 | + "effet_kennedy_pct": math.exp(b - 0.5 * se ** 2) - 1} | ||||
| 55 | + # décote de valeur impliquée par la prime de taux, à RNE donné : V = RNE/R → ΔlnV = −Δln R | ||||
| 56 | + out["decote_valeur_inondable_pct"] = math.exp(-out["inondable"]["coef"]) - 1 | ||||
| 57 | + out["decote_valeur_declaree_pct"] = 1 / 1.05 - 1 | ||||
| 58 | + out["immeubles_inondables"] = pf.loc[pf["zone_inondable"] == 1, ["id_immeuble", "rmr", "nb_logements", "valeur_2026", "rne_2026", "taux_global"]].reset_index(drop=True) | ||||
| 59 | + out["immeubles_sol"] = pf.loc[pf["contamination_sol"] == 1, ["id_immeuble", "rmr", "nb_logements", "valeur_2026", "rne_2026", "taux_global"]].reset_index(drop=True) | ||||
| 60 | + out["portefeuille"] = pf | ||||
| 61 | + return out | ||||
| 62 | + | ||||
| 63 | +# =============================================================================== 2. cas 4 : DiD sur les séries de RNE | ||||
| 64 | + | ||||
| 65 | +CHOC_2019 = {"2019Q2": -0.25, "2019Q3": -0.12, "2019Q4": -0.06} # ln RNE, transitoire (perte d'usage, remise en état) | ||||
| 66 | +CHOC_PERMANENT = -0.05 # ln RNE à partir de 2020Q1 (loyers, inoccupation, assurance) | ||||
| 67 | +T_EVENEMENT = "2019Q2" | ||||
| 68 | + | ||||
| 69 | +def panel_choc(seed: int = SEED) -> pd.DataFrame: | ||||
| 70 | + """Panel long (48 immeubles × 42 trimestres) de ln RNE avec le choc déclaré injecté sur les immeubles | ||||
| 71 | + `zone_inondable == 1` ; colonnes : id, rmr, t (0..41), trimestre, traite, post, ln_rne, ln_rne_sans_choc, g.""" | ||||
| 72 | + d = cas4.charger(); pf, se = d["portefeuille"], d["series"].copy() | ||||
| 73 | + se["trimestre"] = se["trimestre"].astype(str) | ||||
| 74 | + se = se.merge(pf[["id_immeuble", "rmr", "zone_inondable"]], on="id_immeuble") | ||||
| 75 | + trims = sorted(se["trimestre"].unique()); idx = {q: i for i, q in enumerate(trims)} | ||||
| 76 | + se["t"] = se["trimestre"].map(idx); t_ev = idx[T_EVENEMENT] | ||||
| 77 | + se["traite"] = se["zone_inondable"].astype(int); se["post"] = (se["t"] >= t_ev).astype(int) | ||||
| 78 | + se["ln_rne_sans_choc"] = np.log(se["rne_trimestriel"].astype(float)) | ||||
| 79 | + choc = np.zeros(len(se)) | ||||
| 80 | + for q, c in CHOC_2019.items(): | ||||
| 81 | + choc[(se["trimestre"] == q).values & (se["traite"] == 1).values] = c | ||||
| 82 | + choc[(se["t"] >= idx["2020Q1"]).values & (se["traite"] == 1).values] = CHOC_PERMANENT | ||||
| 83 | + se["choc"] = choc; se["ln_rne"] = se["ln_rne_sans_choc"] + choc | ||||
| 84 | + se["g"] = np.where(se["traite"] == 1, float(t_ev), np.nan) | ||||
| 85 | + se["unite"] = se["id_immeuble"] | ||||
| 86 | + return se.sort_values(["id_immeuble", "t"]).reset_index(drop=True) | ||||
| 87 | + | ||||
| 88 | +def did_cas4(pan: pd.DataFrame) -> dict: | ||||
| 89 | + trims = sorted(pan["trimestre"].unique()); idx = {q: i for i, q in enumerate(trims)} | ||||
| 90 | + avant = list(range(idx["2017Q1"], idx["2019Q1"] + 1)); apres = list(range(idx["2020Q1"], idx["2026Q2"] + 1)) | ||||
| 91 | + r = panel.did_2x2(pan, "ln_rne", "unite", "t", "traite", avant, apres) | ||||
| 92 | + # version par strate : Δ_i centré sur la moyenne des témoins de la même RMR | ||||
| 93 | + dl = r["deltas"].merge(pan[["unite", "rmr"]].drop_duplicates(), on="unite") | ||||
| 94 | + m_rmr = dl[dl["traite"] == 0].groupby("rmr")["dy"].mean() | ||||
| 95 | + dl["dy_strate"] = dl["dy"] - dl["rmr"].map(m_rmr) | ||||
| 96 | + est_strate = float(dl.loc[dl["traite"] == 1, "dy_strate"].mean()) | ||||
| 97 | + # inférence de randomisation exacte : toutes les affectations de 3 traités parmi 48 (C(48,3) = 17 296) | ||||
| 98 | + dy = dl["dy"].to_numpy(); ids = np.arange(len(dy)); n_tr = int(dl["traite"].sum()) | ||||
| 99 | + tot = dy.sum(); n = len(dy) | ||||
| 100 | + combos = np.array(list(itertools.combinations(ids, n_tr))) | ||||
| 101 | + s_tr = dy[combos].sum(axis=1) | ||||
| 102 | + est_all = s_tr / n_tr - (tot - s_tr) / (n - n_tr) | ||||
| 103 | + obs = r["estimateur"] | ||||
| 104 | + p_ri = float(np.mean(np.abs(est_all) >= abs(obs) - 1e-12)) | ||||
| 105 | + # idem pour l'estimateur par strate | ||||
| 106 | + dys = dl["dy_strate"].to_numpy() # témoins centrés par RMR ; pour les placebos on recentre à chaque tirage | ||||
| 107 | + rmr_codes = dl["rmr"].astype("category").cat.codes.to_numpy() | ||||
| 108 | + est_strate_all = np.empty(len(combos)) | ||||
| 109 | + for k, c in enumerate(combos): | ||||
| 110 | + mask = np.zeros(n, bool); mask[c] = True | ||||
| 111 | + # moyenne des témoins par RMR | ||||
| 112 | + num = np.bincount(rmr_codes[~mask], weights=dy[~mask], minlength=rmr_codes.max() + 1) | ||||
| 113 | + den = np.bincount(rmr_codes[~mask], minlength=rmr_codes.max() + 1) | ||||
| 114 | + mr = np.where(den > 0, num / np.maximum(den, 1), 0.0) | ||||
| 115 | + est_strate_all[k] = float(np.mean(dy[mask] - mr[rmr_codes[mask]])) | ||||
| 116 | + p_ri_strate = float(np.mean(np.abs(est_strate_all) >= abs(est_strate) - 1e-12)) | ||||
| 117 | + # étude d'événement : effets fixes immeuble + RMR × trimestre, fenêtre −8..+12, référence −1 | ||||
| 118 | + es = panel.event_study(pan, "ln_rne", "unite", "t", "g", fenetre=(-8, 12), ref=-1, fe_supp="rmr") | ||||
| 119 | + # TWFE statique avec RMR × trimestre | ||||
| 120 | + pan2 = pan.copy(); pan2["traite_post"] = pan2["traite"] * pan2["post"] | ||||
| 121 | + pan2["traite_permanent"] = pan2["traite"] * (pan2["t"] >= idx["2020Q1"]).astype(int) | ||||
| 122 | + pan2["traite_transitoire"] = pan2["traite"] * pan2["trimestre"].isin(list(CHOC_2019)).astype(int) | ||||
| 123 | + tw = panel.twfe(pan2, "ln_rne", ["traite_transitoire", "traite_permanent"], "unite", "t", fe_supp="rmr") | ||||
| 124 | + # lien avec la valeur : Δln V = Δln RNE − Δln R | ||||
| 125 | + pr = prime_taux_cas4() | ||||
| 126 | + return {"cellules": r["cellules"], "estimateur_2x2": obs, "coef_reg": r["coef_reg"], "se_hc1": r["se_hc1"], | ||||
| 127 | + "estimateur_strate": est_strate, "p_ri": p_ri, "p_ri_strate": p_ri_strate, "n_combinaisons": int(len(combos)), | ||||
| 128 | + "rang_obs": int(np.sum(est_all <= obs)), "n_traites": r["n_traites"], "n_temoins": r["n_temoins"], | ||||
| 129 | + "deltas": dl, "event_study": es, "twfe": tw["coef"], "placebo_dist": est_all, "placebo_dist_strate": est_strate_all, | ||||
| 130 | + "verite_permanente": CHOC_PERMANENT, "verite_permanente_pct": math.exp(CHOC_PERMANENT) - 1, | ||||
| 131 | + "verite_transitoire_moy": float(np.mean(list(CHOC_2019.values()))), | ||||
| 132 | + "effet_valeur_estime_log": tw["coef"].loc["traite_permanent", "coef"] - pr["inondable"]["coef"], | ||||
| 133 | + "effet_valeur_estime_pct": math.exp(tw["coef"].loc["traite_permanent", "coef"] - pr["inondable"]["coef"]) - 1, | ||||
| 134 | + "effet_valeur_declare_log": CHOC_PERMANENT - math.log(1.05), | ||||
| 135 | + "effet_valeur_declare_pct": math.exp(CHOC_PERMANENT - math.log(1.05)) - 1, | ||||
| 136 | + "idx": idx, "trims": trims} | ||||
| 137 | + | ||||
| 138 | +# =============================================================================== 3. Rivière-Fictive : ventes riveraines | ||||
| 139 | + | ||||
| 140 | +NCOL, NLIG, PAS = 25, 25, 40.0 # 625 lots de 40 m × 40 m ; la rivière longe le bord y = 0, un lac (non inondable) le bord x = 24 | ||||
| 141 | +COLS_LAC = {22, 23, 24} # lots riverains du lac : aménité « vue sur l'eau » sans exposition à l'inondation | ||||
| 142 | +CLASSES = ["très élevée", "élevée", "modérée", "faible", "hors zone"] | ||||
| 143 | +LIGNES_CLASSE = {0: "très élevée", 1: "élevée", 2: "élevée", 3: "modérée", 4: "modérée", 5: "faible", 6: "faible"} | ||||
| 144 | +DECOTE_AVANT = {"très élevée": -0.10, "élevée": -0.07, "modérée": -0.04, "faible": -0.015, "hors zone": 0.0} # ln P, hypothèses | ||||
| 145 | +STIGMATE = 0.5 # après l'inondation du 1er mai 2019, la décote est multipliée par (1 + STIGMATE) | ||||
| 146 | +AMENITE_RIVE = 0.04 # ln P, lots à moins de 120 m de la rivière OU riverains du lac (vue, accès) ; indépendant du risque | ||||
| 147 | +T_INOND = date(2019, 5, 1) | ||||
| 148 | +DATE_DEBUT, DATE_FIN, DATE_REF = date(2016, 1, 1), date(2026, 8, 31), date(2021, 1, 1) | ||||
| 149 | +PRIX_BASE, TAUX_MARCHE, SIGMA = 390_000.0, 0.035, 0.07 | ||||
| 150 | +BETA = {"ln_hab": 0.55, "ln_terr": 0.10, "age": -0.005, "qualite": 0.06, "garage": 0.035} | ||||
| 151 | + | ||||
| 152 | +def generer_riverain(seed: int = SEED) -> tuple[pd.DataFrame, pd.DataFrame]: | ||||
| 153 | + rng = np.random.default_rng(seed) | ||||
| 154 | + lots = [] | ||||
| 155 | + for y in range(NLIG): | ||||
| 156 | + for x in range(NCOL): | ||||
| 157 | + cl = LIGNES_CLASSE.get(y, "hors zone") | ||||
| 158 | + dist = (y + 0.5) * PAS + rng.uniform(-8, 8) | ||||
| 159 | + lots.append({"id_lot": f"RF{y:02d}{x:02d}", "x": x, "y": y, "x_m": (x + 0.5) * PAS, "y_m": (y + 0.5) * PAS, | ||||
| 160 | + "dist_riviere_m": round(dist, 1), "classe": cl, "expose": int(cl != "hors zone"), "rive": int(dist < 120 or x in COLS_LAC)}) | ||||
| 161 | + lots = pd.DataFrame(lots) | ||||
| 162 | + # ventes : chaque lot se vend en moyenne 1,6 fois sur la période (Poisson), dates uniformes | ||||
| 163 | + ventes = [] | ||||
| 164 | + n_jours = (DATE_FIN - DATE_DEBUT).days | ||||
| 165 | + k = 0 | ||||
| 166 | + for _, L in lots.iterrows(): | ||||
| 167 | + nv = rng.poisson(1.6) | ||||
| 168 | + hab = float(np.exp(rng.normal(np.log(140), 0.22))); terr = float(np.clip(rng.normal(560, 110), 300, 1200)) | ||||
| 169 | + annee = int(np.clip(rng.normal(1985, 18), 1930, 2016)); qual = int(np.clip(round(rng.normal(3, 0.8)), 1, 5)); gar = int(rng.random() < 0.55) | ||||
| 170 | + for _ in range(nv): | ||||
| 171 | + dv = DATE_DEBUT + timedelta(days=int(rng.integers(0, n_jours + 1))) | ||||
| 172 | + age = dv.year - annee; post = int(dv >= T_INOND) | ||||
| 173 | + decote = DECOTE_AVANT[L["classe"]] * (1 + STIGMATE * post) | ||||
| 174 | + lnp = (math.log(PRIX_BASE) + BETA["ln_hab"] * math.log(hab / 140) + BETA["ln_terr"] * math.log(terr / 560) + BETA["age"] * age | ||||
| 175 | + + BETA["qualite"] * (qual - 3) + BETA["garage"] * gar + AMENITE_RIVE * L["rive"] + decote | ||||
| 176 | + + math.log(1 + TAUX_MARCHE) * ((dv - DATE_REF).days / 365.25) + rng.normal(0, SIGMA)) | ||||
| 177 | + k += 1 | ||||
| 178 | + ventes.append({"id_vente": f"V{k:04d}", "id_lot": L["id_lot"], "date_vente": dv.isoformat(), "prix_vente": round(math.exp(lnp) / 500) * 500, | ||||
| 179 | + "sup_habitable_m2": round(hab, 1), "sup_terrain_m2": round(terr), "annee_construction": annee, "age": age, "qualite": qual, | ||||
| 180 | + "garage": gar, "classe": L["classe"], "expose": L["expose"], "rive": L["rive"], "dist_riviere_m": L["dist_riviere_m"], | ||||
| 181 | + "post": post, "decote_declaree": round(decote, 5)}) | ||||
| 182 | + ventes = pd.DataFrame(ventes).sort_values("date_vente").reset_index(drop=True) | ||||
| 183 | + return lots, ventes | ||||
| 184 | + | ||||
| 185 | +def charger_ou_generer(ecrire: bool = True) -> tuple[pd.DataFrame, pd.DataFrame]: | ||||
| 186 | + fl, fv = D / "ch43_riverain_lots.csv", D / "ch43_riverain_ventes.csv" | ||||
| 187 | + lots, ventes = generer_riverain() | ||||
| 188 | + if ecrire and not (fl.exists() and fv.exists()): | ||||
| 189 | + lots.to_csv(fl, index=False); ventes.to_csv(fv, index=False) | ||||
| 190 | + return lots, ventes | ||||
| 191 | + | ||||
| 192 | +def _X(v: pd.DataFrame, colonnes_sup: list[pd.Series]) -> np.ndarray: | ||||
| 193 | + t = (pd.to_datetime(v["date_vente"]) - pd.Timestamp(DATE_REF)).dt.days / 365.25 | ||||
| 194 | + base = [np.ones(len(v)), np.log(v["sup_habitable_m2"] / 140), np.log(v["sup_terrain_m2"] / 560), v["age"].astype(float), | ||||
| 195 | + (v["qualite"] - 3).astype(float), v["garage"].astype(float), t] | ||||
| 196 | + return np.column_stack(base + [np.asarray(c, float) for c in colonnes_sup]) | ||||
| 197 | + | ||||
| 198 | +def apparier(ventes: pd.DataFrame, fenetre_mois: float = 12.0) -> pd.DataFrame: | ||||
| 199 | + """Ventes appariées après l'inondation : chaque vente exposée est appariée à la vente hors zone la plus proche | ||||
| 200 | + (distance d'attributs : superficies, âge, qualité, garage, date ≤ 12 mois) ; témoin ajusté du temps et des écarts | ||||
| 201 | + physiques par les coefficients hédoniques estimés sur les témoins (ch. 17).""" | ||||
| 202 | + post = ventes[ventes["post"] == 1].copy(); post["d"] = pd.to_datetime(post["date_vente"]) | ||||
| 203 | + ex = post[post["expose"] == 1]; te = post[post["expose"] == 0].copy() | ||||
| 204 | + # coefficients physiques sur les témoins seulement (hors zone) | ||||
| 205 | + r = h.mco(_X(te, []), np.log(te["prix_vente"].values)); b = r["beta"] | ||||
| 206 | + ech = {"sup_habitable_m2": 25, "sup_terrain_m2": 150, "age": 10, "qualite": 1, "garage": 1} | ||||
| 207 | + rows = []; pris = set() | ||||
| 208 | + for _, s in ex.iterrows(): | ||||
| 209 | + cand = te[(te["d"] - s["d"]).abs() <= pd.Timedelta(days=int(fenetre_mois * 30.4375))] | ||||
| 210 | + cand = cand[~cand["id_vente"].isin(pris)] | ||||
| 211 | + if cand.empty: continue | ||||
| 212 | + dist = sum(((cand[k] - s[k]) / e) ** 2 for k, e in ech.items()) + ((cand["d"] - s["d"]).dt.days / 182.6) ** 2 | ||||
| 213 | + tem = cand.loc[dist.idxmin()]; pris.add(tem["id_vente"]) | ||||
| 214 | + mois = (s["d"] - tem["d"]).days / 30.4375 | ||||
| 215 | + aj_temps = cp.ajustement_temps_taux(tem["prix_vente"], TAUX_MARCHE, mois) | ||||
| 216 | + fact = math.exp(b[1] * math.log(s["sup_habitable_m2"] / tem["sup_habitable_m2"]) + b[2] * math.log(s["sup_terrain_m2"] / tem["sup_terrain_m2"]) | ||||
| 217 | + + b[3] * (s["age"] - tem["age"]) + b[4] * (s["qualite"] - tem["qualite"]) + b[5] * (s["garage"] - tem["garage"])) | ||||
| 218 | + p_aj = (tem["prix_vente"] + aj_temps) * fact | ||||
| 219 | + rows.append({"id_expose": s["id_vente"], "id_temoin": tem["id_vente"], "classe": s["classe"], "rive": s["rive"], "prix_expose": s["prix_vente"], | ||||
| 220 | + "prix_temoin": tem["prix_vente"], "ajust_temps": aj_temps, "facteur_physique": fact, "prix_temoin_ajuste": p_aj, | ||||
| 221 | + "difference": s["prix_vente"] - p_aj, "difference_pct": s["prix_vente"] / p_aj - 1, "decote_declaree": s["decote_declaree"]}) | ||||
| 222 | + return pd.DataFrame(rows) | ||||
| 223 | + | ||||
| 224 | +def resume_paires(p: pd.DataFrame) -> dict: | ||||
| 225 | + r = cp.ventes_appariees(p["prix_expose"].tolist(), p["prix_temoin_ajuste"].tolist()) | ||||
| 226 | + pct = p["difference_pct"]; n = len(p) | ||||
| 227 | + out = {"n": n, "moyenne": r["moyenne"], "mediane": r["mediane"], "ecart_type": r["ecart_type"], "erreur_type": r["ecart_type"] / math.sqrt(n), | ||||
| 228 | + "moyenne_pct": float(pct.mean()), "mediane_pct": float(pct.median()), "et_pct": float(pct.std(ddof=1) / math.sqrt(n)), | ||||
| 229 | + "verite_pct": float(np.exp(p["decote_declaree"]).mean() - 1), "verite_log": float(p["decote_declaree"].mean())} | ||||
| 230 | + out["ic95_pct"] = (out["moyenne_pct"] - 1.96 * out["et_pct"], out["moyenne_pct"] + 1.96 * out["et_pct"]) | ||||
| 231 | + out["par_classe"] = p.groupby("classe")["difference_pct"].agg(["count", "mean"]).rename(columns={"count": "n", "mean": "moyenne_pct"}) | ||||
| 232 | + out["par_rive"] = p.groupby("rive")["difference_pct"].agg(["count", "mean"]).rename(columns={"count": "n", "mean": "moyenne_pct"}) | ||||
| 233 | + return out | ||||
| 234 | + | ||||
| 235 | +def hedoniques(ventes: pd.DataFrame) -> dict: | ||||
| 236 | + """(a) indicatrice `exposé` sans contrôle de l'aménité ; (b) avec `rive` ; (c) par classe avec interactions classe × après ; | ||||
| 237 | + (d) DiD par régression exposé × après (avec rive).""" | ||||
| 238 | + v = ventes; y = np.log(v["prix_vente"].values); out = {} | ||||
| 239 | + ra = h.mco(_X(v, [v["expose"]]), y) | ||||
| 240 | + out["a_sans_rive"] = {"coef": ra["beta"][7], "se": ra["se_hc1"][7], "r2": ra["r2"], "sigma": math.sqrt(ra["sigma2"])} | ||||
| 241 | + rb = h.mco(_X(v, [v["expose"], v["rive"]]), y) | ||||
| 242 | + out["b_avec_rive"] = {"coef": rb["beta"][7], "se": rb["se_hc1"][7], "coef_rive": rb["beta"][8], "se_rive": rb["se_hc1"][8], "r2": rb["r2"], "sigma": math.sqrt(rb["sigma2"])} | ||||
| 243 | + cls = ["très élevée", "élevée", "modérée", "faible"] | ||||
| 244 | + cols = [(v["classe"] == c).astype(float) for c in cls] + [(v["classe"] == c).astype(float) * v["post"] for c in cls] + [v["rive"]] | ||||
| 245 | + rc = h.mco(_X(v, cols), y) | ||||
| 246 | + noms = [f"{c}" for c in cls] + [f"{c} × après" for c in cls] + ["rive"] | ||||
| 247 | + out["c_classes"] = pd.DataFrame({"coef": rc["beta"][7:], "se": rc["se_hc1"][7:]}, index=noms) | ||||
| 248 | + out["c_classes"]["effet_pct"] = np.exp(out["c_classes"]["coef"]) - 1 | ||||
| 249 | + out["c_classes"]["verite"] = [DECOTE_AVANT[c] for c in cls] + [DECOTE_AVANT[c] * STIGMATE for c in cls] + [AMENITE_RIVE] | ||||
| 250 | + out["c_classes"]["r2"] = rc["r2"] | ||||
| 251 | + rd = h.mco(_X(v, [v["expose"], v["expose"] * v["post"], v["rive"]]), y) # `post` seul est colinéaire avec la tendance ? non : on l'ajoute | ||||
| 252 | + rd = h.mco(_X(v, [v["expose"], v["post"], v["expose"] * v["post"], v["rive"]]), y) | ||||
| 253 | + out["d_did_reg"] = {"coef_expose": rd["beta"][7], "se_expose": rd["se_hc1"][7], "coef_post": rd["beta"][8], "coef_did": rd["beta"][9], "se_did": rd["se_hc1"][9], | ||||
| 254 | + "coef_rive": rd["beta"][10], "r2": rd["r2"], "sigma": math.sqrt(rd["sigma2"])} | ||||
| 255 | + # cellules 2 × 2 sur ln P (sans contrôle) | ||||
| 256 | + m = v.assign(lnp=y).groupby(["expose", "post"])["lnp"].mean() | ||||
| 257 | + out["d_did_2x2"] = {"moyennes": m, "estimateur": float((m[(1, 1)] - m[(1, 0)]) - (m[(0, 1)] - m[(0, 0)])), | ||||
| 258 | + "effectifs": v.groupby(["expose", "post"]).size().to_dict()} | ||||
| 259 | + # vérités : décote moyenne (log) des ventes exposées avant / après, stigmate moyen | ||||
| 260 | + ex = v[v["expose"] == 1] | ||||
| 261 | + out["verite"] = {"decote_avant_log": float(ex.loc[ex["post"] == 0, "decote_declaree"].mean()), "decote_apres_log": float(ex.loc[ex["post"] == 1, "decote_declaree"].mean()), | ||||
| 262 | + "decote_toutes_log": float(ex["decote_declaree"].mean())} | ||||
| 263 | + out["verite"]["stigmate_log"] = out["verite"]["decote_apres_log"] - out["verite"]["decote_avant_log"] | ||||
| 264 | + out["effectifs"] = {"ventes": len(v), "exposees": int(v["expose"].sum()), "rive": int(v["rive"].sum()), "apres": int(v["post"].sum())} | ||||
| 265 | + return out | ||||
| 266 | + | ||||
| 267 | +def effets_par_methode(ventes: pd.DataFrame, paires: pd.DataFrame) -> pd.DataFrame: | ||||
| 268 | + """Tableau des estimations de la décote (ln P) des ventes exposées après l'inondation, par méthode, avec IC à 95 %.""" | ||||
| 269 | + he = hedoniques(ventes); rp = resume_paires(paires) | ||||
| 270 | + lignes = [] | ||||
| 271 | + lignes.append({"methode": "Ventes appariées (après 2019, sans contrôle de l'aménité)", "coef": math.log(1 + rp["moyenne_pct"]), "se": rp["et_pct"] / (1 + rp["moyenne_pct"]), "cible": "décote après"}) | ||||
| 272 | + lignes.append({"methode": "Hédonique, indicatrice exposé (sans « rive »)", "coef": he["a_sans_rive"]["coef"], "se": he["a_sans_rive"]["se"], "cible": "décote moyenne"}) | ||||
| 273 | + lignes.append({"methode": "Hédonique, indicatrice exposé + « rive »", "coef": he["b_avec_rive"]["coef"], "se": he["b_avec_rive"]["se"], "cible": "décote moyenne"}) | ||||
| 274 | + d = he["d_did_reg"] | ||||
| 275 | + lignes.append({"methode": "DiD régression : exposé (avant)", "coef": d["coef_expose"], "se": d["se_expose"], "cible": "décote avant"}) | ||||
| 276 | + lignes.append({"methode": "DiD régression : exposé × après (stigmate)", "coef": d["coef_did"], "se": d["se_did"], "cible": "stigmate"}) | ||||
| 277 | + lignes.append({"methode": "DiD régression : décote après = somme", "coef": d["coef_expose"] + d["coef_did"], "se": float("nan"), "cible": "décote après"}) | ||||
| 278 | + lignes.append({"methode": "DiD 2 × 2 sur les moyennes de ln P (stigmate)", "coef": he["d_did_2x2"]["estimateur"], "se": float("nan"), "cible": "stigmate"}) | ||||
| 279 | + t = pd.DataFrame(lignes) | ||||
| 280 | + ver = he["verite"] | ||||
| 281 | + t["verite"] = t["cible"].map({"décote après": ver["decote_apres_log"], "décote moyenne": ver["decote_toutes_log"], "décote avant": ver["decote_avant_log"], "stigmate": ver["stigmate_log"]}) | ||||
| 282 | + t["effet_pct"] = np.exp(t["coef"]) - 1; t["ic_bas"] = t["coef"] - 1.96 * t["se"]; t["ic_haut"] = t["coef"] + 1.96 * t["se"] | ||||
| 283 | + return t | ||||
| 284 | + | ||||
| 285 | +# =============================================================================== 4. modèles de valeur | ||||
| 286 | + | ||||
| 287 | +def valeur_terrain_contamine(v_propre: float, cout_rehab: float, delai_mois: float, r: float, stigmate: float, contingence: float = 0.0, perte_revenu_mensuelle: float = 0.0) -> dict: | ||||
| 288 | + """V_c = V_p (1 − s)(1 + r)^(−T) − C(1 + k)(1 + r)^(−T/2) − Σ pertes de revenu actualisées pendant les travaux. | ||||
| 289 | + T en années ; C payé à mi-parcours ; pertes mensuelles actualisées au taux périodique équivalent.""" | ||||
| 290 | + T = delai_mois / 12.0; rm = (1 + r) ** (1 / 12) - 1 | ||||
| 291 | + va_propre = v_propre * (1 - stigmate) / (1 + r) ** T | ||||
| 292 | + va_cout = cout_rehab * (1 + contingence) / (1 + r) ** (T / 2) | ||||
| 293 | + va_perte = sum(perte_revenu_mensuelle / (1 + rm) ** m for m in range(1, int(round(delai_mois)) + 1)) | ||||
| 294 | + v = va_propre - va_cout - va_perte | ||||
| 295 | + return {"valeur": v, "va_propre_stigmatisee": va_propre, "va_cout": va_cout, "va_perte_revenu": va_perte, "cout_delai": v_propre * (1 - stigmate) - va_propre, | ||||
| 296 | + "cout_stigmate": v_propre * stigmate, "ecart_total": v_propre - v, "ecart_pct": v / v_propre - 1} | ||||
| 297 | + | ||||
| 298 | +def valeur_apres_sinistre(v_avant: float, cout_remise: float, loyer_mensuel_perdu: float, mois_perte: float, r: float, stigmate: float, indemnite: float = 0.0) -> dict: | ||||
| 299 | + """Valeur de l'immeuble sinistré non réparé : V_avant − coût de remise en état − VA de la perte d'usage − stigmate résiduel × V_avant ; | ||||
| 300 | + position du propriétaire assuré : valeur + indemnité (l'indemnité couvre des coûts, jamais le stigmate).""" | ||||
| 301 | + rm = (1 + r) ** (1 / 12) - 1 | ||||
| 302 | + va_perte = sum(loyer_mensuel_perdu / (1 + rm) ** m for m in range(1, int(round(mois_perte)) + 1)) | ||||
| 303 | + stig = stigmate * v_avant | ||||
| 304 | + v = v_avant - cout_remise - va_perte - stig | ||||
| 305 | + return {"valeur": v, "va_perte_usage": va_perte, "stigmate_dollars": stig, "perte_totale": v_avant - v, "perte_pct": v / v_avant - 1, | ||||
| 306 | + "position_assure": v + indemnite, "perte_nette_assure": v_avant - (v + indemnite), "part_non_assuree": (v_avant - (v + indemnite)) / (v_avant - v)} | ||||
| 307 | + | ||||
| 308 | +def desuetude_vs_correction(cout_correction: float, perte_usage: float, perte_rne_annuelle: float, R: float, curable: bool) -> dict: | ||||
| 309 | + """Pathologie curable : D = coût de correction + perte d'usage ; incurable : D = perte de RNE / R (capitalisation) — ch. 24.""" | ||||
| 310 | + d_cur = cout_correction + perte_usage; d_inc = perte_rne_annuelle / R | ||||
| 311 | + return {"curable": d_cur, "incurable": d_inc, "retenu": d_cur if curable else d_inc, "regle": "curable si coût de correction ≤ gain de valeur"} | ||||
| 312 | + | ||||
| 313 | +def exemple_contamination() -> dict: | ||||
| 314 | + """Hypothèses déclarées (fictives) : terrain d'un ancien garage avec immeuble de 8 logements à construire ou existant ; | ||||
| 315 | + valeur propre 2 100 000 $ ; phase I 3 500 $, phase II 18 000 $, réhabilitation 185 000 $ (+ 25 % contingence) ; 15 mois ; 6,5 % ; stigmate 5 %.""" | ||||
| 316 | + hyp = {"v_propre": 2_100_000.0, "phase1": 3_500.0, "phase2": 18_000.0, "rehab": 185_000.0, "contingence": 0.25, "delai_mois": 15, "r": 0.065, "stigmate": 0.05, "perte_mensuelle": 0.0} | ||||
| 317 | + c_total = hyp["phase1"] + hyp["phase2"] + hyp["rehab"] | ||||
| 318 | + base = valeur_terrain_contamine(hyp["v_propre"], c_total, hyp["delai_mois"], hyp["r"], hyp["stigmate"], hyp["contingence"], hyp["perte_mensuelle"]) | ||||
| 319 | + sens = {} | ||||
| 320 | + for s in [0.0, 0.05, 0.10, 0.15]: | ||||
| 321 | + for T in [6, 15, 24]: | ||||
| 322 | + sens[(s, T)] = valeur_terrain_contamine(hyp["v_propre"], c_total, T, hyp["r"], s, hyp["contingence"])["valeur"] | ||||
| 323 | + sens_cout = {k: valeur_terrain_contamine(hyp["v_propre"], c_total * k, hyp["delai_mois"], hyp["r"], hyp["stigmate"], hyp["contingence"])["valeur"] for k in [0.7, 1.0, 1.5, 2.0]} | ||||
| 324 | + return {"hyp": hyp, "cout_total": c_total, "base": base, "sensibilite": sens, "sensibilite_cout": sens_cout} | ||||
| 325 | + | ||||
| 326 | +def exemple_sinistre() -> dict: | ||||
| 327 | + """Immeuble P41 du cas 4 (Montréal, zone inondable) : inondation fictive ; hypothèses déclarées.""" | ||||
| 328 | + pf = cas4.charger()["portefeuille"]; p = pf[pf["id_immeuble"] == "P41"].iloc[0] | ||||
| 329 | + hyp = {"v_avant": float(p["valeur_2026"]), "rne": float(p["rne_2026"]), "nb_logements": int(p["nb_logements"]), "loyer": float(p["loyer_mensuel_moyen"]), | ||||
| 330 | + "cout_remise": 240_000.0, "logements_touches": 4, "mois_perte": 5, "r": 0.065, "stigmate": 0.05, "franchise": 10_000.0, "part_couverte": 0.85} | ||||
| 331 | + loyer_perdu = hyp["loyer"] * hyp["logements_touches"] | ||||
| 332 | + indem = max(0.0, hyp["part_couverte"] * hyp["cout_remise"] - hyp["franchise"]) | ||||
| 333 | + base = valeur_apres_sinistre(hyp["v_avant"], hyp["cout_remise"], loyer_perdu, hyp["mois_perte"], hyp["r"], hyp["stigmate"], indem) | ||||
| 334 | + sens = {s: valeur_apres_sinistre(hyp["v_avant"], hyp["cout_remise"], loyer_perdu, hyp["mois_perte"], hyp["r"], s, indem)["valeur"] for s in [0.0, 0.025, 0.05, 0.10, 0.15]} | ||||
| 335 | + sans_assurance = valeur_apres_sinistre(hyp["v_avant"], hyp["cout_remise"], loyer_perdu, hyp["mois_perte"], hyp["r"], hyp["stigmate"], 0.0) | ||||
| 336 | + return {"hyp": hyp, "loyer_perdu_mensuel": loyer_perdu, "indemnite": indem, "base": base, "sensibilite_stigmate": sens, "sans_assurance": sans_assurance, "immeuble": p} | ||||
| 337 | + | ||||
| 338 | +def exemple_pyrite() -> dict: | ||||
| 339 | + """Pyrite (renvoi ch. 11 et 24) : excavation 25 000 $, perte d'usage 2 000 $ ; variante incurable : perte de RNE 1 800 $/an capitalisée à 5,5 %.""" | ||||
| 340 | + return {"curable": desuetude_vs_correction(25_000, 2_000, 0, 0.055, True), "incurable": desuetude_vs_correction(0, 0, 1_800, 0.055, False)} | ||||
| 341 | + | ||||
| 342 | +# =============================================================================== main | ||||
| 343 | + | ||||
| 344 | +def main(ecrire: bool = True, verbeux: bool = True) -> dict: | ||||
| 345 | + pr = prime_taux_cas4(); pan = panel_choc(); dd = did_cas4(pan) | ||||
| 346 | + lots, ventes = charger_ou_generer(ecrire); paires = apparier(ventes); rp = resume_paires(paires); he = hedoniques(ventes); epm = effets_par_methode(ventes, paires) | ||||
| 347 | + ct = exemple_contamination(); sn = exemple_sinistre(); py = exemple_pyrite() | ||||
| 348 | + res = {"prime": pr, "panel": pan, "did": dd, "lots": lots, "ventes": ventes, "paires": paires, "resume_paires": rp, "hedoniques": he, "effets_methodes": epm, | ||||
| 349 | + "contamination": ct, "sinistre": sn, "pyrite": py} | ||||
| 350 | + if not verbeux: return res | ||||
| 351 | + P = print | ||||
| 352 | + P("=== 1. Cas 4 — prime de risque dans le taux global (effets_regionaux) ===") | ||||
| 353 | + P(f"n = {pr['n']} immeubles ; inondables = {pr['n_inondable']} ; contaminés = {pr['n_sol']} ; part de la valeur en zone inondable = {pr['part_valeur_inondable']*100:.2f} %") | ||||
| 354 | + for v in ["inondable", "sol"]: | ||||
| 355 | + e = pr[v] | ||||
| 356 | + P(f"{v:10s}: coef = {e['coef']:.4f} (se HC1 {e['se']:.4f}) → +{e['effet_pct']*100:.2f} % du taux [IC95 {e['ic_bas_pct']*100:.2f} ; {e['ic_haut_pct']*100:.2f}] ; Kennedy {e['effet_kennedy_pct']*100:.2f} % ; déclaré +{e['declare_pct']*100:.0f} % (t = {e['t_vs_declare']:.2f})") | ||||
| 357 | + P(f"décote de valeur impliquée (à RNE donné) : {pr['decote_valeur_inondable_pct']*100:.2f} % (déclarée {pr['decote_valeur_declaree_pct']*100:.2f} %)") | ||||
| 358 | + P(pr["immeubles_inondables"].to_string(index=False)) | ||||
| 359 | + P("\n=== 2. Cas 4 — DiD sur ln RNE, choc 2019 injecté ===") | ||||
| 360 | + P(f"vérité : transitoire {CHOC_2019} ; permanent {CHOC_PERMANENT} ({dd['verite_permanente_pct']*100:.2f} %)") | ||||
| 361 | + P("cellules (ln RNE moyen) :"); P(dd["cellules"].to_string()) | ||||
| 362 | + P(f"DiD 2×2 naïf = {dd['estimateur_2x2']:.4f} ; régression Δ (se HC1) = {dd['coef_reg']:.4f} ({dd['se_hc1']:.4f}) ; par strate RMR = {dd['estimateur_strate']:.4f}") | ||||
| 363 | + P(f"inférence de randomisation exacte : {dd['n_combinaisons']} affectations ; p (naïf) = {dd['p_ri']:.4f} ; p (strate) = {dd['p_ri_strate']:.4f}") | ||||
| 364 | + P("TWFE immeuble + RMR×trimestre :"); P(dd["twfe"].to_string()) | ||||
| 365 | + P("étude d'événement (k, coef, se grappes) :"); P(dd["event_study"][["coef", "se_grappes", "ic_bas", "ic_haut"]].round(4).to_string()) | ||||
| 366 | + P(f"effet sur la valeur (Δln RNE − Δln R) : estimé {dd['effet_valeur_estime_pct']*100:.2f} % ; déclaré {dd['effet_valeur_declare_pct']*100:.2f} %") | ||||
| 367 | + P("\n=== 3. Rivière-Fictive — ventes riveraines ===") | ||||
| 368 | + P(f"lots {len(lots)} ; exposés {int(lots['expose'].sum())} ; par classe {lots['classe'].value_counts().to_dict()}") | ||||
| 369 | + P(f"ventes {he['effectifs']} ; vérités (ln) : avant {he['verite']['decote_avant_log']:.4f}, après {he['verite']['decote_apres_log']:.4f}, stigmate {he['verite']['stigmate_log']:.4f}") | ||||
| 370 | + P(f"paires : n = {rp['n']} ; moyenne {rp['moyenne']:.0f} $ ({rp['moyenne_pct']*100:.2f} %) ; médiane {rp['mediane_pct']*100:.2f} % ; IC95 [{rp['ic95_pct'][0]*100:.2f} ; {rp['ic95_pct'][1]*100:.2f}] ; vérité {rp['verite_pct']*100:.2f} %") | ||||
| 371 | + P(rp["par_classe"].to_string()); P(rp["par_rive"].to_string()) | ||||
| 372 | + P(f"hédonique (a) exposé sans rive : {he['a_sans_rive']['coef']:.4f} ({he['a_sans_rive']['se']:.4f}) ; (b) avec rive : {he['b_avec_rive']['coef']:.4f} ({he['b_avec_rive']['se']:.4f}), rive {he['b_avec_rive']['coef_rive']:.4f} ({he['b_avec_rive']['se_rive']:.4f}) ; σ = {he['b_avec_rive']['sigma']:.4f}, R² = {he['b_avec_rive']['r2']:.3f}") | ||||
| 373 | + P("(c) par classe :"); P(he["c_classes"].round(4).to_string()) | ||||
| 374 | + d = he["d_did_reg"]; P(f"(d) DiD régression : exposé {d['coef_expose']:.4f} ({d['se_expose']:.4f}) ; exposé×après {d['coef_did']:.4f} ({d['se_did']:.4f}) ; post {d['coef_post']:.4f} ; rive {d['coef_rive']:.4f}") | ||||
| 375 | + P(f" DiD 2×2 : {he['d_did_2x2']['estimateur']:.4f} ; effectifs {he['d_did_2x2']['effectifs']}") | ||||
| 376 | + P("effets par méthode :"); P(epm.round(4).to_string(index=False)) | ||||
| 377 | + P("\n=== 4. Modèles de valeur ===") | ||||
| 378 | + b = ct["base"]; P(f"contamination : coût total {ct['cout_total']:.0f} $ ; V_c = {b['valeur']:.0f} $ ({b['ecart_pct']*100:.2f} %) ; VA propre stigmatisée {b['va_propre_stigmatisee']:.0f} ; VA coût {b['va_cout']:.0f} ; coût du délai {b['cout_delai']:.0f} ; stigmate {b['cout_stigmate']:.0f}") | ||||
| 379 | + P("sensibilité (stigmate, mois) → valeur :"); P({k: round(v) for k, v in ct["sensibilite"].items()}); P("coût ×k :"); P({k: round(v) for k, v in ct["sensibilite_cout"].items()}) | ||||
| 380 | + s = sn["base"]; P(f"sinistre P41 : V_avant {sn['hyp']['v_avant']:.0f} ; loyer perdu {sn['loyer_perdu_mensuel']:.0f} $/mois × {sn['hyp']['mois_perte']} ; VA perte {s['va_perte_usage']:.0f} ; stigmate {s['stigmate_dollars']:.0f} ; V_après {s['valeur']:.0f} ({s['perte_pct']*100:.2f} %) ; indemnité {sn['indemnite']:.0f} ; position assuré {s['position_assure']:.0f} ; perte nette {s['perte_nette_assure']:.0f} ; part non assurée {s['part_non_assuree']*100:.1f} %") | ||||
| 381 | + P("sensibilité stigmate :"); P({k: round(v) for k, v in sn["sensibilite_stigmate"].items()}) | ||||
| 382 | + P(f"pyrite : curable {py['curable']['retenu']:.0f} $ ; incurable {py['incurable']['retenu']:.0f} $") | ||||
| 383 | + return res | ||||
| 384 | + | ||||
| 385 | +if __name__ == "__main__": | ||||
| 386 | + main() | ||||
modified
analysis/python/figures/ch20_figures.py
+11 −10
@@ -68,18 +68,19 @@ def fig_residus_elargissement(r): | |||||
| 68 | 68 | ||||
| 69 | 69 | ||||
| 70 | def fig_fourchettes(r): | 70 | def fig_fourchettes(r): | ||
| 71 | − b, bs, co, cb, vt = r["base"], r["bootstrap"], r["cout"], r["combinaison"], r["verite"] | 71 | + b, bs, co, cb, vt, bk = r["base"], r["bootstrap"], r["cout"], r["combinaison"], r["verite"], r["bootstrap_cout"] | ||
| 72 | ip = b["ip_hed"] | 72 | ip = b["ip_hed"] | ||
| 73 | z90 = 1.6449 | 73 | z90 = 1.6449 | ||
| 74 | lignes = [ | 74 | lignes = [ | ||
| 75 | ("Prix de vente probable\n(intervalle de prédiction à 95 %, ch. 18)", b["V_hed"], ip["bas"], ip["haut"], palette.cat[3], 0.5), | 75 | ("Prix de vente probable\n(intervalle de prédiction à 95 %, ch. 18)", b["V_hed"], ip["bas"], ip["haut"], palette.cat[3], 0.5), | ||
| 76 | − ("Coût (hypothèses α, vie économique :\nétendue de la grille)", co["valeur"], co["min_grille"], co["max_grille"], palette.cat[2], 1.0), | 76 | + ("Coût, lecture ventilée (ch. 11), écartée\n(point)", co["ventilee"], np.nan, np.nan, palette.cat[2], 0.6), | ||
| 77 | + ("Coût, dépréciation extraite (ch. 24)\n(bootstrap, 90 %)", co["valeur"], bk["resume"]["q05"], bk["resume"]["q95"], palette.cat[2], 1.0), | ||||
| 77 | ("Comparaison, 5 comparables\n(bootstrap conjoint, 90 %)", b["V_comp"], bs["resume"]["conjoint"]["q05"], bs["resume"]["conjoint"]["q95"], palette.cat[1], 1.0), | 78 | ("Comparaison, 5 comparables\n(bootstrap conjoint, 90 %)", b["V_comp"], bs["resume"]["conjoint"]["q05"], bs["resume"]["conjoint"]["q95"], palette.cat[1], 1.0), | ||
| 78 | ("Comparaison élargie, 16 candidats\n(point)", r["elargissement"]["valeur_ponderee"].iloc[-1], np.nan, np.nan, palette.cat[1], 1.0), | 79 | ("Comparaison élargie, 16 candidats\n(point)", r["elargissement"]["valeur_ponderee"].iloc[-1], np.nan, np.nan, palette.cat[1], 1.0), | ||
| 79 | ("Hédonique (estimation + spécification,\n± 1,645 écart-type)", b["V_hed"], b["V_hed"] - z90 * cb["s"][1], b["V_hed"] + z90 * cb["s"][1], palette.cat[0], 1.0), | 80 | ("Hédonique (estimation + spécification,\n± 1,645 écart-type)", b["V_hed"], b["V_hed"] - z90 * cb["s"][1], b["V_hed"] + z90 * cb["s"][1], palette.cat[0], 1.0), | ||
| 80 | − ("Conclusion : valeur la plus probable,\nfourchette conclue et bootstrap 90 %", cb["conclusion"], cb["fourchette_conclue"][0], cb["fourchette_conclue"][1], palette.brand, 1.0), | 81 | + ("Conclusion : valeur la plus probable,\nfourchette conclue (bande : bootstrap 90 %)", cb["conclusion"], cb["fourchette_conclue"][0], cb["fourchette_conclue"][1], palette.brand, 1.0), | ||
| 81 | ] | 82 | ] | ||
| 82 | − fig, ax = plt.subplots(figsize=(LARGEUR, 3.6)) | 83 | + fig, ax = plt.subplots(figsize=(LARGEUR, 3.9)) | ||
| 83 | y = np.arange(len(lignes))[::-1] | 84 | y = np.arange(len(lignes))[::-1] | ||
| 84 | for yi, (lab, pt, lo, hi, col, alpha) in zip(y, lignes): | 85 | for yi, (lab, pt, lo, hi, col, alpha) in zip(y, lignes): | ||
| 85 | if not np.isnan(lo): | 86 | if not np.isnan(lo): | ||
@@ -94,7 +95,7 @@ def fig_fourchettes(r): | |||||
| 94 | ax.axvline(vt["V_true"], color=palette.ink2, lw=0.8, ls=":", zorder=0) | 95 | ax.axvline(vt["V_true"], color=palette.ink2, lw=0.8, ls=":", zorder=0) | ||
| 95 | ax.text(vt["V_true"] + 3000, -0.55, "espérance de la loi\nde génération", fontsize=7, color=palette.ink2, ha="left", va="center") | 96 | ax.text(vt["V_true"] + 3000, -0.55, "espérance de la loi\nde génération", fontsize=7, color=palette.ink2, ha="left", va="center") | ||
| 96 | ax.set_ylim(-0.9, len(lignes) - 0.3); ax.set_yticks(y); ax.set_yticklabels([l[0] for l in lignes], fontsize=7.2) | 97 | ax.set_ylim(-0.9, len(lignes) - 0.3); ax.set_yticks(y); ax.set_yticklabels([l[0] for l in lignes], fontsize=7.2) | ||
| 97 | − ax.set_xlim(270000, 770000); ax.xaxis.set_major_formatter(FuncFormatter(fmt_k)); ax.set_xlabel("Dollars (synthétiques), 1er septembre 2026") | 98 | + ax.set_xlim(420000, 770000); ax.xaxis.set_major_formatter(FuncFormatter(fmt_k)); ax.set_xlabel("Dollars (synthétiques), 1er septembre 2026") | ||
| 98 | ax.grid(axis="y", visible=False) | 99 | ax.grid(axis="y", visible=False) | ||
| 99 | fig.tight_layout() | 100 | fig.tight_layout() | ||
| 100 | savefig(fig, "ch20/ch20-fig-fourchettes-methodes.pdf") | 101 | savefig(fig, "ch20/ch20-fig-fourchettes-methodes.pdf") | ||
@@ -106,18 +107,18 @@ def fig_sensibilite_poids(r): | |||||
| 106 | ax = axes[0] | 107 | ax = axes[0] | ||
| 107 | for i, (wc, courbe) in enumerate(cb["courbes"].items()): | 108 | for i, (wc, courbe) in enumerate(cb["courbes"].items()): | ||
| 108 | lab = "poids du coût = " + (f"{wc*100:.0f} %") | 109 | lab = "poids du coût = " + (f"{wc*100:.0f} %") | ||
| 109 | − ax.plot(cb["wh"], courbe, color=palette.cat[i], lw=1.6 if wc == 0.05 else 1.1, ls="-" if wc == 0.05 else "--", label=lab) | 110 | + ax.plot(cb["wh"], courbe, color=palette.cat[i], lw=1.6 if wc == 0.15 else 1.1, ls="-" if wc == 0.15 else "--", label=lab) | ||
| 110 | tab = cb["tableau"] | 111 | tab = cb["tableau"] | ||
| 111 | marqueurs = {"raisonnée (retenue)": ("o", palette.brand), "inverse de la variance (indépendance)": ("s", palette.cat[0]), "variance minimale avec corrélation (MCG)": ("^", palette.cat[0]), "égale (un tiers chacune)": ("D", palette.cat[2])} | 112 | marqueurs = {"raisonnée (retenue)": ("o", palette.brand), "inverse de la variance (indépendance)": ("s", palette.cat[0]), "variance minimale avec corrélation (MCG)": ("^", palette.cat[0]), "égale (un tiers chacune)": ("D", palette.cat[2])} | ||
| 112 | for _, row in tab.iterrows(): | 113 | for _, row in tab.iterrows(): | ||
| 113 | if row["schema"] in marqueurs: | 114 | if row["schema"] in marqueurs: | ||
| 114 | m, c = marqueurs[row["schema"]] | 115 | m, c = marqueurs[row["schema"]] | ||
| 115 | ax.plot(row["w_hed"], row["valeur"], m, color=c, ms=6, mec="white", mew=0.6, zorder=5) | 116 | ax.plot(row["w_hed"], row["valeur"], m, color=c, ms=6, mec="white", mew=0.6, zorder=5) | ||
| 116 | − ax.annotate("raisonnée\n(0,40 / 0,55 / 0,05)", (0.55, tab.loc[tab["schema"] == "raisonnée (retenue)", "valeur"].iloc[0]), xytext=(0.62, 572000), fontsize=7, arrowprops=dict(arrowstyle="-", color=palette.ink2, lw=0.6)) | 117 | + ax.annotate("raisonnée\n(0,40 / 0,45 / 0,15)", (0.45, tab.loc[tab["schema"] == "raisonnée (retenue)", "valeur"].iloc[0]), xytext=(0.62, 588000), fontsize=7, arrowprops=dict(arrowstyle="-", color=palette.ink2, lw=0.6)) | ||
| 117 | − ax.annotate("inverse de la variance\net MCG", (0.70, tab.loc[tab["schema"] == "inverse de la variance (indépendance)", "valeur"].iloc[0]), xytext=(0.42, 628000), fontsize=7, arrowprops=dict(arrowstyle="-", color=palette.ink2, lw=0.6)) | 118 | + ax.annotate("inverse de la variance\net MCG", (0.616, tab.loc[tab["schema"] == "inverse de la variance (indépendance)", "valeur"].iloc[0]), xytext=(0.30, 630000), fontsize=7, arrowprops=dict(arrowstyle="-", color=palette.ink2, lw=0.6)) | ||
| 118 | − ax.annotate("égale", (1 / 3, tab.loc[tab["schema"] == "égale (un tiers chacune)", "valeur"].iloc[0]), xytext=(0.12, 540000), fontsize=7, arrowprops=dict(arrowstyle="-", color=palette.ink2, lw=0.6)) | 119 | + ax.annotate("égale (un tiers au coût)", (1 / 3, tab.loc[tab["schema"] == "égale (un tiers chacune)", "valeur"].iloc[0]), xytext=(0.03, 622000), fontsize=7, arrowprops=dict(arrowstyle="-", color=palette.ink2, lw=0.6)) | ||
| 119 | ax.set_xlabel("Poids de l'indication hédonique $w_h$ (le reste à la comparaison)"); ax.set_ylabel("Valeur combinée") | 120 | ax.set_xlabel("Poids de l'indication hédonique $w_h$ (le reste à la comparaison)"); ax.set_ylabel("Valeur combinée") | ||
| 120 | − ax.yaxis.set_major_formatter(FuncFormatter(fmt_k)); ax.set_xlim(0, 1); ax.legend(loc="lower right", fontsize=7, frameon=False) | 121 | + ax.yaxis.set_major_formatter(FuncFormatter(fmt_k)); ax.set_xlim(0, 1); ax.legend(loc="upper left", fontsize=7, frameon=False) | ||
| 121 | ax.set_title("(a) Conclusion selon les poids", loc="left", fontsize=8.5) | 122 | ax.set_title("(a) Conclusion selon les poids", loc="left", fontsize=8.5) | ||
| 122 | ax = axes[1] | 123 | ax = axes[1] | ||
| 123 | s = cb["s"] | 124 | s = cb["s"] | ||
modified
analysis/python/figures/ch36_figures.py
+2 −2
@@ -45,13 +45,13 @@ def fig_event_study(res): | |||||
| 45 | ax.fill_between(es.index, es["ic_bas"], es["ic_haut"], color=palette.cat[0], alpha=0.18, lw=0) | 45 | ax.fill_between(es.index, es["ic_bas"], es["ic_haut"], color=palette.cat[0], alpha=0.18, lw=0) | ||
| 46 | ax.plot(es.index, es["coef"], "o-", color=palette.cat[0], ms=3.8, lw=1.4, label="Coefficient estimé (IC à 95 %, grappes)") | 46 | ax.plot(es.index, es["coef"], "o-", color=palette.cat[0], ms=3.8, lw=1.4, label="Coefficient estimé (IC à 95 %, grappes)") | ||
| 47 | ax.step(es.index, es["vrai"], where="mid", color=palette.ink, lw=1.1, ls="--", label="Effet déclaré δ(k)") | 47 | ax.step(es.index, es["vrai"], where="mid", color=palette.ink, lw=1.1, ls="--", label="Effet déclaré δ(k)") | ||
| 48 | − ax.set_title("(a) Étude d'événement TWFE (RMR × trimestre)", fontsize=8.5); ax.set_xlabel("Trimestres depuis la mise en service, k") | 48 | + ax.set_title("(a) Étude d'événement TWFE (RMR × trimestre)", fontsize=8); ax.set_xlabel("Trimestres depuis la mise en service, k") | ||
| 49 | ax.set_ylabel("Effet sur ln RNE (points de log)") | 49 | ax.set_ylabel("Effet sur ln RNE (points de log)") | ||
| 50 | ax = axes[1] | 50 | ax = axes[1] | ||
| 51 | ax.fill_between(cs.index, cs["ic_bas"], cs["ic_haut"], color=palette.cat[1], alpha=0.18, lw=0) | 51 | ax.fill_between(cs.index, cs["ic_bas"], cs["ic_haut"], color=palette.cat[1], alpha=0.18, lw=0) | ||
| 52 | ax.plot(cs.index, cs["att"], "s-", color=palette.cat[1], ms=3.4, lw=1.4, label="ATT(k) de Callaway–Sant'Anna (IC bootstrap)") | 52 | ax.plot(cs.index, cs["att"], "s-", color=palette.cat[1], ms=3.4, lw=1.4, label="ATT(k) de Callaway–Sant'Anna (IC bootstrap)") | ||
| 53 | kk = np.arange(-8, 16); ax.step(kk, c36.effet_vrai(kk), where="mid", color=palette.ink, lw=1.1, ls="--", label="Effet déclaré δ(k)") | 53 | kk = np.arange(-8, 16); ax.step(kk, c36.effet_vrai(kk), where="mid", color=palette.ink, lw=1.1, ls="--", label="Effet déclaré δ(k)") | ||
| 54 | − ax.set_title("(b) Callaway–Sant'Anna, témoins de la même RMR", fontsize=8.5); ax.set_xlabel("Trimestres depuis la mise en service, k") | 54 | + ax.set_title("(b) Callaway–Sant'Anna (témoins de la même RMR)", fontsize=8); ax.set_xlabel("Trimestres depuis la mise en service, k") | ||
| 55 | for ax in axes: | 55 | for ax in axes: | ||
| 56 | ax.axhline(0, color=palette.ink2, lw=0.8); ax.axvline(-0.5, color=palette.ink2, lw=0.8, ls=":"); ax.legend(fontsize=6.8, loc="upper left"); ax.grid(True, lw=0.4) | 56 | ax.axhline(0, color=palette.ink2, lw=0.8); ax.axvline(-0.5, color=palette.ink2, lw=0.8, ls=":"); ax.legend(fontsize=6.8, loc="upper left"); ax.grid(True, lw=0.4) | ||
| 57 | savefig(fig, "ch36/ch36-fig-event-study.pdf") | 57 | savefig(fig, "ch36/ch36-fig-event-study.pdf") | ||
modified
analysis/python/figures/ch38_figures.py
+14 −12
@@ -64,7 +64,7 @@ def fig_carte_cas1(r): | |||||
| 64 | _km(ax, xy); ax.set_xlabel("Est, NAD83 / MTM zone 9 (km) — coordonnées synthétiques"); ax.set_ylabel("Nord (km)") | 64 | _km(ax, xy); ax.set_xlabel("Est, NAD83 / MTM zone 9 (km) — coordonnées synthétiques"); ax.set_ylabel("Nord (km)") | ||
| 65 | leg = [Patch(color=palette.seq[5], alpha=0.6, label="Zone d'exposition fictive, classe A (≤ 350 m)"), Patch(color=palette.seq[1], alpha=0.55, label="Classe B (350–900 m)"), | 65 | leg = [Patch(color=palette.seq[5], alpha=0.6, label="Zone d'exposition fictive, classe A (≤ 350 m)"), Patch(color=palette.seq[1], alpha=0.55, label="Classe B (350–900 m)"), | ||
| 66 | Line2D([], [], color=palette.cat[1], lw=1.4, label="Corridor de transport fictif et tampon de 300 m"), | 66 | Line2D([], [], color=palette.cat[1], lw=1.4, label="Corridor de transport fictif et tampon de 300 m"), | ||
| 67 | − Line2D([], [], marker="^", color="none", markerfacecolor=palette.cat[2], markeredgecolor=palette.ink, markersize=7, label="Pôles fictifs (taille ∝ masse)"), | 67 | + Line2D([], [], marker="^", color="none", markerfacecolor=palette.cat[2], markeredgecolor=palette.ink, markersize=7, label="Pôles fictifs (taille selon la masse)"), | ||
| 68 | Line2D([], [], marker="o", color="none", markerfacecolor=palette.seq[9], markersize=5, label="Vente en classe A"), | 68 | Line2D([], [], marker="o", color="none", markerfacecolor=palette.seq[9], markersize=5, label="Vente en classe A"), | ||
| 69 | Line2D([], [], marker="o", color="none", markerfacecolor=palette.seq[4], markersize=5, label="Vente en classe B"), | 69 | Line2D([], [], marker="o", color="none", markerfacecolor=palette.seq[4], markersize=5, label="Vente en classe B"), | ||
| 70 | Line2D([], [], marker="o", color="none", markerfacecolor=palette.ink2, markersize=4, alpha=0.6, label="Autre vente"), | 70 | Line2D([], [], marker="o", color="none", markerfacecolor=palette.ink2, markersize=4, alpha=0.6, label="Autre vente"), | ||
@@ -80,25 +80,25 @@ def _carte_classes(ax, cells, z, bornes, cmap_colors, titre, fmt=lambda x: fmt_m | |||||
| 80 | ax.fill(ext[:, 0], ext[:, 1], color=cmap(normc(val)), lw=0.3, ec=palette.surface) | 80 | ax.fill(ext[:, 0], ext[:, 1], color=cmap(normc(val)), lw=0.3, ec=palette.surface) | ||
| 81 | ax.set_title(titre, fontsize=8.5) | 81 | ax.set_title(titre, fontsize=8.5) | ||
| 82 | handles = [Patch(color=cmap(i), label=f"{fmt(bornes[i])} – {fmt(bornes[i + 1])}") for i in range(len(bornes) - 1)] | 82 | handles = [Patch(color=cmap(i), label=f"{fmt(bornes[i])} – {fmt(bornes[i + 1])}") for i in range(len(bornes) - 1)] | ||
| 83 | − ax.legend(handles=handles, fontsize=6, loc="lower right", frameon=True, framealpha=0.9, edgecolor="none", facecolor=palette.surface, title_fontsize=6.5) | 83 | + ax.legend(handles=handles, fontsize=5.8, loc="upper left", frameon=True, framealpha=0.9, edgecolor="none", facecolor=palette.surface, handlelength=1.2, borderpad=0.3, labelspacing=0.25) | ||
| 84 | 84 | ||||
| 85 | 85 | ||||
| 86 | def fig_choroplethe(r): | 86 | def fig_choroplethe(r): | ||
| 87 | g = r["grille"]; ok = g["cartographiees"]; occ = g["occupees"]; b = r["base"]; xy = b["xy_m"] / 1000 | 87 | g = r["grille"]; ok = g["cartographiees"]; occ = g["occupees"]; b = r["base"]; xy = b["xy_m"] / 1000 | ||
| 88 | z = ok["prix_m2_med"].values; k = c38.K_CLASSES | 88 | z = ok["prix_m2_med"].values; k = c38.K_CLASSES | ||
| 89 | seq = [palette.seq[i] for i in np.linspace(0, 9, k).round().astype(int)] | 89 | seq = [palette.seq[i] for i in np.linspace(0, 9, k).round().astype(int)] | ||
| 90 | − fig, axes = plt.subplots(2, 2, figsize=(LARGEUR, 6.4), sharex=True, sharey=True) | 90 | + fig, axes = plt.subplots(2, 2, figsize=(LARGEUR, 4.0), sharex=True, sharey=True) | ||
| 91 | − titres = {"intervalles égaux": "(a) Intervalles égaux", "quantiles": "(b) Quantiles", "Jenks": "(c) Ruptures naturelles (Fisher–Jenks)"} | 91 | + titres = {"intervalles égaux": "(a) Intervalles égaux", "quantiles": "(b) Quantiles", "Jenks": "(c) Fisher–Jenks"} | ||
| 92 | for ax, (nom, cl) in zip(axes.flat[:3], g["classif"].items()): | 92 | for ax, (nom, cl) in zip(axes.flat[:3], g["classif"].items()): | ||
| 93 | for geom in occ.loc[occ["n"] < c38.MIN_VENTES_CELLULE].geometry: | 93 | for geom in occ.loc[occ["n"] < c38.MIN_VENTES_CELLULE].geometry: | ||
| 94 | ext = np.asarray(geom.exterior.coords) / 1000; ax.fill(ext[:, 0], ext[:, 1], facecolor="none", edgecolor=palette.ink2, hatch="////", lw=0.3) | 94 | ext = np.asarray(geom.exterior.coords) / 1000; ax.fill(ext[:, 0], ext[:, 1], facecolor="none", edgecolor=palette.ink2, hatch="////", lw=0.3) | ||
| 95 | − _carte_classes(ax, ok, z, cl["bornes"], seq, f"{titres[nom]} — GVF = {cl['gvf']:.3f}") | 95 | + _carte_classes(ax, ok, z, cl["bornes"], seq, f"{titres[nom]}, GVF = {cl['gvf']:.3f}".replace(".", ",")) | ||
| 96 | ax.scatter(xy[:, 0], xy[:, 1], s=1.2, color=palette.ink, alpha=0.25, lw=0) | 96 | ax.scatter(xy[:, 0], xy[:, 1], s=1.2, color=palette.ink, alpha=0.25, lw=0) | ||
| 97 | ax = axes.flat[3]; res = ok["residu_moy"].values; vmax = float(np.abs(res).max()); bornes = np.linspace(-vmax, vmax, 7) | 97 | ax = axes.flat[3]; res = ok["residu_moy"].values; vmax = float(np.abs(res).max()); bornes = np.linspace(-vmax, vmax, 7) | ||
| 98 | div = [palette.div["neg"], "#7fa8e0", "#c5d4ea", "#ead0cf", "#eb9a99", palette.div["pos"]] | 98 | div = [palette.div["neg"], "#7fa8e0", "#c5d4ea", "#ead0cf", "#eb9a99", palette.div["pos"]] | ||
| 99 | for geom in occ.loc[occ["n"] < c38.MIN_VENTES_CELLULE].geometry: | 99 | for geom in occ.loc[occ["n"] < c38.MIN_VENTES_CELLULE].geometry: | ||
| 100 | ext = np.asarray(geom.exterior.coords) / 1000; ax.fill(ext[:, 0], ext[:, 1], facecolor="none", edgecolor=palette.ink2, hatch="////", lw=0.3) | 100 | ext = np.asarray(geom.exterior.coords) / 1000; ax.fill(ext[:, 0], ext[:, 1], facecolor="none", edgecolor=palette.ink2, hatch="////", lw=0.3) | ||
| 101 | − _carte_classes(ax, ok, res, bornes, div, "(d) Résidu moyen du modèle de référence (divergent)", fmt=lambda x: f"{x:+.3f}".replace(".", ",")) | 101 | + _carte_classes(ax, ok, res, bornes, div, "(d) Résidu moyen, modèle de référence", fmt=lambda x: f"{x:+.3f}".replace(".", ",")) | ||
| 102 | ax.scatter(xy[:, 0], xy[:, 1], s=1.2, color=palette.ink, alpha=0.25, lw=0) | 102 | ax.scatter(xy[:, 0], xy[:, 1], s=1.2, color=palette.ink, alpha=0.25, lw=0) | ||
| 103 | for ax in axes.flat: | 103 | for ax in axes.flat: | ||
| 104 | _km(ax, xy, marge=1.0) | 104 | _km(ax, xy, marge=1.0) | ||
@@ -106,8 +106,8 @@ def fig_choroplethe(r): | |||||
| 106 | ax.set_xlabel("Est, MTM 9 (km)") | 106 | ax.set_xlabel("Est, MTM 9 (km)") | ||
| 107 | for ax in axes[:, 0]: | 107 | for ax in axes[:, 0]: | ||
| 108 | ax.set_ylabel("Nord (km)") | 108 | ax.set_ylabel("Nord (km)") | ||
| 109 | − fig.text(0.5, 0.005, f"Cellules hexagonales de {g['aire_cellule_km2']:.2f} km² ; hachures : cellules de moins de {c38.MIN_VENTES_CELLULE} ventes (non cartographiées) ; points : ventes", ha="center", fontsize=7, color=palette.ink2) | 109 | + fig.text(0.5, 0.0, f"Cellules hexagonales de {g['aire_cellule_km2']:.2f} km² ; hachures : cellules de moins de {c38.MIN_VENTES_CELLULE} ventes (non cartographiées) ; points : ventes", ha="center", fontsize=7, color=palette.ink2) | ||
| 110 | − fig.subplots_adjust(hspace=0.18, wspace=0.08) | 110 | + fig.subplots_adjust(hspace=0.28, wspace=0.06, bottom=0.12, top=0.95) | ||
| 111 | savefig(fig, "ch38/ch38-fig-choroplethe.pdf") | 111 | savefig(fig, "ch38/ch38-fig-choroplethe.pdf") | ||
| 112 | 112 | ||||
| 113 | 113 | ||||
@@ -122,7 +122,7 @@ def fig_faux_positifs(r): | |||||
| 122 | for s in (-1.96, 1.96): | 122 | for s in (-1.96, 1.96): | ||
| 123 | ax.axvline(s, color=palette.ink2, lw=0.7, ls=":") | 123 | ax.axvline(s, color=palette.ink2, lw=0.7, ls=":") | ||
| 124 | ax.set_xlabel("Statistique $t$ de l'indicatrice d'un disque aléatoire de 1 km"); ax.set_ylabel("Densité"); ax.set_title("(a) Distribution des $t$ (346 disques)", fontsize=8.5) | 124 | ax.set_xlabel("Statistique $t$ de l'indicatrice d'un disque aléatoire de 1 km"); ax.set_ylabel("Densité"); ax.set_title("(a) Distribution des $t$ (346 disques)", fontsize=8.5) | ||
| 125 | − ax.legend(fontsize=6.8, loc="upper left") | 125 | + ax.legend(fontsize=6.8, loc="upper right") | ||
| 126 | ax = axes[1] | 126 | ax = axes[1] | ||
| 127 | for i, spec in enumerate(["référence", "sans secteur", "sans secteur ni gradient"]): | 127 | for i, spec in enumerate(["référence", "sans secteur", "sans secteur ni gradient"]): | ||
| 128 | w = tab[(tab["spec"] == spec) & (tab["erreur_type"] == "classique")].sort_values("rayon_km") | 128 | w = tab[(tab["spec"] == spec) & (tab["erreur_type"] == "classique")].sort_values("rayon_km") | ||
@@ -161,13 +161,15 @@ def fig_cas4(r): | |||||
| 161 | ax.scatter(x[m2], y[m2], marker="s", s=s[m2] + 70, facecolor="none", edgecolor=palette.cat[3], lw=1.3, zorder=4) | 161 | ax.scatter(x[m2], y[m2], marker="s", s=s[m2] + 70, facecolor="none", edgecolor=palette.cat[3], lw=1.3, zorder=4) | ||
| 162 | for rmr, w in g.groupby("rmr"): | 162 | for rmr, w in g.groupby("rmr"): | ||
| 163 | cx, cy = w.geometry.x.mean() / 1000, w.geometry.y.mean() / 1000 | 163 | cx, cy = w.geometry.x.mean() / 1000, w.geometry.y.mean() / 1000 | ||
| 164 | − ax.annotate(f"{rmr} ({len(w)})", (cx, cy), xytext=(0, 16 if rmr != "Montréal" else -22), textcoords="offset points", fontsize=7, ha="center", color=palette.ink2) | 164 | + dx, dy, ha = (0, 14, "center") if rmr not in ("Montréal", "Sherbrooke") else ((0, -20, "center") if rmr == "Montréal" else (14, -4, "left")) | ||
| 165 | + ax.annotate(f"{rmr} ({len(w)})", (cx, cy), xytext=(dx, dy), textcoords="offset points", fontsize=7, ha=ha, color=palette.ink2) | ||||
| 165 | ax.set_aspect("equal"); ax.grid(True, lw=0.4); ax.set_xlabel("Est, NAD83 / Québec Lambert (km)"); ax.set_ylabel("Nord (km)") | 166 | ax.set_aspect("equal"); ax.grid(True, lw=0.4); ax.set_xlabel("Est, NAD83 / Québec Lambert (km)"); ax.set_ylabel("Nord (km)") | ||
| 166 | − ax.set_title("(a) 48 immeubles, taille ∝ valeur 2026", fontsize=8.5) | 167 | + ax.set_title("(a) 48 immeubles, taille selon la valeur 2026", fontsize=8.5) | ||
| 167 | leg = [Line2D([], [], marker="o", color="none", markerfacecolor=palette.cat[0], alpha=0.6, markersize=7, label="Immeuble"), | 168 | leg = [Line2D([], [], marker="o", color="none", markerfacecolor=palette.cat[0], alpha=0.6, markersize=7, label="Immeuble"), | ||
| 168 | Line2D([], [], marker="o", color="none", markerfacecolor="none", markeredgecolor=palette.cat[5], markeredgewidth=1.6, markersize=9, label="Zone inondable (3)"), | 169 | Line2D([], [], marker="o", color="none", markerfacecolor="none", markeredgecolor=palette.cat[5], markeredgewidth=1.6, markersize=9, label="Zone inondable (3)"), | ||
| 169 | Line2D([], [], marker="s", color="none", markerfacecolor="none", markeredgecolor=palette.cat[3], markeredgewidth=1.3, markersize=9, label="Contamination du sol (2)")] | 170 | Line2D([], [], marker="s", color="none", markerfacecolor="none", markeredgecolor=palette.cat[3], markeredgewidth=1.3, markersize=9, label="Contamination du sol (2)")] | ||
| 170 | − ax.legend(handles=leg, fontsize=6.8, loc="lower right") | 171 | + ax.legend(handles=leg, fontsize=6.8, loc="upper left") | ||
| 172 | + ax.set_ylim(y.min() - 55, y.max() + 60); ax.set_xlim(x.min() - 60, x.max() + 90) | ||||
| 171 | ax = axes[1]; ordre = par.index[::-1]; yy = np.arange(len(ordre)) | 173 | ax = axes[1]; ordre = par.index[::-1]; yy = np.arange(len(ordre)) | ||
| 172 | ax.barh(yy, par.loc[ordre, "part_inond_rmr"] * 100, color=palette.cat[5], alpha=0.85, height=0.6) | 174 | ax.barh(yy, par.loc[ordre, "part_inond_rmr"] * 100, color=palette.cat[5], alpha=0.85, height=0.6) | ||
| 173 | for yi, rmr in zip(yy, ordre): | 175 | for yi, rmr in zip(yy, ordre): | ||
added
analysis/python/figures/ch39_figures.py
+146 −0
Large diff (146 lines) is not loaded by default to keep the page fast.Load diff
added
analysis/python/figures/ch40_figures.py
+139 −0
Large diff (139 lines) is not loaded by default to keep the page fast.Load diff
added
analysis/python/figures/ch42_bloc_piece.py
+38 −0
Large diff (38 lines) is not loaded by default to keep the page fast.Load diff
added
analysis/python/figures/ch42_cotisation_speciale.py
+36 −0
Large diff (36 lines) is not loaded by default to keep the page fast.Load diff
added
analysis/python/figures/ch42_grille.py
+41 −0
Large diff (41 lines) is not loaded by default to keep the page fast.Load diff
added
analysis/python/figures/ch42_medianes.py
+42 −0
Large diff (42 lines) is not loaded by default to keep the page fast.Load diff
added
analysis/python/figures/ch43_carte_cas4.py
+38 −0
Large diff (38 lines) is not loaded by default to keep the page fast.Load diff
added
analysis/python/figures/ch43_carte_riverain.py
+52 −0
Large diff (52 lines) is not loaded by default to keep the page fast.Load diff
added
analysis/python/figures/ch43_did_cas4.py
+46 −0
Large diff (46 lines) is not loaded by default to keep the page fast.Load diff
added
analysis/python/figures/ch43_effets_methodes.py
+50 −0
Large diff (50 lines) is not loaded by default to keep the page fast.Load diff
added
analysis/python/figures/ch43_placebos.py
+32 −0
Large diff (32 lines) is not loaded by default to keep the page fast.Load diff
modified
analysis/python/tests/test_ch20.py
+45 −27
Large diff (105 lines) is not loaded by default to keep the page fast.Load diff
added
analysis/python/tests/test_ch42.py
+206 −0
Large diff (206 lines) is not loaded by default to keep the page fast.Load diff
modified
assets/figures/ch20/ch20-fig-bootstrap-densite.pdf
+0 −0
Binary file not shown.
modified
assets/figures/ch20/ch20-fig-fourchettes-methodes.pdf
+0 −0
Binary file not shown.
modified
assets/figures/ch20/ch20-fig-residus-elargissement.pdf
+0 −0
Binary file not shown.
modified
assets/figures/ch20/ch20-fig-sensibilite-poids.pdf
+0 −0
Binary file not shown.
modified
assets/figures/ch38/ch38-fig-carte-cas1.pdf
+0 −0
Binary file not shown.
modified
assets/figures/ch38/ch38-fig-cas4.pdf
+0 −0
Binary file not shown.
modified
assets/figures/ch38/ch38-fig-choroplethe.pdf
+0 −0
Binary file not shown.
modified
assets/figures/ch38/ch38-fig-faux-positifs.pdf
+0 −0
Binary file not shown.
modified
assets/figures/ch38/ch38-fig-tampon.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch39/ch39-fig-posterieures.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch39/ch39-fig-predictive.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch39/ch39-fig-prior-informatif.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch39/ch39-fig-retrecissement.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch39/ch39-fig-traces.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch40/ch40-fig-convergence.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch40/ch40-fig-distribution.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch40/ch40-fig-lhs.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch40/ch40-fig-nuage.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch40/ch40-fig-portefeuille.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch40/ch40-fig-rcd.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch40/ch40-fig-sobol.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch42/ch42-fig-bloc-piece.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch42/ch42-fig-cotisation-speciale.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch42/ch42-fig-grille-fraction.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch42/ch42-fig-medianes-rmr.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch43/ch43-fig-carte-cas4.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch43/ch43-fig-carte-riverain.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch43/ch43-fig-did-cas4.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch43/ch43-fig-effets-methodes.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch43/ch43-fig-placebos.pdf
+0 −0
Binary file not shown.
added
data/synthetic/ch43_riverain_lots.csv
+626 −0
Large diff (626 lines) is not loaded by default to keep the page fast.Load diff
added
data/synthetic/ch43_riverain_ventes.csv
+765 −0
Large diff (765 lines) is not loaded by default to keep the page fast.Load diff
modified
manuscript/chapters/ch34.tex
+12 −12
Large diff (70 lines) is not loaded by default to keep the page fast.Load diff
modified
manuscript/chapters/ch35.tex
+30 −27
Large diff (142 lines) is not loaded by default to keep the page fast.Load diff
added
references/lib-ch34.bib
+16 −0
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
added
references/lib-ch35.bib
+23 −0
Large diff (23 lines) is not loaded by default to keep the page fast.Load diff
modified
references/library.bib
+18 −0
Large diff (21 lines) is not loaded by default to keep the page fast.Load diff
modified
solutions/ch35.tex
+38 −0
Large diff (39 lines) is not loaded by default to keep the page fast.Load diff
modified
sources/registry.csv
+6 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
modified
verification/calculations.csv
+13 −0
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
added
verification/chapters/ch34-claims.csv
+24 −0
Large diff (24 lines) is not loaded by default to keep the page fast.Load diff
added
verification/chapters/ch34-registry.csv
+4 −0
Large diff (4 lines) is not loaded by default to keep the page fast.Load diff
added
verification/chapters/ch34.md
+35 −0
Large diff (35 lines) is not loaded by default to keep the page fast.Load diff
added
verification/chapters/ch35-calculations.csv
+14 −0
Large diff (14 lines) is not loaded by default to keep the page fast.Load diff
added
verification/chapters/ch35-claims.csv
+13 −0
Large diff (13 lines) is not loaded by default to keep the page fast.Load diff
added
verification/chapters/ch35-figures.csv
+6 −0
Large diff (6 lines) is not loaded by default to keep the page fast.Load diff
added
verification/chapters/ch35-registry.csv
+4 −0
Large diff (4 lines) is not loaded by default to keep the page fast.Load diff
modified
verification/claims.csv
+35 −0
Large diff (38 lines) is not loaded by default to keep the page fast.Load diff
modified
verification/figures.csv
+5 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff