Ch. 20 livré et validé (compilation isolée, tests, fusion des registres)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
69 changed files +5,128 −47
modified
analysis/python/cases/ch20_reconciliation.py
+5 −3
@@ -89,12 +89,12 @@ def valeur_ponderee(P_aj: np.ndarray, brut: np.ndarray, plancher: float = PLANCH | ||
| 89 | 89 | |
| 90 | 90 | |
| 91 | 91 | # ---------------------------------------------------------------- 2. bootstrap des comparables |
| 92 | −def bootstrap(b: dict, B: int = B, graine: int = GRAINE) -> dict: | |
| 92 | +def bootstrap(b: dict, B: int = B, graine: int = GRAINE, k: int | None = None) -> dict: | |
| 93 | 93 | """Trois rééchantillonnages : (A) les 16 candidats avec remise puis sélection des 5 plus proches (modèle fixe) ; |
| 94 | 94 | (B) les 600 ventes avec remise, réestimation du modèle, grille des 5 comparables fixes et prédiction du sujet ; |
| 95 | 95 | (C) les deux à la fois (même réplication b pour la corrélation) ; variante paramétrique β* ~ N(β̂, V_HC1).""" |
| 96 | 96 | rng = np.random.default_rng(graine) |
| 97 | − n_c = len(b["P16"]); k = len(b["comps"]) | |
| 97 | + n_c = len(b["P16"]); k = k or len(b["comps"]) | |
| 98 | 98 | g0 = grille_rapide(b["beta"], b["Xs"], b["Xc16"], b["P16"], b["G"]) |
| 99 | 99 | P_aj16, brut16 = g0["P_aj"], g0["brut"] |
| 100 | 100 | # (A) candidats |
@@ -335,7 +335,9 @@ def exercices(b: dict, bs: dict, co: dict, cb: dict) -> dict: | ||
| 335 | 335 | w2 = np.array([0.30, 0.55, 0.15]); w1 = np.array([POIDS_RAISONNES["comparaison"], POIDS_RAISONNES["hedonique"], POIDS_RAISONNES["cout"]]) |
| 336 | 336 | V = np.array([b["V_comp"], b["V_hed"], co["valeur"]]); V16v = np.array([V16, b["V_hed"], co["valeur"]]) |
| 337 | 337 | a = ARRONDI_CONCLUSION |
| 338 | − out["ex6"] = {"V_30_55_15": float(w2 @ V), "arrondi_30_55_15": round(float(w2 @ V) / a) * a, "V16": V16, | |
| 338 | + bs8 = bootstrap(b, B=B, k=8) | |
| 339 | + out["ex6"] = {"sd_conjoint_k8": bs8["resume"]["conjoint"]["ecart_type"], "V_k8": float(valeur_ponderee(bs["P_aj16"][:8], bs["brut16"][:8])), | |
| 340 | + "V_30_55_15": float(w2 @ V), "arrondi_30_55_15": round(float(w2 @ V) / a) * a, "V16": V16, | |
| 339 | 341 | "V_k16_raisonnee": float(w1 @ V16v), "arrondi_k16": round(float(w1 @ V16v) / a) * a, |
| 340 | 342 | "V_55_30_15": float(np.array([0.55, 0.30, 0.15]) @ V), "arrondi_55_30_15": round(float(np.array([0.55, 0.30, 0.15]) @ V) / a) * a} |
| 341 | 343 | # ex. 7 : incidence d'une hypothèse extraordinaire (eq. 10.incidence / 10.esperance) |
modified
analysis/python/cases/ch36_panel.py
+20 −1
@@ -236,6 +236,24 @@ def exercices() -> dict: | ||
| 236 | 236 | out["ex6"] = {"att": att, "moyenne_post": float(np.mean([att[2], att[3]]))} |
| 237 | 237 | return out |
| 238 | 238 | |
| 239 | +def exercice_7(se: pd.DataFrame) -> pd.DataFrame: | |
| 240 | + """Contrôle synthétique de la cohorte A selon le bassin de donneurs, et placebo temporel (mise en service fictive 2020T3).""" | |
| 241 | + W = se.pivot(index="id_immeuble", columns="t", values="y"); coh = se.groupby("id_immeuble")["cohorte"].first() | |
| 242 | + rmr = se.groupby("id_immeuble")["rmr"].first() | |
| 243 | + A = W.loc[coh[coh == "A"].index].mean(axis=0).values | |
| 244 | + rows = [] | |
| 245 | + for nom, ids, T0 in [("35 donneurs, six RMR", coh[coh == "jamais"].index, T_A), | |
| 246 | + ("9 donneurs, Montréal", coh[(coh == "jamais") & (rmr == RMR_A)].index, T_A), | |
| 247 | + ("26 donneurs, hors Montréal", coh[(coh == "jamais") & (rmr != RMR_A)].index, T_A), | |
| 248 | + ("placebo 2020T3, 35 donneurs", coh[coh == "jamais"].index, 18)]: | |
| 249 | + D = W.loc[ids]; Y0 = (D.values - D.values[:, :T0].mean(axis=1, keepdims=True)).T; A_c = A - A[:T0].mean() | |
| 250 | + r = pn.controle_synthetique(Y0, A_c, T0) | |
| 251 | + fin = T_A if T0 < T_A else W.shape[1] # placebo : écart moyen jusqu'à la vraie mise en service | |
| 252 | + effet = float(r["ecarts"][T0:fin].mean()) | |
| 253 | + w = pd.Series(r["poids"], index=ids); top = ", ".join(f"{i} {v:.2f}" for i, v in w.sort_values(ascending=False).head(3).items()) | |
| 254 | + rows.append({"variante": nom, "n_donneurs": len(ids), "rmspe_pre": r["rmspe_pre"], "effet_post": effet, "n_poids_sup_1pct": int((w > 0.01).sum()), "trois_premiers": top}) | |
| 255 | + return pd.DataFrame(rows) | |
| 256 | + | |
| 239 | 257 | # ----------------------------------------------------------------------------- orchestration |
| 240 | 258 | |
| 241 | 259 | def main(verbose: bool = True) -> dict: |
@@ -252,7 +270,7 @@ def main(verbose: bool = True) -> dict: | ||
| 252 | 270 | res["rdd"] = discontinuite() |
| 253 | 271 | res["sens"] = sensibilite(se); res["temoins"] = groupes_temoins(se) |
| 254 | 272 | res["labo"] = laboratoire() |
| 255 | − res["ex"] = exercices() | |
| 273 | + res["ex"] = exercices(); res["ex7"] = exercice_7(se) | |
| 256 | 274 | _CACHE["res"] = res |
| 257 | 275 | if verbose: imprimer(res) |
| 258 | 276 | return res |
@@ -279,6 +297,7 @@ def imprimer(res: dict) -> None: | ||
| 279 | 297 | print(sv["event_study"][["coef", "se_grappes"]].round(4).T) |
| 280 | 298 | print("Témoins :\n", res["temoins"].round(4)) |
| 281 | 299 | L = res["labo"]; print(f"Laboratoire : TWFE {L['twfe']:.4f} (se {L['se_grappes']:.4f}) ; vérité {L['verite']:.4f} (k {L['verite_k']:.4f}, l {L['verite_l']:.4f}) ; CS {L['cs']['globale']:.4f} (se {L['cs']['globale_se_boot']:.4f})\n{L['cs']['par_cohorte'].round(4)}\n{L['bacon']['table'].round(4)}\n pondéré {L['bacon']['beta_pondere']:.6f} TWFE {L['bacon']['beta_twfe']:.6f}") |
| 300 | + print("Exercice 7 :\n", res["ex7"].round(4).to_string()) | |
| 282 | 301 | print("Exercices :", {k: (vv if not isinstance(vv, dict) else {kk: (np.round(x, 4) if isinstance(x, (float, np.ndarray)) else x) for kk, x in vv.items()}) for k, vv in res["ex"].items()}) |
| 283 | 302 | |
| 284 | 303 | def controle_croise(res: dict) -> None: |
modified
analysis/python/cases/ch37_ml.py
+5 −6
@@ -17,7 +17,7 @@ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) | ||
| 17 | 17 | from traite import cas1 |
| 18 | 18 | from traite import hedonique as h |
| 19 | 19 | |
| 20 | −warnings.filterwarnings("ignore", category=FutureWarning) | |
| 20 | +warnings.filterwarnings("ignore", category=FutureWarning); warnings.filterwarnings("ignore", category=UserWarning) | |
| 21 | 21 | ROOT = pathlib.Path(__file__).resolve().parents[3] |
| 22 | 22 | GRAINE = 20261031 |
| 23 | 23 | N_ARBRES_FORET = 500 |
@@ -219,7 +219,7 @@ def importance_permutation(Xh: pd.DataFrame, Xm: pd.DataFrame, y: np.ndarray, pl | ||
| 219 | 219 | for nom, ctor in (("Gradient boosting", _boosting), ("Forêt aléatoire", _foret)): |
| 220 | 220 | gain = np.zeros(Xm.shape[1]); base_err = 0.0 |
| 221 | 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 | |
| 222 | + tr = np.setdiff1d(np.arange(n), f); m = ctor(); m.fit(Xm.iloc[tr].to_numpy(), y[tr]); Xt = Xm.iloc[f].to_numpy(); e0 = np.mean((m.predict(Xt) - y[f]) ** 2); base_err += e0 * len(f) / n | |
| 223 | 223 | for j in range(Xm.shape[1]): |
| 224 | 224 | acc = 0.0 |
| 225 | 225 | for _ in range(repetitions): |
@@ -309,16 +309,15 @@ def exercices(v: pd.DataFrame, comp: dict, secteurs: dict, scen: dict, sh: dict) | ||
| 309 | 309 | for _ in range(3): F = F + nu * (yv - F); traj.append(F) |
| 310 | 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 | 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} | |
| 312 | + vS = {frozenset(): 0.0, frozenset("S"): 60.0, frozenset("Q"): 30.0, frozenset("L"): 20.0, frozenset("SQ"): 100.0, frozenset("SL"): 85.0, frozenset("QL"): 45.0, frozenset("SQL"): 130.0} | |
| 313 | 313 | import itertools |
| 314 | 314 | joueurs = ["S", "Q", "L"]; phi = {} |
| 315 | 315 | for j in joueurs: |
| 316 | 316 | tot = 0.0 |
| 317 | 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] | |
| 318 | + avant = frozenset(perm[:perm.index(j)]); tot += vS[avant | {j}] - vS[avant] | |
| 320 | 319 | 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")]} | |
| 320 | + e["4"] = {"v": {"+".join(sorted(k)) if k else "∅": val for k, val in vS.items()}, "phi": phi, "somme": sum(phi.values()), "total": vS[frozenset("SQL")]} | |
| 322 | 321 | # 37.5 : dix prédictions d'un AVM fictif |
| 323 | 322 | prix = np.array([312000, 358500, 401000, 445000, 489500, 532000, 598000, 655000, 742000, 895000], float) |
| 324 | 323 | pred = np.array([335000, 349000, 388000, 468000, 471000, 561000, 575000, 702000, 718000, 839000], float) |
modified
analysis/python/cases/ch38_sig.py
+45 −0
@@ -348,5 +348,50 @@ def main(): | ||
| 348 | 348 | print("\n=== Capitalisation du risque (exemple)\n", r["capitalisation"].round(4).to_string()) |
| 349 | 349 | |
| 350 | 350 | |
| 351 | +# ----------------------------------------------------------------------------- exercices 6 et 7 (solutions/ch38.tex) | |
| 352 | + | |
| 353 | +def exercice_6(n_poly: int = 1000, rayon_km: float = 3.0) -> dict: | |
| 354 | + """Disques aléatoires de 3 km (1 000 tirages) pour les trois spécifications, puis comparaison classique / HC1 / HC3 à 0,5 km (référence).""" | |
| 355 | + r = tout(); b, v = r["base"], r["ventes"] | |
| 356 | + global RAYONS_POLYGONES_KM | |
| 357 | + sauve = RAYONS_POLYGONES_KM; RAYONS_POLYGONES_KM = [rayon_km] | |
| 358 | + try: | |
| 359 | + fp = faux_positifs(b, v, n_poly=n_poly)["tableau"] | |
| 360 | + finally: | |
| 361 | + RAYONS_POLYGONES_KM = sauve | |
| 362 | + rng = np.random.default_rng(GRAINE); X = _X_ref(v); y = np.log(v["prix_vente"].values.astype(float)); xy = b["xy_m"] | |
| 363 | + ts = {"classique": [], "HC1": [], "HC3": []} | |
| 364 | + for cc in rng.integers(0, len(v), size=N_POLYGONES_ALEATOIRES): | |
| 365 | + d = np.sqrt(((xy - xy[cc]) ** 2).sum(1)) / 1000; ind = (d <= 0.5).astype(float); n_in = int(ind.sum()) | |
| 366 | + if n_in < 10 or n_in > len(v) - 10: | |
| 367 | + continue | |
| 368 | + Xa = np.column_stack([X.values, ind]); m = h.mco(Xa, y); e = m["residus"] | |
| 369 | + XtXi = np.linalg.pinv(Xa.T @ Xa); lev = np.einsum("ij,jk,ik->i", Xa, XtXi, Xa) | |
| 370 | + V3 = XtXi @ ((Xa * ((e / (1 - lev)) ** 2)[:, None]).T @ Xa) @ XtXi | |
| 371 | + ts["classique"].append(m["beta"][-1] / m["se"][-1]); ts["HC1"].append(m["beta"][-1] / m["se_hc1"][-1]); ts["HC3"].append(m["beta"][-1] / np.sqrt(V3[-1, -1])) | |
| 372 | + comp = {k: {"n": len(t), "part_5pct": float(np.mean(np.abs(t) > 1.96)), "ecart_type": float(np.std(t))} for k, t in ts.items()} | |
| 373 | + return {"tableau_3km": fp, "comparaison_05km": comp} | |
| 374 | + | |
| 375 | + | |
| 376 | +def exercice_7() -> pd.DataFrame: | |
| 377 | + """Médiane du prix au m² par cellule pour trois rayons hexagonaux et une grille carrée : cellules, bornes de Jenks, GVF, part intercellulaire de la variance.""" | |
| 378 | + from shapely.geometry import Polygon, box | |
| 379 | + gpd = _gpd(); r = tout(); b, v = r["base"], r["ventes"]; xy = b["xy_m"] | |
| 380 | + pts = v[["geometry", "prix_vente", "superficie_habitable_m2"]].copy(); pts["prix_m2"] = pts["prix_vente"] / pts["superficie_habitable_m2"] | |
| 381 | + xmin, ymin, xmax, ymax = xy[:, 0].min(), xy[:, 1].min(), xy[:, 0].max(), xy[:, 1].max(); tot = ((pts["prix_m2"] - pts["prix_m2"].mean()) ** 2).sum() | |
| 382 | + def evaluer(cells, nom): | |
| 383 | + g = gpd.GeoDataFrame({"cellule": range(len(cells))}, geometry=cells, crs=EPSG_MTM9) | |
| 384 | + j = gpd.sjoin(pts, g, how="inner", predicate="within"); assert len(j) == len(pts) | |
| 385 | + agg = j.groupby("cellule").agg(n=("prix_m2", "count"), med=("prix_m2", "median"), moy=("prix_m2", "mean")); ok = agg[agg["n"] >= MIN_VENTES_CELLULE] | |
| 386 | + z = ok["med"].values; bj = geo.bornes_jenks(z, K_CLASSES); inter = float((agg["n"] * (agg["moy"] - pts["prix_m2"].mean()) ** 2).sum()) | |
| 387 | + return {"grille": nom, "cellules": len(g), "occupees": len(agg), "n_min5": len(ok), "bornes_jenks": np.round(bj, 0), "gvf": geo.gvf(z, bj), "part_inter": inter / tot} | |
| 388 | + rows = [] | |
| 389 | + for R in (800.0, 1200.0, 2000.0): | |
| 390 | + rows.append(evaluer([Polygon(cc) for cc in geo.grille_hexagonale(xmin - R, ymin - R, xmax, ymax, R)], f"hexagones R = {R:.0f} m")) | |
| 391 | + pas = 2000.0 | |
| 392 | + rows.append(evaluer([box(x, y_, x + pas, y_ + pas) for x in np.arange(xmin - 0.5 * pas, xmax + pas, pas) for y_ in np.arange(ymin - 0.5 * pas, ymax + pas, pas)], "carrés de 2 km")) | |
| 393 | + return pd.DataFrame(rows) | |
| 394 | + | |
| 395 | + | |
| 351 | 396 | if __name__ == "__main__": |
| 352 | 397 | main() |
modified
analysis/python/cases/ch40_montecarlo.py
+6 −3
@@ -356,11 +356,12 @@ def simuler_portefeuille(pf: pd.DataFrame, n: int = N_MC, graine: int = GRAINE, | ||
| 356 | 356 | return {"valeurs": V, "valeur_portefeuille": VP, "rne": rne, "rcd": rcd, "service_dette": sd, "stats": stats_, |
| 357 | 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)]})} |
| 358 | 358 | |
| 359 | −def comparer_structures_correlation(pf: pd.DataFrame, croissance: pd.DataFrame, n: int = N_MC) -> pd.DataFrame: | |
| 360 | − """Trois structures : indépendance (0, 0), corrélation régionale (0,30 ; 0,60), corrélation parfaite (1, 1).""" | |
| 359 | +def comparer_structures_correlation(pf: pd.DataFrame, croissance: pd.DataFrame | None, n: int = N_MC, **kw) -> pd.DataFrame: | |
| 360 | + """Trois structures : indépendance (0, 0), corrélation régionale (0,30 ; 0,60), corrélation parfaite (1, 1). kw → simuler_portefeuille | |
| 361 | + (p. ex. croissance=None, sigma_g_defaut=0.10 pour un choc de revenu de type récession).""" | |
| 361 | 362 | rows = [] |
| 362 | 363 | 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)]: |
| 363 | − s = simuler_portefeuille(pf, n, GRAINE, rho_rmr=rr, rho_prov=rp, croissance=croissance)["stats"] | |
| 364 | + s = simuler_portefeuille(pf, n, GRAINE, rho_rmr=rr, rho_prov=rp, croissance=croissance, **kw)["stats"] | |
| 364 | 365 | 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"]}}) |
| 365 | 366 | return pd.DataFrame(rows) |
| 366 | 367 | |
@@ -417,6 +418,7 @@ def tout(rapide: bool = False) -> dict: | ||
| 417 | 418 | D["lhs"] = comparer_lhs(r, n=1000, k=k_rep) |
| 418 | 419 | c4 = cas4.charger(); cr = cas4.croissance_rne(c4["series"]) |
| 419 | 420 | D["portefeuille"] = simuler_portefeuille(c4["portefeuille"], N_MC, GRAINE, croissance=cr); D["portefeuille_structures"] = comparer_structures_correlation(c4["portefeuille"], cr) |
| 421 | + D["portefeuille_recession"] = comparer_structures_correlation(c4["portefeuille"], None, sigma_g_defaut=0.10) # choc de revenu sigma_g = 10 %, g = 0 | |
| 420 | 422 | D["cas4"] = c4; D["cas4_croissance"] = cr |
| 421 | 423 | D["exercices"] = exercices(r, mc) |
| 422 | 424 | return D |
@@ -456,6 +458,7 @@ def imprimer(D: dict) -> None: | ||
| 456 | 458 | print({k: (round(v, 4) if isinstance(v, float) else v) for k, v in D["portefeuille"]["stats"].items()}) |
| 457 | 459 | print(D["portefeuille"]["par_rmr"].to_string(index=False)) |
| 458 | 460 | print("\nTrois structures de corrélation :\n", D["portefeuille_structures"].to_string(index=False)) |
| 461 | + print("\nTrois structures, choc de revenu sigma_g = 10 % (g = 0) :\n", D["portefeuille_recession"].to_string(index=False)) | |
| 459 | 462 | print("\n=== Exercices") |
| 460 | 463 | for k, v in D["exercices"].items(): print(k, v) |
| 461 | 464 | |
added
analysis/python/cases/ch41_phmu.py
+386 −0
@@ -0,0 +1,386 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""Chapitre 41 — Plus haute et meilleure utilisation, terrains et développement : calculs de l'exemple intégral. | |
| 3 | + | |
| 4 | +Sujet : le terrain de 620 m² du cas 1 (cottage de 1998, 165 m², secteur Hull de Gatineau ; DONNÉES SYNTHÉTIQUES, | |
| 5 | +data/synthetic/cas1_gatineau_*). Le zonage retenu pour l'exemple est FICTIF (zone « H-2f » : 1 à 3 logements). | |
| 6 | + | |
| 7 | +Quatre usages sont confrontés : | |
| 8 | + S0 — maison actuelle conservée (valeur dans l'usage actuel : conclusion du ch. 20, 600 000 $) ; | |
| 9 | + U — démolition et maison neuve de 165 m² (prix de sortie : modèle hédonique du cas 1, âge 0) ; | |
| 10 | + D — démolition et duplex (2 × 120 m², vendus en copropriété divise) ; | |
| 11 | + T — démolition et triplex (3 × 100 m²). | |
| 12 | +Pour chacun : quatre tests de la PHMU, méthode résiduelle de développement avec calendrier (financement intérimaire | |
| 13 | +explicite via traite.finance.interets_intermediaires, actualisation du résidu, contribution pour fins de parcs), | |
| 14 | +sensibilité, puis option réelle de redéveloppement (arbre binomial à deux périodes) et seuil de Capozza–Helsley. | |
| 15 | + | |
| 16 | +Réutilise : traite.cas1 (hedonique, predire), traite.finance (interets_intermediaires, marge_sur_prix, marge_sur_cout), | |
| 17 | +ch21_cout_neuf (devis du cas 1 : D + I), ch08_zonage (densite_admissible, contribution_parcs, prime_option, | |
| 18 | +valeur_residuelle_redeveloppement pour la version statique). | |
| 19 | + | |
| 20 | +Usage : python3 analysis/python/cases/ch41_phmu.py | |
| 21 | +""" | |
| 22 | +from __future__ import annotations | |
| 23 | +import sys, pathlib, math | |
| 24 | +import numpy as np | |
| 25 | +import pandas as pd | |
| 26 | + | |
| 27 | +ROOT = pathlib.Path(__file__).resolve().parents[3] | |
| 28 | +sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases")) | |
| 29 | +from traite import cas1, finance as fin # noqa: E402 | |
| 30 | +import ch21_cout_neuf as c21 # noqa: E402 | |
| 31 | +import ch08_zonage as c08 # noqa: E402 | |
| 32 | + | |
| 33 | +# ----------------------------------------------------------------------------- constantes partagées (provenance déclarée) | |
| 34 | +V_ACT = 600_000.0 # ch. 20 : valeur la plus probable conclue pour la maison du cas 1 (fourchette 575 000 – 640 000 $) | |
| 35 | +V_L_MARCHE = 277_000.0 # ch. 24/25 : valeur du terrain du sujet retenue par comparaison/extraction (fourchette 240 000 – 285 000 $) | |
| 36 | +IPCB_MAISON_T3_2026 = 116.5 # Statistique Canada 18-10-0289-01, Montréal, maison individuelle, T3 2026 (SRC-E-017) | |
| 37 | +IPCB_APPART_T3_2026 = 118.5 # idem, immeuble d'appartements de moins de cinq étages | |
| 38 | +TAUX_PRET = 0.0619 # taux hypothécaire affiché 5 ans, prêt conventionnel, 28 octobre 2026 (Banque du Canada, série V80691335 ; ch. 23) | |
| 39 | +PART_PRET = 0.75 # prêt de construction : 75 % des coûts (hypothèse, comme au ch. 23) | |
| 40 | +EFFICACITE = 0.85 # superficie habitable / aire brute de plancher (hypothèse, comme au ch. 23) | |
| 41 | + | |
| 42 | +# zone FICTIVE « H-2f » : hypothèses de zonage déclarées (grille calquée sur la colonne H2 de Val-Fictif, ch. 8, plafond ramené à 3 logements) | |
| 43 | +GRILLE = {"s_min_lot": 450.0, "s_min_par_logement": 150.0, "logements_max": 3, "cos_max": 0.60, "hauteur_max_etages": 3, | |
| 44 | + "stationnement_par_logement": 1, "marge_avant": 6.0, "marge_lat": 2.0, "marge_arr": 9.0} | |
| 45 | + | |
| 46 | +# hypothèses communes du promoteur (déclarées dans le texte ; sensibilité à la fin) | |
| 47 | +HYP = {"demolition": 30_000.0, # démolition, désamiantage sommaire, préparation du site (hypothèse, idem ch. 8) | |
| 48 | + "frais_vente": 0.04, # courtage, notaire, mise en marché : 4 % du produit de la vente (hypothèse) | |
| 49 | + "marge": 0.15, "marge_base": "prix", # marge du promoteur-marchand : 15 % du produit de la vente (hypothèse, idem ch. 8) | |
| 50 | + "taux_parcs": 0.10, # contribution pour fins de parcs exigée au redéveloppement : 10 % de la valeur du site (LAU 117.4, plafond) | |
| 51 | + "taux_pret": TAUX_PRET, "part_pret": PART_PRET, "r_portage": TAUX_PRET} | |
| 52 | + | |
| 53 | +# variante « construction sur commande » (acheteur-occupant qui fait bâtir) : la convention du ch. 21 — incitatif de 10 % des coûts de | |
| 54 | +# construction (directs, indirects, démolition, financement), pas de frais de vente (rien n'est vendu), pas de contribution pour fins de parcs | |
| 55 | +HYP_OCCUPANT = {**HYP, "frais_vente": 0.0, "marge": 0.10, "marge_base": "cout", "taux_parcs": 0.0} | |
| 56 | + | |
| 57 | +# programmes de construction (hypothèses) ; facteur_prix = décote déclarée d'une unité superposée/contiguë vendue en copropriété divise | |
| 58 | +# par rapport à la maison isolée neuve de même superficie que prédit le modèle hédonique (1,00 = maison isolée) | |
| 59 | +PROGRAMMES = { | |
| 60 | + "S0": {"nom": "Maison actuelle conservée", "n": 1, "s_unite": 165.0, "garage": 1, "sous_sol": 1, "facteur_prix": 1.0, "duree_ans": 0.0, "neuf": False}, | |
| 61 | + "U": {"nom": "Maison neuve de 165 m²", "n": 1, "s_unite": 165.0, "garage": 1, "sous_sol": 1, "facteur_prix": 1.00, "duree_ans": 1.00, "neuf": True}, | |
| 62 | + "D": {"nom": "Duplex, 2 × 120 m²", "n": 2, "s_unite": 120.0, "garage": 0, "sous_sol": 1, "facteur_prix": 0.90, "duree_ans": 1.25, "neuf": True}, | |
| 63 | + "T": {"nom": "Triplex, 3 × 100 m²", "n": 3, "s_unite": 100.0, "garage": 0, "sous_sol": 1, "facteur_prix": 0.85, "duree_ans": 1.50, "neuf": True}, | |
| 64 | + "Q": {"nom": "Quadruplex, 4 × 85 m²", "n": 4, "s_unite": 85.0, "garage": 0, "sous_sol": 1, "facteur_prix": 0.80, "duree_ans": 1.75, "neuf": True}, | |
| 65 | +} | |
| 66 | + | |
| 67 | + | |
| 68 | +# ----------------------------------------------------------------------------- intrants : coûts (ch. 21) et prix de sortie (cas 1) | |
| 69 | + | |
| 70 | +def couts_unitaires() -> dict: | |
| 71 | + """Coût direct + indirect par m² habitable du devis du cas 1 (ch. 21), et transposition aux plex par le ratio IPCB (ch. 23).""" | |
| 72 | + devis, sujet = c21.charger(); cn = c21.cout_neuf_sujet(devis) | |
| 73 | + hab = float(sujet["superficie_habitable_m2"]) | |
| 74 | + di_m2 = (cn["direct"] + cn["indirects"]) / hab | |
| 75 | + ratio = IPCB_APPART_T3_2026 / IPCB_MAISON_T3_2026 | |
| 76 | + return {"direct": cn["direct"], "indirects": cn["indirects"], "incitatif": cn["incitatif"], "cout_neuf": cn["total"], | |
| 77 | + "di_m2_maison": di_m2, "ratio_ipcb_plex": ratio, "di_m2_plex": di_m2 * ratio, "cn_m2_maison": cn["total"] / hab, "sujet": sujet} | |
| 78 | + | |
| 79 | + | |
| 80 | +def modele_cas1() -> dict: | |
| 81 | + d = cas1.charger(); m = cas1.hedonique(d["ventes"]); return {"modele": m, "sujet": d["sujet"]} | |
| 82 | + | |
| 83 | + | |
| 84 | +def prix_unite(m: dict, sujet: dict, s_unite: float, n: int, garage: int, sous_sol: int, neuf: bool = True) -> float: | |
| 85 | + """Prix prédit par le modèle hédonique du cas 1 pour une habitation isolée neuve de s_unite m² sur 620/n m² de terrain, | |
| 86 | + à Hull, à la date d'évaluation (correction de Duan incluse). La décote d'unité superposée est appliquée ensuite (facteur_prix).""" | |
| 87 | + x = dict(sujet); x.update({"superficie_habitable_m2": s_unite, "superficie_terrain_m2": sujet["superficie_terrain_m2"] / n, | |
| 88 | + "garage_places": garage, "sous_sol_fini": sous_sol}) | |
| 89 | + if neuf: | |
| 90 | + x["age"] = 0; x["annee_construction"] = 2026 | |
| 91 | + return float(cas1.predire(m, cas1.sujet_df(x))[0]) | |
| 92 | + | |
| 93 | + | |
| 94 | +def produit_vente(m: dict, sujet: dict, prog: dict) -> dict: | |
| 95 | + p_isolee = prix_unite(m, sujet, prog["s_unite"], prog["n"], prog["garage"], prog["sous_sol"], prog["neuf"]) | |
| 96 | + p_unite = p_isolee * prog["facteur_prix"] | |
| 97 | + return {"prix_isolee_equiv": p_isolee, "prix_unite": p_unite, "gdv": p_unite * prog["n"], "s_total": prog["s_unite"] * prog["n"]} | |
| 98 | + | |
| 99 | + | |
| 100 | +# ----------------------------------------------------------------------------- les quatre tests | |
| 101 | + | |
| 102 | +def test_legal(prog: dict, grille: dict = GRILLE, s_terrain: float = 620.0) -> dict: | |
| 103 | + g = c08.densite_admissible(s_terrain, grille["s_min_par_logement"], grille["logements_max"], grille["cos_max"], grille["s_min_lot"]) | |
| 104 | + brut = prog["n"] * prog["s_unite"] / EFFICACITE | |
| 105 | + ok_n = prog["n"] <= g["logements_admissibles"]; ok_cos = brut <= g["aire_plancher_max_m2"] | |
| 106 | + return {"logements_admissibles": g["logements_admissibles"], "aire_max_m2": g["aire_plancher_max_m2"], "aire_brute_m2": brut, | |
| 107 | + "ok_logements": ok_n, "ok_cos": ok_cos, "permis": bool(g["lot_conforme"] and ok_n and ok_cos)} | |
| 108 | + | |
| 109 | + | |
| 110 | +def test_physique(prog: dict, grille: dict = GRILLE, facade_m: float = 20.0, profondeur_m: float = 31.0) -> dict: | |
| 111 | + """Enveloppe constructible après marges (façade et profondeur du lot : hypothèses, le dossier du cas ne les précise pas) ; | |
| 112 | + stationnement : 1 case par logement de 2,6 m × 5,5 m en cour avant/latérale (hypothèse).""" | |
| 113 | + largeur = facade_m - 2 * grille["marge_lat"]; prof = profondeur_m - grille["marge_avant"] - grille["marge_arr"] | |
| 114 | + empreinte_max = largeur * prof | |
| 115 | + brut = prog["n"] * prog["s_unite"] / EFFICACITE | |
| 116 | + etages = math.ceil(brut / empreinte_max) if empreinte_max > 0 else 99 | |
| 117 | + ok_etages = etages <= grille["hauteur_max_etages"] | |
| 118 | + cases = prog["n"] * grille["stationnement_par_logement"]; ok_stat = cases * 2.6 <= largeur + 2 * grille["marge_lat"] # cases côte à côte en façade | |
| 119 | + return {"empreinte_max_m2": empreinte_max, "etages_requis": etages, "ok_etages": ok_etages, "cases": cases, "ok_stationnement": ok_stat, | |
| 120 | + "possible": bool(ok_etages and ok_stat)} | |
| 121 | + | |
| 122 | + | |
| 123 | +# ----------------------------------------------------------------------------- méthode résiduelle de développement (dynamique) | |
| 124 | + | |
| 125 | +def residuel_developpement(gdv: float, s_total: float, di_m2: float, duree_ans: float, hyp: dict = HYP, demolir: bool = True) -> dict: | |
| 126 | + """Valeur résiduelle du terrain avec calendrier : | |
| 127 | + V_L = [GDV(1 − c_v) − C − D − F − m·GDV] / [(1 + τ_parcs)(1 + r)^T] | |
| 128 | + où C = coûts directs + indirects (di_m2 × s_total), D = démolition, F = intérêts intérimaires du prêt de construction | |
| 129 | + (tirage uniforme, mise de fonds d'abord), m = marge sur le prix, τ_parcs = contribution pour fins de parcs (assise sur | |
| 130 | + la valeur du site), r = taux de portage du terrain, T = durée totale (chantier + vente). r = 0 redonne la version | |
| 131 | + statique du ch. 8 (contribution incluse) ; r = 0 et τ = 0 redonnent le résidu « de manuel ».""" | |
| 132 | + C = di_m2 * s_total; D = hyp["demolition"] if demolir else 0.0 | |
| 133 | + couts_fin = C + D | |
| 134 | + pret = hyp["part_pret"] * couts_fin | |
| 135 | + F = fin.interets_intermediaires(pret, hyp["taux_pret"], duree_ans, mode="continu", mise_de_fonds=couts_fin - pret, cout_total=couts_fin)["interets"] if duree_ans > 0 else 0.0 | |
| 136 | + frais = hyp["frais_vente"] * gdv | |
| 137 | + marge = hyp["marge"] * gdv if hyp.get("marge_base", "prix") == "prix" else hyp["marge"] * (C + D + F) | |
| 138 | + reste = gdv - frais - C - D - F - marge | |
| 139 | + facteur_temps = (1.0 + hyp["r_portage"]) ** duree_ans | |
| 140 | + v_l = reste / ((1.0 + hyp["taux_parcs"]) * facteur_temps) | |
| 141 | + return {"gdv": gdv, "frais_vente": frais, "couts_construction": C, "demolition": D, "pret": pret, "financement": F, "marge": marge, | |
| 142 | + "reste": reste, "facteur_temps": facteur_temps, "valeur_residuelle": v_l, "contribution_parcs": v_l * hyp["taux_parcs"], | |
| 143 | + "reste_statique": reste, "v_l_statique": reste / (1.0 + hyp["taux_parcs"]), "cout_total_hors_terrain": C + D + F + frais + marge, | |
| 144 | + "marge_sur_cout": marge / (C + D + F + frais + v_l) if (C + D + F + frais + v_l) > 0 else float("nan")} | |
| 145 | + | |
| 146 | + | |
| 147 | +def seuil_gdv(res: dict, v_cible: float, hyp: dict = HYP) -> float: | |
| 148 | + """Produit de vente qui rend la valeur résiduelle égale à v_cible (résolution de la forme linéaire en GDV).""" | |
| 149 | + a, b = _a_b(res, hyp) | |
| 150 | + return (v_cible + b) / a | |
| 151 | + | |
| 152 | + | |
| 153 | +def _a_b(res: dict, hyp: dict) -> tuple[float, float]: | |
| 154 | + """Forme linéaire du résidu : V_L = a·GDV − b. La marge entre dans a si elle est assise sur le prix, dans b si elle est assise sur le coût.""" | |
| 155 | + den = (1.0 + hyp["taux_parcs"]) * res["facteur_temps"] | |
| 156 | + fixes = res["couts_construction"] + res["demolition"] + res["financement"] | |
| 157 | + if hyp.get("marge_base", "prix") == "prix": | |
| 158 | + return (1.0 - hyp["frais_vente"] - hyp["marge"]) / den, fixes / den | |
| 159 | + return (1.0 - hyp["frais_vente"]) / den, fixes * (1.0 + hyp["marge"]) / den | |
| 160 | + | |
| 161 | + | |
| 162 | +# ----------------------------------------------------------------------------- option réelle : arbre binomial | |
| 163 | + | |
| 164 | +def parametres_binomiaux(sigma: float, dt: float, r: float, y: float) -> dict: | |
| 165 | + """u = e^{σ√Δt}, d = 1/u ; probabilité risque-neutre d'un actif à rendement de détention y : | |
| 166 | + q = (F − d)/(u − d), F = [(1 + r)/(1 + y)]^Δt (facteur de croissance à terme).""" | |
| 167 | + u = math.exp(sigma * math.sqrt(dt)); d = 1.0 / u | |
| 168 | + F = ((1.0 + r) / (1.0 + y)) ** dt | |
| 169 | + q = (F - d) / (u - d) | |
| 170 | + return {"u": u, "d": d, "F": F, "q": q, "disc": (1.0 + r) ** (-dt)} | |
| 171 | + | |
| 172 | + | |
| 173 | +def option_redeveloppement(gdv0: float, a: float, b: float, sigma: float, dt: float, r: float, y: float, n: int = 2) -> dict: | |
| 174 | + """Option américaine de redéveloppement sur n périodes de Δt années. À chaque nœud, le propriétaire peut vendre à un | |
| 175 | + promoteur (ou se faire promoteur) pour V_L(GDV) = a·GDV − b' ; la prime par rapport à la valeur en usage V_act est | |
| 176 | + max(a·GDV − b, 0) avec b = b' + V_act. Rend la prime (américaine et européenne), la règle VAN immédiate et la politique | |
| 177 | + d'exercice par nœud.""" | |
| 178 | + p = parametres_binomiaux(sigma, dt, r, y); u, d, q, disc = p["u"], p["d"], p["q"], p["disc"] | |
| 179 | + payoff = lambda g: max(a * g - b, 0.0) | |
| 180 | + noeuds = {} | |
| 181 | + V = [payoff(gdv0 * u ** j * d ** (n - j)) for j in range(n + 1)] | |
| 182 | + for j in range(n + 1): | |
| 183 | + noeuds[(n, j)] = {"gdv": gdv0 * u ** j * d ** (n - j), "exercice": V[j], "continuation": None, "valeur": V[j]} | |
| 184 | + V_eur = list(V) | |
| 185 | + for t in range(n - 1, -1, -1): | |
| 186 | + Vn, Vn_eur = [], [] | |
| 187 | + for j in range(t + 1): | |
| 188 | + g = gdv0 * u ** j * d ** (t - j) | |
| 189 | + cont = disc * (q * V[j + 1] + (1 - q) * V[j]); ex = payoff(g) | |
| 190 | + Vn.append(max(ex, cont)); Vn_eur.append(disc * (q * V_eur[j + 1] + (1 - q) * V_eur[j])) | |
| 191 | + noeuds[(t, j)] = {"gdv": g, "exercice": ex, "continuation": cont, "valeur": max(ex, cont), "exercer": ex > cont and ex > 0} | |
| 192 | + V, V_eur = Vn, Vn_eur | |
| 193 | + return {**p, "prime_americaine": V[0], "prime_europeenne": V_eur[0], "van_immediate": payoff(gdv0), "noeuds": noeuds, | |
| 194 | + "gdv_seuil_van": b / a, "a": a, "b": b, "n": n, "dt": dt, "sigma": sigma, "r": r, "y": y} | |
| 195 | + | |
| 196 | + | |
| 197 | +def coefficients_option(res: dict, v_act: float, hyp: dict = HYP) -> tuple[float, float]: | |
| 198 | + """V_L(GDV) = a·GDV − b' (forme linéaire du résidu) ; prime d'exercice = a·GDV − (b' + V_act).""" | |
| 199 | + a, b_prime = _a_b(res, hyp) | |
| 200 | + return a, b_prime + v_act | |
| 201 | + | |
| 202 | + | |
| 203 | +def frontiere_exercice_t1(a: float, b: float, sigma: float, dt: float, r: float, y: float) -> float: | |
| 204 | + """Plus petit GDV au nœud t = 1 (arbre à deux périodes) où l'exercice immédiat vaut au moins la continuation (bissection).""" | |
| 205 | + p = parametres_binomiaux(sigma, dt, r, y); u, d, q, disc = p["u"], p["d"], p["q"], p["disc"] | |
| 206 | + f = lambda g: (a * g - b) - disc * (q * max(a * g * u - b, 0.0) + (1 - q) * max(a * g * d - b, 0.0)) | |
| 207 | + lo, hi = b / a, 50 * b / a | |
| 208 | + if f(hi) < 0: | |
| 209 | + return float("inf") | |
| 210 | + for _ in range(200): | |
| 211 | + mid = 0.5 * (lo + hi) | |
| 212 | + if f(mid) >= 0: | |
| 213 | + hi = mid | |
| 214 | + else: | |
| 215 | + lo = mid | |
| 216 | + return hi | |
| 217 | + | |
| 218 | + | |
| 219 | +# ----------------------------------------------------------------------------- Capozza–Helsley (version simplifiée) | |
| 220 | + | |
| 221 | +def seuil_capozza_helsley(R_A: float, C: float, r: float, g: float, sigma: float) -> dict: | |
| 222 | + """Rente de réserve R* déclenchant la conversion lorsque la rente du nouvel usage suit dR = g dt + σ dz : | |
| 223 | + R* = R_A + rC − g/r + 1/α, α = [−g + sqrt(g² + 2 r σ²)] / σ² (σ → 0 : R* → R_A + rC). | |
| 224 | + Prime d'irréversibilité = 1/α − g/r (nulle sans incertitude).""" | |
| 225 | + if sigma <= 0: | |
| 226 | + alpha = r / g if g > 0 else float("inf"); prime = 0.0 | |
| 227 | + else: | |
| 228 | + alpha = 2.0 * r / (g + math.sqrt(g * g + 2.0 * r * sigma * sigma)) # forme rationalisée de [−g + √(g² + 2rσ²)]/σ², stable quand σ → 0 | |
| 229 | + prime = 1.0 / alpha - g / r | |
| 230 | + R_cert = R_A + r * C | |
| 231 | + return {"alpha": alpha, "R_certitude": R_cert, "prime_irreversibilite": prime, "R_seuil": R_cert + prime} | |
| 232 | + | |
| 233 | + | |
| 234 | +# ----------------------------------------------------------------------------- exemple intégral | |
| 235 | + | |
| 236 | +OPTION = {"sigma": 0.10, "dt": 5.0, "r": 0.02, "y": 0.045, "n": 2} # volatilité réelle annuelle du prix des unités, pas de 5 ans, taux réel, rendement locatif net | |
| 237 | + | |
| 238 | + | |
| 239 | +def prix_logement_plex_existant() -> dict: | |
| 240 | + """Prix par logement des triplex existants du cas 2 (Québec, Limoilou ; traite.cas2) — contrepoint « vente en bloc ».""" | |
| 241 | + from traite import cas2 | |
| 242 | + d = cas2.charger(); c = cas2.comparables_plex(d["ventes"], d["sujet"]); ind = cas2.indicateurs_marche(c) | |
| 243 | + return {"prix_par_logement_triplex": ind["prix_par_logement_triplex"], "mrb_triplex": ind["mrb_median_triplex"], "n": ind["n"]} | |
| 244 | + | |
| 245 | + | |
| 246 | +def exemple_complet() -> dict: | |
| 247 | + cu = couts_unitaires(); mc = modele_cas1(); m, sujet = mc["modele"], mc["sujet"] | |
| 248 | + rows, res, tests = [], {}, {} | |
| 249 | + for k, prog in PROGRAMMES.items(): | |
| 250 | + pv = produit_vente(m, sujet, prog) if prog["neuf"] else {"prix_isolee_equiv": float(cas1.predire(m, cas1.sujet_df(sujet))[0]), "prix_unite": V_ACT, "gdv": V_ACT, "s_total": 165.0} | |
| 251 | + tl = test_legal(prog); tp = test_physique(prog) | |
| 252 | + di = cu["di_m2_maison"] if prog["n"] == 1 else cu["di_m2_plex"] | |
| 253 | + if prog["neuf"]: | |
| 254 | + r_prom = residuel_developpement(pv["gdv"], pv["s_total"], di, prog["duree_ans"], HYP) | |
| 255 | + r_occ = residuel_developpement(pv["gdv"], pv["s_total"], di, prog["duree_ans"], HYP_OCCUPANT) | |
| 256 | + else: | |
| 257 | + r_prom = r_occ = None | |
| 258 | + res[k] = {"programme": prog, "vente": pv, "legal": tl, "physique": tp, "promoteur": r_prom, "occupant": r_occ, "di_m2": di} | |
| 259 | + rows.append({"usage": k, "nom": prog["nom"], "n": prog["n"], "s_total": pv["s_total"], "prix_unite": pv["prix_unite"], "gdv": pv["gdv"], | |
| 260 | + "permis": tl["permis"], "possible": tp["possible"], | |
| 261 | + "v_l_promoteur": r_prom["valeur_residuelle"] if r_prom else float("nan"), | |
| 262 | + "v_l_occupant": r_occ["valeur_residuelle"] if r_occ else float("nan")}) | |
| 263 | + tableau = pd.DataFrame(rows) | |
| 264 | + # contrepoint : triplex vendu en bloc à un investisseur au prix par logement des triplex existants du cas 2 | |
| 265 | + bloc = prix_logement_plex_existant(); uT = res["T"] | |
| 266 | + gdv_bloc = bloc["prix_par_logement_triplex"] * 3 | |
| 267 | + res["T_bloc"] = {"gdv": gdv_bloc, "vente": {"gdv": gdv_bloc}, "promoteur": residuel_developpement(gdv_bloc, uT["vente"]["s_total"], uT["di_m2"], uT["programme"]["duree_ans"], HYP), **bloc} | |
| 268 | + # PHMU du terrain vacant : usages permis et possibles, résidu positif ; maximalement productif = plus grande valeur résiduelle | |
| 269 | + # (marge du promoteur pour les plex ; marge du constructeur sur commande pour la maison, usage dont l'acheteur type est un occupant) | |
| 270 | + cand = tableau[(tableau.usage != "S0") & tableau.permis & tableau.possible].copy() | |
| 271 | + cand["v_l_retenue"] = np.where(cand.n == 1, cand.v_l_occupant, cand.v_l_promoteur) | |
| 272 | + cand["faisable"] = cand["v_l_retenue"] > 0 | |
| 273 | + cand_f = cand[cand.faisable] | |
| 274 | + phmu_vacant = cand_f.loc[cand_f.v_l_retenue.idxmax(), "usage"] | |
| 275 | + phmu_vacant_promoteur = cand_f.loc[cand_f.v_l_promoteur.idxmax(), "usage"] | |
| 276 | + # PHMU du bien amélioré : max(V_act, V_L − D) — la démolition est déjà incluse dans V_L | |
| 277 | + meilleure_redev = cand["v_l_promoteur"].max() | |
| 278 | + primes = {u: c08.prime_option(V_ACT, v) for u, v in zip(cand.usage, cand.v_l_promoteur)} | |
| 279 | + phmu_ameliore = "S0" if V_ACT >= meilleure_redev else cand.loc[cand.v_l_promoteur.idxmax(), "usage"] | |
| 280 | + contribution_batiment = V_ACT - V_L_MARCHE | |
| 281 | + # seuils de bascule (triplex, convention promoteur) | |
| 282 | + rT = res["T"]["promoteur"]; seuil_T = seuil_gdv(rT, V_ACT); hausse_T = seuil_T / rT["gdv"] - 1 | |
| 283 | + seuil_T_marche = seuil_gdv(rT, V_L_MARCHE); hausse_T_marche = seuil_T_marche / rT["gdv"] - 1 | |
| 284 | + # option réelle (triplex) : arbre binomial à deux périodes de 5 ans | |
| 285 | + a, b = coefficients_option(rT, V_ACT); O = OPTION | |
| 286 | + opt = option_redeveloppement(rT["gdv"], a, b, O["sigma"], O["dt"], O["r"], O["y"], O["n"]) | |
| 287 | + front = frontiere_exercice_t1(a, b, O["sigma"], O["dt"], O["r"], O["y"]) | |
| 288 | + # sensibilité de l'option : σ et V_act | |
| 289 | + sens_opt = {} | |
| 290 | + for v_act in (600_000.0, 450_000.0, 300_000.0): | |
| 291 | + a_, b_ = coefficients_option(rT, v_act) | |
| 292 | + sens_opt[v_act] = {s: option_redeveloppement(rT["gdv"], a_, b_, s, O["dt"], O["r"], O["y"], O["n"])["prime_americaine"] for s in (0.05, 0.10, 0.15, 0.20, 0.25)} | |
| 293 | + # Capozza–Helsley : rente implicite de la maison R_A = y·V_act ; rente du triplex R = y·GDV ; coût de conversion C = coûts + démolition + financement | |
| 294 | + ch = {"R_A": O["y"] * V_ACT, "R_T": O["y"] * rT["gdv"], "C": rT["couts_construction"] + rT["demolition"] + rT["financement"]} | |
| 295 | + ch["g"] = 0.01 * ch["R_T"]; ch["sigma"] = 0.10 * ch["R_T"] | |
| 296 | + ch["resultat"] = seuil_capozza_helsley(ch["R_A"], ch["C"], O["y"], ch["g"], ch["sigma"]) | |
| 297 | + ch["hausse_requise_certitude"] = ch["resultat"]["R_certitude"] / ch["R_T"] - 1; ch["hausse_requise_incertitude"] = ch["resultat"]["R_seuil"] / ch["R_T"] - 1 | |
| 298 | + # sensibilité de la valeur résiduelle du triplex (convention promoteur) | |
| 299 | + sens = sensibilite_triplex(res["T"], cu) | |
| 300 | + return {"couts": cu, "modele": m, "sujet": sujet, "usages": res, "tableau": tableau, "candidats": cand, "phmu_vacant": phmu_vacant, | |
| 301 | + "phmu_vacant_promoteur": phmu_vacant_promoteur, "phmu_ameliore": phmu_ameliore, "primes": primes, "contribution_batiment": contribution_batiment, | |
| 302 | + "seuil_gdv_T": seuil_T, "hausse_requise_T": hausse_T, "seuil_gdv_T_marche": seuil_T_marche, "hausse_requise_T_marche": hausse_T_marche, | |
| 303 | + "option": opt, "a": a, "b": b, "frontiere_t1": front, "sens_option": sens_opt, "capozza": ch, "sensibilite": sens, "v_act": V_ACT, "v_l_marche": V_L_MARCHE} | |
| 304 | + | |
| 305 | + | |
| 306 | +def sensibilite_triplex(uT: dict, cu: dict) -> pd.DataFrame: | |
| 307 | + """Variations une à une autour du cas de base (triplex, convention promoteur).""" | |
| 308 | + prog = uT["programme"]; pv = uT["vente"]; base = uT["promoteur"]["valeur_residuelle"] | |
| 309 | + def run(gdv=pv["gdv"], di=cu["di_m2_plex"], duree=prog["duree_ans"], **kw): | |
| 310 | + h = {**HYP, **kw}; return residuel_developpement(gdv, pv["s_total"], di, duree, h)["valeur_residuelle"] | |
| 311 | + lignes = [ | |
| 312 | + ("Prix des unités", "−15 %", run(gdv=pv["gdv"] * 0.85), "+15 %", run(gdv=pv["gdv"] * 1.15)), | |
| 313 | + ("Coût de construction", "−10 %", run(di=cu["di_m2_plex"] * 0.90), "+10 %", run(di=cu["di_m2_plex"] * 1.10)), | |
| 314 | + ("Marge du promoteur", "10 % du prix", run(marge=0.10), "20 % du prix", run(marge=0.20)), | |
| 315 | + ("Durée totale", "12 mois", run(duree=1.0), "24 mois", run(duree=2.0)), | |
| 316 | + ("Taux (prêt et portage)", "5,00 %", run(taux_pret=0.05, r_portage=0.05), "7,50 %", run(taux_pret=0.075, r_portage=0.075)), | |
| 317 | + ("Contribution pour fins de parcs", "0 %", run(taux_parcs=0.0), "10 %", base), | |
| 318 | + ("Frais de vente", "2 %", run(frais_vente=0.02), "6 %", run(frais_vente=0.06)), | |
| 319 | + ] | |
| 320 | + return pd.DataFrame(lignes, columns=["parametre", "bas_etiquette", "bas", "haut_etiquette", "haut"]).assign(base=base) | |
| 321 | + | |
| 322 | + | |
| 323 | +def courbe_densite(res: dict, cu: dict, facteurs_prix=(0.85, 1.0, 1.15)) -> pd.DataFrame: | |
| 324 | + """Valeur résiduelle (convention promoteur) selon le nombre de logements, pour plusieurs niveaux de prix.""" | |
| 325 | + rows = [] | |
| 326 | + for k in ("U", "D", "T", "Q"): | |
| 327 | + u = res["usages"][k]; prog = u["programme"] | |
| 328 | + for f in facteurs_prix: | |
| 329 | + r = residuel_developpement(u["vente"]["gdv"] * f, u["vente"]["s_total"], u["di_m2"], prog["duree_ans"], HYP) | |
| 330 | + rows.append({"usage": k, "n": prog["n"], "facteur_prix": f, "v_l": r["valeur_residuelle"], "permis": u["legal"]["permis"]}) | |
| 331 | + return pd.DataFrame(rows) | |
| 332 | + | |
| 333 | + | |
| 334 | +def surface_prix_cout(uT: dict, cu: dict, fp=np.linspace(0.80, 1.40, 61), fc=np.linspace(0.80, 1.20, 41)) -> tuple[np.ndarray, np.ndarray, np.ndarray]: | |
| 335 | + prog = uT["programme"]; pv = uT["vente"] | |
| 336 | + Z = np.array([[residuel_developpement(pv["gdv"] * p, pv["s_total"], cu["di_m2_plex"] * c, prog["duree_ans"], HYP)["valeur_residuelle"] for p in fp] for c in fc]) | |
| 337 | + return fp, fc, Z | |
| 338 | + | |
| 339 | + | |
| 340 | +def _d(x): return f"{x:,.0f} $".replace(",", " ") | |
| 341 | + | |
| 342 | + | |
| 343 | +def main() -> dict: | |
| 344 | + R = exemple_complet(); cu = R["couts"] | |
| 345 | + print("=== Ch. 41 — PHMU du terrain du cas 1 (620 m², Hull ; DONNÉES SYNTHÉTIQUES ; zonage FICTIF H-2f) ===") | |
| 346 | + print(f"Coûts ch. 21 : D = {_d(cu['direct'])} ; I = {_d(cu['indirects'])} ; D+I/m² hab. = {cu['di_m2_maison']:.2f} $/m² ; ratio IPCB plex {cu['ratio_ipcb_plex']:.4f} → {cu['di_m2_plex']:.2f} $/m²") | |
| 347 | + print(f"V_act (ch. 20) = {_d(R['v_act'])} ; V_L marché (ch. 24/25) = {_d(R['v_l_marche'])} ; contribution du bâtiment = {_d(R['contribution_batiment'])}") | |
| 348 | + print("\n--- Programmes, tests et produits de vente ---") | |
| 349 | + for k, u in R["usages"].items(): | |
| 350 | + if "legal" not in u: | |
| 351 | + continue | |
| 352 | + tl, tp, pv = u["legal"], u["physique"], u["vente"] | |
| 353 | + print(f"{k:>2} {u['programme']['nom']:<28} n={u['programme']['n']} S={pv['s_total']:.0f} m² brut={tl['aire_brute_m2']:.0f} (max {tl['aire_max_m2']:.0f}) " | |
| 354 | + f"permis={tl['permis']} possible={tp['possible']} (étages {tp['etages_requis']}, cases {tp['cases']}) | prix isolée équiv. {_d(pv['prix_isolee_equiv'])} → unité {_d(pv['prix_unite'])} ; GDV {_d(pv['gdv'])}") | |
| 355 | + print("\n--- Résidu de développement (convention promoteur : marge 15 % du prix, frais de vente 4 %, parcs 10 %, prêt 75 % à 6,19 %, portage 6,19 %) ---") | |
| 356 | + for k in ("U", "D", "T", "Q"): | |
| 357 | + r = R["usages"][k]["promoteur"] | |
| 358 | + print(f"{k}: GDV {_d(r['gdv'])} − frais {_d(r['frais_vente'])} − C {_d(r['couts_construction'])} − démol. {_d(r['demolition'])} − F {_d(r['financement'])} − marge {_d(r['marge'])} = reste {_d(r['reste'])} ; " | |
| 359 | + f"/(1,10 × {r['facteur_temps']:.4f}) → V_L = {_d(r['valeur_residuelle'])} (parcs {_d(r['contribution_parcs'])}) ; statique {_d(r['v_l_statique'])}") | |
| 360 | + print("--- Convention constructeur sur commande (incitatif 10 % des coûts de construction, pas de frais de vente ni de parcs) ---") | |
| 361 | + for k in ("U", "D", "T"): | |
| 362 | + r = R["usages"][k]["occupant"]; print(f"{k}: V_L = {_d(r['valeur_residuelle'])} (marge {_d(r['marge'])}, reste {_d(r['reste'])}, F {_d(r['financement'])})") | |
| 363 | + tb = R["usages"]["T_bloc"]; rb = tb["promoteur"] | |
| 364 | + print(f"--- Triplex vendu en bloc (cas 2 : {_d(tb['prix_par_logement_triplex'])} par logement, n = {tb['n']} ventes) : GDV {_d(tb['gdv'])} → reste {_d(rb['reste'])} → V_L = {_d(rb['valeur_residuelle'])}") | |
| 365 | + print(f"\nPHMU du terrain vacant : {R['phmu_vacant']} (par résidu promoteur seul : {R['phmu_vacant_promoteur']}) ; PHMU du bien amélioré : {R['phmu_ameliore']}") | |
| 366 | + print("Primes d'option immédiates max(0, V_L − V_act) : " + ", ".join(f"{u} {_d(p)}" for u, p in R["primes"].items())) | |
| 367 | + print(f"Seuil GDV triplex pour égaler V_act : {_d(R['seuil_gdv_T'])} (+{R['hausse_requise_T']*100:.1f} %) ; pour égaler V_L marché : {_d(R['seuil_gdv_T_marche'])} ({R['hausse_requise_T_marche']*100:+.1f} %)") | |
| 368 | + o = R["option"] | |
| 369 | + print(f"\n--- Option réelle (triplex) : σ = {o['sigma']:.2f}, Δt = {o['dt']:.0f} ans, r = {o['r']:.3f}, y = {o['y']:.3f} ; u = {o['u']:.4f}, d = {o['d']:.4f}, F = {o['F']:.4f}, q = {o['q']:.4f}, disc = {o['disc']:.4f}") | |
| 370 | + print(f"a = {R['a']:.4f} ; b = {_d(R['b'])} ; GDV* (VAN nulle) = {_d(o['gdv_seuil_van'])} ; frontière d'exercice à t = 1 : {_d(R['frontiere_t1'])}") | |
| 371 | + for (t, j), nd in sorted(o["noeuds"].items()): | |
| 372 | + print(f" nœud t={t} j={j}: GDV {_d(nd['gdv'])} ; exercice {_d(nd['exercice'])} ; continuation {('—' if nd['continuation'] is None else _d(nd['continuation']))} ; valeur {_d(nd['valeur'])}") | |
| 373 | + print(f"Prime américaine {_d(o['prime_americaine'])} ({o['prime_americaine']/R['v_act']*100:.2f} % de V_act) ; européenne {_d(o['prime_europeenne'])} ; VAN immédiate {_d(o['van_immediate'])}") | |
| 374 | + print("Sensibilité de la prime (lignes V_act, colonnes σ = 5/10/15/20/25 %) :") | |
| 375 | + for v, d in R["sens_option"].items(): | |
| 376 | + print(f" V_act {_d(v)} : " + " ".join(_d(x) for x in d.values())) | |
| 377 | + ch = R["capozza"]; cr = ch["resultat"] | |
| 378 | + print(f"\n--- Capozza–Helsley : R_A {_d(ch['R_A'])}/an ; R_T {_d(ch['R_T'])}/an ; C {_d(ch['C'])} ; g {_d(ch['g'])}/an ; σ {_d(ch['sigma'])}/an ; r = y = {OPTION['y']}") | |
| 379 | + print(f"α = {cr['alpha']:.6e} ; R* certitude {_d(cr['R_certitude'])} (+{ch['hausse_requise_certitude']*100:.1f} %) ; prime d'irréversibilité {_d(cr['prime_irreversibilite'])} ; R* {_d(cr['R_seuil'])} (+{ch['hausse_requise_incertitude']*100:.1f} %)") | |
| 380 | + print("\n--- Sensibilité V_L triplex (promoteur) ---") | |
| 381 | + print(R["sensibilite"].assign(bas=lambda d: d.bas.round(0), haut=lambda d: d.haut.round(0), base=lambda d: d.base.round(0)).to_string(index=False)) | |
| 382 | + return R | |
| 383 | + | |
| 384 | + | |
| 385 | +if __name__ == "__main__": | |
| 386 | + main() | |
modified
analysis/python/cases/ch42_copro.py
+26 −0
@@ -152,6 +152,28 @@ def copro_locative(valeur: float, loyer: float = LOYER_F4, inocc: float = INOCC_ | ||
| 152 | 152 | "mrb": fin.multiplicateur_revenu_brut(valeur, rbp)} |
| 153 | 153 | |
| 154 | 154 | |
| 155 | +# ----------------------------------------------------------------------------- C'. financement de l'acheteur (fraction divise vs part indivise) | |
| 156 | +TAUX_ASSURE = 0.0412 # BdC, fonds avancés, prêts assurés à taux fixe ≥ 5 ans, août 2026 (sources/notes-E.md, ch. 31) | |
| 157 | +AMORT_MOIS = 300 | |
| 158 | +MISE_DIVISE = 0.05 # mise de fonds minimale assurée (prix ≤ 500 000 $ ; Finances Canada, 15 décembre 2024) | |
| 159 | +MISE_INDIVISE = 0.20 # pratique des prêteurs pour une part indivise (NON VÉRIFIÉE dans une source normative ; hypothèse déclarée) | |
| 160 | + | |
| 161 | + | |
| 162 | +def financement_acheteur(prix: float, mise: float = MISE_DIVISE, taux: float = TAUX_ASSURE, amort: int = AMORT_MOIS, | |
| 163 | + charges_mensuelles: float | None = None, taxes_annuelles: float = TAXES_F4) -> dict: | |
| 164 | + """Mise de fonds, prêt (hors prime d'assurance), paiement mensuel (composition semestrielle), coût mensuel total et paiement au taux | |
| 165 | + admissible minimal (max(taux + 2 %, 5,25 %), BSIF).""" | |
| 166 | + if charges_mensuelles is None: | |
| 167 | + charges_mensuelles = float(c7.repartition_charges(c7.cotisation_seuil(), QP)["par_fraction_total"][3]) / 12.0 | |
| 168 | + mdf = mise * prix; pret = prix - mdf | |
| 169 | + h = fin.Hypotheque(pret, taux, amort) | |
| 170 | + tam = fin.taux_admissible_minimal(taux) | |
| 171 | + h_tam = fin.Hypotheque(pret, tam, amort) | |
| 172 | + return {"mise_de_fonds": mdf, "pret": pret, "paiement": h.paiement, "charges_mensuelles": charges_mensuelles, | |
| 173 | + "taxes_mensuelles": taxes_annuelles / 12.0, "cout_mensuel": h.paiement + charges_mensuelles + taxes_annuelles / 12.0, | |
| 174 | + "tam": tam, "paiement_tam": h_tam.paiement} | |
| 175 | + | |
| 176 | + | |
| 155 | 177 | # ----------------------------------------------------------------------------- D. conversion locatif → copropriété divise |
| 156 | 178 | # L'immeuble fictif de huit fractions, vu AVANT conversion comme un immeuble locatif de 8 logements (hypothèses déclarées). |
| 157 | 179 | 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 |
@@ -299,6 +321,10 @@ def main() -> dict: | ||
| 299 | 321 | 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 | 322 | print(f" RNE hors cotisation au fonds ({fmt(cl['fonds'])} $) = {fmt(cl['noi_sans_reserve'])} $ → taux {cl['taux_implicite_sans_reserve']:.2%}") |
| 301 | 323 | 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'])} $") |
| 324 | + fa = financement_acheteur(380_000.0); fi = financement_acheteur(380_000.0, MISE_INDIVISE); out["fin_divise"] = fa; out["fin_indivise"] = fi | |
| 325 | + print(f"\n== C'. Financement d'un acheteur à 380 000 $ : divise (mise {MISE_DIVISE:.0%}) → mise {fmt(fa['mise_de_fonds'])}, prêt {fmt(fa['pret'])}, paiement {fa['paiement']:,.0f} $/mois à {TAUX_ASSURE:.2%} ; " | |
| 326 | + f"charges {fa['charges_mensuelles']:,.0f} + taxes {fa['taxes_mensuelles']:,.0f} → coût mensuel {fa['cout_mensuel']:,.0f} $ ; TAM {fa['tam']:.2%} → paiement {fa['paiement_tam']:,.0f} $") | |
| 327 | + print(f" part indivise (mise {MISE_INDIVISE:.0%}, hypothèse) → mise {fmt(fi['mise_de_fonds'])}, prêt {fmt(fi['pret'])}, paiement {fi['paiement']:,.0f} $/mois ; écart de mise de fonds {fmt(fi['mise_de_fonds'] - fa['mise_de_fonds'])} $") | |
| 302 | 328 | fb = faisabilite(); out["faisabilite"] = fb |
| 303 | 329 | b = fb["bloc"] |
| 304 | 330 | 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})") |
added
analysis/python/cases/ch44_renovation.py
+382 −0
@@ -0,0 +1,382 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""Chapitre 44 — rénovation et efficacité énergétique : analyse coût-bénéfice de deux mesures sur le sujet du cas 1 | |
| 3 | +(cottage de Hull, Gatineau, DONNÉES SYNTHÉTIQUES) et lectures de la contribution à la valeur. | |
| 4 | + | |
| 5 | +Imprime tous les chiffres cités dans le chapitre 44 : | |
| 6 | + * facture d'électricité du sujet au tarif D d'Hydro-Québec en vigueur le 1er avril 2026 (structure vérifiée : | |
| 7 | + 46,154 ¢/jour ; 7,065 ¢/kWh jusqu'à 40 kWh/jour ; 11,142 ¢/kWh au-delà) pour une consommation DÉCLARÉE | |
| 8 | + (hypothèse pédagogique) ; prix moyen et prix marginal du kilowattheure ; | |
| 9 | + * deux mesures : (A) remplacement de la thermopompe centrale de 2016 par un appareil ENERGY STAR climat froid | |
| 10 | + (subvention LogisVert 120 $ par 1 000 BTU/h, plafond 6 700 $) ; (B) isolation de l'entretoit (Rénoclimat, plafond | |
| 11 | + « toit » 2 000 $ pour une maison) ; et leur combinaison (économies non additives) ; | |
| 12 | + * flux annuels, VAN, TRI, délai de récupération simple et actualisé, annuité équivalente, coût de l'énergie | |
| 13 | + économisée (traite.finance : van, tri, delai_recuperation, facteur_recouvrement) ; | |
| 14 | + * sensibilité : grille VAN × (taux d'actualisation, croissance du prix de l'électricité), durée de vie, économie, | |
| 15 | + coût, subvention ; seuils d'équilibre ; | |
| 16 | + * lectures de la contribution à la valeur : coût, coût net, valeur actuelle des économies, prime verte de la | |
| 17 | + littérature appliquée à la prédiction hédonique du cas 1 (traite.cas1.predire) ; | |
| 18 | + * paires de ventes « qualité 4 vs qualité 3 » dans les 600 ventes du cas 1 (traite.comparables.ventes_appariees), | |
| 19 | + comme indicateur de la contribution d'une rénovation non énergétique (cuisine, salles de bain) ; | |
| 20 | + * composantes du sujet en fin de vie (fiche d'état du ch. 11 : analysis/python/cases/ch11_inspection.py) ; | |
| 21 | + * chiffres des exercices. | |
| 22 | + | |
| 23 | +Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch44_renovation.py | |
| 24 | +Toutes les formules financières proviennent de analysis/python/traite/finance.py (aucune réécriture).""" | |
| 25 | +from __future__ import annotations | |
| 26 | +import sys, pathlib, math | |
| 27 | +import numpy as np, pandas as pd | |
| 28 | +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) | |
| 29 | +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) | |
| 30 | +from traite import finance as fin, cas1 | |
| 31 | +from traite import comparables as cp | |
| 32 | + | |
| 33 | +ROOT = pathlib.Path(__file__).resolve().parents[3] | |
| 34 | + | |
| 35 | +# --- Tarif D d'Hydro-Québec, en vigueur le 1er avril 2026 (hydrotarifs2026, section 2.5 ; vérifié le 2026-10-31) -------- | |
| 36 | +FRAIS_ACCES_JOUR = 0.46154 # $ par jour | |
| 37 | +PRIX_TRANCHE_1 = 0.07065 # $/kWh jusqu'à 40 kWh par jour | |
| 38 | +SEUIL_KWH_JOUR = 40.0 # kWh par jour | |
| 39 | +PRIX_TRANCHE_2 = 0.11142 # $/kWh au-delà | |
| 40 | +JOURS = 365 | |
| 41 | + | |
| 42 | +# --- Consommation du sujet : HYPOTHÈSE PÉDAGOGIQUE (aucune facture réelle ; Hydro-Québec ne publie pas de facture | |
| 43 | +# moyenne par type de maison — notes-E) -------------------------------------------------------------------------- | |
| 44 | +CONSO_KWH = 22000.0 # kWh/an, maison tout électrique de 165 m² (1998) avec thermopompe centrale de 2016 | |
| 45 | +PART_CHAUFFAGE = 0.50 # part du chauffage des locaux dans la consommation annuelle | |
| 46 | +PART_EAU_CHAUDE = 0.20 | |
| 47 | +PART_AUTRES = 0.30 | |
| 48 | + | |
| 49 | +# --- Paramètres financiers ---------------------------------------------------------------------------------------- | |
| 50 | +TAUX_ACTUALISATION = 0.0458 # taux effectif des fonds avancés, prêts non assurés à taux fixe ≥ 5 ans, août 2026 (BdC, groupe A4) | |
| 51 | +CROISSANCE_ENERGIE = 0.030 # hausse domestique 3,0 % en 2026 et 2027 (Régie, D-2026-033) ; maintenue par hypothèse au-delà | |
| 52 | +VIE_RESTANTE_BATIMENT = 31 # ans (ch. 11 et 24 : vie économique 60 ans, âge effectif 29 ans) | |
| 53 | + | |
| 54 | +# --- Mesure A : thermopompe centrale ENERGY STAR climat froid ------------------------------------------------------- | |
| 55 | +TP_CAPACITE_BTU = 36000 # BTU/h à −8 °C (3 tonnes ; hypothèse cohérente avec 165 m²) | |
| 56 | +TP_COUT_STANDARD = 10481.32 * 1.33043 # poste « Thermopompe centrale et plinthes » du devis du cas 1, majoré (ch. 21) | |
| 57 | +TP_COUT = 16500.0 # coût installé d'un appareil climat froid (hypothèse pédagogique, ≈ +18 % sur l'appareil standard) | |
| 58 | +TP_LOGISVERT_STANDARD = 50.0 # $ par 1 000 BTU/h, appareil ENERGY STAR | |
| 59 | +TP_LOGISVERT_FROID = 120.0 # $ par 1 000 BTU/h, appareil climat froid | |
| 60 | +TP_LOGISVERT_MAX = 6700.0 | |
| 61 | +TP_ECONOMIE_CHAUFFAGE = 0.30 # fraction de l'énergie de chauffage économisée par rapport à l'appareil de 2016 (hypothèse) | |
| 62 | +TP_VIE = 15 # ans (fiche d'état du ch. 11 : vie utile 15 ans) | |
| 63 | +TP_ECONOMIE_INCR = 0.20 # fraction du chauffage économisée par l'appareil climat froid PAR RAPPORT à un appareil standard neuf (hypothèse) | |
| 64 | + | |
| 65 | +# --- Mesure B : isolation de l'entretoit --------------------------------------------------------------------------- | |
| 66 | +ISO_SURFACE_M2 = 95.0 # empreinte du bâtiment (ch. 22) | |
| 67 | +ISO_COUT_M2 = 44.0 # $/m² installé, cellulose soufflée R-20 → R-60 (hypothèse pédagogique) | |
| 68 | +ISO_COUT = ISO_SURFACE_M2 * ISO_COUT_M2 | |
| 69 | +ISO_RENOCLIMAT_MAX = 2000.0 # plafond « toit », maison (Rénoclimat refondu le 1er octobre 2026) | |
| 70 | +ISO_SUBVENTION = 2000.0 # hypothèse : travaux admissibles au plafond | |
| 71 | +ISO_ECONOMIE_CHAUFFAGE = 0.08 # fraction de l'énergie de chauffage économisée (hypothèse) | |
| 72 | +ISO_VIE = VIE_RESTANTE_BATIMENT # composante de longue durée : horizon = vie économique restante du bâtiment | |
| 73 | + | |
| 74 | +# --- Prime verte : résultats publiés (vérifiés le 2026-10-31, voir ch44-claims.csv) --------------------------------- | |
| 75 | +PRIME_VERTE = [ | |
| 76 | + # (clé, pays/marché, mesure, bas %, haut %, point %, type) | |
| 77 | + ("brounenKok2011", "Pays-Bas, 2008-2009", "étiquette A, B ou C vs D-G", 3.6, 3.7, 3.7, "prix"), | |
| 78 | + ("brounenKok2011", "Pays-Bas, 2008-2009", "étiquette A vs D ; G vs D", -5.0, 10.2, None, "prix"), | |
| 79 | + ("kahnKok2014", "Californie, 2007-2012", "maison étiquetée verte vs non étiquetée", 4.5, 5.0, 5.0, "prix"), | |
| 80 | + ("hylandLyonsLyons2013", "Irlande, 2008-2012", "cote A vs D (prix) ; 1 échelon BER", 1.3, 9.3, 9.3, "prix"), | |
| 81 | + ("hylandLyonsLyons2013", "Irlande, 2008-2012", "cote A vs D (loyers)", 0.5, 1.8, 1.8, "loyer"), | |
| 82 | + ("fuerstMcallisterNandaWyatt2015", "Angleterre, 1995-2012", "EPC A/B vs D ; C vs D ; G vs D", -6.0, 5.0, None, "prix"), | |
| 83 | + ("aydinBrounenKok2020", "Pays-Bas", "+10 % d'efficacité énergétique mesurée", 2.2, 2.2, 2.2, "prix"), | |
| 84 | + ("cespedeslopez2019", "Méta-analyse, 66 documents", "prix avec certificat : monde ; Europe ; Amérique", 2.32, 5.36, 4.20, "prix"), | |
| 85 | + ("eichholtzKokQuigley2010", "É.-U., bureaux", "immeuble certifié : loyer ; loyer effectif ; prix", 3.0, 16.0, None, "bureaux"), | |
| 86 | +] | |
| 87 | +HYPOTHESE_COUT_CUISINE_SDB = 65000.0 # coût d'une rénovation complète cuisine + 2 salles de bain (hypothèse pédagogique) | |
| 88 | + | |
| 89 | + | |
| 90 | +# ------------------------------------------------------------------------------------------------------- tarif D | |
| 91 | +def facture_tarif_d(kwh: float, jours: int = JOURS, indice: float = 1.0) -> dict: | |
| 92 | + """Facture annuelle au tarif D (hors taxes) : frais d'accès + énergie en deux tranches ; `indice` multiplie les prix.""" | |
| 93 | + seuil = SEUIL_KWH_JOUR * jours | |
| 94 | + t1 = min(kwh, seuil); t2 = max(kwh - seuil, 0.0) | |
| 95 | + acces = FRAIS_ACCES_JOUR * jours * indice | |
| 96 | + e1 = t1 * PRIX_TRANCHE_1 * indice; e2 = t2 * PRIX_TRANCHE_2 * indice | |
| 97 | + total = acces + e1 + e2 | |
| 98 | + return {"kwh": kwh, "acces": acces, "tranche1_kwh": t1, "tranche2_kwh": t2, "energie_t1": e1, "energie_t2": e2, | |
| 99 | + "total": total, "prix_moyen": total / kwh if kwh else float("nan"), | |
| 100 | + "prix_marginal": (PRIX_TRANCHE_2 if kwh > seuil else PRIX_TRANCHE_1) * indice} | |
| 101 | + | |
| 102 | + | |
| 103 | +def economie_annuelle(kwh_avant: float, kwh_apres: float, indice: float = 1.0) -> float: | |
| 104 | + """Économie exacte = différence de factures (tient compte d'un éventuel franchissement du seuil).""" | |
| 105 | + return facture_tarif_d(kwh_avant, indice=indice)["total"] - facture_tarif_d(kwh_apres, indice=indice)["total"] | |
| 106 | + | |
| 107 | + | |
| 108 | +# ------------------------------------------------------------------------------------------------------- mesures | |
| 109 | +def mesures() -> dict: | |
| 110 | + chauffage = CONSO_KWH * PART_CHAUFFAGE | |
| 111 | + eco_tp = chauffage * TP_ECONOMIE_CHAUFFAGE | |
| 112 | + eco_iso = chauffage * ISO_ECONOMIE_CHAUFFAGE | |
| 113 | + # combinaison : l'isolation réduit d'abord la charge ; la thermopompe économise une fraction du reste | |
| 114 | + eco_combi = eco_iso + (chauffage - eco_iso) * TP_ECONOMIE_CHAUFFAGE | |
| 115 | + sub_tp = min(TP_LOGISVERT_FROID * TP_CAPACITE_BTU / 1000.0, TP_LOGISVERT_MAX) | |
| 116 | + sub_tp_std = min(TP_LOGISVERT_STANDARD * TP_CAPACITE_BTU / 1000.0, TP_LOGISVERT_MAX) | |
| 117 | + return { | |
| 118 | + "A": {"nom": "Thermopompe climat froid", "cout": TP_COUT, "subvention": sub_tp, "kwh": eco_tp, "vie": TP_VIE}, | |
| 119 | + "B": {"nom": "Isolation de l'entretoit", "cout": ISO_COUT, "subvention": ISO_SUBVENTION, "kwh": eco_iso, "vie": ISO_VIE}, | |
| 120 | + "AB": {"nom": "Les deux mesures", "cout": TP_COUT + ISO_COUT, "subvention": sub_tp + ISO_SUBVENTION, "kwh": eco_combi, | |
| 121 | + "vie": TP_VIE}, # horizon commun = vie de la thermopompe (l'isolation continue au-delà : voir texte) | |
| 122 | + # décision incrémentale à la fin de vie de l'appareil de 2016 : climat froid plutôt que standard | |
| 123 | + "A_incr": {"nom": "Climat froid plutôt que standard (fin de vie)", "cout": TP_COUT - TP_COUT_STANDARD, | |
| 124 | + "subvention": sub_tp - sub_tp_std, "kwh": chauffage * TP_ECONOMIE_INCR, "vie": TP_VIE}, | |
| 125 | + "chauffage_kwh": chauffage, "eco_additive_kwh": eco_tp + eco_iso, | |
| 126 | + "subvention_tp_standard": sub_tp_std, "cout_tp_standard": TP_COUT_STANDARD, | |
| 127 | + "surcout_climat_froid": TP_COUT - TP_COUT_STANDARD, "surcout_net_climat_froid": (TP_COUT - TP_COUT_STANDARD) - (sub_tp - sub_tp_std), | |
| 128 | + } | |
| 129 | + | |
| 130 | + | |
| 131 | +def flux_mesure(m: dict, r: float = TAUX_ACTUALISATION, g: float = CROISSANCE_ENERGIE, vie: int | None = None, | |
| 132 | + kwh_avant: float = CONSO_KWH, subvention: float | None = None, cout: float | None = None, | |
| 133 | + kwh: float | None = None) -> dict: | |
| 134 | + """Flux : t = 0 : −(coût − subvention) ; t = 1..n : économie_t = [F(kWh avant) − F(kWh après)] × (1+g)^t. | |
| 135 | + Indicateurs : VAN, TRI, délai simple et actualisé, annuité équivalente, coût de l'énergie économisée.""" | |
| 136 | + n = int(vie if vie is not None else m["vie"]); sub = m["subvention"] if subvention is None else subvention | |
| 137 | + K = m["cout"] if cout is None else cout; dk = m["kwh"] if kwh is None else kwh | |
| 138 | + eco0 = economie_annuelle(kwh_avant, kwh_avant - dk) | |
| 139 | + flux = [-(K - sub)] + [eco0 * (1.0 + g) ** t for t in range(1, n + 1)] | |
| 140 | + van = fin.van(flux, r) | |
| 141 | + try: | |
| 142 | + t_ri = fin.tri(flux) | |
| 143 | + except ValueError: | |
| 144 | + t_ri = float("nan") | |
| 145 | + actualises = [cf / (1.0 + r) ** t for t, cf in enumerate(flux)] | |
| 146 | + crf = fin.facteur_recouvrement(r, n) | |
| 147 | + va_eco = sum(actualises[1:]) | |
| 148 | + return {"flux": flux, "actualises": actualises, "cumul_actualise": np.cumsum(actualises).tolist(), | |
| 149 | + "economie_an1": flux[1], "economie_courante": eco0, "cout_net": K - sub, "van": van, "tri": t_ri, | |
| 150 | + "delai_simple": fin.delai_recuperation(flux), "delai_actualise": fin.delai_recuperation(actualises), | |
| 151 | + "va_economies": va_eco, "indice_rentabilite": va_eco / (K - sub) if K > sub else float("inf"), | |
| 152 | + "annuite_cout_net": (K - sub) * crf, "annuite_cout_brut": K * crf, | |
| 153 | + "cee_net": (K - sub) * crf / dk, "cee_brut": K * crf / dk, # coût de l'énergie économisée, $/kWh | |
| 154 | + "n": n, "r": r, "g": g} | |
| 155 | + | |
| 156 | + | |
| 157 | +def seuils(m: dict, r: float = TAUX_ACTUALISATION, g: float = CROISSANCE_ENERGIE) -> dict: | |
| 158 | + """Valeurs d'équilibre (VAN = 0) : croissance du prix de l'énergie, coût net, économie en kWh, taux (= TRI).""" | |
| 159 | + f0 = flux_mesure(m, r=r, g=g) | |
| 160 | + cout_net_eq = f0["va_economies"] | |
| 161 | + # économie en kWh d'équilibre : VAN(kwh) = 0, recherche par bissection (économie linéaire si pas de changement de tranche) | |
| 162 | + lo, hi = 0.0, CONSO_KWH * 0.9 | |
| 163 | + for _ in range(200): | |
| 164 | + mid = 0.5 * (lo + hi) | |
| 165 | + if flux_mesure(m, r=r, g=g, kwh=mid)["van"] < 0: lo = mid | |
| 166 | + else: hi = mid | |
| 167 | + kwh_eq = 0.5 * (lo + hi) | |
| 168 | + # croissance d'équilibre | |
| 169 | + glo, ghi = -0.5, 0.5 | |
| 170 | + for _ in range(200): | |
| 171 | + gm = 0.5 * (glo + ghi) | |
| 172 | + if flux_mesure(m, r=r, g=gm)["van"] < 0: glo = gm | |
| 173 | + else: ghi = gm | |
| 174 | + g_eq = 0.5 * (glo + ghi) | |
| 175 | + return {"cout_net_equilibre": cout_net_eq, "subvention_equilibre": m["cout"] - cout_net_eq, "kwh_equilibre": kwh_eq, | |
| 176 | + "fraction_chauffage_equilibre": kwh_eq / (CONSO_KWH * PART_CHAUFFAGE), "g_equilibre": g_eq, "tri": f0["tri"]} | |
| 177 | + | |
| 178 | + | |
| 179 | +def grille_van(m: dict, taux=(0.02, 0.03, 0.04, 0.0458, 0.05, 0.06, 0.07, 0.08, 0.10), croissances=(0.0, 0.01, 0.02, 0.03, 0.04, 0.05, 0.06)) -> pd.DataFrame: | |
| 180 | + return pd.DataFrame([[flux_mesure(m, r=r, g=g)["van"] for g in croissances] for r in taux], index=list(taux), columns=list(croissances)) | |
| 181 | + | |
| 182 | + | |
| 183 | +def sensibilite_tornade(m: dict) -> list[dict]: | |
| 184 | + """Variation de la VAN de la mesure A pour des chocs déclarés sur chaque paramètre (toutes choses égales par ailleurs).""" | |
| 185 | + base = flux_mesure(m)["van"] | |
| 186 | + out = [] | |
| 187 | + def add(nom, bas, haut): out.append({"param": nom, "base": base, "bas": bas, "haut": haut}) | |
| 188 | + add("Économie (20 % / 40 % du chauffage)", flux_mesure(m, kwh=CONSO_KWH * PART_CHAUFFAGE * 0.20)["van"], flux_mesure(m, kwh=CONSO_KWH * PART_CHAUFFAGE * 0.40)["van"]) | |
| 189 | + add("Taux d'actualisation (8 % / 2 %)", flux_mesure(m, r=0.08)["van"], flux_mesure(m, r=0.02)["van"]) | |
| 190 | + add("Croissance du prix (0 % / 6 %)", flux_mesure(m, g=0.0)["van"], flux_mesure(m, g=0.06)["van"]) | |
| 191 | + add("Durée de vie (12 / 20 ans)", flux_mesure(m, vie=12)["van"], flux_mesure(m, vie=20)["van"]) | |
| 192 | + add("Coût installé (+20 % / −20 %)", flux_mesure(m, cout=m["cout"] * 1.2)["van"], flux_mesure(m, cout=m["cout"] * 0.8)["van"]) | |
| 193 | + add("Subvention (0 $ / plafond 6 700 $)", flux_mesure(m, subvention=0.0)["van"], flux_mesure(m, subvention=TP_LOGISVERT_MAX)["van"]) | |
| 194 | + add("Consommation (16 000 / 28 000 kWh)", flux_mesure(m, kwh_avant=16000.0, kwh=16000.0 * PART_CHAUFFAGE * TP_ECONOMIE_CHAUFFAGE)["van"], | |
| 195 | + flux_mesure(m, kwh_avant=28000.0, kwh=28000.0 * PART_CHAUFFAGE * TP_ECONOMIE_CHAUFFAGE)["van"]) | |
| 196 | + return out | |
| 197 | + | |
| 198 | + | |
| 199 | +def sensibilite_duree(m: dict, vies=(10, 12, 15, 18, 20, 25), taux=(0.02, 0.0458, 0.07, 0.10)) -> pd.DataFrame: | |
| 200 | + return pd.DataFrame([[flux_mesure(m, r=r, vie=v)["van"] for r in taux] for v in vies], index=list(vies), columns=list(taux)) | |
| 201 | + | |
| 202 | + | |
| 203 | +# -------------------------------------------------------------------------------------------- contribution à la valeur | |
| 204 | +def lectures_contribution(m: dict, f: dict, valeur_sujet: float) -> dict: | |
| 205 | + """Quatre lectures de ce que la mesure « vaut » pour le bien : coût, coût net, VA des économies (acheteur rationnel), | |
| 206 | + prime verte de la littérature appliquée à la valeur du sujet (bornes).""" | |
| 207 | + primes = {"Europe (méta-analyse)": 0.0232, "Monde (méta-analyse)": 0.0420, "Amérique du Nord (méta-analyse)": 0.0536, | |
| 208 | + "Pays-Bas, étiquette verte": 0.037, "Angleterre, EPC C vs D": 0.018, "Californie, étiquette verte": 0.05} | |
| 209 | + return {"cout": m["cout"], "cout_net": f["cout_net"], "va_economies": f["va_economies"], "van": f["van"], | |
| 210 | + "ratio_va_cout": f["va_economies"] / m["cout"], "ratio_va_cout_net": f["va_economies"] / f["cout_net"], | |
| 211 | + "primes_dollars": {k: v * valeur_sujet for k, v in primes.items()}, "primes_pct": primes, "valeur_sujet": valeur_sujet} | |
| 212 | + | |
| 213 | + | |
| 214 | +def va_economies_restantes(m: dict, annees_ecoulees: int, r: float = TAUX_ACTUALISATION, g: float = CROISSANCE_ENERGIE) -> float: | |
| 215 | + """Valeur actuelle, à la date d'une revente future, des économies restantes (ce qu'un acheteur rationnel paierait au plus).""" | |
| 216 | + f = flux_mesure(m, r=r, g=g) | |
| 217 | + rest = f["flux"][1 + annees_ecoulees:] | |
| 218 | + return sum(cf / (1.0 + r) ** (t + 1) for t, cf in enumerate(rest)) | |
| 219 | + | |
| 220 | + | |
| 221 | +# -------------------------------------------------------------------------------------------- paires « qualité » | |
| 222 | +def paires_qualite(v: pd.DataFrame, modele: dict, q_haut: int = 4, q_bas: int = 3, tol_s: float = 25.0, tol_age: int = 10, | |
| 223 | + tol_mois: float = 18.0, tol_dist_km: float = 5.0, date_eval: str = "2026-09-01") -> dict: | |
| 224 | + """Paires de ventes du cas 1 ne différant (à des tolérances déclarées près) que par la note de qualité (q_haut vs q_bas) : | |
| 225 | + même secteur, même type, |ΔS| ≤ tol_s m², |Δâge| ≤ tol_age, mêmes garage / sous-sol / piscine, |Δdate| ≤ tol_mois, | |
| 226 | + distance ≤ tol_dist_km. Le prix du membre « bas » est ramené à la date du membre « haut » par la tendance du modèle | |
| 227 | + hédonique (croissance mensuelle composée). Chaque vente n'entre que dans une paire (appariement glouton par distance).""" | |
| 228 | + g_m = modele["croissance_mensuelle"] | |
| 229 | + hauts = v[v["qualite_1a5"] == q_haut].copy(); bas = v[v["qualite_1a5"] == q_bas].copy() | |
| 230 | + coords = lambda df: np.radians(df[["lat", "lon"]].values) | |
| 231 | + utilises = set(); rows = [] | |
| 232 | + for _, h in hauts.iterrows(): | |
| 233 | + cand = bas[(bas["secteur"] == h["secteur"]) & (bas["type"] == h["type"]) & (~bas["id_vente"].isin(utilises)) | |
| 234 | + & ((bas["superficie_habitable_m2"] - h["superficie_habitable_m2"]).abs() <= tol_s) | |
| 235 | + & ((bas["age"] - h["age"]).abs() <= tol_age) & (bas["garage_places"] == h["garage_places"]) | |
| 236 | + & (bas["sous_sol_fini"] == h["sous_sol_fini"]) & (bas["piscine"] == h["piscine"])] | |
| 237 | + if cand.empty: continue | |
| 238 | + dmois = ((cand["date_vente"] - h["date_vente"]).dt.days / 30.4375) | |
| 239 | + cand = cand[dmois.abs() <= tol_mois] | |
| 240 | + if cand.empty: continue | |
| 241 | + # distance haversine approximative (km) | |
| 242 | + lat1, lon1 = math.radians(h["lat"]), math.radians(h["lon"]) | |
| 243 | + lat2 = np.radians(cand["lat"].values); lon2 = np.radians(cand["lon"].values) | |
| 244 | + a = np.sin((lat2 - lat1) / 2) ** 2 + np.cos(lat1) * np.cos(lat2) * np.sin((lon2 - lon1) / 2) ** 2 | |
| 245 | + dist = 6371.0 * 2 * np.arcsin(np.sqrt(a)) | |
| 246 | + cand = cand.assign(dist_km=dist) | |
| 247 | + cand = cand[cand["dist_km"] <= tol_dist_km] | |
| 248 | + if cand.empty: continue | |
| 249 | + # score de ressemblance : superficie, âge, date, distance normalisés | |
| 250 | + score = ((cand["superficie_habitable_m2"] - h["superficie_habitable_m2"]).abs() / tol_s + (cand["age"] - h["age"]).abs() / tol_age | |
| 251 | + + ((cand["date_vente"] - h["date_vente"]).dt.days / 30.4375).abs() / tol_mois + cand["dist_km"] / tol_dist_km) | |
| 252 | + b = cand.loc[score.idxmin()] | |
| 253 | + utilises.add(b["id_vente"]) | |
| 254 | + mois = (h["date_vente"] - b["date_vente"]).days / 30.4375 | |
| 255 | + prix_bas_aj = b["prix_vente"] * (1.0 + g_m) ** mois # ramené à la date de la vente « haute » | |
| 256 | + rows.append({"id_haut": h["id_vente"], "id_bas": b["id_vente"], "secteur": h["secteur"], "prix_haut": h["prix_vente"], | |
| 257 | + "prix_bas": b["prix_vente"], "prix_bas_ajuste": prix_bas_aj, "mois": mois, "dist_km": float(b["dist_km"]), | |
| 258 | + "dS": h["superficie_habitable_m2"] - b["superficie_habitable_m2"], "dage": h["age"] - b["age"], | |
| 259 | + "difference": h["prix_vente"] - prix_bas_aj, "difference_pct": h["prix_vente"] / prix_bas_aj - 1.0}) | |
| 260 | + P = pd.DataFrame(rows) | |
| 261 | + res = cp.ventes_appariees(P["prix_haut"].tolist(), P["prix_bas_ajuste"].tolist()) if len(P) else {"n": 0, "moyenne": float("nan"), "mediane": float("nan"), "ecart_type": float("nan")} | |
| 262 | + res["erreur_type"] = res["ecart_type"] / math.sqrt(res["n"]) if res["n"] > 1 else float("nan") | |
| 263 | + beta_q = float(modele["coef"].loc["qualite", "coef"]) | |
| 264 | + return {"paires": P, "resultat": res, "mediane_pct": float(P["difference_pct"].median()) if len(P) else float("nan"), | |
| 265 | + "moyenne_pct": float(P["difference_pct"].mean()) if len(P) else float("nan"), | |
| 266 | + "ecart_type_pct": float(P["difference_pct"].std(ddof=1)) if len(P) > 1 else float("nan"), | |
| 267 | + "erreur_type_pct": float(P["difference_pct"].std(ddof=1) / math.sqrt(len(P))) if len(P) > 1 else float("nan"), | |
| 268 | + "ic95_pct": (float(P["difference_pct"].mean() - 1.96 * P["difference_pct"].std(ddof=1) / math.sqrt(len(P))), | |
| 269 | + float(P["difference_pct"].mean() + 1.96 * P["difference_pct"].std(ddof=1) / math.sqrt(len(P)))) if len(P) > 1 else (float("nan"), float("nan")), | |
| 270 | + "beta_qualite": beta_q, "effet_hedonique_pct": math.exp(beta_q) - 1.0} | |
| 271 | + | |
| 272 | + | |
| 273 | +def contribution_renovation(paires: dict, valeur_sujet: float, cout: float = HYPOTHESE_COUT_CUISINE_SDB) -> dict: | |
| 274 | + """Contribution d'un échelon de qualité au sujet (paires et hédonique) rapportée au coût hypothétique d'une rénovation | |
| 275 | + cuisine + salles de bain qui ferait passer la note de 3 à 4.""" | |
| 276 | + c_paires = paires["mediane_pct"] * valeur_sujet; c_hed = paires["effet_hedonique_pct"] * valeur_sujet | |
| 277 | + return {"contribution_paires": c_paires, "contribution_hedonique": c_hed, "cout": cout, | |
| 278 | + "ratio_paires": c_paires / cout, "ratio_hedonique": c_hed / cout, "van_renovation": c_paires - cout} | |
| 279 | + | |
| 280 | + | |
| 281 | +# -------------------------------------------------------------------------------------------- fiche d'état (ch. 11) | |
| 282 | +def composantes_fin_de_vie(seuil: float = 0.90) -> pd.DataFrame: | |
| 283 | + import ch11_inspection as c11 | |
| 284 | + devis, sujet, cn = c11.charger() | |
| 285 | + f = c11.fiche(devis, cn) | |
| 286 | + f = f.copy(); f["ratio"] = f["age_effectif"] / f["vie_utile"] | |
| 287 | + cols = [c for c in ["composante", "annee", "age", "vie_utile", "etat", "age_effectif", "ratio", "cout_neuf"] if c in f.columns] | |
| 288 | + return f[(~f["longue_duree"]) & (f["ratio"] >= seuil)][cols].sort_values("ratio", ascending=False).reset_index(drop=True) | |
| 289 | + | |
| 290 | + | |
| 291 | +# -------------------------------------------------------------------------------------------- exercices | |
| 292 | +def exercices() -> dict: | |
| 293 | + ex = {} | |
| 294 | + # Ex. 1 : factures à 18 000 et 26 000 kWh ; prix moyen et marginal | |
| 295 | + ex["ex1"] = {k: facture_tarif_d(k) for k in (18000.0, 26000.0)} | |
| 296 | + ex["ex1"]["economie_1000_kwh_a_26000"] = economie_annuelle(26000.0, 25000.0) | |
| 297 | + ex["ex1"]["economie_1000_kwh_a_15000"] = economie_annuelle(15000.0, 14000.0) | |
| 298 | + # Ex. 2 : LogisVert 24 000 BTU/h standard vs climat froid ; capacité où le plafond mord | |
| 299 | + ex["ex2"] = {"standard": min(50.0 * 24, 6700.0), "froid": min(120.0 * 24, 6700.0), "capacite_plafond_btu": 6700.0 / 120.0 * 1000.0, | |
| 300 | + "froid_60000": min(120.0 * 60, 6700.0)} | |
| 301 | + # Ex. 3 : remplacement des 16 fenêtres (fin de vie 0,717) : coût neuf 22 311 $ (fiche ch. 11), économie 600 kWh, Rénoclimat 300 $/fenêtre max 4 500 $ | |
| 302 | + m_fen = {"nom": "Fenêtres", "cout": 22311.0, "subvention": min(300.0 * 16, 4500.0), "kwh": 600.0, "vie": 30} | |
| 303 | + ex["ex3"] = {"mesure": m_fen, "flux": flux_mesure(m_fen), "sans_subvention": flux_mesure(m_fen, subvention=0.0)} | |
| 304 | + # Ex. 4 : coût de l'énergie économisée de l'isolation à 4,58 % et à 8 % | |
| 305 | + mm = mesures() | |
| 306 | + ex["ex4"] = {"r458": flux_mesure(mm["B"]), "r8": flux_mesure(mm["B"], r=0.08), "sans_sub": flux_mesure(mm["B"], subvention=0.0)} | |
| 307 | + # Ex. 5 : primes transposées à 637 953 $ | |
| 308 | + d = cas1.charger(); mod = cas1.hedonique(d["ventes"]); vs = float(cas1.predire(mod, cas1.sujet_df(d["sujet"]))[0]) | |
| 309 | + ex["ex5"] = {"valeur": vs, "europe": 0.0232 * vs, "amerique": 0.0536 * vs, "va_economies_AB": flux_mesure(mm["AB"])["va_economies"], | |
| 310 | + "va_economies_AB_31": flux_mesure(mm["AB"], vie=31)["va_economies"]} | |
| 311 | + # Ex. 6 : seuils de la thermopompe | |
| 312 | + ex["ex6"] = seuils(mm["A"]) | |
| 313 | + ex["ex6"]["tornade"] = sensibilite_tornade(mm["A"]) | |
| 314 | + # Ex. 7 : paires avec tolérances élargies / resserrées | |
| 315 | + p_base = paires_qualite(d["ventes"], mod) | |
| 316 | + p_large = paires_qualite(d["ventes"], mod, tol_s=30.0, tol_age=15, tol_mois=24.0, tol_dist_km=99.0) | |
| 317 | + p_serre = paires_qualite(d["ventes"], mod, tol_s=15.0, tol_age=8, tol_mois=9.0, tol_dist_km=2.0) | |
| 318 | + ex["ex7"] = {"base": p_base, "large": p_large, "serre": p_serre, "kennedy": math.exp(mod["coef"].loc["qualite", "coef"] - 0.5 * mod["coef"].loc["qualite", "se_hc1"] ** 2) - 1.0} | |
| 319 | + return ex | |
| 320 | + | |
| 321 | + | |
| 322 | +# -------------------------------------------------------------------------------------------- impression | |
| 323 | +def main() -> dict: | |
| 324 | + f = lambda x: f"{x:,.2f}".replace(",", " ") | |
| 325 | + print("=== Facture du sujet au tarif D (hypothèse : 22 000 kWh/an) ===") | |
| 326 | + F = facture_tarif_d(CONSO_KWH) | |
| 327 | + for k, v in F.items(): print(f" {k:16s} {v:,.4f}") | |
| 328 | + print(f" seuil annuel {SEUIL_KWH_JOUR * JOURS:,.0f} kWh ; conso/jour {CONSO_KWH / JOURS:.1f} kWh") | |
| 329 | + print("\n=== Mesures ===") | |
| 330 | + mm = mesures() | |
| 331 | + for k in ("A", "B", "AB", "A_incr"): | |
| 332 | + m = mm[k]; fl = flux_mesure(m) | |
| 333 | + print(f"--- {k} {m['nom']} : coût {f(m['cout'])} ; subvention {f(m['subvention'])} ; net {f(fl['cout_net'])} ; " | |
| 334 | + f"économie {m['kwh']:,.0f} kWh = {f(fl['economie_courante'])} $/an (an 1 : {f(fl['economie_an1'])}) ; vie {m['vie']} ans") | |
| 335 | + print(f" VAN {f(fl['van'])} ; TRI {fl['tri']*100:.2f} % ; délai simple {fl['delai_simple']} ; actualisé {fl['delai_actualise']} ; " | |
| 336 | + f"VA éco {f(fl['va_economies'])} ; IR {fl['indice_rentabilite']:.3f} ; annuité nette {f(fl['annuite_cout_net'])} ; " | |
| 337 | + f"CEE net {fl['cee_net']*100:.2f} ¢/kWh ; brut {fl['cee_brut']*100:.2f} ¢/kWh") | |
| 338 | + print(f" chauffage {mm['chauffage_kwh']:,.0f} kWh ; économie additive {mm['eco_additive_kwh']:,.0f} vs combinée {mm['AB']['kwh']:,.0f} kWh") | |
| 339 | + print(f" thermopompe standard : coût {f(mm['cout_tp_standard'])} ; LogisVert {f(mm['subvention_tp_standard'])} ; surcoût climat froid {f(mm['surcout_climat_froid'])} ; net {f(mm['surcout_net_climat_froid'])}") | |
| 340 | + print("\n=== Seuils d'équilibre (A) ===") | |
| 341 | + for k, v in seuils(mm["A"]).items(): print(f" {k:30s} {v:,.4f}") | |
| 342 | + print("\n=== Grille VAN (A) : taux × croissance ===") | |
| 343 | + print(grille_van(mm["A"]).round(0).to_string()) | |
| 344 | + print("\n=== Grille VAN (B) ===") | |
| 345 | + print(grille_van(mm["B"]).round(0).to_string()) | |
| 346 | + print("\n=== Sensibilité durée (A) ===") | |
| 347 | + print(sensibilite_duree(mm["A"]).round(0).to_string()) | |
| 348 | + print("\n=== Tornade (A) ===") | |
| 349 | + for o in sensibilite_tornade(mm["A"]): print(f" {o['param']:40s} {f(o['bas'])} … {f(o['haut'])}") | |
| 350 | + d = cas1.charger(); mod = cas1.hedonique(d["ventes"]); vs = float(cas1.predire(mod, cas1.sujet_df(d["sujet"]))[0]) | |
| 351 | + print(f"\n=== Lectures de la contribution (valeur hédonique du sujet {f(vs)}) ===") | |
| 352 | + for k in ("A", "B", "AB"): | |
| 353 | + L = lectures_contribution(mm[k], flux_mesure(mm[k]), vs) | |
| 354 | + print(f" {k}: coût {f(L['cout'])} ; net {f(L['cout_net'])} ; VA éco {f(L['va_economies'])} ; VA/coût {L['ratio_va_cout']:.3f} ; VA/net {L['ratio_va_cout_net']:.3f}") | |
| 355 | + for k, v in lectures_contribution(mm["A"], flux_mesure(mm["A"]), vs)["primes_dollars"].items(): print(f" prime {k:34s} {f(v)}") | |
| 356 | + for a in (0, 5, 10): print(f" VA des économies restantes de A après {a} ans : {f(va_economies_restantes(mm['A'], a))}") | |
| 357 | + print("\n=== Paires qualité 4 vs 3 (cas 1) ===") | |
| 358 | + P = paires_qualite(d["ventes"], mod) | |
| 359 | + print(f" n = {P['resultat']['n']} ; différence moyenne {f(P['resultat']['moyenne'])} ; médiane {f(P['resultat']['mediane'])} ; écart-type {f(P['resultat']['ecart_type'])} ; erreur-type {f(P['resultat']['erreur_type'])}") | |
| 360 | + print(f" en % : moyenne {P['moyenne_pct']*100:.2f} ; médiane {P['mediane_pct']*100:.2f} ; écart-type {P['ecart_type_pct']*100:.2f} ; erreur-type {P['erreur_type_pct']*100:.2f} ; IC95 {P['ic95_pct'][0]*100:.2f}..{P['ic95_pct'][1]*100:.2f} ; hédonique {P['effet_hedonique_pct']*100:.2f} (β = {P['beta_qualite']:.5f})") | |
| 361 | + print(P["paires"].round(2).to_string()) | |
| 362 | + C = contribution_renovation(P, vs) | |
| 363 | + for k, v in C.items(): print(f" {k:26s} {v:,.4f}") | |
| 364 | + print("\n=== Composantes en fin de vie (fiche ch. 11, ratio ≥ 0,90) ===") | |
| 365 | + print(composantes_fin_de_vie().round(3).to_string()) | |
| 366 | + print("\n=== Exercices ===") | |
| 367 | + ex = exercices() | |
| 368 | + for k, v in ex["ex1"].items(): | |
| 369 | + print(f" ex1 {k}: {v if not isinstance(v, dict) else {kk: round(vv, 4) for kk, vv in v.items()}}") | |
| 370 | + print(f" ex2 {ex['ex2']}") | |
| 371 | + print(f" ex3 fenêtres : VAN {f(ex['ex3']['flux']['van'])} ; TRI {ex['ex3']['flux']['tri']} ; CEE net {ex['ex3']['flux']['cee_net']*100:.2f} ¢ ; VA éco {f(ex['ex3']['flux']['va_economies'])} ; sans sub VAN {f(ex['ex3']['sans_subvention']['van'])}") | |
| 372 | + print(f" ex4 CEE iso : 4,58 % net {ex['ex4']['r458']['cee_net']*100:.2f} ¢ brut {ex['ex4']['r458']['cee_brut']*100:.2f} ¢ ; 8 % net {ex['ex4']['r8']['cee_net']*100:.2f} ¢ ; VAN sans sub {f(ex['ex4']['sans_sub']['van'])}") | |
| 373 | + print(" ex5 " + str({k: round(v, 2) for k, v in ex["ex5"].items()})) | |
| 374 | + print(f" ex6 g_eq {ex['ex6']['g_equilibre']*100:.2f} % ; coût net eq {f(ex['ex6']['cout_net_equilibre'])} ; kWh eq {ex['ex6']['kwh_equilibre']:,.0f} ({ex['ex6']['fraction_chauffage_equilibre']*100:.1f} % du chauffage)") | |
| 375 | + for k in ("base", "large", "serre"): | |
| 376 | + p = ex["ex7"][k]; print(f" ex7 {k}: n {p['resultat']['n']} ; médiane {p['mediane_pct']*100:.2f} % ; moyenne {p['moyenne_pct']*100:.2f} % ; σ {p['ecart_type_pct']*100:.2f} %") | |
| 377 | + print(f" ex7 Kennedy {ex['ex7']['kennedy']*100:.3f} %") | |
| 378 | + return {"facture": F, "mesures": mm, "valeur_sujet": vs, "paires": P, "contribution": C, "exercices": ex} | |
| 379 | + | |
| 380 | + | |
| 381 | +if __name__ == "__main__": | |
| 382 | + main() | |
added
analysis/python/cases/ch46_dossier.py
+340 −0
@@ -0,0 +1,340 @@ | ||
| 1 | +"""Chapitre 46 — Cas 2 : triplex à Québec (Limoilou), dossier complet (DONNÉES SYNTHÉTIQUES, graine 20261031). | |
| 2 | + | |
| 3 | +Tout chiffre cité dans manuscript/chapters/ch46.tex et solutions/ch46.tex provient de ce script. Il s'appuie sur : | |
| 4 | +- traite.cas2 (revenus, RNE, 127 ventes de plex, indicateurs) — partagé avec les ch. 26, 27, 28 et 32 ; | |
| 5 | +- analysis/python/cases/ch32_plex.py (sélection des cinq comparables triplex de Limoilou, régression taux/loyer relatif, | |
| 6 | + financement résidentiel assuré, coût d'occupation) — importé, jamais recalculé autrement ; | |
| 7 | +- traite.finance (Hypotheque, capitalisation_directe, rne), traite.comparables (ComparableAjuste, poids_inverse_brut, | |
| 8 | + reconciliation, ajustement_temps_taux), traite.cout (cout_par_superficie_ajuste, depreciation_age_vie, valeur_par_cout). | |
| 9 | + | |
| 10 | +Ce que le chapitre 46 AJOUTE au chapitre 32 : normalisation des taxes aux tarifs 2026 de la Ville de Québec (RNE normalisé), | |
| 11 | +grille d'ajustements explicites en $/logement sur les cinq comparables (régression auxiliaire sur les 127 ventes), indication | |
| 12 | +par le coût (contrôle), droits de mutation de la Ville de Québec, valeur de la faculté de reprise, réconciliation et rapport. | |
| 13 | + | |
| 14 | +Exécution : python3 analysis/python/cases/ch46_dossier.py (depuis la racine du dépôt) | |
| 15 | +""" | |
| 16 | +from __future__ import annotations | |
| 17 | +import sys, pathlib | |
| 18 | +import numpy as np, pandas as pd | |
| 19 | + | |
| 20 | +ROOT = pathlib.Path(__file__).resolve().parents[3] | |
| 21 | +sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases")) | |
| 22 | +from traite import cas2, finance as fin, comparables as cmp, cout # noqa: E402 | |
| 23 | +import ch32_plex as C # noqa: E402 | |
| 24 | + | |
| 25 | +GRAINE = 20261031 | |
| 26 | +DATE_EVAL = "2026-09-01" | |
| 27 | + | |
| 28 | +# ----------------------------------------------------------------------------- paramètres déclarés (sources : notes-E, notes-B, notes-C, ch. 21/24) | |
| 29 | +TAUX_TAXE_1A5 = 0.7464 / 100 # Ville de Québec 2026, résidentiel 1 à 5 logements, $/100 $ (R.V.Q. 3492 ; SRC-E-027) | |
| 30 | +TARIF_AQUEDUC_EGOUT = 386.0 # $/logement (idem) | |
| 31 | +TARIF_MATIERES_RESIDUELLES = 195.0 # $/logement (idem) | |
| 32 | +TAUX_TAXE_SCOLAIRE = 0.07899 / 100 # 2026-2027, $/100 $ (SRC-E-026) ; exemption des premiers 25 000 $ | |
| 33 | +EXEMPTION_SCOLAIRE = 25_000.0 | |
| 34 | +FACTEUR_COMPARATIF_2026 = 1.08 # Ville de Québec, facteur comparatif 2026 (source secondaire, SRC-A-023) | |
| 35 | +# Droits sur les mutations immobilières, Ville de Québec, 2026 (LDMI art. 2 + taux municipaux ; SRC-A-023, partiellement vérifié) | |
| 36 | +TRANCHES_MUTATION_QUEBEC = [(62_900.0, 0.005), (315_000.0, 0.01), (500_000.0, 0.015), (750_000.0, 0.025), (float("inf"), 0.03)] | |
| 37 | +# Coût (contrôle seulement) : coût unitaire du cas 1 au T3 2026 (ch. 21, 2 409 $/m² habitable pour 165 m²), élasticité de taille −0,10, | |
| 38 | +# ratio IPCB « appartements < 5 étages » / « maison individuelle », Montréal, T3 2026 (118,5 / 116,5 ; tableau 18-10-0289-01) | |
| 39 | +COUT_UNITAIRE_CAS1, SUPERFICIE_CAS1, ELASTICITE = 2409.0, 165.0, -0.10 | |
| 40 | +IPCB_APPART, IPCB_MAISON = 118.5, 116.5 | |
| 41 | +VIE_ECONOMIQUE = 90.0 # hypothèse du dossier : plex de brique de 1948, vie économique totale | |
| 42 | +PHI_ETAT3 = 0.5 # fraction de vie consommée associée à la note d'état 3 (convention ch. 11/24) | |
| 43 | +TERRAIN_PAR_M2 = 900.0 # HYPOTHÈSE DU DOSSIER (non vérifiée) : valeur unitaire du terrain, Limoilou | |
| 44 | +POIDS_46 = {"par_logement_ajuste": 0.40, "mrb": 0.35, "capitalisation": 0.25} # pondération de l'ouvrage pour un triplex de quartier central (ch. 32, sect. 32.4) | |
| 45 | +T_MIN = 2.0 # seuil de significativité (|t|) pour appliquer un coefficient de la régression auxiliaire dans la grille | |
| 46 | +PAS_ARRONDI = 5_000.0 | |
| 47 | +ROTATION_QUEBEC = 0.13 # taux de rotation des locataires, RMR de Québec, SCHL oct. 2025 (SRC-E-007) | |
| 48 | +G_MARCHE, G_TAL, R_ACT = 0.035, 0.031, 0.06 # croissance des loyers de marché, pourcentage de base TAL 2026, taux d'actualisation (hypothèses, ch. 26/32) | |
| 49 | + | |
| 50 | + | |
| 51 | +# ----------------------------------------------------------------------------- 46.5 normalisation des taxes et RNE | |
| 52 | +def taxes_2026(valeur_role: float, nb_logements: int) -> dict: | |
| 53 | + """Taxes municipales (taux 1-5 logements + tarifs par logement) et taxe scolaire (exemption des premiers 25 000 $) sur la valeur au rôle.""" | |
| 54 | + fonciere = valeur_role * TAUX_TAXE_1A5 | |
| 55 | + tarifs = nb_logements * (TARIF_AQUEDUC_EGOUT + TARIF_MATIERES_RESIDUELLES) | |
| 56 | + scolaire = max(valeur_role - EXEMPTION_SCOLAIRE, 0.0) * TAUX_TAXE_SCOLAIRE | |
| 57 | + return {"fonciere": fonciere, "tarifs": tarifs, "municipale": fonciere + tarifs, "scolaire": scolaire, "total": fonciere + tarifs + scolaire} | |
| 58 | + | |
| 59 | + | |
| 60 | +def depenses_normalisees_2026(dep: pd.DataFrame, sujet: dict) -> pd.DataFrame: | |
| 61 | + """Dépenses du dossier avec les taxes recalculées aux taux 2026 (les autres postes sont conservés : polices, contrats, factures).""" | |
| 62 | + t = taxes_2026(sujet["valeur_role_2025"], sujet["nb_logements"]) | |
| 63 | + d = dep.copy(); d["declare"] = d["montant_annuel"].astype(float); d["montant_annuel"] = d["montant_annuel"].astype(float) | |
| 64 | + d.loc[d["poste"] == "Taxes municipales", "montant_annuel"] = t["municipale"] | |
| 65 | + d.loc[d["poste"] == "Taxe scolaire", "montant_annuel"] = t["scolaire"] | |
| 66 | + return d | |
| 67 | + | |
| 68 | + | |
| 69 | +def base_normalisee(d: dict) -> dict: | |
| 70 | + """RNE du dossier (taxes 2026) par traite.cas2.rne, et RNE « déclaré » (ch. 32) pour mémoire.""" | |
| 71 | + dep_n = depenses_normalisees_2026(d["depenses"], d["sujet"]) | |
| 72 | + b = cas2.rne(d["loyers"], dep_n) | |
| 73 | + b["detail"]["declare"] = dep_n["declare"].values | |
| 74 | + b_decl = cas2.rne(d["loyers"], d["depenses"]) | |
| 75 | + b["rne_declare"] = b_decl["rne"]; b["depenses_declarees"] = b_decl["depenses_totales"] | |
| 76 | + b["ecart_taxes"] = b_decl["depenses_totales"] - b["depenses_totales"] | |
| 77 | + b["taxes"] = taxes_2026(d["sujet"]["valeur_role_2025"], d["sujet"]["nb_logements"]) | |
| 78 | + # RNE aux loyers de marché (même structure de dépenses, gestion imputée sur le RBE de marché) | |
| 79 | + det = b["detail"]; dep_m = float(det.loc[~det["type"].isin(["imputée", "réserve"]), "normalise"].sum()) + cas2.GESTION_IMPUTEE * b["rbe_marche"] | |
| 80 | + b["rne_marche"] = fin.rne(b["rbe_marche"], dep_m, b["reserve"]) | |
| 81 | + b["loyer_marche_moyen"] = float(d["loyers"]["loyer_marche_estime"].mean()); b["loyer_contrat_moyen"] = float(d["loyers"]["loyer_mensuel"].mean()) | |
| 82 | + b["rho"] = b["rbp_contrat"] / b["rbp_marche"] | |
| 83 | + return b | |
| 84 | + | |
| 85 | + | |
| 86 | +# ----------------------------------------------------------------------------- 46.6 régression auxiliaire et grille d'ajustements | |
| 87 | +VARS = ["mois", "ln_sup_log", "age", "etat_1a5", "stationnement", "ln_loyer_marche", "loyer_relatif", "triplex", "q_Saint-Sauveur", "q_Saint-Roch", "q_Maizerets"] | |
| 88 | + | |
| 89 | + | |
| 90 | +def _matrice(c: pd.DataFrame) -> tuple[np.ndarray, np.ndarray]: | |
| 91 | + g = c.copy() | |
| 92 | + g["ln_sup_log"] = np.log(g["superficie_habitable_m2"] / g["nb_logements"]) | |
| 93 | + g["ln_loyer_marche"] = np.log(g["loyer_mensuel_moyen_marche"]) | |
| 94 | + g["loyer_relatif"] = g["loyer_mensuel_moyen_contrat"] / g["loyer_mensuel_moyen_marche"] | |
| 95 | + g["triplex"] = (g["nb_logements"] == 3).astype(float) | |
| 96 | + for q in ("Saint-Sauveur", "Saint-Roch", "Maizerets"): g[f"q_{q}"] = (g["quartier"] == q).astype(float) | |
| 97 | + X = np.column_stack([np.ones(len(g))] + [g[v].to_numpy(dtype=float) for v in VARS]) | |
| 98 | + y = np.log(g["prix_par_logement"].to_numpy(dtype=float)) | |
| 99 | + return X, y | |
| 100 | + | |
| 101 | + | |
| 102 | +def regression_auxiliaire(c: pd.DataFrame) -> dict: | |
| 103 | + """OLS : ln(prix par logement) sur l'ancienneté de la vente (mois), ln(m² par logement), l'âge, l'état, le stationnement, | |
| 104 | + ln(loyer de marché moyen), le loyer relatif, un indicateur triplex et les quartiers (référence Limoilou). Erreurs-types classiques.""" | |
| 105 | + X, y = _matrice(c); n, k = X.shape | |
| 106 | + beta, *_ = np.linalg.lstsq(X, y, rcond=None); res = y - X @ beta | |
| 107 | + s2 = float(res @ res) / (n - k); cov = s2 * np.linalg.inv(X.T @ X) | |
| 108 | + se = np.sqrt(np.diag(cov)); r2 = 1 - float(res @ res) / float(((y - y.mean()) ** 2).sum()) | |
| 109 | + noms = ["const"] + VARS | |
| 110 | + return {"beta": dict(zip(noms, beta)), "se": dict(zip(noms, se)), "t": dict(zip(noms, beta / se)), "r2": r2, "sigma": float(np.sqrt(s2)), "n": n, "k": k, | |
| 111 | + "taux_annuel_marche": float(np.exp(-12 * beta[1]) - 1)} # croissance annuelle implicite des prix (mois = ancienneté de la vente) | |
| 112 | + | |
| 113 | + | |
| 114 | +def grille_ajustements(comp5: pd.DataFrame, sujet: dict, base: dict, reg: dict) -> dict: | |
| 115 | + """Grille d'ajustements en $ par logement, ordre de l'ouvrage : conditions du marché (temps, %) puis attributs (additifs, $) : | |
| 116 | + superficie par logement, âge, état, stationnement, niveau de loyer de marché, loyer relatif. Signe du point de vue du sujet.""" | |
| 117 | + b = reg["beta"]; n_s = sujet["nb_logements"] | |
| 118 | + s = {"ln_sup_log": np.log(sujet["superficie_habitable_m2"] / n_s), "age": sujet["age"], "etat_1a5": sujet["etat_1a5"], "stationnement": sujet["stationnement"], | |
| 119 | + "ln_loyer_marche": np.log(base["loyer_marche_moyen"]), "loyer_relatif": base["rho"]} | |
| 120 | + g_annuel = reg["taux_annuel_marche"] | |
| 121 | + comps, lignes = [], [] | |
| 122 | + for _, r in comp5.iterrows(): | |
| 123 | + p = float(r["prix_par_logement"]) | |
| 124 | + a_t = cmp.ajustement_temps_taux(p, g_annuel, float(r["mois"])) # conditions du marché | |
| 125 | + p_t = p + a_t | |
| 126 | + x = {"ln_sup_log": np.log(r["superficie_habitable_m2"] / r["nb_logements"]), "age": float(r["age"]), "etat_1a5": float(r["etat_1a5"]), | |
| 127 | + "stationnement": float(r["stationnement"]), "ln_loyer_marche": np.log(r["loyer_mensuel_moyen_marche"]), | |
| 128 | + "loyer_relatif": r["loyer_mensuel_moyen_contrat"] / r["loyer_mensuel_moyen_marche"]} | |
| 129 | + aj = {"temps": a_t} | |
| 130 | + for v, lib in (("ln_sup_log", "superficie"), ("age", "age"), ("etat_1a5", "etat"), ("stationnement", "stationnement"), ("ln_loyer_marche", "loyer_marche"), ("loyer_relatif", "loyer_relatif")): | |
| 131 | + # seuls les coefficients significatifs (|t| >= T_MIN) sont appliqués ; le loyer relatif (non significatif) est traité par le taux et le multiplicateur | |
| 132 | + aj[lib] = p_t * (np.exp(b[v] * (s[v] - x[v])) - 1.0) if abs(reg["t"][v]) >= T_MIN else 0.0 | |
| 133 | + ca = cmp.ComparableAjuste(r["id_vente"], p, aj); comps.append(ca) | |
| 134 | + lignes.append({"id_vente": r["id_vente"], "date_vente": r["date_vente"].date().isoformat(), "mois": float(r["mois"]), "prix_vente": float(r["prix_vente"]), | |
| 135 | + "prix_logement": p, **aj, "prix_ajuste": ca.prix_ajuste, "brut_pct": ca.brut_pct, "net_pct": ca.net_pct}) | |
| 136 | + poids = cmp.poids_inverse_brut(comps); rec = cmp.reconciliation(comps, poids) | |
| 137 | + tab = pd.DataFrame(lignes); tab["poids"] = poids | |
| 138 | + se_log = rec["ecart_type_pondere"] * float(np.sqrt(sum(w * w for w in poids))) # erreur-type de la moyenne pondérée des prix ajustés | |
| 139 | + return {"comparables": comps, "tableau": tab, "reconciliation": rec, "poids": poids, "g_annuel": g_annuel, | |
| 140 | + "valeur_par_logement": rec["valeur_ponderee"], "valeur": rec["valeur_ponderee"] * n_s, | |
| 141 | + "moyenne_simple": rec["moyenne"] * n_s, "ecart_type_valeur": rec["ecart_type_pondere"] * n_s, "se_valeur": se_log * n_s} | |
| 142 | + | |
| 143 | + | |
| 144 | +# ----------------------------------------------------------------------------- 46.7 revenu | |
| 145 | +def indications_revenu(base: dict, ind: dict, comp5: pd.DataFrame, reg_taux: dict, valeur_comparaison: float, mrb_comp: float) -> dict: | |
| 146 | + rne = base["rne"]; rbp = base["rbp_contrat"] | |
| 147 | + taux_ajuste = reg_taux["a"] + reg_taux["b_rho"] * (base["rho"] - 1.0) | |
| 148 | + taux_comp = float(comp5["taux_global"].median()) | |
| 149 | + out = {"taux_median_127": ind["taux_median"], "taux_q1": ind["taux_q1"], "taux_q3": ind["taux_q3"], "taux_ajuste": taux_ajuste, "taux_comp": taux_comp, | |
| 150 | + "v_cap_median": fin.capitalisation_directe(rne, ind["taux_median"]), "v_cap_ajuste": fin.capitalisation_directe(rne, taux_ajuste), | |
| 151 | + "v_cap_comp": fin.capitalisation_directe(rne, taux_comp), "v_cap_q1": fin.capitalisation_directe(rne, ind["taux_q1"]), "v_cap_q3": fin.capitalisation_directe(rne, ind["taux_q3"]), | |
| 152 | + "v_cap_ajuste_rne_declare": fin.capitalisation_directe(base["rne_declare"], taux_ajuste), | |
| 153 | + "mrb_comp": mrb_comp, "v_mrb_comp": mrb_comp * rbp, "mrb_127": ind["mrb_median_triplex"], "v_mrb_127": ind["mrb_median_triplex"] * rbp, | |
| 154 | + "taux_implicite_comparaison": rne / valeur_comparaison, "mrb_implicite_comparaison": valeur_comparaison / rbp, | |
| 155 | + "taux_implicite_marche": base["rne_marche"] / valeur_comparaison} | |
| 156 | + # sensibilité : multiplicateur des comparables ramené au loyer relatif du sujet par la pente de la régression ln MRB (ch. 32) | |
| 157 | + rho_j = comp5["loyer_mensuel_moyen_contrat"] / comp5["loyer_mensuel_moyen_marche"] | |
| 158 | + mrb_aj = comp5["mrb"] * np.exp(reg_taux["mrb_b_rho"] * (base["rho"] - rho_j)) | |
| 159 | + out["mrb_comp_ajuste_rho"] = float(mrb_aj.median()); out["v_mrb_comp_ajuste_rho"] = out["mrb_comp_ajuste_rho"] * rbp | |
| 160 | + # lecture « taux implicite » : écart entre le taux ajusté et le taux implicite du prix de comparaison, en points de base | |
| 161 | + out["ecart_pb"] = (out["taux_ajuste"] - out["taux_implicite_comparaison"]) * 1e4 | |
| 162 | + # ratio de dépenses du sujet vs médiane des ventes : part de l'écart capitalisation/comparaison expliquée par la structure de dépenses | |
| 163 | + return out | |
| 164 | + | |
| 165 | + | |
| 166 | +# ----------------------------------------------------------------------------- 46.8 coût (contrôle) | |
| 167 | +def indication_cout(sujet: dict, terrain_par_m2: float = TERRAIN_PAR_M2, vie: float = VIE_ECONOMIQUE, phi: float = PHI_ETAT3) -> dict: | |
| 168 | + cu_taille = cout.cout_par_superficie_ajuste(COUT_UNITAIRE_CAS1, SUPERFICIE_CAS1, sujet["superficie_habitable_m2"], ELASTICITE) | |
| 169 | + cu = cout.indexer_cout(cu_taille, IPCB_MAISON, IPCB_APPART) | |
| 170 | + cn = cu * sujet["superficie_habitable_m2"] | |
| 171 | + a_eff = 0.5 * sujet["age"] + 0.5 * phi * vie # convention ch. 24 (eq. âge effectif) avec la note d'état 3 | |
| 172 | + a_eff = min(a_eff, vie) | |
| 173 | + dep = cout.depreciation_age_vie(cn, a_eff, vie) | |
| 174 | + terrain = terrain_par_m2 * sujet["superficie_terrain_m2"] | |
| 175 | + v = cout.valeur_par_cout(terrain, cn, dep) | |
| 176 | + return {"cu_taille": cu_taille, "cu": cu, "cout_neuf": cn, "age_effectif": a_eff, "vie": vie, "taux_depreciation": dep / cn, "depreciation": dep, | |
| 177 | + "terrain": terrain, "contribution_batiment": v["contribution_batiment"], "valeur": v["valeur"]} | |
| 178 | + | |
| 179 | + | |
| 180 | +def sensibilite_cout(sujet: dict) -> pd.DataFrame: | |
| 181 | + rows = [] | |
| 182 | + for t in (700.0, 900.0, 1100.0): | |
| 183 | + for vie, phi in ((80.0, 0.5), (90.0, 0.5), (100.0, 0.5), (90.0, 0.35)): | |
| 184 | + r = indication_cout(sujet, t, vie, phi); rows.append({"terrain_m2": t, "vie": vie, "phi": phi, "age_effectif": r["age_effectif"], "taux_dep": r["taux_depreciation"], "valeur": r["valeur"]}) | |
| 185 | + return pd.DataFrame(rows) | |
| 186 | + | |
| 187 | + | |
| 188 | +def depreciation_extraite(prix_marche: float, sujet: dict) -> dict: | |
| 189 | + """Lecture inverse : dépréciation que le marché implique si l'on retient le coût neuf et le terrain du dossier.""" | |
| 190 | + r = indication_cout(sujet) | |
| 191 | + return cout.extraction_depreciation_marche(prix_marche, r["terrain"], r["cout_neuf"], sujet["age"]) | |
| 192 | + | |
| 193 | + | |
| 194 | +# ----------------------------------------------------------------------------- 46.9 financement de l'acheteur-occupant | |
| 195 | +def droits_mutation_quebec(base_imposition: float) -> dict: | |
| 196 | + d, bas, detail = 0.0, 0.0, [] | |
| 197 | + for haut, taux in TRANCHES_MUTATION_QUEBEC: | |
| 198 | + tranche = max(min(base_imposition, haut) - bas, 0.0); detail.append({"de": bas, "a": haut, "taux": taux, "montant": tranche * taux}); d += tranche * taux | |
| 199 | + bas = haut | |
| 200 | + if base_imposition <= haut: break | |
| 201 | + return {"base": base_imposition, "droits": d, "detail": detail} | |
| 202 | + | |
| 203 | + | |
| 204 | +def financement(prix: float, base: dict, loyers: pd.DataFrame, sujet: dict) -> dict: | |
| 205 | + mf = C.mise_de_fonds_minimale_assuree(prix) | |
| 206 | + fi = C.financement_occupant(prix, mf, C.TAUX_ASSURE) | |
| 207 | + occ = C.cout_occupation(fi, base, loyers, "RC") | |
| 208 | + h = fin.Hypotheque(fi["pret"], C.TAUX_ASSURE, C.AMORT_MOIS) | |
| 209 | + base_mut = max(prix, sujet["valeur_role_2025"] * FACTEUR_COMPARATIF_2026) | |
| 210 | + mut = droits_mutation_quebec(base_mut) | |
| 211 | + autres = loyers[loyers["logement"] != "RC"]; loyers_bruts = float(autres["loyer_mensuel"].sum()) | |
| 212 | + variantes = [] | |
| 213 | + for lib, mfv, taux, amort in (("assuré, mise minimale, 25 ans", mf, C.TAUX_ASSURE, 300), ("assuré, mise minimale, 30 ans (premier acheteur)", mf, C.TAUX_ASSURE, 360), | |
| 214 | + ("assuré, mise de 10 %", 0.10 * prix, C.TAUX_ASSURE, 300), ("non assuré, mise de 20 %", 0.20 * prix, C.TAUX_NON_ASSURE, 300), | |
| 215 | + ("assuré, mise minimale, taux +100 pb", mf, C.TAUX_ASSURE + 0.01, 300), ("assuré, mise minimale, taux +200 pb", mf, C.TAUX_ASSURE + 0.02, 300)): | |
| 216 | + f = C.financement_occupant(prix, mfv, taux, amort); o = C.cout_occupation(f, base, loyers, "RC") | |
| 217 | + variantes.append({"scenario": lib, "mise_de_fonds": f["mise_de_fonds"], "rpv": f["rpv"], "pret": f["pret"], "taux": taux, "amort_mois": amort, "paiement_mensuel": f["paiement_mensuel"], | |
| 218 | + "paiement_mensuel_tam": f["paiement_mensuel_tam"], "cout_net_mensuel": o["cout_net_mensuel"], "cout_net_mensuel_hors_capital": o["cout_net_mensuel_hors_capital"]}) | |
| 219 | + return {**fi, "interets_an1": h.interets_annee(1), "capital_an1": h.capital_annee(1), "occupation": occ, "mutation": mut, "liquidites": mf + mut["droits"], | |
| 220 | + "loyers_bruts_autres": loyers_bruts, "part_paiement_couverte_brute": loyers_bruts / fi["paiement_mensuel"], | |
| 221 | + "part_paiement_couverte_nette": occ["loyers_percus_nets"] / 12 / fi["paiement_mensuel"], "variantes": pd.DataFrame(variantes), | |
| 222 | + "ratio_couverture_investisseur": base["rne"] / fi["service_dette"]} | |
| 223 | + | |
| 224 | + | |
| 225 | +def grille_cout_occupation(prix: float, base: dict, loyers: pd.DataFrame) -> pd.DataFrame: | |
| 226 | + """Coût économique mensuel d'occupation (hors capital) selon la mise de fonds (part du prix) et le taux hypothécaire.""" | |
| 227 | + rows = [] | |
| 228 | + for part in (None, 0.10, 0.15, 0.20): | |
| 229 | + for taux in np.arange(0.0312, 0.0713, 0.0025): | |
| 230 | + mf = C.mise_de_fonds_minimale_assuree(prix) if part is None else part * prix | |
| 231 | + t = taux if (part is None or part < 0.20) else taux + (C.TAUX_NON_ASSURE - C.TAUX_ASSURE) | |
| 232 | + f = C.financement_occupant(prix, mf, t, 300); o = C.cout_occupation(f, base, loyers, "RC") | |
| 233 | + rows.append({"mise": "minimale" if part is None else f"{int(part*100)} %", "taux": taux, "taux_effectif": t, "cout_net_mensuel": o["cout_net_mensuel"], "hors_capital": o["cout_net_mensuel_hors_capital"]}) | |
| 234 | + return pd.DataFrame(rows) | |
| 235 | + | |
| 236 | + | |
| 237 | +# ----------------------------------------------------------------------------- 46.3 valeur de la faculté de reprise (rez-de-chaussée) | |
| 238 | +def va_ecart_rc(loyers: pd.DataFrame, rotation: float = ROTATION_QUEBEC, g: float = G_MARCHE, g_tal: float = G_TAL, r: float = R_ACT, v: float = cas2.TAUX_VACANCE_PERTES) -> dict: | |
| 239 | + rc = loyers[loyers["logement"] == "RC"].iloc[0]; lc, lm = float(rc["loyer_mensuel"]), float(rc["loyer_marche_estime"]) | |
| 240 | + delta1 = (lm - lc) * 12 * (1 - v) | |
| 241 | + # écart croissant : marché à g, contrat à g_tal ; valeur actuelle sur 10 ans sans rotation (reprise impossible, locataire reste) | |
| 242 | + va_10 = sum(((lm * (1 + g) ** (t - 1) - lc * (1 + g_tal) ** (t - 1)) * 12 * (1 - v)) / (1 + r) ** t for t in range(1, 11)) | |
| 243 | + # espérance sous rotation géométrique (ch. 26, éq. va-ecart) avec g = croissance de l'écart ≈ croissance moyenne pondérée | |
| 244 | + g_ecart = (lm * (1 + g) - lc * (1 + g_tal)) / (lm - lc) - 1 | |
| 245 | + va_rot = delta1 / (r - g_ecart + rotation * (1 + g_ecart)) | |
| 246 | + return {"loyer_contrat": lc, "loyer_marche": lm, "ecart_mensuel": lm - lc, "delta1": delta1, "g_ecart": g_ecart, "va_10ans_sans_rotation": va_10, "va_rotation": va_rot, | |
| 247 | + "valeur_faculte_reprise": va_10 - va_rot, "date_limite_avis": "2026-12-31", "echeance_bail": "2027-06-30"} | |
| 248 | + | |
| 249 | + | |
| 250 | +# ----------------------------------------------------------------------------- 46.10 réconciliation | |
| 251 | +def reconciliation(v_log: float, v_mrb: float, v_cap: float, base: dict, sujet: dict, sigma_log: float, poids: dict = POIDS_46) -> dict: | |
| 252 | + ret = {"par_logement_ajuste": v_log, "mrb": v_mrb, "capitalisation": v_cap} | |
| 253 | + vp = sum(poids[k] * ret[k] for k in ret) | |
| 254 | + arr = round(vp / PAS_ARRONDI) * PAS_ARRONDI | |
| 255 | + alt = {"retenue 40/35/25": vp, "45/30/25": 0.45 * v_log + 0.30 * v_mrb + 0.25 * v_cap, "égale": sum(ret.values()) / 3, | |
| 256 | + "comparaison seule 60/40/0": 0.60 * v_log + 0.40 * v_mrb, "revenu dominant 20/20/60": 0.2 * v_log + 0.2 * v_mrb + 0.6 * v_cap} | |
| 257 | + fourchette = (min(ret.values()), max(ret.values())) | |
| 258 | + # fourchette conclue : ± une erreur-type de la moyenne pondérée des prix ajustés (comparaison) autour de la valeur pondérée, arrondie vers l'extérieur | |
| 259 | + bas = np.floor((vp - sigma_log) / PAS_ARRONDI) * PAS_ARRONDI; haut = np.ceil((vp + sigma_log) / PAS_ARRONDI) * PAS_ARRONDI | |
| 260 | + contributions = {k: poids[k] * ret[k] for k in ret} | |
| 261 | + return {"retenues": ret, "poids": poids, "contributions": contributions, "valeur_ponderee": vp, "valeur_finale": arr, "alternatives": alt, "fourchette_indications": fourchette, "se_comparaison": sigma_log, | |
| 262 | + "fourchette_conclue": (bas, haut), "prix_par_logement": arr / sujet["nb_logements"], "prix_m2": arr / sujet["superficie_habitable_m2"], | |
| 263 | + "taux_implicite": base["rne"] / arr, "taux_implicite_marche": base["rne_marche"] / arr, "mrb_implicite": arr / base["rbp_contrat"], | |
| 264 | + "ratio_role": arr / sujet["valeur_role_2025"], "ratio_role_indexe": arr / (sujet["valeur_role_2025"] * FACTEUR_COMPARATIF_2026), | |
| 265 | + "ecart_ch32": arr - 870_000.0, "ecart_ch32_pct": arr / 870_000.0 - 1} | |
| 266 | + | |
| 267 | + | |
| 268 | +# ----------------------------------------------------------------------------- tout | |
| 269 | +def dossier() -> dict: | |
| 270 | + r = cas2.tout(); d = {k: r[k] for k in ("ventes", "sujet", "loyers", "depenses")} | |
| 271 | + base = base_normalisee(d); c = r["comparables"]; ind = r["indicateurs"] | |
| 272 | + cand = C.selection_comparables(c, d["sujet"], base); comp5 = cand[cand["retenu"]] | |
| 273 | + reg = regression_auxiliaire(c); gr = grille_ajustements(comp5, d["sujet"], base, reg) | |
| 274 | + reg_taux = C.regression_taux_loyer_relatif(C.decomposition_taux(c)) | |
| 275 | + rev = indications_revenu(base, ind, comp5, reg_taux, gr["valeur"], float(comp5["mrb"].median())) | |
| 276 | + rec = reconciliation(gr["valeur"], rev["v_mrb_comp"], rev["v_cap_ajuste"], base, d["sujet"], gr["se_valeur"]) | |
| 277 | + co = indication_cout(d["sujet"]); co_sens = sensibilite_cout(d["sujet"]); dep_ext = depreciation_extraite(rec["valeur_finale"], d["sujet"]) | |
| 278 | + fi = financement(rec["valeur_finale"], base, d["loyers"], d["sujet"]); grille_occ = grille_cout_occupation(rec["valeur_finale"], base, d["loyers"]) | |
| 279 | + rep = va_ecart_rc(d["loyers"]) | |
| 280 | + ch32 = C.indications(C.cas2.rne(d["loyers"], d["depenses"]), d["sujet"], ind, comp5, reg_taux, c) | |
| 281 | + return {**d, "base": base, "comparables127": c, "indicateurs": ind, "candidats": cand, "comparables5": comp5, "regression": reg, "grille": gr, | |
| 282 | + "regression_taux": reg_taux, "revenu": rev, "cout": co, "cout_sensibilite": co_sens, "depreciation_extraite": dep_ext, "financement": fi, | |
| 283 | + "grille_occupation": grille_occ, "reprise": rep, "reconciliation": rec, "ch32": ch32} | |
| 284 | + | |
| 285 | + | |
| 286 | +# ----------------------------------------------------------------------------- exercices | |
| 287 | +def exercices(D: dict | None = None) -> dict: | |
| 288 | + D = D or dossier(); b = D["base"]; s = D["sujet"]; rec = D["reconciliation"]; ex = {} | |
| 289 | + # 1. taxes : compte déclaré vs recalcul 2026, effet sur le RNE et sur la capitalisation au taux ajusté | |
| 290 | + t = b["taxes"]; ex["1"] = {"municipale_2026": t["municipale"], "scolaire_2026": t["scolaire"], "declare": 5900 + 470, "ecart": b["ecart_taxes"], "rne_declare": b["rne_declare"], "rne_2026": b["rne"], | |
| 291 | + "v_cap_declare": fin.capitalisation_directe(b["rne_declare"], D["revenu"]["taux_ajuste"]), "v_cap_2026": D["revenu"]["v_cap_ajuste"]} | |
| 292 | + # 2. avis de reprise : calendrier et VA de l'écart | |
| 293 | + ex["2"] = D["reprise"] | |
| 294 | + # 3. grille : refaire la cascade de Q0243 et vérifier brut/net | |
| 295 | + tab = D["grille"]["tableau"]; q = tab[tab["id_vente"] == "Q0243"].iloc[0].to_dict(); ex["3"] = q | |
| 296 | + # 4. capitalisation : taux médian, Q1, Q3, ajusté, comparables | |
| 297 | + ex["4"] = {k: D["revenu"][k] for k in ("taux_median_127", "taux_q1", "taux_q3", "taux_ajuste", "taux_comp", "v_cap_median", "v_cap_q1", "v_cap_q3", "v_cap_ajuste", "v_cap_comp")} | |
| 298 | + # 5. financement : prix final, mise de fonds, paiement, TAM, coût net ; variante 2e étage | |
| 299 | + f = D["financement"]; occ2 = C.cout_occupation({k: f[k] for k in ("mise_de_fonds", "service_dette", "capital_rembourse_5ans")}, b, D["loyers"], "2e") | |
| 300 | + ex["5"] = {"prix": f["prix"], "mise": f["mise_de_fonds"], "pret": f["pret"], "paiement": f["paiement_mensuel"], "tam": f["taux_admissible"], "paiement_tam": f["paiement_mensuel_tam"], | |
| 301 | + "cout_net_rc": f["occupation"]["cout_net_mensuel"], "hors_capital_rc": f["occupation"]["cout_net_mensuel_hors_capital"], "cout_net_2e": occ2["cout_net_mensuel"], "hors_capital_2e": occ2["cout_net_mensuel_hors_capital"], | |
| 302 | + "droits_mutation": f["mutation"]["droits"], "liquidites": f["liquidites"]} | |
| 303 | + # 6. pondérations alternatives | |
| 304 | + ex["6"] = rec["alternatives"] | |
| 305 | + # 7. coût : sensibilité et dépréciation extraite | |
| 306 | + ex["7"] = {"cout": D["cout"], "extraite": D["depreciation_extraite"]} | |
| 307 | + return ex | |
| 308 | + | |
| 309 | + | |
| 310 | +def _d(x): return f"{x:,.2f} $".replace(",", " ") | |
| 311 | +def _p(x, k=2): return f"{100 * x:.{k}f} %" | |
| 312 | + | |
| 313 | + | |
| 314 | +if __name__ == "__main__": | |
| 315 | + D = dossier(); b = D["base"]; s = D["sujet"] | |
| 316 | + print("== 46.2/46.5 Sujet, taxes 2026, RNE normalisé (traite.cas2.rne sur dépenses normalisées)") | |
| 317 | + print(b["taxes"]); print(f"écart taxes déclarées − 2026 : {_d(b['ecart_taxes'])}") | |
| 318 | + print(b["detail"][["poste", "declare", "normalise", "type"]].to_string(index=False)) | |
| 319 | + print(f"RBP {_d(b['rbp_contrat'])} ; marché {_d(b['rbp_marche'])} ; ρ {b['rho']:.4f} ; RBE {_d(b['rbe'])} ; dépenses {_d(b['depenses_totales'])} (ratio {_p(b['ratio_depenses'],1)}) ; " | |
| 320 | + f"RNE 2026 {_d(b['rne'])} ; RNE déclaré (ch. 32) {_d(b['rne_declare'])} ; RNE marché {_d(b['rne_marche'])}") | |
| 321 | + print("\n== 46.6 Régression auxiliaire (127 ventes, ln prix/logement)") | |
| 322 | + reg = D["regression"] | |
| 323 | + for k in reg["beta"]: print(f" {k:18s} β = {reg['beta'][k]:+.5f} (e.-t. {reg['se'][k]:.5f}, t = {reg['t'][k]:+.2f})") | |
| 324 | + print(f" R² {reg['r2']:.3f} ; σ {reg['sigma']:.4f} ; croissance annuelle implicite des prix {_p(reg['taux_annuel_marche'])}") | |
| 325 | + print("\n== Comparables (ch. 32) et grille d'ajustements ($/logement)") | |
| 326 | + cols = ["id_vente", "date_vente", "superficie_habitable_m2", "annee_construction", "etat_1a5", "stationnement", "loyer_mensuel_moyen_contrat", "loyer_mensuel_moyen_marche", "loyer_relatif", "prix_vente", "mrb", "taux_global", "prix_par_logement", "mois"] | |
| 327 | + print(D["comparables5"][cols].assign(date_vente=lambda x: x["date_vente"].dt.date).round(3).to_string(index=False)) | |
| 328 | + print(D["grille"]["tableau"].round(2).to_string(index=False)) | |
| 329 | + g = D["grille"]; print(f"prix/logement pondéré {_d(g['valeur_par_logement'])} → valeur {_d(g['valeur'])} ; moyenne simple {_d(g['moyenne_simple'])} ; é.-t. {_d(g['ecart_type_valeur'])} ; " | |
| 330 | + f"médiane {g['reconciliation']['mediane']:.0f} ; min {g['reconciliation']['min']:.0f} ; max {g['reconciliation']['max']:.0f} ; étendue {_p(g['reconciliation']['etendue_pct'],1)}") | |
| 331 | + print("\n== 46.7 Revenu"); print({k: (round(v, 6) if isinstance(v, float) else v) for k, v in D["revenu"].items()}) | |
| 332 | + print("\n== 46.8 Coût (contrôle)"); print({k: round(v, 2) for k, v in D["cout"].items()}); print(D["cout_sensibilite"].round(3).to_string(index=False)); print(D["depreciation_extraite"]) | |
| 333 | + print("\n== 46.10 Réconciliation"); rec = D["reconciliation"] | |
| 334 | + for k, v in rec.items(): print(f" {k}: {v}") | |
| 335 | + print("\n== 46.9 Financement au prix final"); f = D["financement"] | |
| 336 | + print({k: (round(v, 4) if isinstance(v, float) else v) for k, v in f.items() if k not in ("occupation", "mutation", "variantes")}) | |
| 337 | + print({k: round(v, 2) for k, v in f["occupation"].items() if isinstance(v, float)}); print(f["mutation"]); print(f["variantes"].round(2).to_string(index=False)) | |
| 338 | + print("\n== 46.3 Reprise du rez-de-chaussée"); print({k: (round(v, 2) if isinstance(v, float) else v) for k, v in D["reprise"].items()}) | |
| 339 | + print("\n== Exercices"); E = exercices(D) | |
| 340 | + for k, v in E.items(): print(k, v) | |
added
analysis/python/cases/ch48_portefeuille.py
+457 −0
@@ -0,0 +1,457 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""Chapitre 48 — Cas 4 : portefeuille multirésidentiel interrégional (DONNÉES SYNTHÉTIQUES, 48 immeubles fictifs, 6 RMR). | |
| 3 | + | |
| 4 | +Dossier complet d'évaluation de portefeuille : sommaire par RMR, distribution des tailles/âges/états, modèle d'évaluation de | |
| 5 | +masse (régression semi-logarithmique du taux global : traite.cas4.effets_regionaux, facteur de Duan, valeur prédite par immeuble, | |
| 6 | +statistiques de ratio IAAO), dynamique des RNE 2016-2026 (croissance, volatilité, choc pandémique, corrélations intra- et | |
| 7 | +inter-régionales), risques (inondation, sol, concentration, dette : échéancier, RCD, renouvellement à +100/+200 pb, capacité de | |
| 8 | +refinancement), agrégation de l'incertitude par Monte-Carlo avec corrélation régionale (copule gaussienne, décomposition de Cholesky, | |
| 9 | +10 000 tirages, graine 20261031 ; démonstration de la diversification : VaR du portefeuille vs somme des VaR), allocation | |
| 10 | +(score rendement/croissance/risque, frontière rendement-risque par RMR), réconciliation et exercices. | |
| 11 | + | |
| 12 | +Les calculs partagés viennent de `traite.cas4` (sommaire, croissance_rne, effets_regionaux), `traite.finance` (hypothèques, | |
| 13 | +RCD, capitalisation) et `traite.hedonique` (MCO HC1, statistiques de ratio) ; ce script n'y ajoute que les calculs propres au | |
| 14 | +dossier et IMPRIME tous les chiffres cités dans manuscript/chapters/ch48.tex. | |
| 15 | + | |
| 16 | +Hypothèses déclarées (voir §48.1 et §48.6 du chapitre) : | |
| 17 | + - date d'évaluation 2026-06-30 (fin de la série de RNE) ; valeurs et RNE « 2026 » du fichier = indications individuelles ; | |
| 18 | + - dette du fichier = solde au 2026-06-30 ; service de la dette calculé sur un amortissement de 480 mois (prêts assurés | |
| 19 | + multilogement, même hypothèse que le ch. 33 pour P45) ; sensibilité à 300 mois ; | |
| 20 | + - renouvellement : à l'échéance, le solde (formule prospective) est refinancé sur l'amortissement résiduel au taux + Δ. | |
| 21 | + | |
| 22 | +Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch48_portefeuille.py [--n 10000] | |
| 23 | +""" | |
| 24 | +from __future__ import annotations | |
| 25 | +import sys, pathlib, argparse, math | |
| 26 | +import numpy as np, pandas as pd | |
| 27 | +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) | |
| 28 | +from traite import cas4, finance as fin, hedonique as h | |
| 29 | + | |
| 30 | +ROOT = pathlib.Path(__file__).resolve().parents[3] | |
| 31 | +GRAINE = 20261031 | |
| 32 | +DATE_EVAL = "2026-06-30" | |
| 33 | +RMR = ["Montréal", "Québec", "Gatineau", "Sherbrooke", "Trois-Rivières", "Saguenay"] | |
| 34 | +RMR_REG = ["Québec", "Gatineau", "Sherbrooke", "Trois-Rivières", "Saguenay"] # indicatrices (référence Montréal) | |
| 35 | + | |
| 36 | +# ----------------------------------------------------------------------------- hypothèses propres au chapitre 48 | |
| 37 | +AMORT_BASE = 480 # mois (prêts assurés multilogement ; cohérent avec le ch. 33, P45) | |
| 38 | +AMORT_ALT = 300 # sensibilité | |
| 39 | +RCD_MIN = 1.20 # covenant conventionnel (ch. 31, 47) | |
| 40 | +RPV_MAX = 0.75 | |
| 41 | +CHOCS_PB = (0, 100, 200) | |
| 42 | +N_MC = 10_000 | |
| 43 | +# corrélations déclarées du choc de TAUX (facteur commun des marchés de capitaux) ; celles du choc de RNE sont estimées sur les séries | |
| 44 | +RHO_TAUX = {"global": 0.50, "regional": 0.70} | |
| 45 | +OBLIG_10_ANS = 0.0384 # Banque du Canada, 2026-10-29 (notes-C §4.1) — repère pour la frontière | |
| 46 | +ARRONDI_PORTEFEUILLE = 100_000 | |
| 47 | + | |
| 48 | +# ----------------------------------------------------------------------------- utilitaires | |
| 49 | + | |
| 50 | +def _d(x): return f"{x:,.0f}".replace(",", " ") + " $" | |
| 51 | +def _p(x, d=2): return f"{100*x:.{d}f}".replace(".", ",") + " %" | |
| 52 | + | |
| 53 | +def arrondir(x: float, pas: int = ARRONDI_PORTEFEUILLE) -> float: | |
| 54 | + return round(x / pas) * pas | |
| 55 | + | |
| 56 | +# ----------------------------------------------------------------------------- 48.2 portefeuille | |
| 57 | + | |
| 58 | +def sommaire_etendu(pf: pd.DataFrame) -> dict: | |
| 59 | + """Sommaire de traite.cas4.sommaire complété : médiane et étendue des taux, taille moyenne, âge, état, loyer, RNE/logement, part de la valeur.""" | |
| 60 | + s = cas4.sommaire(pf); g = s["par_rmr"].copy() | |
| 61 | + agg = pf.groupby("rmr").agg(taux_median=("taux_global", "median"), taux_min=("taux_global", "min"), taux_max=("taux_global", "max"), | |
| 62 | + taille_moy=("nb_logements", "mean"), age_moy=("age", "mean"), etat_moy=("etat_1a5", "mean"), | |
| 63 | + loyer_moy=("loyer_mensuel_moyen", "mean"), inondables=("zone_inondable", "sum"), sols=("contamination_sol", "sum")) | |
| 64 | + g = g.join(agg); g["rne_par_logement"] = g["rne"] / g["logements"]; g["part_valeur"] = g["valeur"] / s["total"]["valeur"] | |
| 65 | + g["taux_dette_pondere"] = pf.groupby("rmr").apply(lambda d: (d["dette"] * d["taux_dette"]).sum() / d["dette"].sum(), include_groups=False) | |
| 66 | + g = g.loc[RMR] | |
| 67 | + tot = s["total"]; tot["valeur_par_logement"] = tot["valeur"] / tot["logements"]; tot["rne_par_logement"] = tot["rne"] / tot["logements"] | |
| 68 | + tot["taux_dette_pondere"] = float((pf["dette"] * pf["taux_dette"]).sum() / pf["dette"].sum()) | |
| 69 | + tot["taux_median"] = float(pf["taux_global"].median()); tot["taux_moyen"] = float(pf["taux_global"].mean()); tot["taux_sd"] = float(pf["taux_global"].std(ddof=1)) | |
| 70 | + tot["part_sol"] = float(pf.loc[pf["contamination_sol"] == 1, "valeur_2026"].sum() / tot["valeur"]) | |
| 71 | + # distributions | |
| 72 | + tailles = pf.groupby("nb_logements").agg(n=("id_immeuble", "count"), logements=("nb_logements", "sum"), valeur=("valeur_2026", "sum"), taux_median=("taux_global", "median")) | |
| 73 | + bornes_age = [0, 30, 50, 65, 200]; etiq_age = ["< 30 ans", "30-49 ans", "50-64 ans", "65 ans et +"] | |
| 74 | + ages = pf.assign(classe=pd.cut(pf["age"], bornes_age, labels=etiq_age, right=False)).groupby("classe", observed=False).agg(n=("id_immeuble", "count"), valeur=("valeur_2026", "sum"), taux_median=("taux_global", "median"), etat_moy=("etat_1a5", "mean")) | |
| 75 | + etats = pf.groupby("etat_1a5").agg(n=("id_immeuble", "count"), valeur=("valeur_2026", "sum"), taux_median=("taux_global", "median"), loyer_moy=("loyer_mensuel_moyen", "mean")) | |
| 76 | + # concentration (Herfindahl) par RMR et par immeuble, sur la valeur | |
| 77 | + w_rmr = g["part_valeur"].values; w_imm = (pf["valeur_2026"] / tot["valeur"]).values | |
| 78 | + conc = {"hhi_rmr": float((w_rmr ** 2).sum()), "hhi_immeuble": float((w_imm ** 2).sum()), "n_equiv_rmr": float(1 / (w_rmr ** 2).sum()), "n_equiv_immeuble": float(1 / (w_imm ** 2).sum()), | |
| 79 | + "top5_part": float(np.sort(w_imm)[-5:].sum()), "top5_ids": pf.nlargest(5, "valeur_2026")["id_immeuble"].tolist()} | |
| 80 | + return {"par_rmr": g, "total": tot, "tailles": tailles, "ages": ages, "etats": etats, "concentration": conc} | |
| 81 | + | |
| 82 | +# ----------------------------------------------------------------------------- 48.4 modèle d'évaluation de masse | |
| 83 | + | |
| 84 | +def regression_masse(pf: pd.DataFrame) -> dict: | |
| 85 | + """Même spécification que traite.cas4.effets_regionaux (coefficients identiques) ; conserve résidus, R², facteur de Duan et la matrice X.""" | |
| 86 | + eff = cas4.effets_regionaux(pf) | |
| 87 | + X = pd.DataFrame({"const": 1.0, "etat": pf["etat_1a5"] - 3, "ln_logements": np.log(pf["nb_logements"] / 12), "inondable": pf["zone_inondable"], | |
| 88 | + "sol": pf["contamination_sol"], "age": (pf["age"] - 50) / 10}) | |
| 89 | + for r in RMR_REG: X[f"rmr_{r}"] = (pf["rmr"] == r).astype(float) | |
| 90 | + res = h.mco(X.values, np.log(pf["taux_global"].values)) | |
| 91 | + assert np.allclose(res["beta"], eff["coef"].values) | |
| 92 | + duan = float(np.mean(np.exp(res["residus"]))) | |
| 93 | + tab = eff.copy(); tab["t_hc1"] = tab["coef"] / tab["se_hc1"] | |
| 94 | + tab["effet_pb_a_520"] = tab["effet_pct"] * 0.0520 * 1e4 # effet en points de base au taux pondéré du portefeuille | |
| 95 | + tab["ic_bas_pct"] = np.exp(tab["coef"] - 1.96 * tab["se_hc1"]) - 1; tab["ic_haut_pct"] = np.exp(tab["coef"] + 1.96 * tab["se_hc1"]) - 1 | |
| 96 | + return {"table": tab, "beta": res["beta"], "colonnes": list(X.columns), "X": X.values, "residus": res["residus"], "sigma": float(np.sqrt(res["sigma2"])), | |
| 97 | + "r2": float(res["r2"]), "r2_ajuste": float(res["r2_ajuste"]), "n": int(res["n"]), "k": int(res["k"]), "duan": duan} | |
| 98 | + | |
| 99 | +def x_ligne(rmr: str, etat: int, n: int, age: float, inond: int = 0, sol: int = 0) -> np.ndarray: | |
| 100 | + x = [1.0, etat - 3, math.log(n / 12), inond, sol, (age - 50) / 10] + [1.0 if rmr == r else 0.0 for r in RMR_REG] | |
| 101 | + return np.array(x) | |
| 102 | + | |
| 103 | +def taux_predit(reg: dict, rmr: str, etat: int, n: int, age: float, inond: int = 0, sol: int = 0, duan: bool = True) -> float: | |
| 104 | + return float(np.exp(x_ligne(rmr, etat, n, age, inond, sol) @ reg["beta"]) * (reg["duan"] if duan else 1.0)) | |
| 105 | + | |
| 106 | +def valeurs_predites(pf: pd.DataFrame, reg: dict) -> pd.DataFrame: | |
| 107 | + """Taux prédit et valeur prédite (RNE / taux prédit) de chaque immeuble ; ratio valeur prédite / indication individuelle.""" | |
| 108 | + t = pf.copy() | |
| 109 | + t["taux_predit"] = np.exp(reg["X"] @ reg["beta"]) * reg["duan"] | |
| 110 | + t["valeur_predite"] = t["rne_2026"] / t["taux_predit"] | |
| 111 | + t["ratio"] = t["valeur_predite"] / t["valeur_2026"] | |
| 112 | + t["ecart_pb"] = (t["taux_predit"] - t["taux_global"]) * 1e4 | |
| 113 | + t["rpv"] = t["dette"] / t["valeur_2026"]; t["valeur_par_logement"] = t["valeur_2026"] / t["nb_logements"] | |
| 114 | + return t | |
| 115 | + | |
| 116 | +def ratios_iaao(t: pd.DataFrame) -> dict: | |
| 117 | + """Statistiques de ratio (IAAO) : modèle de masse vs indications individuelles, portefeuille et par RMR.""" | |
| 118 | + out = {"portefeuille": h.statistiques_ratio(t["valeur_predite"].values, t["valeur_2026"].values)} | |
| 119 | + for r in RMR: | |
| 120 | + s = t[t["rmr"] == r] | |
| 121 | + out[r] = h.statistiques_ratio(s["valeur_predite"].values, s["valeur_2026"].values) if len(s) >= 4 else {"n": len(s), "ratio_median": float((s["valeur_predite"] / s["valeur_2026"]).median()), "COD": float("nan"), "PRD": float("nan"), "PRB": float("nan")} | |
| 122 | + out["somme_predite"] = float(t["valeur_predite"].sum()); out["somme_individuelle"] = float(t["valeur_2026"].sum()) | |
| 123 | + out["ecart_agrege_pct"] = out["somme_predite"] / out["somme_individuelle"] - 1 | |
| 124 | + out["extremes"] = t.loc[(t["ratio"] - 1).abs().nlargest(5).index, ["id_immeuble", "rmr", "nb_logements", "etat_1a5", "taux_global", "taux_predit", "valeur_2026", "valeur_predite", "ratio"]] | |
| 125 | + return out | |
| 126 | + | |
| 127 | +# ----------------------------------------------------------------------------- 48.5 dynamique des RNE | |
| 128 | + | |
| 129 | +def dynamique_rne(se: pd.DataFrame, pf: pd.DataFrame) -> dict: | |
| 130 | + cr = cas4.croissance_rne(se).merge(pf[["id_immeuble", "rmr", "valeur_2026", "zone_inondable"]], on="id_immeuble") | |
| 131 | + piv = se.pivot(index="trimestre", columns="id_immeuble", values="rne_trimestriel").sort_index() | |
| 132 | + rmr_de = pf.set_index("id_immeuble")["rmr"] | |
| 133 | + agg_rmr = piv.T.groupby(rmr_de).sum().T[RMR]; agg_tot = piv.sum(axis=1) | |
| 134 | + t = np.arange(len(piv)) / 4.0 | |
| 135 | + def croiss(y): | |
| 136 | + b = np.polyfit(t, np.log(y.values), 1)[0]; return float(np.exp(b) - 1) | |
| 137 | + def vol(y): return float(np.std(np.diff(np.log(y.values))) * 2) | |
| 138 | + reg_tab = pd.DataFrame({"croissance_agregee": {r: croiss(agg_rmr[r]) for r in RMR}, "volatilite_agregee": {r: vol(agg_rmr[r]) for r in RMR}, | |
| 139 | + "croissance_mediane_immeubles": cr.groupby("rmr")["croissance_annuelle"].median(), "croissance_min": cr.groupby("rmr")["croissance_annuelle"].min(), | |
| 140 | + "croissance_max": cr.groupby("rmr")["croissance_annuelle"].max(), "volatilite_mediane_immeubles": cr.groupby("rmr")["volatilite"].median(), | |
| 141 | + "n": cr.groupby("rmr")["id_immeuble"].count()}).loc[RMR] | |
| 142 | + tot = {"croissance_agregee": croiss(agg_tot), "volatilite_agregee": vol(agg_tot), "croissance_mediane_immeubles": float(cr["croissance_annuelle"].median()), | |
| 143 | + "volatilite_mediane_immeubles": float(cr["volatilite"].median()), "rne_2016T1_annualise": float(agg_tot.iloc[0] * 4), "rne_2026T2_annualise": float(agg_tot.iloc[-1] * 4), | |
| 144 | + "rne_fichier_2026": float(pf["rne_2026"].sum())} | |
| 145 | + # choc pandémique : écart moyen (log) à la tendance pendant 2020T2-2021T2, par RMR et total | |
| 146 | + def choc(y): | |
| 147 | + ly = np.log(y.values); b = np.polyfit(t, ly, 1); trend = np.polyval(b, t) | |
| 148 | + m = (piv.index >= pd.Period("2020Q2", "Q")) & (piv.index <= pd.Period("2021Q2", "Q")) | |
| 149 | + return float(np.exp(np.mean(ly[m] - trend[m])) - 1) | |
| 150 | + pand = {r: choc(agg_rmr[r]) for r in RMR}; pand["Portefeuille"] = choc(agg_tot) | |
| 151 | + # variation glissante sur 4 trimestres de l'agrégat (point bas) | |
| 152 | + g4 = agg_tot / agg_tot.shift(4) - 1 | |
| 153 | + # corrélations des variations trimestrielles (log) entre immeubles | |
| 154 | + dl = np.log(piv).diff().dropna() | |
| 155 | + C = dl.corr(); ids = list(piv.columns); r_of = rmr_de.loc[ids].values | |
| 156 | + same = np.equal.outer(r_of, r_of); iu = np.triu_indices(len(ids), 1) | |
| 157 | + cvals = C.values[iu]; svals = same[iu] | |
| 158 | + corr = {"intra_rmr_moyenne": float(cvals[svals].mean()), "inter_rmr_moyenne": float(cvals[~svals].mean()), "toutes_moyenne": float(cvals.mean()), | |
| 159 | + "intra_rmr_mediane": float(np.median(cvals[svals])), "inter_rmr_mediane": float(np.median(cvals[~svals])), "n_paires_intra": int(svals.sum()), "n_paires_inter": int((~svals).sum())} | |
| 160 | + # corrélations hors période pandémique (le choc commun crée de la corrélation) | |
| 161 | + m_hors = ~((dl.index >= pd.Period("2020Q1", "Q")) & (dl.index <= pd.Period("2021Q3", "Q"))) | |
| 162 | + C2 = dl[m_hors].corr().values[iu] | |
| 163 | + corr["intra_hors_pandemie"] = float(C2[svals].mean()); corr["inter_hors_pandemie"] = float(C2[~svals].mean()) | |
| 164 | + corr_rmr = np.log(agg_rmr).diff().dropna().corr() | |
| 165 | + return {"par_immeuble": cr, "par_rmr": reg_tab, "total": tot, "agregats_rmr": agg_rmr, "agregat_total": agg_tot, "pandemie": pand, "glissant4": g4, | |
| 166 | + "correlations": corr, "corr_rmr": corr_rmr, "matrice_corr_immeubles": C, "diff_log": dl} | |
| 167 | + | |
| 168 | +# ----------------------------------------------------------------------------- 48.6 risques et dette | |
| 169 | + | |
| 170 | +def risques(pf: pd.DataFrame, reg: dict, t: pd.DataFrame) -> dict: | |
| 171 | + tot_v = float(pf["valeur_2026"].sum()) | |
| 172 | + inond = pf[pf["zone_inondable"] == 1].copy(); sol = pf[pf["contamination_sol"] == 1].copy() | |
| 173 | + b_in = float(reg["table"].loc["inondable", "coef"]); b_sol = float(reg["table"].loc["sol", "coef"]) | |
| 174 | + # valeur « sans le risque » : même RNE, taux divisé par exp(β) (effet proportionnel du modèle) | |
| 175 | + inond["taux_sans_risque"] = inond["taux_global"] / np.exp(b_in); inond["valeur_sans_risque"] = inond["valeur_2026"] * np.exp(b_in) # même RNE, taux ÷ exp(β) | |
| 176 | + inond["decote"] = inond["valeur_2026"] / inond["valeur_sans_risque"] - 1; inond["pb"] = (inond["taux_global"] - inond["taux_sans_risque"]) * 1e4 | |
| 177 | + sol["taux_sans_risque"] = sol["taux_global"] / np.exp(b_sol); sol["valeur_sans_risque"] = sol["valeur_2026"] * np.exp(b_sol) | |
| 178 | + sol["decote"] = sol["valeur_2026"] / sol["valeur_sans_risque"] - 1; sol["pb"] = (sol["taux_global"] - sol["taux_sans_risque"]) * 1e4 | |
| 179 | + return {"inondables": inond, "part_inondable": float(inond["valeur_2026"].sum() / tot_v), "valeur_inondable": float(inond["valeur_2026"].sum()), | |
| 180 | + "decote_inondable_agregee": float(inond["valeur_2026"].sum() / inond["valeur_sans_risque"].sum() - 1), "effet_inondable_pct_taux": float(np.exp(b_in) - 1), | |
| 181 | + "sols": sol, "part_sol": float(sol["valeur_2026"].sum() / tot_v), "valeur_sol": float(sol["valeur_2026"].sum()), "effet_sol_pct_taux": float(np.exp(b_sol) - 1), | |
| 182 | + "decote_sol_agregee": float(sol["valeur_2026"].sum() / sol["valeur_sans_risque"].sum() - 1), | |
| 183 | + "dette_inondable": float(inond["dette"].sum()), "rpv_inondable": float(inond["dette"].sum() / inond["valeur_2026"].sum())} | |
| 184 | + | |
| 185 | +def dette(pf: pd.DataFrame, amort: int = AMORT_BASE, chocs=CHOCS_PB, croissance: pd.Series | None = None) -> dict: | |
| 186 | + """Service de la dette par immeuble (amortissement `amort`), RCD, échéancier, renouvellement à +Δ pb, capacité de refinancement.""" | |
| 187 | + rows = [] | |
| 188 | + for r in pf.to_dict("records"): | |
| 189 | + hyp = fin.Hypotheque(float(r["dette"]), float(r["taux_dette"]), amort) | |
| 190 | + sd = hyp.service_dette_annuel; rne = float(r["rne_2026"]) | |
| 191 | + annees = int(r["echeance_dette"]) - 2026; mois = annees * 12 | |
| 192 | + solde = hyp.solde(mois); amort_res = amort - mois | |
| 193 | + g = float(croissance.loc[r["id_immeuble"]]) if croissance is not None else 0.0 | |
| 194 | + rne_ech = rne * (1 + g) ** annees | |
| 195 | + row = {"id_immeuble": r["id_immeuble"], "rmr": r["rmr"], "valeur": r["valeur_2026"], "dette": r["dette"], "taux_dette": r["taux_dette"], "echeance": r["echeance_dette"], | |
| 196 | + "rpv": r["dette"] / r["valeur_2026"], "service_dette": sd, "constante": hyp.constante_hypothecaire, "rcd": rne / sd, "solde_echeance": solde, "rne_echeance": rne_ech, | |
| 197 | + "levier": "négatif" if hyp.constante_hypothecaire > r["taux_global"] else "positif"} | |
| 198 | + for pb in chocs: | |
| 199 | + h2 = fin.Hypotheque(solde, float(r["taux_dette"]) + pb / 1e4, amort_res) | |
| 200 | + sd2 = h2.service_dette_annuel | |
| 201 | + cap_rcd = fin.pret_max_par_rcd(rne_ech, RCD_MIN, float(r["taux_dette"]) + pb / 1e4, amort_res) | |
| 202 | + cap_rpv = fin.pret_max_par_rpv(float(r["valeur_2026"]), RPV_MAX) | |
| 203 | + cap = min(cap_rcd, cap_rpv) | |
| 204 | + row[f"sd_+{pb}"] = sd2; row[f"rcd_+{pb}"] = rne_ech / sd2; row[f"capacite_+{pb}"] = cap; row[f"injection_+{pb}"] = max(solde - cap, 0.0) | |
| 205 | + rows.append(row) | |
| 206 | + d = pd.DataFrame(rows) | |
| 207 | + tot = {"dette": float(d["dette"].sum()), "service_dette": float(d["service_dette"].sum()), "rcd_agrege": float(pf["rne_2026"].sum() / d["service_dette"].sum()), | |
| 208 | + "rcd_median": float(d["rcd"].median()), "rcd_min": float(d["rcd"].min()), "id_rcd_min": d.loc[d["rcd"].idxmin(), "id_immeuble"], | |
| 209 | + "n_rcd_sous_1.20": int((d["rcd"] < 1.20).sum()), "n_rcd_sous_1.00": int((d["rcd"] < 1.0).sum()), | |
| 210 | + "dette_rcd_sous_1.20": float(d.loc[d["rcd"] < 1.20, "dette"].sum()), "dette_rcd_sous_1.00": float(d.loc[d["rcd"] < 1.0, "dette"].sum()), | |
| 211 | + "constante_pondere": float(d["service_dette"].sum() / d["dette"].sum()), "n_levier_negatif": int((d["levier"] == "négatif").sum()), | |
| 212 | + "valeur_levier_negatif": float(d.loc[d["levier"] == "négatif", "valeur"].sum() / d["valeur"].sum())} | |
| 213 | + for pb in chocs: | |
| 214 | + tot[f"rcd_agrege_+{pb}"] = float(d["rne_echeance"].sum() / d[f"sd_+{pb}"].sum()) | |
| 215 | + tot[f"n_rcd_sous_1.20_+{pb}"] = int((d[f"rcd_+{pb}"] < 1.20).sum()); tot[f"n_rcd_sous_1.00_+{pb}"] = int((d[f"rcd_+{pb}"] < 1.0).sum()) | |
| 216 | + tot[f"injection_+{pb}"] = float(d[f"injection_+{pb}"].sum()); tot[f"n_injection_+{pb}"] = int((d[f"injection_+{pb}"] > 0).sum()) | |
| 217 | + tot[f"solde_echeance"] = float(d["solde_echeance"].sum()) | |
| 218 | + ech = d.groupby("echeance").agg(n=("id_immeuble", "count"), dette=("dette", "sum"), solde=("solde_echeance", "sum"), rne=("rne_echeance", "sum")) | |
| 219 | + for pb in chocs: | |
| 220 | + ech[f"sd_+{pb}"] = d.groupby("echeance")[f"sd_+{pb}"].sum(); ech[f"rcd_+{pb}"] = ech["rne"] / ech[f"sd_+{pb}"] | |
| 221 | + ech[f"injection_+{pb}"] = d.groupby("echeance")[f"injection_+{pb}"].sum() | |
| 222 | + ech["part_dette"] = ech["dette"] / tot["dette"] | |
| 223 | + ech_rmr = d.pivot_table(index="echeance", columns="rmr", values="dette", aggfunc="sum").fillna(0.0)[RMR] | |
| 224 | + par_rmr = d.groupby("rmr").agg(dette=("dette", "sum"), sd=("service_dette", "sum"), rpv=("rpv", "mean")).loc[RMR] | |
| 225 | + par_rmr["rcd"] = pf.groupby("rmr")["rne_2026"].sum().loc[RMR] / par_rmr["sd"]; par_rmr["rpv_pondere"] = par_rmr["dette"] / pf.groupby("rmr")["valeur_2026"].sum().loc[RMR] | |
| 226 | + return {"par_immeuble": d, "total": tot, "echeancier": ech, "echeancier_rmr": ech_rmr, "par_rmr": par_rmr, "amortissement": amort} | |
| 227 | + | |
| 228 | +# ----------------------------------------------------------------------------- 48.7 Monte-Carlo avec corrélation régionale | |
| 229 | + | |
| 230 | +def matrice_correlation(rmr_vec: np.ndarray, rho_global: float, rho_regional: float) -> np.ndarray: | |
| 231 | + """Matrice de corrélation à deux facteurs (national, régional) : ρ_ij = ρ_reg si même RMR, ρ_glob sinon, 1 sur la diagonale.""" | |
| 232 | + same = np.equal.outer(rmr_vec, rmr_vec) | |
| 233 | + S = np.where(same, rho_regional, rho_global).astype(float); np.fill_diagonal(S, 1.0) | |
| 234 | + return S | |
| 235 | + | |
| 236 | +def simuler_portefeuille(pf: pd.DataFrame, dyn: dict, reg: dict, n: int = N_MC, graine: int = GRAINE, rho_taux: dict = RHO_TAUX, rho_rne: dict | None = None, | |
| 237 | + sigma_taux: float | None = None, regime: str = "declare") -> dict: | |
| 238 | + """Simulation de la valeur de chaque immeuble : ln V_i = ln RNE_i + ε_i − ln R_i − η_i, avec (ε, η) gaussiens corrélés entre immeubles par une | |
| 239 | + structure à deux facteurs (copule gaussienne, échantillonnée par décomposition de Cholesky). σ_ε,i = volatilité annualisée estimée du RNE de | |
| 240 | + l'immeuble ; σ_η = écart-type résiduel de la régression du ln taux (même pour tous). regime ∈ {declare, independant, parfait}.""" | |
| 241 | + ids = pf["id_immeuble"].values; rmr_vec = pf["rmr"].values; N = len(pf) | |
| 242 | + vol = dyn["par_immeuble"].set_index("id_immeuble").loc[ids, "volatilite"].values | |
| 243 | + sig_eta = reg["sigma"] if sigma_taux is None else sigma_taux | |
| 244 | + corr = dyn["correlations"] | |
| 245 | + # estimation sur les séries : corrélation inter-RMR moyenne des variations trimestrielles ; la composante régionale additionnelle est | |
| 246 | + # max(intra − inter, 0) — nulle dans ce jeu, où le seul facteur commun est le choc pandémique (voir §48.5) | |
| 247 | + if rho_rne is None: rho_rne = {"global": max(corr["inter_rmr_moyenne"], 0.0), "regional": max(corr["intra_rmr_moyenne"], corr["inter_rmr_moyenne"], 0.0)} | |
| 248 | + if regime == "independant": S_e = np.eye(N); S_n = np.eye(N) | |
| 249 | + elif regime == "parfait": S_e = np.ones((N, N)); S_n = np.ones((N, N)) | |
| 250 | + else: | |
| 251 | + S_e = matrice_correlation(rmr_vec, rho_rne["global"], rho_rne["regional"]); S_n = matrice_correlation(rmr_vec, rho_taux["global"], rho_taux["regional"]) | |
| 252 | + L_e = np.linalg.cholesky(S_e + 1e-10 * np.eye(N)); L_n = np.linalg.cholesky(S_n + 1e-10 * np.eye(N)) | |
| 253 | + rng = np.random.default_rng(graine) | |
| 254 | + Z_e = rng.standard_normal((n, N)) @ L_e.T; Z_n = rng.standard_normal((n, N)) @ L_n.T | |
| 255 | + eps = Z_e * vol[None, :]; eta = Z_n * sig_eta | |
| 256 | + V0 = pf["valeur_2026"].values.astype(float); RNE0 = pf["rne_2026"].values.astype(float); R0 = pf["taux_global"].values.astype(float) | |
| 257 | + V = (RNE0[None, :] * np.exp(eps)) / (R0[None, :] * np.exp(eta)) | |
| 258 | + Vp = V.sum(1); tot0 = V0.sum() | |
| 259 | + q = lambda a, p: float(np.quantile(a, p)) | |
| 260 | + stats = {"V0": float(tot0), "moyenne": float(Vp.mean()), "mediane": q(Vp, 0.5), "ecart_type": float(Vp.std(ddof=1)), "cv": float(Vp.std(ddof=1) / Vp.mean()), | |
| 261 | + "q05": q(Vp, 0.05), "q25": q(Vp, 0.25), "q75": q(Vp, 0.75), "q95": q(Vp, 0.95), "q01": q(Vp, 0.01), "min": float(Vp.min()), "max": float(Vp.max())} | |
| 262 | + # VaR à 95 % (perte par rapport à la valeur centrale V0) : portefeuille vs somme des VaR individuelles | |
| 263 | + var_p = tot0 - q(Vp, 0.05); var_i = V0 - np.quantile(V, 0.05, axis=0); somme_var_i = float(var_i.sum()) | |
| 264 | + es_p = tot0 - float(Vp[Vp <= q(Vp, 0.05)].mean()) | |
| 265 | + dette_tot = float(pf["dette"].sum()) | |
| 266 | + probs = {"rpv_sup_0.65": float((dette_tot / Vp > 0.65).mean()), "rpv_sup_0.70": float((dette_tot / Vp > 0.70).mean()), "rpv_sup_0.75": float((dette_tot / Vp > 0.75).mean()), | |
| 267 | + "V_sous_130M": float((Vp < 130e6).mean()), "V_sous_120M": float((Vp < 120e6).mean()), "baisse_sup_10pct": float((Vp < 0.9 * tot0).mean()), "baisse_sup_20pct": float((Vp < 0.8 * tot0).mean())} | |
| 268 | + # par RMR | |
| 269 | + par_rmr = {} | |
| 270 | + for r in RMR: | |
| 271 | + m = rmr_vec == r; Vr = V[:, m].sum(1); v0 = V0[m].sum() | |
| 272 | + par_rmr[r] = {"V0": float(v0), "mediane": q(Vr, 0.5), "q05": q(Vr, 0.05), "q95": q(Vr, 0.95), "cv": float(Vr.std(ddof=1) / Vr.mean()), "var95": float(v0 - q(Vr, 0.05)), | |
| 273 | + "somme_var_individuelles": float((V0[m] - np.quantile(V[:, m], 0.05, axis=0)).sum())} | |
| 274 | + par_rmr = pd.DataFrame(par_rmr).T | |
| 275 | + sigma_ln_i = np.sqrt(vol ** 2 + sig_eta ** 2) | |
| 276 | + return {"n": n, "graine": graine, "regime": regime, "valeur_portefeuille": Vp, "valeurs": V, "stats": stats, "var95_portefeuille": var_p, "somme_var95_individuelles": somme_var_i, | |
| 277 | + "ratio_diversification": var_p / somme_var_i, "es95_portefeuille": es_p, "var95_individuelles": var_i, "probabilites": probs, "par_rmr": par_rmr, | |
| 278 | + "parametres": {"sigma_taux": sig_eta, "rho_taux": rho_taux, "rho_rne": rho_rne, "vol_rne_mediane": float(np.median(vol)), "vol_rne_min": float(vol.min()), "vol_rne_max": float(vol.max())}, | |
| 279 | + "sigma_ln_individuel": sigma_ln_i, "cv_individuel_median": float(np.median(V.std(axis=0, ddof=1) / V.mean(axis=0)))} | |
| 280 | + | |
| 281 | +def variance_normale_portefeuille(sigmas: np.ndarray, poids: np.ndarray, S: np.ndarray) -> dict: | |
| 282 | + """Démonstration analytique : σ_P² = w'Σw ; comparaison avec (Σ w_i σ_i)² (corrélation parfaite) et Σ w_i² σ_i² (indépendance).""" | |
| 283 | + Sig = np.outer(sigmas, sigmas) * S | |
| 284 | + return {"sigma_portefeuille": float(np.sqrt(poids @ Sig @ poids)), "sigma_parfait": float((poids * sigmas).sum()), "sigma_independant": float(np.sqrt((poids ** 2 * sigmas ** 2).sum()))} | |
| 285 | + | |
| 286 | +# ----------------------------------------------------------------------------- 48.8 allocation et arbitrage | |
| 287 | + | |
| 288 | +def allocation(pf: pd.DataFrame, dyn: dict, mc: dict, det: dict) -> dict: | |
| 289 | + t = pf[["id_immeuble", "rmr", "nb_logements", "age", "etat_1a5", "zone_inondable", "contamination_sol", "rne_2026", "taux_global", "valeur_2026", "dette", "echeance_dette"]].copy() | |
| 290 | + cr = dyn["par_immeuble"].set_index("id_immeuble") | |
| 291 | + t["croissance"] = cr.loc[t["id_immeuble"], "croissance_annuelle"].values; t["volatilite_rne"] = cr.loc[t["id_immeuble"], "volatilite"].values | |
| 292 | + t["rendement_total"] = t["taux_global"] + t["croissance"] # R + g (Gordon, ch. 29) | |
| 293 | + t["sigma_valeur"] = mc["sigma_ln_individuel"] | |
| 294 | + t["ratio_rr"] = (t["rendement_total"] - OBLIG_10_ANS) / t["sigma_valeur"] # excédent de rendement par unité de risque | |
| 295 | + d = det["par_immeuble"].set_index("id_immeuble") | |
| 296 | + t["rcd"] = d.loc[t["id_immeuble"], "rcd"].values; t["rcd_+200"] = d.loc[t["id_immeuble"], "rcd_+200"].values; t["rpv"] = t["dette"] / t["valeur_2026"] | |
| 297 | + t["drapeaux"] = t["zone_inondable"] + t["contamination_sol"] + (t["rcd_+200"] < 1.0).astype(int) + (t["etat_1a5"] <= 2).astype(int) | |
| 298 | + t["score"] = t["ratio_rr"].rank(pct=True) - 0.10 * t["drapeaux"] | |
| 299 | + t = t.sort_values("score", ascending=False).reset_index(drop=True) | |
| 300 | + vendre = t.tail(6).copy(); conserver = t.head(6).copy() | |
| 301 | + # portefeuille après cession des 6 candidats (produit net = valeur − dette ; coûts de vente 2 %) | |
| 302 | + reste = pf[~pf["id_immeuble"].isin(vendre["id_immeuble"])] | |
| 303 | + apres = {"n": len(reste), "valeur": float(reste["valeur_2026"].sum()), "rne": float(reste["rne_2026"].sum()), "dette": float(reste["dette"].sum()), | |
| 304 | + "taux_pondere": float(reste["rne_2026"].sum() / reste["valeur_2026"].sum()), "rpv": float(reste["dette"].sum() / reste["valeur_2026"].sum()), | |
| 305 | + "produit_net_cession": float((vendre["valeur_2026"] * 0.98 - vendre["dette"]).sum()), "valeur_cedee": float(vendre["valeur_2026"].sum()), | |
| 306 | + "part_inondable": float(reste.loc[reste["zone_inondable"] == 1, "valeur_2026"].sum() / reste["valeur_2026"].sum())} | |
| 307 | + # frontière rendement-risque par RMR : μ_r = taux pondéré + croissance agrégée ; Σ_r = covariance des rendements simulés des agrégats régionaux | |
| 308 | + g = cas4.sommaire(pf)["par_rmr"].loc[RMR] | |
| 309 | + mu = (g["taux_pondere"] + dyn["par_rmr"]["croissance_agregee"]).values | |
| 310 | + rmr_vec = pf["rmr"].values; V = mc["valeurs"] | |
| 311 | + rend = np.column_stack([np.log(V[:, rmr_vec == r].sum(1) / pf.loc[rmr_vec == r, "valeur_2026"].sum()) for r in RMR]) | |
| 312 | + Sigma = np.cov(rend.T); sig = np.sqrt(np.diag(Sigma)) | |
| 313 | + w0 = (g["valeur"] / g["valeur"].sum()).values | |
| 314 | + from scipy.optimize import minimize | |
| 315 | + def frontiere(cibles): | |
| 316 | + pts = [] | |
| 317 | + for m in cibles: | |
| 318 | + cons = [{"type": "eq", "fun": lambda w: w.sum() - 1}, {"type": "eq", "fun": lambda w, m=m: w @ mu - m}] | |
| 319 | + r = minimize(lambda w: w @ Sigma @ w, np.full(6, 1 / 6), bounds=[(0, 1)] * 6, constraints=cons, method="SLSQP", options={"ftol": 1e-12, "maxiter": 500}) | |
| 320 | + if r.success: pts.append({"mu": m, "sigma": float(np.sqrt(r.fun)), "poids": r.x}) | |
| 321 | + return pts | |
| 322 | + cibles = np.linspace(mu.min() + 1e-6, mu.max() - 1e-6, 41) | |
| 323 | + fr = frontiere(cibles) | |
| 324 | + r_min = minimize(lambda w: w @ Sigma @ w, np.full(6, 1 / 6), bounds=[(0, 1)] * 6, constraints=[{"type": "eq", "fun": lambda w: w.sum() - 1}], method="SLSQP") | |
| 325 | + w_min = r_min.x | |
| 326 | + # portefeuille de même rendement attendu que l'actuel, à risque minimal | |
| 327 | + mu0 = float(w0 @ mu); sig0 = float(np.sqrt(w0 @ Sigma @ w0)) | |
| 328 | + r_same = minimize(lambda w: w @ Sigma @ w, w0, bounds=[(0, 1)] * 6, constraints=[{"type": "eq", "fun": lambda w: w.sum() - 1}, {"type": "eq", "fun": lambda w: w @ mu - mu0}], method="SLSQP", options={"ftol": 1e-12}) | |
| 329 | + # portefeuille de même risque, rendement maximal | |
| 330 | + r_maxmu = minimize(lambda w: -(w @ mu), w0, bounds=[(0, 1)] * 6, constraints=[{"type": "eq", "fun": lambda w: w.sum() - 1}, {"type": "ineq", "fun": lambda w: sig0 ** 2 - w @ Sigma @ w}], method="SLSQP", options={"ftol": 1e-12}) | |
| 331 | + front = {"mu": mu, "sigma": sig, "Sigma": Sigma, "corr": Sigma / np.outer(sig, sig), "w_actuel": w0, "mu_actuel": mu0, "sigma_actuel": sig0, | |
| 332 | + "frontiere": fr, "w_min_var": w_min, "mu_min_var": float(w_min @ mu), "sigma_min_var": float(np.sqrt(w_min @ Sigma @ w_min)), | |
| 333 | + "w_meme_mu": r_same.x, "sigma_meme_mu": float(np.sqrt(r_same.x @ Sigma @ r_same.x)), "w_meme_sigma": r_maxmu.x, "mu_meme_sigma": float(r_maxmu.x @ mu)} | |
| 334 | + return {"table": t, "vendre": vendre, "conserver": conserver, "apres_cession": apres, "frontiere": front} | |
| 335 | + | |
| 336 | +# ----------------------------------------------------------------------------- 48.9 réconciliation | |
| 337 | + | |
| 338 | +def reconciliation(pf: pd.DataFrame, iaao: dict, mc: dict, det: dict, som: dict) -> dict: | |
| 339 | + ind = float(pf["valeur_2026"].sum()); pred = iaao["somme_predite"] | |
| 340 | + V = arrondir(ind) | |
| 341 | + return {"somme_individuelle": ind, "somme_modele": pred, "ecart_modele_pct": pred / ind - 1, "valeur_retenue": V, | |
| 342 | + "fourchette_mc": (mc["stats"]["q05"], mc["stats"]["q95"]), "fourchette_mc_pct": (mc["stats"]["q05"] / ind - 1, mc["stats"]["q95"] / ind - 1), | |
| 343 | + "valeur_par_logement": V / som["total"]["logements"], "taux_implicite": som["total"]["rne"] / V, "rpv": som["total"]["dette"] / V, | |
| 344 | + "rcd_agrege": det["total"]["rcd_agrege"], "par_rmr": som["par_rmr"]["valeur"], "mrb": V / float(pf["revenu_brut_potentiel"].sum()), | |
| 345 | + "fonds_propres": V - som["total"]["dette"]} | |
| 346 | + | |
| 347 | +# ----------------------------------------------------------------------------- exercices | |
| 348 | + | |
| 349 | +def exercices(D: dict) -> dict: | |
| 350 | + pf, reg, dyn, det, mc = D["portefeuille"], D["regression"], D["dynamique"], D["dette"], D["mc"] | |
| 351 | + out = {} | |
| 352 | + # ex1 : Sherbrooke — taux pondéré vs moyenne simple, valeur/logement, RPV | |
| 353 | + s = pf[pf["rmr"] == "Sherbrooke"] | |
| 354 | + out["ex1"] = {"n": len(s), "logements": int(s["nb_logements"].sum()), "valeur": float(s["valeur_2026"].sum()), "rne": float(s["rne_2026"].sum()), "dette": float(s["dette"].sum()), | |
| 355 | + "taux_pondere": float(s["rne_2026"].sum() / s["valeur_2026"].sum()), "taux_moyen_simple": float(s["taux_global"].mean()), "taux_median": float(s["taux_global"].median()), | |
| 356 | + "valeur_par_logement": float(s["valeur_2026"].sum() / s["nb_logements"].sum()), "rpv": float(s["dette"].sum() / s["valeur_2026"].sum()), | |
| 357 | + "valeur_au_taux_moyen": float(s["rne_2026"].sum() / s["taux_global"].mean()), "ecart_pct": float(s["rne_2026"].sum() / s["taux_global"].mean() / s["valeur_2026"].sum() - 1)} | |
| 358 | + # ex2 : modèle de masse — 24 log., état 3, 45 ans, Trois-Rivières, inondable ; RNE 150 000 | |
| 359 | + rne = 150_000.0 | |
| 360 | + R_in = taux_predit(reg, "Trois-Rivières", 3, 24, 45, inond=1); R_hors = taux_predit(reg, "Trois-Rivières", 3, 24, 45, inond=0); R_mtl = taux_predit(reg, "Montréal", 3, 24, 45, inond=0) | |
| 361 | + out["ex2"] = {"taux_inondable": R_in, "taux_hors_zone": R_hors, "taux_montreal": R_mtl, "valeur_inondable": rne / R_in, "valeur_hors_zone": rne / R_hors, "valeur_montreal": rne / R_mtl, | |
| 362 | + "ecart_inondation": rne / R_in / (rne / R_hors) - 1, "pb_inondation": (R_in - R_hors) * 1e4, "duan": reg["duan"], "sans_duan": float(np.exp(x_ligne("Trois-Rivières", 3, 24, 45, 1) @ reg["beta"]))} | |
| 363 | + # ex3 : P45 — RCD 480 et 300 mois, renouvellement 2028 à +200 pb, capacité de refinancement | |
| 364 | + p = pf.set_index("id_immeuble").loc["P45"] | |
| 365 | + h480 = fin.Hypotheque(float(p["dette"]), float(p["taux_dette"]), 480); h300 = fin.Hypotheque(float(p["dette"]), float(p["taux_dette"]), 300) | |
| 366 | + mois = (int(p["echeance_dette"]) - 2026) * 12; solde = h480.solde(mois); t2 = float(p["taux_dette"]) + 0.02 | |
| 367 | + h2 = fin.Hypotheque(solde, t2, 480 - mois); cap = min(fin.pret_max_par_rcd(float(p["rne_2026"]), RCD_MIN, t2, 480 - mois), 0.75 * float(p["valeur_2026"])) | |
| 368 | + out["ex3"] = {"dette": float(p["dette"]), "taux": float(p["taux_dette"]), "rne": float(p["rne_2026"]), "sd_480": h480.service_dette_annuel, "rcd_480": float(p["rne_2026"]) / h480.service_dette_annuel, | |
| 369 | + "sd_300": h300.service_dette_annuel, "rcd_300": float(p["rne_2026"]) / h300.service_dette_annuel, "solde_2028": solde, "taux_renouvele": t2, "sd_renouvele": h2.service_dette_annuel, | |
| 370 | + "rcd_renouvele": float(p["rne_2026"]) / h2.service_dette_annuel, "capacite_rcd": fin.pret_max_par_rcd(float(p["rne_2026"]), RCD_MIN, t2, 480 - mois), "capacite_rpv": 0.75 * float(p["valeur_2026"]), | |
| 371 | + "capacite": cap, "injection": max(solde - cap, 0.0), "valeur_ch33": 6_716_534.0, "rpv_ch33": float(p["dette"]) / 6_716_534.0} | |
| 372 | + # ex4 : ratios IAAO sur le sous-ensemble montréalais | |
| 373 | + t = D["predites"]; m = t[t["rmr"] == "Montréal"] | |
| 374 | + out["ex4"] = {**h.statistiques_ratio(m["valeur_predite"].values, m["valeur_2026"].values), "portefeuille": D["iaao"]["portefeuille"]} | |
| 375 | + # ex5 : choc de taux généralisé — ρ_taux global 0,90 / régional 0,95 | |
| 376 | + mc5 = simuler_portefeuille(pf, dyn, reg, n=mc["n"], graine=GRAINE, rho_taux={"global": 0.90, "regional": 0.95}) | |
| 377 | + out["ex5"] = {"q05": mc5["stats"]["q05"], "q95": mc5["stats"]["q95"], "cv": mc5["stats"]["cv"], "var95": mc5["var95_portefeuille"], "ratio_div": mc5["ratio_diversification"], | |
| 378 | + "p_rpv_sup_0.75": mc5["probabilites"]["rpv_sup_0.75"], "base_var95": mc["var95_portefeuille"], "base_ratio_div": mc["ratio_diversification"], "base_cv": mc["stats"]["cv"]} | |
| 379 | + # ex6 : cession des 3 immeubles en zone inondable | |
| 380 | + inond = pf[pf["zone_inondable"] == 1]; reste = pf[pf["zone_inondable"] == 0] | |
| 381 | + mc6 = simuler_portefeuille(reste.reset_index(drop=True), dyn, reg, n=mc["n"], graine=GRAINE) | |
| 382 | + d6 = dette(reste) | |
| 383 | + out["ex6"] = {"ids": inond["id_immeuble"].tolist(), "valeur_cedee": float(inond["valeur_2026"].sum()), "dette_remboursee": float(inond["dette"].sum()), "produit_net": float(inond["valeur_2026"].sum() * 0.98 - inond["dette"].sum()), | |
| 384 | + "rne_perdu": float(inond["rne_2026"].sum()), "taux_pondere_cede": float(inond["rne_2026"].sum() / inond["valeur_2026"].sum()), | |
| 385 | + "apres_valeur": float(reste["valeur_2026"].sum()), "apres_taux_pondere": float(reste["rne_2026"].sum() / reste["valeur_2026"].sum()), "apres_rpv": float(reste["dette"].sum() / reste["valeur_2026"].sum()), | |
| 386 | + "apres_rcd": d6["total"]["rcd_agrege"], "avant_rcd": det["total"]["rcd_agrege"], "apres_cv": mc6["stats"]["cv"], "avant_cv": mc["stats"]["cv"], "apres_var95": mc6["var95_portefeuille"], "avant_var95": mc["var95_portefeuille"], | |
| 387 | + "apres_ratio_div": mc6["ratio_diversification"], "avant_ratio_div": mc["ratio_diversification"]} | |
| 388 | + # ex7 : croissance de P36 (72 log., Québec, inondable) vs agrégat de Québec ; choc pandémique | |
| 389 | + cr = dyn["par_immeuble"].set_index("id_immeuble").loc["P36"] | |
| 390 | + out["ex7"] = {"croissance_P36": float(cr["croissance_annuelle"]), "volatilite_P36": float(cr["volatilite"]), "croissance_quebec": float(dyn["par_rmr"].loc["Québec", "croissance_agregee"]), | |
| 391 | + "pandemie_quebec": dyn["pandemie"]["Québec"], "pandemie_portefeuille": dyn["pandemie"]["Portefeuille"], "rendement_total_P36": float(pf.set_index("id_immeuble").loc["P36", "taux_global"]) + float(cr["croissance_annuelle"])} | |
| 392 | + return out | |
| 393 | + | |
| 394 | +# ----------------------------------------------------------------------------- dossier complet | |
| 395 | + | |
| 396 | +def dossier(n_mc: int = N_MC, graine: int = GRAINE) -> dict: | |
| 397 | + d = cas4.charger(); pf, se = d["portefeuille"], d["series"] | |
| 398 | + som = sommaire_etendu(pf) | |
| 399 | + reg = regression_masse(pf); pred = valeurs_predites(pf, reg); iaao = ratios_iaao(pred) | |
| 400 | + dyn = dynamique_rne(se, pf) | |
| 401 | + ris = risques(pf, reg, pred) | |
| 402 | + det = dette(pf, AMORT_BASE); det_alt = dette(pf, AMORT_ALT) | |
| 403 | + det_g = dette(pf, AMORT_BASE, croissance=dyn["par_immeuble"].set_index("id_immeuble")["croissance_annuelle"]) | |
| 404 | + mc = simuler_portefeuille(pf, dyn, reg, n_mc, graine, regime="declare") | |
| 405 | + mc_ind = simuler_portefeuille(pf, dyn, reg, n_mc, graine, regime="independant") | |
| 406 | + mc_parf = simuler_portefeuille(pf, dyn, reg, n_mc, graine, regime="parfait") | |
| 407 | + w = pf["valeur_2026"].values / pf["valeur_2026"].sum() | |
| 408 | + S_e = matrice_correlation(pf["rmr"].values, mc["parametres"]["rho_rne"]["global"], mc["parametres"]["rho_rne"]["regional"]) | |
| 409 | + analytique = variance_normale_portefeuille(mc["sigma_ln_individuel"], w, 0.5 * (S_e + matrice_correlation(pf["rmr"].values, RHO_TAUX["global"], RHO_TAUX["regional"]))) | |
| 410 | + alloc = allocation(pf, dyn, mc, det) | |
| 411 | + rec = reconciliation(pf, iaao, mc, det, som) | |
| 412 | + D = {"portefeuille": pf, "series": se, "sommaire": som, "regression": reg, "predites": pred, "iaao": iaao, "dynamique": dyn, "risques": ris, | |
| 413 | + "dette": det, "dette_alt": det_alt, "dette_croissance": det_g, "mc": mc, "mc_independant": mc_ind, "mc_parfait": mc_parf, "analytique": analytique, | |
| 414 | + "allocation": alloc, "reconciliation": rec} | |
| 415 | + D["exercices"] = exercices(D) | |
| 416 | + return D | |
| 417 | + | |
| 418 | +def imprimer(D: dict) -> None: | |
| 419 | + pd.set_option("display.width", 250); pd.set_option("display.max_columns", 40); pd.set_option("display.float_format", lambda v: f"{v:,.4f}") | |
| 420 | + pf, som, reg = D["portefeuille"], D["sommaire"], D["regression"] | |
| 421 | + print("=" * 110 + "\nCH. 48 — CAS 4 : PORTEFEUILLE INTERRÉGIONAL (DONNÉES SYNTHÉTIQUES, 48 immeubles fictifs) — date d'évaluation", DATE_EVAL) | |
| 422 | + print("\n--- 48.2 Sommaire par RMR ---"); print(som["par_rmr"].to_string()); print({k: (round(v, 6) if isinstance(v, float) else v) for k, v in som["total"].items()}) | |
| 423 | + print("tailles :\n", som["tailles"].to_string()); print("âges :\n", som["ages"].to_string()); print("états :\n", som["etats"].to_string()); print("concentration", som["concentration"]) | |
| 424 | + print("\n--- 48.4 Modèle de masse ---"); print(reg["table"].to_string()); print("R²", round(reg["r2"], 4), "R² aj.", round(reg["r2_ajuste"], 4), "σ résiduel", round(reg["sigma"], 5), "Duan", round(reg["duan"], 5), "n", reg["n"], "k", reg["k"]) | |
| 425 | + t = D["predites"]; print(t[["id_immeuble", "rmr", "nb_logements", "age", "etat_1a5", "zone_inondable", "contamination_sol", "rne_2026", "taux_global", "taux_predit", "ecart_pb", "valeur_2026", "valeur_predite", "ratio", "dette", "rpv", "echeance_dette"]].to_string(index=False)) | |
| 426 | + ia = D["iaao"]; print("IAAO portefeuille", {k: round(v, 4) for k, v in ia["portefeuille"].items()}) | |
| 427 | + for r in RMR: print(" ", r, {k: (round(v, 4) if isinstance(v, float) else v) for k, v in ia[r].items() if k in ["n", "ratio_median", "COD", "PRD", "PRB"]}) | |
| 428 | + print("somme prédite", _d(ia["somme_predite"]), "vs individuelle", _d(ia["somme_individuelle"]), _p(ia["ecart_agrege_pct"])); print(ia["extremes"].to_string(index=False)) | |
| 429 | + print("\n--- 48.5 Dynamique des RNE ---"); dyn = D["dynamique"]; print(dyn["par_rmr"].to_string()); print({k: round(v, 5) for k, v in dyn["total"].items()}) | |
| 430 | + print("choc pandémique (écart à la tendance, 2020T2-2021T2)", {k: round(v, 4) for k, v in dyn["pandemie"].items()}); print("variation glissante 4 trim. min :", round(dyn["glissant4"].min(), 4), dyn["glissant4"].idxmin()) | |
| 431 | + print("corrélations", {k: round(v, 4) for k, v in dyn["correlations"].items()}); print(dyn["corr_rmr"].round(3).to_string()) | |
| 432 | + print("\n--- 48.6 Risques ---"); ris = D["risques"]; print(ris["inondables"][["id_immeuble", "rmr", "nb_logements", "taux_global", "taux_sans_risque", "pb", "valeur_2026", "valeur_sans_risque", "decote", "dette"]].to_string(index=False)) | |
| 433 | + print({k: (round(v, 5) if isinstance(v, float) else v) for k, v in ris.items() if not isinstance(v, pd.DataFrame)}) | |
| 434 | + print(ris["sols"][["id_immeuble", "rmr", "nb_logements", "taux_global", "taux_sans_risque", "pb", "valeur_2026", "valeur_sans_risque", "decote"]].to_string(index=False)) | |
| 435 | + for nom in ["dette", "dette_alt", "dette_croissance"]: | |
| 436 | + det = D[nom]; print(f"\n--- Dette ({nom}, amortissement {det['amortissement']} mois) ---"); print({k: (round(v, 4) if isinstance(v, float) else v) for k, v in det["total"].items()}) | |
| 437 | + print(det["echeancier"].to_string()); print(det["par_rmr"].to_string()) | |
| 438 | + det = D["dette"]; print(det["par_immeuble"][["id_immeuble", "rmr", "dette", "taux_dette", "echeance", "rpv", "service_dette", "constante", "rcd", "levier", "solde_echeance", "rcd_+0", "rcd_+100", "rcd_+200", "capacite_+200", "injection_+200"]].to_string(index=False)) | |
| 439 | + print(det["echeancier_rmr"].to_string()) | |
| 440 | + print("\n--- 48.7 Monte-Carlo ---") | |
| 441 | + for nom in ["mc", "mc_independant", "mc_parfait"]: | |
| 442 | + m = D[nom]; print(nom, m["regime"], {k: round(v, 1) for k, v in m["stats"].items()}); print(" VaR95 portefeuille", _d(m["var95_portefeuille"]), "somme VaR95 indiv.", _d(m["somme_var95_individuelles"]), "ratio", round(m["ratio_diversification"], 4), "ES95", _d(m["es95_portefeuille"])) | |
| 443 | + print(" probabilités", {k: round(v, 4) for k, v in m["probabilites"].items()}); print(" paramètres", m["parametres"], "CV individuel médian", round(m["cv_individuel_median"], 4)) | |
| 444 | + print(D["mc"]["par_rmr"].to_string()); print("analytique (normale)", {k: round(v, 5) for k, v in D["analytique"].items()}) | |
| 445 | + print("\n--- 48.8 Allocation ---"); al = D["allocation"] | |
| 446 | + print(al["table"][["id_immeuble", "rmr", "nb_logements", "etat_1a5", "taux_global", "croissance", "rendement_total", "sigma_valeur", "ratio_rr", "rcd", "rcd_+200", "rpv", "drapeaux", "score"]].to_string(index=False)) | |
| 447 | + print("vendre :", al["vendre"]["id_immeuble"].tolist(), "conserver :", al["conserver"]["id_immeuble"].tolist()); print("après cession", {k: round(v, 4) for k, v in al["apres_cession"].items()}) | |
| 448 | + fr = al["frontiere"]; print("μ par RMR", dict(zip(RMR, fr["mu"].round(5)))); print("σ par RMR", dict(zip(RMR, fr["sigma"].round(5)))); print("corr RMR\n", pd.DataFrame(fr["corr"], index=RMR, columns=RMR).round(3).to_string()) | |
| 449 | + print("actuel : w", fr["w_actuel"].round(3), "μ", round(fr["mu_actuel"], 5), "σ", round(fr["sigma_actuel"], 5)); print("min var : w", fr["w_min_var"].round(3), "μ", round(fr["mu_min_var"], 5), "σ", round(fr["sigma_min_var"], 5)) | |
| 450 | + print("même μ : w", fr["w_meme_mu"].round(3), "σ", round(fr["sigma_meme_mu"], 5)); print("même σ : w", fr["w_meme_sigma"].round(3), "μ", round(fr["mu_meme_sigma"], 5)) | |
| 451 | + print("\n--- 48.9 Réconciliation ---"); print({k: (round(v, 5) if isinstance(v, float) else v) for k, v in D["reconciliation"].items() if not isinstance(v, pd.Series)}) | |
| 452 | + print("\n--- Exercices ---") | |
| 453 | + for k, v in D["exercices"].items(): print(k, {kk: (round(vv, 5) if isinstance(vv, float) else vv) for kk, vv in v.items()}) | |
| 454 | + | |
| 455 | +if __name__ == "__main__": | |
| 456 | + ap = argparse.ArgumentParser(); ap.add_argument("--n", type=int, default=N_MC); a = ap.parse_args() | |
| 457 | + imprimer(dossier(a.n)) | |
added
analysis/python/figures/ch37_figures.py
+160 −0
@@ -0,0 +1,160 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""Figures du chapitre 37 — Apprentissage automatique et explicabilité (cas 1, Gatineau, DONNÉES SYNTHÉTIQUES, graine 20261031). | |
| 3 | +Produit dans assets/figures/ch37/ : | |
| 4 | + ch37-fig-arbre.pdf arbre de régression de profondeur 3 estimé sur les 600 ventes (feuilles en $) | |
| 5 | + ch37-fig-apprentissage.pdf erreur d'entraînement et de validation croisée du boosting selon le nombre d'arbres et la profondeur | |
| 6 | + ch37-fig-erreurs.pdf distribution des erreurs hors échantillon (log, en %) par modèle et par schéma de validation | |
| 7 | + ch37-fig-predit-observe.pdf prix prédit hors échantillon (plis aléatoires) contre prix observé : hédonique, forêt, boosting | |
| 8 | + ch37-fig-shap-beeswarm.pdf valeurs SHAP des 600 ventes (boosting) : importance et sens des effets | |
| 9 | + ch37-fig-pdp.pdf dépendance partielle : superficie et âge (hédonique, forêt, boosting, loi de génération) ; âge dans le scénario non linéaire | |
| 10 | + ch37-fig-waterfall.pdf cascade SHAP de la prédiction du sujet par le boosting (en dollars) | |
| 11 | +Usage (racine) : PYTHONPATH=analysis/python python3 analysis/python/figures/ch37_figures.py""" | |
| 12 | +from __future__ import annotations | |
| 13 | +import sys, pathlib, math, re | |
| 14 | +ROOT = pathlib.Path(__file__).resolve().parents[3] | |
| 15 | +sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases")) | |
| 16 | +import numpy as np, pandas as pd | |
| 17 | +import matplotlib.pyplot as plt | |
| 18 | +from matplotlib.ticker import FuncFormatter | |
| 19 | +from traite.style import setup, palette, savefig, LARGEUR, fmt_milliers | |
| 20 | +import ch37_ml as c37 | |
| 21 | + | |
| 22 | +COURT = {"Hédonique (référence)": "Hédonique", "Arbre (profondeur 3)": "Arbre", "Forêt aléatoire": "Forêt", "Gradient boosting": "Boosting", "XGBoost": "XGBoost", | |
| 23 | + "Hédonique respécifié": "Hédonique respécifié"} | |
| 24 | +COUL = {"Hédonique (référence)": palette.cat[0], "Arbre (profondeur 3)": palette.cat[4], "Forêt aléatoire": palette.cat[1], "Gradient boosting": palette.cat[2], "XGBoost": palette.cat[3]} | |
| 25 | +def _k(x, pos=None): return f"{x/1000:,.0f}".replace(",", " ") | |
| 26 | + | |
| 27 | +def fig_arbre(res): | |
| 28 | + from sklearn.tree import plot_tree | |
| 29 | + arbre = res["ajustes"]["Arbre (profondeur 3)"] | |
| 30 | + noms = [c37.ETIQUETTES[c].replace(" (m²)", " (m²)") for c in res["Xm"].columns] | |
| 31 | + fig, ax = plt.subplots(figsize=(LARGEUR, 3.6)) | |
| 32 | + plot_tree(arbre, feature_names=noms, filled=False, impurity=False, rounded=True, fontsize=6.4, ax=ax, precision=3) | |
| 33 | + for t in ax.texts: | |
| 34 | + s = t.get_text() | |
| 35 | + s = re.sub(r"samples = (\d+)", lambda m: f"n = {m.group(1)}", s) | |
| 36 | + s = re.sub(r"value = (-?\d+\.\d+)", lambda m: f"ln P = {float(m.group(1)):.3f}\n≈ {math.exp(float(m.group(1)))/1000:,.0f} k$".replace(",", " "), s) | |
| 37 | + s = s.replace(" <= ", " ≤ ") | |
| 38 | + t.set_text(s); t.set_color(palette.ink) | |
| 39 | + bb = t.get_bbox_patch() | |
| 40 | + if bb is not None: bb.set_edgecolor(palette.ink2); bb.set_facecolor(palette.surface); bb.set_linewidth(0.6) | |
| 41 | + ax.set_axis_off(); ax.grid(False) | |
| 42 | + savefig(fig, "ch37/ch37-fig-arbre.pdf") | |
| 43 | + | |
| 44 | +def fig_apprentissage(res): | |
| 45 | + c = res["courbes"]; ref = res["comparaison"]["metriques"]["Hédonique (référence)"]["aleatoire"]["rmse_log"] | |
| 46 | + fig, ax = plt.subplots(figsize=(LARGEUR, 3.1)) | |
| 47 | + it = np.arange(1, 1201) | |
| 48 | + for i, (d, cc) in enumerate(sorted(c.items())): | |
| 49 | + ax.plot(it, cc["validation"], color=palette.cat[i], lw=1.6, label=f"validation croisée, profondeur {d}") | |
| 50 | + ax.plot(it, cc["entrainement"], color=palette.cat[i], lw=1.0, ls="--", alpha=0.85) | |
| 51 | + ax.plot([cc["optimum"]], [cc["rmse_min"]], marker="o", ms=4, color=palette.cat[i]) | |
| 52 | + ax.axhline(ref, color=palette.ink2, lw=0.9, ls=":") | |
| 53 | + ax.text(1180, ref + 0.002, f"hédonique de référence (validation croisée) : {ref:.4f}", ha="right", va="bottom", fontsize=7.5, color=palette.ink2) | |
| 54 | + ax.plot([], [], color=palette.ink2, lw=1.0, ls="--", label="erreur d'entraînement (tiretés)") | |
| 55 | + ax.set_xlabel("Nombre d'arbres (itérations du boosting, ν = 0,03)"); ax.set_ylabel("Racine de l'erreur quadratique moyenne (log du prix)") | |
| 56 | + ax.set_ylim(0.0, 0.20); ax.set_xlim(0, 1200); ax.legend(loc="upper right", ncol=1) | |
| 57 | + savefig(fig, "ch37/ch37-fig-apprentissage.pdf") | |
| 58 | + | |
| 59 | +def fig_erreurs(res): | |
| 60 | + comp = res["comparaison"]; y = res["y"]; noms = list(c37.MODELES) | |
| 61 | + fig, axes = plt.subplots(1, 3, figsize=(LARGEUR, 3.0), sharey=True) | |
| 62 | + titres = {"aleatoire": "Plis aléatoires (10)", "spatial": "Blocs spatiaux par secteur (10)", "temporel": "Prospectif : ventes de 2026"} | |
| 63 | + for ax, sch in zip(axes, ["aleatoire", "spatial", "temporel"]): | |
| 64 | + data = [] | |
| 65 | + for nom in noms: | |
| 66 | + p = comp["pred"][nom][sch]; m = ~np.isnan(p); data.append((np.exp(p[m] - y[m]) - 1) * 100) | |
| 67 | + bp = ax.boxplot(data, widths=0.6, patch_artist=True, showfliers=True, flierprops=dict(marker=".", ms=2.5, markeredgecolor=palette.ink2, alpha=0.5), | |
| 68 | + medianprops=dict(color=palette.ink, lw=1.2), whiskerprops=dict(color=palette.ink2, lw=0.8), capprops=dict(color=palette.ink2, lw=0.8)) | |
| 69 | + for patch, nom in zip(bp["boxes"], noms): patch.set_facecolor(COUL[nom]); patch.set_alpha(0.55); patch.set_edgecolor(palette.ink2) | |
| 70 | + ax.axhline(0, color=palette.ink2, lw=0.8, ls=":") | |
| 71 | + ax.set_xticks(range(1, len(noms) + 1)); ax.set_xticklabels([COURT[n] for n in noms], rotation=35, ha="right") | |
| 72 | + ax.set_title(titres[sch], fontsize=8.5) | |
| 73 | + for i, nom in enumerate(noms): | |
| 74 | + mm = comp["metriques"][nom][sch]; ax.text(i + 1, 43, f"COD\n{mm['COD']:.1f}".replace(".", ","), ha="center", va="top", fontsize=6.8, color=palette.ink) | |
| 75 | + axes[0].set_ylabel("Erreur de prédiction hors échantillon (%)"); axes[0].set_ylim(-45, 45) | |
| 76 | + savefig(fig, "ch37/ch37-fig-erreurs.pdf") | |
| 77 | + | |
| 78 | +def fig_predit_observe(res): | |
| 79 | + comp = res["comparaison"]; P = np.exp(res["y"]) | |
| 80 | + fig, axes = plt.subplots(1, 3, figsize=(LARGEUR, 2.9), sharex=True, sharey=True) | |
| 81 | + for ax, nom in zip(axes, ["Hédonique (référence)", "Forêt aléatoire", "Gradient boosting"]): | |
| 82 | + pred = np.exp(comp["pred"][nom]["aleatoire"]) * comp["duan"][nom] | |
| 83 | + ax.scatter(P, pred, s=7, color=COUL[nom], alpha=0.55, edgecolors="none") | |
| 84 | + lim = [180_000, 900_000]; ax.plot(lim, lim, color=palette.ink2, lw=0.9) | |
| 85 | + ax.plot(lim, [l * 1.18 for l in lim], color=palette.ink2, lw=0.6, ls="--"); ax.plot(lim, [l / 1.18 for l in lim], color=palette.ink2, lw=0.6, ls="--") | |
| 86 | + m = comp["metriques"][nom]["aleatoire"] | |
| 87 | + ax.set_title(COURT[nom], fontsize=8.5) | |
| 88 | + ax.text(0.03, 0.97, f"RMSE log {m['rmse_log']:.3f}\nMdAPE {m['mdape']:.1f} %\nCOD {m['COD']:.2f}".replace(".", ","), transform=ax.transAxes, va="top", ha="left", fontsize=7.2) | |
| 89 | + ax.set_xlim(lim); ax.set_ylim(lim); ax.set_xlabel("Prix observé (k$)") | |
| 90 | + ax.xaxis.set_major_formatter(FuncFormatter(_k)); ax.yaxis.set_major_formatter(FuncFormatter(_k)) | |
| 91 | + axes[0].set_ylabel("Prix prédit hors échantillon (k$)") | |
| 92 | + savefig(fig, "ch37/ch37-fig-predit-observe.pdf") | |
| 93 | + | |
| 94 | +def fig_beeswarm(res, top: int = 11): | |
| 95 | + sh = res["shap"]; Xm = res["Xm"]; sv = sh["valeurs"]; imp = sh["importance"] | |
| 96 | + cols = list(imp.index[:top]); rng = np.random.default_rng(20261031) | |
| 97 | + fig, ax = plt.subplots(figsize=(LARGEUR, 3.7)); cmap = palette.cmap_seq() | |
| 98 | + for i, c in enumerate(cols[::-1]): | |
| 99 | + j = list(Xm.columns).index(c); x = sv[:, j] * 100; val = Xm[c].to_numpy() | |
| 100 | + lo, hi = np.nanpercentile(val, [5, 95]); col = np.clip((val - lo) / (hi - lo if hi > lo else 1), 0, 1) | |
| 101 | + # essaim : décalage vertical proportionnel à la densité locale | |
| 102 | + ordre = np.argsort(x); yj = np.zeros(len(x)); bins = np.digitize(x, np.linspace(x.min(), x.max() + 1e-9, 60)) | |
| 103 | + for b in np.unique(bins): | |
| 104 | + idx = np.where(bins == b)[0]; k = len(idx); yj[idx] = (np.arange(k) - (k - 1) / 2) * min(0.32 / max(k, 1) * 2, 0.06) + rng.normal(0, 0.004, k) | |
| 105 | + ax.scatter(x, i + yj, c=cmap(col), s=6, edgecolors="none", alpha=0.85) | |
| 106 | + ax.text(ax.get_xlim()[1] if False else 20.5, i, f"{imp[c]*100:.2f}".replace(".", ","), va="center", ha="left", fontsize=7.2, color=palette.ink2) | |
| 107 | + ax.axvline(0, color=palette.ink2, lw=0.8) | |
| 108 | + ax.set_yticks(range(len(cols))); ax.set_yticklabels([c37.ETIQUETTES[c] for c in cols[::-1]]) | |
| 109 | + ax.set_xlabel("Valeur SHAP φ (points de pourcentage du logarithme du prix)"); ax.set_xlim(-22, 26) | |
| 110 | + ax.text(20.5, len(cols) - 0.35, "moy. |φ|", fontsize=7.2, color=palette.ink2, ha="left", va="bottom") | |
| 111 | + sm = plt.cm.ScalarMappable(cmap=cmap, norm=plt.Normalize(0, 1)); sm.set_array([]) | |
| 112 | + cb = fig.colorbar(sm, ax=ax, pad=0.01, fraction=0.03, ticks=[0, 1]); cb.ax.set_yticklabels(["faible", "élevée"]); cb.set_label("Valeur de la variable", fontsize=7.5) | |
| 113 | + cb.outline.set_visible(False) | |
| 114 | + savefig(fig, "ch37/ch37-fig-shap-beeswarm.pdf") | |
| 115 | + | |
| 116 | +def _centrer(c): return c - np.mean(c) | |
| 117 | + | |
| 118 | +def fig_pdp(res): | |
| 119 | + pdp = res["pdp"]; v = res["ventes"] | |
| 120 | + fig, axes = plt.subplots(1, 3, figsize=(LARGEUR, 2.9)) | |
| 121 | + series = [("vraie", "loi de génération", palette.ink, ":"), ("Hédonique (référence)", "hédonique", palette.cat[0], "-"), ("Forêt aléatoire", "forêt aléatoire", palette.cat[1], "-"), ("Gradient boosting", "boosting", palette.cat[2], "-")] | |
| 122 | + for ax, (cle, var, xlab) in zip(axes, [("superficie", "superficie_habitable_m2", "Superficie habitable (m²)"), ("age", "age", "Âge (années)"), ("age_scenario", "age", "Âge (années), scénario non linéaire")]): | |
| 123 | + d = pdp[cle]; g = d["grille"] | |
| 124 | + for k, lab, col, ls in series: | |
| 125 | + if k in d: ax.plot(g, _centrer(d[k]) * 100, color=col, ls=ls, lw=1.7 if k == "vraie" else 1.4, label=lab) | |
| 126 | + q = np.percentile(v[var], np.arange(5, 100, 5)); ax.plot(q, np.full_like(q, ax.get_ylim()[0] if False else -38), "|", color=palette.ink2, ms=5, alpha=0.8) | |
| 127 | + ax.set_xlabel(xlab); ax.set_ylim(-42, 42) | |
| 128 | + axes[0].set_ylabel("Dépendance partielle, écart au niveau moyen (%)"); axes[0].legend(loc="upper left", fontsize=7) | |
| 129 | + axes[2].text(0.97, 0.04, "prime +12 % (qualité ≥ 4, âge ≥ 40)\n« mur » −10 % (25 à 45 ans)", transform=axes[2].transAxes, ha="right", va="bottom", fontsize=6.8, color=palette.ink2) | |
| 130 | + savefig(fig, "ch37/ch37-fig-pdp.pdf") | |
| 131 | + | |
| 132 | +def fig_waterfall(res, top: int = 8): | |
| 133 | + sh = res["shap"]; phi = sh["sujet"].copy(); base = sh["base"] | |
| 134 | + ordre = phi.abs().sort_values(ascending=False); princip = list(ordre.index[:top]); autres = float(phi.drop(princip).sum()) | |
| 135 | + etapes = [(c37.ETIQUETTES[c], float(phi[c])) for c in princip] + [("Autres variables (somme)", autres)] | |
| 136 | + fig, ax = plt.subplots(figsize=(LARGEUR, 3.3)) | |
| 137 | + cur = base; ys = [] | |
| 138 | + for i, (lab, ph) in enumerate(etapes): | |
| 139 | + a, b = math.exp(cur), math.exp(cur + ph); col = palette.div["pos"] if ph >= 0 else palette.div["neg"] | |
| 140 | + ax.barh(i, b - a, left=a, color=col, height=0.62, edgecolor="none") | |
| 141 | + ax.text(max(a, b) + 4000, i, f"{ph*100:+.1f} %".replace(".", ","), va="center", ha="left", fontsize=7.5, color=palette.ink) | |
| 142 | + if i + 1 < len(etapes): ax.plot([b, b], [i + 0.31, i + 0.69], color=palette.ink2, lw=0.6) | |
| 143 | + cur += ph; ys.append(lab) | |
| 144 | + fin = math.exp(cur) | |
| 145 | + ax.axvline(math.exp(base), color=palette.ink2, lw=0.9, ls=":"); ax.axvline(fin, color=palette.ink, lw=0.9, ls="--") | |
| 146 | + ax.text(math.exp(base), -0.9, f"base : {math.exp(base):,.0f} $".replace(",", " "), ha="center", va="top", fontsize=7.5, color=palette.ink2) | |
| 147 | + ax.text(fin, len(etapes) - 0.2, f"prédiction : {fin:,.0f} $".replace(",", " "), ha="center", va="bottom", fontsize=7.5, color=palette.ink) | |
| 148 | + ax.set_yticks(range(len(etapes))); ax.set_yticklabels(ys); ax.invert_yaxis() | |
| 149 | + ax.set_xlabel("Prix prédit (k$) — chaque contribution est un facteur multiplicatif exp(φ)") | |
| 150 | + ax.xaxis.set_major_formatter(FuncFormatter(_k)); ax.set_xlim(430_000, 700_000); ax.set_ylim(len(etapes) - 0.3, -1.5) | |
| 151 | + savefig(fig, "ch37/ch37-fig-waterfall.pdf") | |
| 152 | + | |
| 153 | +def main(): | |
| 154 | + setup(); res = c37.main(verbose=False) | |
| 155 | + fig_arbre(res); fig_apprentissage(res); fig_erreurs(res); fig_predit_observe(res); fig_beeswarm(res); fig_pdp(res); fig_waterfall(res) | |
| 156 | + print("figures ch37 écrites dans", ROOT / "assets/figures/ch37") | |
| 157 | + return res | |
| 158 | + | |
| 159 | +if __name__ == "__main__": | |
| 160 | + main() | |
added
analysis/python/figures/ch41_figures.py
+86 −0
@@ -0,0 +1,86 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""Figures du chapitre 41 (DONNÉES SYNTHÉTIQUES du cas 1 ; zonage FICTIF ; hypothèses déclarées dans ch41_phmu.py) : | |
| 3 | + ch41-fig-residuel-densite.pdf — valeur résiduelle du terrain selon le nombre de logements et le niveau des prix ; | |
| 4 | + ch41-fig-surface-prix-cout.pdf — surface de la valeur résiduelle du triplex (prix des unités × coût de construction) ; | |
| 5 | + ch41-fig-frontiere-option.pdf — prime de l'option de redéveloppement et frontière d'exercice selon la volatilité. | |
| 6 | +Usage : python3 analysis/python/figures/ch41_figures.py""" | |
| 7 | +import sys, pathlib | |
| 8 | +ROOT = pathlib.Path(__file__).resolve().parents[3] | |
| 9 | +sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases")) | |
| 10 | +import numpy as np, matplotlib.pyplot as plt | |
| 11 | +from matplotlib.ticker import FuncFormatter | |
| 12 | +from traite.style import setup, palette, savefig, LARGEUR | |
| 13 | +import ch41_phmu as cas | |
| 14 | + | |
| 15 | +K = FuncFormatter(lambda x, p: f"{x/1000:.0f}") | |
| 16 | + | |
| 17 | + | |
| 18 | +def fig_densite(R): | |
| 19 | + df = cas.courbe_densite(R, R["couts"]) | |
| 20 | + fig, ax = plt.subplots(figsize=(LARGEUR, 3.1)) | |
| 21 | + for k, f in enumerate(sorted(df.facteur_prix.unique())): | |
| 22 | + d = df[df.facteur_prix == f].sort_values("n") | |
| 23 | + lab = {0.85: "prix des unités −15 %", 1.0: "prix de base (modèle hédonique)", 1.15: "prix des unités +15 %"}[round(f, 2)] | |
| 24 | + ax.plot(d.n, d.v_l, marker="o", color=palette.cat[k], label=lab) | |
| 25 | + np_ = d[~d.permis] | |
| 26 | + ax.plot(np_.n, np_.v_l, marker="o", linestyle="none", markerfacecolor="white", color=palette.cat[k]) | |
| 27 | + ax.axhline(R["v_l_marche"], color=palette.ink2, linestyle="--", linewidth=1) | |
| 28 | + ax.text(2.55, R["v_l_marche"] + 6000, "valeur du terrain par comparaison (ch. 25) : 277 000 $", fontsize=7.5, color=palette.ink2) | |
| 29 | + occ = R["usages"]["U"]["occupant"]["valeur_residuelle"] | |
| 30 | + ax.plot([1], [occ], marker="s", color=palette.ink, linestyle="none") | |
| 31 | + ax.annotate("maison neuve, convention\n« construction sur commande »\n(266 187 $)", (1, occ), xytext=(1.3, occ - 60000), fontsize=7.5, | |
| 32 | + arrowprops=dict(arrowstyle="-", color=palette.ink2, lw=0.8)) | |
| 33 | + ax.axvspan(3.5, 4.5, color=palette.grid, alpha=0.6, lw=0) | |
| 34 | + ax.text(4, 20000, "non permis\n(COS 0,60)", ha="center", fontsize=7.5, color=palette.ink2) | |
| 35 | + ax.set_xticks([1, 2, 3, 4]); ax.set_xticklabels(["1 logement\n(maison neuve)", "2 logements\n(duplex)", "3 logements\n(triplex)", "4 logements\n(quadruplex)"]) | |
| 36 | + ax.set_xlim(0.7, 4.5); ax.set_ylim(0, None) | |
| 37 | + ax.yaxis.set_major_formatter(K); ax.set_ylabel("Valeur résiduelle du terrain (milliers de $)") | |
| 38 | + ax.legend(loc="lower right", fontsize=7.5, bbox_to_anchor=(0.78, 0.02)) | |
| 39 | + fig.tight_layout(); savefig(fig, "ch41/ch41-fig-residuel-densite.pdf") | |
| 40 | + | |
| 41 | + | |
| 42 | +def fig_surface(R): | |
| 43 | + fp, fc, Z = cas.surface_prix_cout(R["usages"]["T"], R["couts"], fp=np.linspace(0.80, 1.55, 76)) | |
| 44 | + X, Y = fp * 100 - 100, fc * 100 - 100 | |
| 45 | + fig, ax = plt.subplots(figsize=(LARGEUR, 3.4)) | |
| 46 | + cf = ax.contourf(X, Y, np.clip(Z, 0, None) / 1000, levels=24, cmap=palette.cmap_seq()) | |
| 47 | + cb = fig.colorbar(cf, ax=ax, pad=0.02); cb.set_label("Valeur résiduelle du terrain (milliers de $)") | |
| 48 | + cs = ax.contour(X, Y, Z, levels=[0, R["v_l_marche"], R["v_act"]], colors=[palette.ink2, palette.ink, palette.ink], linewidths=[0.9, 1.0, 1.4], linestyles=["dotted", "dashed", "solid"]) | |
| 49 | + ax.clabel(cs, fmt={0: "résidu nul", R["v_l_marche"]: "277 000 $ : terrain au prix du marché", R["v_act"]: "600 000 $ : démolition rentable"}, fontsize=7, inline=True, inline_spacing=6) | |
| 50 | + ax.plot([0], [0], marker="o", color=palette.ink, markersize=5) | |
| 51 | + ax.annotate("cas de base : 229 265 $", (0, 0), xytext=(-18, -8), fontsize=7.5, color=palette.ink, arrowprops=dict(arrowstyle="-", color=palette.ink, lw=0.7)) | |
| 52 | + ax.set_xlabel("Variation du prix des unités neuves (%)"); ax.set_ylabel("Variation du coût de construction (%)") | |
| 53 | + ax.grid(False) | |
| 54 | + fig.tight_layout(); savefig(fig, "ch41/ch41-fig-surface-prix-cout.pdf") | |
| 55 | + | |
| 56 | + | |
| 57 | +def fig_frontiere(R): | |
| 58 | + O = cas.OPTION; rT = R["usages"]["T"]["promoteur"]; gdv0 = rT["gdv"] | |
| 59 | + sigmas = np.linspace(0.03, 0.30, 55) | |
| 60 | + fig, (a, b) = plt.subplots(1, 2, figsize=(LARGEUR, 3.1)) | |
| 61 | + for k, v_act in enumerate((600_000.0, 450_000.0, 300_000.0)): | |
| 62 | + aa, bb = cas.coefficients_option(rT, v_act) | |
| 63 | + prem = [cas.option_redeveloppement(gdv0, aa, bb, s, O["dt"], O["r"], O["y"], O["n"])["prime_americaine"] for s in sigmas] | |
| 64 | + a.plot(sigmas * 100, np.array(prem) / 1000, color=palette.cat[k], label=f"valeur en usage {v_act/1000:.0f} k$") | |
| 65 | + a.set_xlabel("Volatilité annuelle du prix des unités σ (%)"); a.set_ylabel("Prime de l'option (milliers de $)") | |
| 66 | + a.set_title("(a) valeur de l'attente", loc="left"); a.legend(fontsize=7.5) | |
| 67 | + aa, bb = cas.coefficients_option(rT, R["v_act"]) | |
| 68 | + seuil_van = bb / aa / gdv0 | |
| 69 | + front = [cas.frontiere_exercice_t1(aa, bb, s, O["dt"], O["r"], O["y"]) / gdv0 for s in sigmas] | |
| 70 | + b.plot(sigmas * 100, np.array(front), color=palette.cat[0], label="frontière d'exercice (attente optimale au-dessous)") | |
| 71 | + b.axhline(seuil_van, color=palette.cat[1], linestyle="--", label="seuil de la règle de la VAN (résidu = valeur en usage)") | |
| 72 | + b.axhline(1.0, color=palette.ink2, linestyle=":", linewidth=1); b.text(3.5, 1.02, "produit de vente actuel", fontsize=7.5, color=palette.ink2) | |
| 73 | + b.fill_between(sigmas * 100, seuil_van, np.array(front), color=palette.cat[0], alpha=0.12, lw=0) | |
| 74 | + b.text(16, (seuil_van + 1.9) / 2, "zone d'attente :\nVAN > 0 mais attendre vaut plus", fontsize=7.5, ha="center", color=palette.ink) | |
| 75 | + b.set_xlabel("Volatilité annuelle du prix des unités σ (%)"); b.set_ylabel("Produit de vente / produit actuel") | |
| 76 | + b.set_ylim(0.9, 2.6); b.set_title("(b) frontière d'exercice au nœud t = 1", loc="left"); b.legend(fontsize=7, loc="upper left") | |
| 77 | + fig.tight_layout(); savefig(fig, "ch41/ch41-fig-frontiere-option.pdf") | |
| 78 | + | |
| 79 | + | |
| 80 | +def main(): | |
| 81 | + setup(); R = cas.exemple_complet() | |
| 82 | + fig_densite(R); fig_surface(R); fig_frontiere(R) | |
| 83 | + | |
| 84 | + | |
| 85 | +if __name__ == "__main__": | |
| 86 | + main() | |
modified
analysis/python/figures/ch42_grille.py
+1 −1
@@ -32,7 +32,7 @@ def main(): | ||
| 32 | 32 | ax.text(rec["valeur_ponderee"], -0.75, f"valeur pondérée {rec['valeur_ponderee']:,.0f} $".replace(",", " "), ha="center", fontsize=7.6, color=palette.ink) |
| 33 | 33 | ax.set_yticks(y); ax.set_yticklabels([f"{c['nom']} — {c['immeuble']}" for c in comps]); ax.invert_yaxis() |
| 34 | 34 | ax.xaxis.set_major_formatter(plt.FuncFormatter(fmt_dollars)) |
| 35 | − ax.set_xlim(300_000, 450_000); ax.set_xlabel("Prix observé (cercle) et prix ajusté (losange) ; ajustements positifs en teinte pleine, négatifs en teinte pâle") | |
| 35 | + ax.set_xlim(300_000, 450_000); ax.set_xlabel("Prix observé (cercle) → prix ajusté (losange) ; ajustements négatifs en teinte pâle") | |
| 36 | 36 | ax.grid(axis="y", visible=False) |
| 37 | 37 | from matplotlib.patches import Patch |
| 38 | 38 | handles = [Patch(color=COUL[k], label=k) for k in ORDRE] |
modified
analysis/python/figures/ch43_carte_riverain.py
+2 −2
@@ -15,7 +15,7 @@ import ch43_risques as cas | ||
| 15 | 15 | def main(): |
| 16 | 16 | setup() |
| 17 | 17 | lots, ventes = cas.charger_ou_generer(ecrire=False) |
| 18 | − fig, (a, b) = plt.subplots(1, 2, figsize=(LARGEUR, 3.4), gridspec_kw={"width_ratios": [1.15, 1]}) | |
| 18 | + fig, (a, b) = plt.subplots(1, 2, figsize=(LARGEUR, 3.4), gridspec_kw={"width_ratios": [1, 1.15]}) | |
| 19 | 19 | couleurs = {"très élevée": palette.seq[-1], "élevée": palette.seq[-2], "modérée": palette.seq[-3], "faible": palette.seq[-4], "hors zone": "#e9e7e2"} |
| 20 | 20 | for _, L in lots.iterrows(): |
| 21 | 21 | a.add_patch(Rectangle((L["x"] * cas.PAS, L["y"] * cas.PAS), cas.PAS, cas.PAS, facecolor=couleurs[L["classe"]], edgecolor="white", linewidth=0.3)) |
@@ -47,7 +47,7 @@ def main(): | ||
| 47 | 47 | b.bar(x + dx, g["mean"], w, color=col, label=lab) |
| 48 | 48 | b.errorbar(x + dx, g["mean"], yerr=1.96 * g["std"] / np.sqrt(g["count"]), fmt="none", ecolor=palette.ink2, elinewidth=0.9, capsize=2) |
| 49 | 49 | b.axhline(0, color=palette.ink2, linewidth=0.8) |
| 50 | − b.set_xticks(x); b.set_xticklabels([o.replace(" ", "\n") for o in ordre], fontsize=7.5) | |
| 50 | + b.set_xticks(x); b.set_xticklabels(["très\nélevée", "élevée", "\nmodérée", "faible", "\nhors zone"], fontsize=7) | |
| 51 | 51 | b.set_ylabel("ln P net des attributs, écart aux lots hors zone (pts de %)") |
| 52 | 52 | b.set_title("(b) Écart de prix par classe, avant / après", loc="left"); b.legend(loc="lower right", fontsize=7) |
| 53 | 53 | fig.tight_layout() |
modified
analysis/python/figures/ch43_effets_methodes.py
+1 −1
@@ -29,7 +29,7 @@ def main(): | ||
| 29 | 29 | a.set_xlabel("Effet sur ln P (points de pourcentage)"); a.set_title("(a) Sept estimations, quatre méthodes", loc="left") |
| 30 | 30 | a.plot([], [], "o", color=palette.cat[0], label="estimation et IC à 95 %"); a.plot([], [], "o", color=palette.cat[2], label="estimation sans erreur-type") |
| 31 | 31 | a.plot([], [], marker="|", color=palette.ink, linestyle="none", markersize=10, markeredgewidth=1.6, label="vérité du générateur (cible)") |
| 32 | − a.set_xlim(-8.6, 2.6); a.legend(loc="upper right", fontsize=6.8, handlelength=1.2) | |
| 32 | + a.set_xlim(-8.6, 4.2); a.legend(loc="upper right", fontsize=6.8, handlelength=1.2) | |
| 33 | 33 | # (b) classes |
| 34 | 34 | cl = ["très élevée", "élevée", "modérée", "faible"]; cc = he["c_classes"] |
| 35 | 35 | x = np.arange(len(cl)); w = 0.36 |
added
analysis/python/figures/ch44_capitalisation.py
+50 −0
@@ -0,0 +1,50 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""Figure ch44 — (a) quatre lectures de ce que la thermopompe « vaut » pour le sujet du cas 1 : coût installé, coût net de | |
| 3 | +la subvention, valeur actuelle des économies, primes vertes de la littérature appliquées à la valeur hédonique ; | |
| 4 | +(b) paires de ventes « qualité 4 vs 3 » du cas 1 : dispersion des écarts, médiane, moyenne et intervalle, prix implicite | |
| 5 | +hédonique. DONNÉES SYNTHÉTIQUES. Sortie : assets/figures/ch44/ch44-fig-capitalisation.pdf""" | |
| 6 | +import sys, pathlib | |
| 7 | +ROOT = pathlib.Path(__file__).resolve().parents[3] | |
| 8 | +sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases")) | |
| 9 | +import numpy as np, matplotlib.pyplot as plt | |
| 10 | +from traite.style import setup, palette, savefig, LARGEUR, fmt_dollars | |
| 11 | +import ch44_renovation as cas | |
| 12 | +from traite import cas1 | |
| 13 | + | |
| 14 | + | |
| 15 | +def main(): | |
| 16 | + setup() | |
| 17 | + mm = cas.mesures(); d = cas1.charger(); mod = cas1.hedonique(d["ventes"]) | |
| 18 | + vs = float(cas1.predire(mod, cas1.sujet_df(d["sujet"]))[0]) | |
| 19 | + fa = cas.flux_mesure(mm["A"]); L = cas.lectures_contribution(mm["A"], fa, vs) | |
| 20 | + fig, (a, b) = plt.subplots(1, 2, figsize=(LARGEUR, 3.4), gridspec_kw={"wspace": 0.45, "width_ratios": [1.15, 1]}) | |
| 21 | + # (a) lectures | |
| 22 | + etiq = ["Coût installé", "Coût net\n(après LogisVert)", "VA des économies\n(15 ans, 4,58 %)", "Prime 2,32 %\n(Europe)", "Prime 4,20 %\n(monde)", "Prime 5,36 %\n(Amérique)"] | |
| 23 | + vals = [L["cout"], L["cout_net"], L["va_economies"], L["primes_dollars"]["Europe (méta-analyse)"], L["primes_dollars"]["Monde (méta-analyse)"], L["primes_dollars"]["Amérique du Nord (méta-analyse)"]] | |
| 24 | + cols = [palette.ink2, palette.ink2, palette.cat[0], palette.cat[3], palette.cat[3], palette.cat[3]] | |
| 25 | + y = np.arange(len(etiq))[::-1] | |
| 26 | + a.barh(y, vals, color=cols, height=0.62) | |
| 27 | + for yi, v in zip(y, vals): a.text(v + 500, yi, fmt_dollars(v), va="center", fontsize=7.5, color=palette.ink) | |
| 28 | + a.set_yticks(y); a.set_yticklabels(etiq, fontsize=7.5); a.set_xlim(0, 44000) | |
| 29 | + a.xaxis.set_major_formatter(plt.FuncFormatter(lambda x, p: f"{x/1000:.0f} k$")); a.grid(axis="y", visible=False) | |
| 30 | + a.set_title("(a) Thermopompe : quatre lectures", loc="left", fontsize=9) | |
| 31 | + # (b) paires | |
| 32 | + P = cas.paires_qualite(d["ventes"], mod); diff = P["paires"]["difference_pct"].values * 100 | |
| 33 | + rng = np.random.default_rng(20261031); jitter = rng.uniform(-0.18, 0.18, size=len(diff)) | |
| 34 | + b.scatter(jitter, diff, s=18, color=palette.cat[0], alpha=0.75, edgecolor="white", lw=0.4, zorder=3) | |
| 35 | + b.hlines(P["mediane_pct"] * 100, -0.3, 0.3, color=palette.ink, lw=1.6, label="Médiane des paires") | |
| 36 | + b.hlines(P["moyenne_pct"] * 100, -0.3, 0.3, color=palette.ink2, lw=1.2, ls="--", label="Moyenne des paires") | |
| 37 | + lo, hi = [x * 100 for x in P["ic95_pct"]] | |
| 38 | + b.fill_between([-0.3, 0.3], lo, hi, color=palette.ink2, alpha=0.12, label="IC à 95 % de la moyenne") | |
| 39 | + b.hlines(P["effet_hedonique_pct"] * 100, -0.45, 0.45, color=palette.cat[1], lw=1.6, ls="-.", label="Prix implicite hédonique") | |
| 40 | + b.axhline(0, color=palette.ink2, lw=0.6) | |
| 41 | + b.set_xlim(-0.6, 0.6); b.set_xticks([]); b.set_ylabel("Écart de prix (qualité 4 vs 3), en %") | |
| 42 | + b.set_title(f"(b) {P['resultat']['n']} paires, qualité 4 vs 3", loc="left", fontsize=9); b.grid(axis="x", visible=False) | |
| 43 | + b.legend(loc="upper center", fontsize=6.8, ncol=2, bbox_to_anchor=(0.5, -0.04), frameon=False) | |
| 44 | + b.text(0.33, P["mediane_pct"] * 100 - 1.6, f"médiane {P['mediane_pct']*100:.1f} %".replace(".", ","), va="top", ha="left", fontsize=7.2, color=palette.ink) | |
| 45 | + b.text(-0.47, P["effet_hedonique_pct"] * 100 + 1.2, f"hédonique {P['effet_hedonique_pct']*100:.1f} %".replace(".", ","), va="bottom", ha="left", fontsize=7.2, color=palette.ink) | |
| 46 | + out = savefig(fig, "ch44/ch44-fig-capitalisation.pdf"); print("OK", out) | |
| 47 | + | |
| 48 | + | |
| 49 | +if __name__ == "__main__": | |
| 50 | + main() | |
added
analysis/python/figures/ch44_flux.py
+38 −0
@@ -0,0 +1,38 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""Figure ch44 — flux annuels des deux mesures énergétiques du sujet du cas 1 (DONNÉES SYNTHÉTIQUES, hypothèses déclarées | |
| 3 | +dans analysis/python/cases/ch44_renovation.py) : coût net à t = 0, économies annuelles croissantes et cumul actualisé. | |
| 4 | +Sortie : assets/figures/ch44/ch44-fig-flux.pdf""" | |
| 5 | +import sys, pathlib | |
| 6 | +ROOT = pathlib.Path(__file__).resolve().parents[3] | |
| 7 | +sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases")) | |
| 8 | +import numpy as np, matplotlib.pyplot as plt | |
| 9 | +from traite.style import setup, palette, savefig, LARGEUR, fmt_dollars | |
| 10 | +import ch44_renovation as cas | |
| 11 | + | |
| 12 | + | |
| 13 | +def panneau(ax, fl, titre, couleur): | |
| 14 | + t = np.arange(len(fl["flux"])) | |
| 15 | + ax.bar(t[0], fl["flux"][0], color=palette.ink2, width=0.8, label="Coût net (t = 0)") | |
| 16 | + ax.bar(t[1:], fl["flux"][1:], color=couleur, width=0.8, label="Économie annuelle") | |
| 17 | + ax.plot(t, fl["cumul_actualise"], color=palette.ink, lw=1.4, ls="--", marker="o", ms=2.5, label="Cumul actualisé") | |
| 18 | + ax.axhline(0, color=palette.ink2, lw=0.6) | |
| 19 | + ax.set_title(titre, loc="left", fontsize=9) | |
| 20 | + ax.set_xlabel("Année"); ax.yaxis.set_major_formatter(plt.FuncFormatter(fmt_dollars)) | |
| 21 | + ax.annotate(f"VAN {fmt_dollars(fl['van'])}".replace("-", "−"), xy=(t[-1], fl["cumul_actualise"][-1]), xytext=(-6, -14 if fl["van"] < 0 else 10), | |
| 22 | + textcoords="offset points", ha="right", va="top" if fl["van"] < 0 else "bottom", fontsize=8, color=palette.ink) | |
| 23 | + | |
| 24 | + | |
| 25 | +def main(): | |
| 26 | + setup() | |
| 27 | + mm = cas.mesures() | |
| 28 | + fa, fb = cas.flux_mesure(mm["A"]), cas.flux_mesure(mm["B"]) | |
| 29 | + fig, (a, b) = plt.subplots(1, 2, figsize=(LARGEUR, 3.2), gridspec_kw={"wspace": 0.32, "width_ratios": [1, 1.35]}) | |
| 30 | + panneau(a, fa, "(a) Thermopompe climat froid (15 ans)", palette.cat[0]) | |
| 31 | + panneau(b, fb, "(b) Isolation de l'entretoit (31 ans)", palette.cat[1]) | |
| 32 | + a.set_ylim(-13500, 2500); b.set_ylim(-2600, 600) | |
| 33 | + h, l = a.get_legend_handles_labels(); fig.legend(h, l, loc="lower center", ncol=3, bbox_to_anchor=(0.5, -0.08), frameon=False) | |
| 34 | + out = savefig(fig, "ch44/ch44-fig-flux.pdf"); print("OK", out) | |
| 35 | + | |
| 36 | + | |
| 37 | +if __name__ == "__main__": | |
| 38 | + main() | |
added
analysis/python/figures/ch44_heatmap_van.py
+51 −0
@@ -0,0 +1,51 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""Figure ch44 — VAN des deux mesures selon le taux d'actualisation et la croissance du prix de l'électricité | |
| 3 | +(DONNÉES SYNTHÉTIQUES, hypothèses de analysis/python/cases/ch44_renovation.py). Cartes de chaleur divergentes centrées | |
| 4 | +sur VAN = 0 avec la courbe d'équilibre. Sortie : assets/figures/ch44/ch44-fig-heatmap-van.pdf""" | |
| 5 | +import sys, pathlib | |
| 6 | +ROOT = pathlib.Path(__file__).resolve().parents[3] | |
| 7 | +sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases")) | |
| 8 | +import numpy as np, matplotlib.pyplot as plt | |
| 9 | +from matplotlib.colors import TwoSlopeNorm | |
| 10 | +from traite.style import setup, palette, savefig, LARGEUR | |
| 11 | +import ch44_renovation as cas | |
| 12 | + | |
| 13 | +TAUX = np.array([0.02, 0.03, 0.04, 0.0458, 0.05, 0.06, 0.07, 0.08, 0.10]) | |
| 14 | +CROIS = np.array([0.0, 0.01, 0.02, 0.03, 0.04, 0.05, 0.06]) | |
| 15 | + | |
| 16 | + | |
| 17 | +def panneau(ax, m, titre, vmax): | |
| 18 | + G = cas.grille_van(m, taux=tuple(TAUX), croissances=tuple(CROIS)).values | |
| 19 | + norm = TwoSlopeNorm(vmin=-vmax, vcenter=0.0, vmax=vmax) | |
| 20 | + # colormap divergente du traité : négatif = bleu, positif = rouge → ici on inverse pour que le positif (VAN > 0) soit bleu | |
| 21 | + cmap = palette.cmap_div().reversed() | |
| 22 | + ax.imshow(G, cmap=cmap, norm=norm, aspect="auto", origin="upper") | |
| 23 | + ax.set_xticks(range(len(CROIS))); ax.set_xticklabels([f"{g*100:.0f} %" for g in CROIS]) | |
| 24 | + ax.set_yticks(range(len(TAUX))); ax.set_yticklabels([f"{r*100:.2f} %".replace(".", ",") if r == 0.0458 else f"{r*100:.0f} %" for r in TAUX]) | |
| 25 | + ax.grid(False) | |
| 26 | + for i in range(len(TAUX)): | |
| 27 | + for j in range(len(CROIS)): | |
| 28 | + v = G[i, j] | |
| 29 | + ax.text(j, i, f"{v/1000:+.1f}".replace(".", ",").replace("-", "−"), ha="center", va="center", fontsize=6.8, | |
| 30 | + color="white" if abs(v) > 0.55 * vmax else palette.ink) | |
| 31 | + # courbe d'équilibre sur une grille fine (interpolée sur les indices de cellules) | |
| 32 | + rf = np.linspace(TAUX[0], TAUX[-1], 60); gf = np.linspace(CROIS[0], CROIS[-1], 60) | |
| 33 | + Z = np.array([[cas.flux_mesure(m, r=r, g=g)["van"] for g in gf] for r in rf]) | |
| 34 | + xi = np.interp(gf, CROIS, np.arange(len(CROIS))); yi = np.interp(rf, TAUX, np.arange(len(TAUX))) | |
| 35 | + if (Z.min() < 0) and (Z.max() > 0): | |
| 36 | + ax.contour(xi, yi, Z, levels=[0.0], colors=[palette.ink], linewidths=1.3) | |
| 37 | + ax.set_xlabel("Croissance annuelle du prix de l'électricité"); ax.set_title(titre, loc="left", fontsize=9) | |
| 38 | + | |
| 39 | + | |
| 40 | +def main(): | |
| 41 | + setup() | |
| 42 | + mm = cas.mesures() | |
| 43 | + fig, (a, b) = plt.subplots(1, 2, figsize=(LARGEUR, 3.5), gridspec_kw={"wspace": 0.30}) | |
| 44 | + panneau(a, mm["A"], "(a) Thermopompe climat froid — VAN (k$)", 10000.0) | |
| 45 | + panneau(b, mm["B"], "(b) Isolation de l'entretoit — VAN (k$)", 4000.0) | |
| 46 | + a.set_ylabel("Taux d'actualisation") | |
| 47 | + out = savefig(fig, "ch44/ch44-fig-heatmap-van.pdf"); print("OK", out) | |
| 48 | + | |
| 49 | + | |
| 50 | +if __name__ == "__main__": | |
| 51 | + main() | |
added
analysis/python/figures/ch44_prime_verte.py
+59 −0
@@ -0,0 +1,59 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""Figure ch44 — la « prime verte » dans la littérature : fourchettes publiées par étude (valeurs vérifiées, voir | |
| 3 | +verification/chapters/ch44-claims.csv) et, en regard, la valeur actuelle des économies des deux mesures du sujet du | |
| 4 | +cas 1 rapportée à sa valeur hédonique (DONNÉES SYNTHÉTIQUES). Sortie : assets/figures/ch44/ch44-fig-prime-verte.pdf""" | |
| 5 | +import sys, pathlib | |
| 6 | +ROOT = pathlib.Path(__file__).resolve().parents[3] | |
| 7 | +sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases")) | |
| 8 | +import matplotlib.pyplot as plt | |
| 9 | +from matplotlib.lines import Line2D | |
| 10 | +from traite.style import setup, palette, savefig, LARGEUR | |
| 11 | +import ch44_renovation as cas | |
| 12 | +from traite import cas1 | |
| 13 | + | |
| 14 | +ETUDES = [ | |
| 15 | + # (étiquette, bas, haut, point, catégorie) | |
| 16 | + ("Brounen et Kok (2011), Pays-Bas :\nétiquette verte (A–C) vs autres", 3.6, 3.7, 3.7, "prix"), | |
| 17 | + ("Brounen et Kok (2011), Pays-Bas :\nG vs D … A vs D", -5.0, 10.2, None, "prix"), | |
| 18 | + ("Kahn et Kok (2014), Californie :\nmaison étiquetée verte", 4.5, 5.0, 5.0, "prix"), | |
| 19 | + ("Hyland et al. (2013), Irlande :\n1 échelon BER … A vs D (prix)", 1.3, 9.3, 9.3, "prix"), | |
| 20 | + ("Hyland et al. (2013), Irlande :\n1 échelon … A vs D (loyers)", 0.5, 1.8, 1.8, "loyer"), | |
| 21 | + ("Fuerst et al. (2015), Angleterre :\nG vs D … A/B vs D", -6.0, 5.0, None, "prix"), | |
| 22 | + ("Aydin et al. (2020), Pays-Bas :\n+10 % d'efficacité mesurée", 2.2, 2.2, 2.2, "prix"), | |
| 23 | + ("Cespedes-Lopez et al. (2019), méta-analyse :\nEurope … monde … Amérique", 2.32, 5.36, 4.20, "prix"), | |
| 24 | + ("Eichholtz et al. (2010), bureaux É.-U. :\nloyer … prix de vente", 3.0, 16.0, None, "bureaux"), | |
| 25 | +] | |
| 26 | +COUL = {"prix": palette.cat[0], "loyer": palette.cat[2], "bureaux": palette.cat[3]} | |
| 27 | + | |
| 28 | + | |
| 29 | +def main(): | |
| 30 | + setup() | |
| 31 | + mm = cas.mesures(); d = cas1.charger(); mod = cas1.hedonique(d["ventes"]) | |
| 32 | + vs = float(cas1.predire(mod, cas1.sujet_df(d["sujet"]))[0]) | |
| 33 | + va_ab = cas.flux_mesure(mm["AB"])["va_economies"]; va_a = cas.flux_mesure(mm["A"])["va_economies"] | |
| 34 | + cout_ab = mm["AB"]["cout"] | |
| 35 | + fig, ax = plt.subplots(figsize=(LARGEUR, 4.6)) | |
| 36 | + n = len(ETUDES) | |
| 37 | + for i, (lab, lo, hi, pt, cat) in enumerate(ETUDES): | |
| 38 | + y = n - 1 - i; c = COUL[cat] | |
| 39 | + ax.plot([lo, hi], [y, y], color=c, lw=3.0, solid_capstyle="round", alpha=0.85) | |
| 40 | + if pt is not None: ax.plot([pt], [y], marker="o", ms=6, color=c, mec="white", mew=0.8) | |
| 41 | + ax.text(hi + 0.35, y, f"{lo:+.1f} à {hi:+.1f} %".replace(".", ",").replace("-", "−") if lo != hi else f"{hi:+.1f} %".replace(".", ",").replace("-", "−"), | |
| 42 | + va="center", ha="left", fontsize=7.5, color=palette.ink) | |
| 43 | + # repère du cas 1 : VA des économies rapportée à la valeur hédonique | |
| 44 | + for k, (val, lab) in enumerate([(va_ab / vs * 100, "VA des économies\n(A + B) / valeur"), (cout_ab / vs * 100, "coût installé\n(A + B) / valeur")]): | |
| 45 | + ax.axvline(val, color=palette.ink2, lw=0.9, ls=":" if k else "--") | |
| 46 | + ax.text(val + (0.25 if k else -0.25), n - 0.2, f"Cas 1 : {lab} = {val:.2f} %".replace(".", ","), ha="left" if k else "right", va="bottom", fontsize=7, color=palette.ink2) | |
| 47 | + ax.axvline(0, color=palette.ink, lw=0.7) | |
| 48 | + ax.set_yticks(range(n)); ax.set_yticklabels([e[0] for e in ETUDES][::-1], fontsize=7.6) | |
| 49 | + ax.set_xlim(-8, 22); ax.set_ylim(-0.7, n + 1.2) | |
| 50 | + ax.set_xlabel("Écart de prix (ou de loyer) attribué à l'étiquette ou à l'efficacité énergétique, en %") | |
| 51 | + ax.grid(axis="y", visible=False) | |
| 52 | + leg = [Line2D([0], [0], color=COUL["prix"], lw=3, label="Prix de vente, logements"), Line2D([0], [0], color=COUL["loyer"], lw=3, label="Loyers, logements"), | |
| 53 | + Line2D([0], [0], color=COUL["bureaux"], lw=3, label="Bureaux (loyers et prix)")] | |
| 54 | + ax.legend(handles=leg, loc="upper right", fontsize=7.5, bbox_to_anchor=(1.0, 0.86)) | |
| 55 | + out = savefig(fig, "ch44/ch44-fig-prime-verte.pdf"); print("OK", out) | |
| 56 | + | |
| 57 | + | |
| 58 | +if __name__ == "__main__": | |
| 59 | + main() | |
added
analysis/python/figures/ch46_figures.py
+125 −0
@@ -0,0 +1,125 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""Chapitre 46 — figures du dossier « triplex à Québec » (DONNÉES SYNTHÉTIQUES, graine 20261031). | |
| 3 | +Produit assets/figures/ch46/ch46-fig-{loyers,cascade,mrb,occupation,fourchettes}.pdf | |
| 4 | +Usage (racine) : python3 analysis/python/figures/ch46_figures.py""" | |
| 5 | +from __future__ import annotations | |
| 6 | +import sys, pathlib | |
| 7 | +import numpy as np | |
| 8 | +import matplotlib.pyplot as plt | |
| 9 | +from matplotlib.ticker import FuncFormatter | |
| 10 | + | |
| 11 | +ROOT = pathlib.Path(__file__).resolve().parents[3] | |
| 12 | +sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases")) | |
| 13 | +from traite.style import setup, palette, savefig, LARGEUR, fmt_dollars # noqa: E402 | |
| 14 | +import ch46_dossier as ch # noqa: E402 | |
| 15 | + | |
| 16 | + | |
| 17 | +def fig_loyers(D): | |
| 18 | + L = D["loyers"]; x = np.arange(len(L)); w = 0.36 | |
| 19 | + fig, axes = plt.subplots(1, 2, figsize=(LARGEUR, 2.9), gridspec_kw={"width_ratios": [1.6, 1]}) | |
| 20 | + ax = axes[0] | |
| 21 | + ax.bar(x - w / 2, L["loyer_mensuel"], w, color=palette.cat[0], label="Loyer contractuel (bail 2026-2027)") | |
| 22 | + ax.bar(x + w / 2, L["loyer_marche_estime"], w, color=palette.cat[1], label="Loyer de marché estimé") | |
| 23 | + for i, r in L.iterrows(): | |
| 24 | + e = (r["loyer_marche_estime"] - r["loyer_mensuel"]) / r["loyer_marche_estime"] | |
| 25 | + ax.text(x[i], r["loyer_marche_estime"] + 20, f"−{e*100:.1f} %".replace(".", ","), ha="center", va="bottom", fontsize=7.5, color=palette.ink) | |
| 26 | + ax.set_xticks(x); ax.set_xticklabels([f"{l}\n{p}, {s} m²\nbail {a}" for l, p, s, a in zip(["Rez-de-ch.", "2e étage", "3e étage"], L["pieces"], L["superficie_m2"], L["locataire_depuis"])], fontsize=7.2) | |
| 27 | + ax.set_ylabel("Loyer mensuel ($)"); ax.yaxis.set_major_formatter(FuncFormatter(fmt_dollars)); ax.set_ylim(0, 2050) | |
| 28 | + ax.legend(loc="upper left", fontsize=7, ncol=1); ax.grid(axis="x", visible=False); ax.set_title("A. Loyer contractuel et loyer de marché", loc="left", fontsize=8.5) | |
| 29 | + ax = axes[1] | |
| 30 | + anc = 2026 - L["locataire_depuis"]; ecart = (1 - L["loyer_mensuel"] / L["loyer_marche_estime"]) * 100 | |
| 31 | + ax.scatter(anc, ecart, s=48, color=palette.cat[0], zorder=3) | |
| 32 | + for a, e, l in zip(anc, ecart, ["RC", "2e", "3e"]): ax.annotate(l, (a, e), textcoords="offset points", xytext=(6, 2), fontsize=7.5, color=palette.ink) | |
| 33 | + xx = np.linspace(0, 8, 20); ax.plot(xx, 1.5 * xx, color=palette.ink2, lw=1, ls="--", label="Loi de génération des ventes (1,5 %/an)") | |
| 34 | + ax.plot(xx, 2.0 * xx, color=palette.cat[2], lw=1, ls=":", label="Décote du cas 3 (2 %/an)") | |
| 35 | + ax.set_xlabel("Ancienneté du bail (années)"); ax.set_ylabel("Écart au marché (%)"); ax.set_xlim(0, 8); ax.set_ylim(0, 40) | |
| 36 | + ax.legend(loc="upper left", fontsize=6.5); ax.set_title("B. Écart et ancienneté", loc="left", fontsize=8.5) | |
| 37 | + fig.tight_layout(w_pad=1.5) | |
| 38 | + savefig(fig, "ch46/ch46-fig-loyers.pdf") | |
| 39 | + | |
| 40 | + | |
| 41 | +def fig_cascade(D): | |
| 42 | + t = D["grille"]["tableau"]; r = t[t["id_vente"] == "Q0243"].iloc[0] | |
| 43 | + etapes = [("Prix/log.\n2026-07-24", r["prix_logement"]), ("Marché\n(temps)", r["temps"]), ("Superficie\npar log.", r["superficie"]), ("Âge", r["age"]), | |
| 44 | + ("État", r["etat"]), ("Station-\nnement", r["stationnement"]), ("Loyer de\nmarché", r["loyer_marche"]), ("Loyer relatif\n(non appl.)", r["loyer_relatif"])] | |
| 45 | + fig, ax = plt.subplots(figsize=(LARGEUR, 3.2)) | |
| 46 | + cum = etapes[0][1]; ax.bar(0, cum, color=palette.cat[0], width=0.6) | |
| 47 | + ax.text(0, cum + 2500, fmt_dollars(cum), ha="center", fontsize=7.5) | |
| 48 | + for i, (lib, a) in enumerate(etapes[1:], 1): | |
| 49 | + col = palette.cat[1] if a >= 0 else palette.cat[3] | |
| 50 | + bas = cum if a >= 0 else cum + a | |
| 51 | + ax.bar(i, abs(a) if abs(a) > 1 else 600, bottom=bas if abs(a) > 1 else cum, color=col if abs(a) > 1 else palette.grid, width=0.6) | |
| 52 | + ax.text(i, max(cum, cum + a) + 2500, (f"+{a:,.0f}" if a >= 0 else f"−{abs(a):,.0f}").replace(",", " ") + " $", ha="center", fontsize=7.2) | |
| 53 | + ax.plot([i - 0.7, i + 0.3], [cum, cum], color=palette.ink2, lw=0.6) | |
| 54 | + cum += a | |
| 55 | + ax.bar(len(etapes), cum, color=palette.cat[0], width=0.6); ax.text(len(etapes), cum + 2500, fmt_dollars(cum), ha="center", fontsize=7.5, fontweight="bold") | |
| 56 | + ax.set_xticks(range(len(etapes) + 1)); ax.set_xticklabels([e[0] for e in etapes] + ["Prix\najusté"], fontsize=7) | |
| 57 | + ax.set_ylim(300000, 400000); ax.yaxis.set_major_formatter(FuncFormatter(fmt_dollars)); ax.set_ylabel("$ par logement"); ax.grid(axis="x", visible=False) | |
| 58 | + ax.text(0.99, 0.04, f"Ajustement brut {r['brut_pct']*100:.1f} % ; net +{r['net_pct']*100:.1f} %".replace(".", ","), transform=ax.transAxes, ha="right", fontsize=7.5, color=palette.ink2) | |
| 59 | + savefig(fig, "ch46/ch46-fig-cascade.pdf") | |
| 60 | + | |
| 61 | + | |
| 62 | +def fig_mrb(D): | |
| 63 | + c = D["comparables127"]; tri = c[c["nb_logements"] == 3]; comp5 = D["comparables5"]; rec = D["reconciliation"]; rev = D["revenu"] | |
| 64 | + fig, axes = plt.subplots(1, 2, figsize=(LARGEUR, 3.0), gridspec_kw={"width_ratios": [1.5, 1]}) | |
| 65 | + ax = axes[0] | |
| 66 | + ax.hist(tri["mrb"], bins=np.arange(12, 32, 1.0), color=palette.cat[0], alpha=0.35, label="67 triplex (4 quartiers, 24 mois)") | |
| 67 | + ax.hist(c.loc[(c["quartier"] == "Limoilou") & (c["nb_logements"] == 3), "mrb"], bins=np.arange(12, 32, 1.0), color=palette.cat[0], alpha=0.8, label="28 triplex de Limoilou") | |
| 68 | + for i, (_, r) in enumerate(comp5.iterrows()): | |
| 69 | + ax.axvline(r["mrb"], color=palette.cat[1], lw=1.2, label="5 comparables (MRB 20,75 à 23,35)" if i == 0 else None) | |
| 70 | + ax.axvline(rec["mrb_implicite"], color=palette.ink, lw=1.6, ls="--"); ax.text(12.3, 12.7, f"Sujet, valeur conclue : {rec['mrb_implicite']:.2f} (tireté)".replace(".", ","), fontsize=7, color=palette.ink) | |
| 71 | + ax.axvline(rev["mrb_127"], color=palette.ink2, lw=1, ls=":"); ax.text(12.3, 11.5, f"Médiane des 67 triplex : {rev['mrb_127']:.2f} (pointillé)".replace(".", ","), fontsize=7, color=palette.ink2) | |
| 72 | + ax.set_xlabel("Multiplicateur du revenu brut (prix / RBP contractuel)"); ax.set_ylabel("Nombre de ventes"); ax.set_ylim(0, 14); ax.set_xlim(12, 31); ax.legend(loc="center right", fontsize=6.8) | |
| 73 | + ax.set_title("A. Les cinq comparables dans la distribution", loc="left", fontsize=8.5) | |
| 74 | + ax = axes[1] | |
| 75 | + rho5 = comp5["loyer_mensuel_moyen_contrat"] / comp5["loyer_mensuel_moyen_marche"]; rho_tri = tri["loyer_mensuel_moyen_contrat"] / tri["loyer_mensuel_moyen_marche"] | |
| 76 | + ax.scatter(rho_tri, tri["mrb"], s=10, color=palette.cat[0], alpha=0.35, label="67 triplex") | |
| 77 | + ax.scatter(rho5, comp5["mrb"], s=40, color=palette.cat[1], zorder=3, label="5 comparables") | |
| 78 | + reg = D["regression_taux"]; xx = np.linspace(0.72, 1.0, 50); ax.plot(xx, np.exp(reg["mrb_a"] + reg["mrb_b_rho"] * (xx - 1)), color=palette.ink, lw=1.2, label="ln MRB = a + b(ρ − 1)") | |
| 79 | + ax.axvline(D["base"]["rho"], color=palette.ink2, lw=0.8, ls="--"); ax.text(D["base"]["rho"] + 0.005, 13, "ρ du sujet", fontsize=7, color=palette.ink2) | |
| 80 | + ax.set_xlabel("Loyer relatif ρ (contrat / marché)"); ax.set_ylabel("MRB"); ax.set_ylim(12, 31); ax.legend(loc="upper right", fontsize=6.8) | |
| 81 | + ax.set_title("B. MRB et loyer relatif", loc="left", fontsize=8.5) | |
| 82 | + fig.tight_layout(w_pad=1.5) | |
| 83 | + savefig(fig, "ch46/ch46-fig-mrb.pdf") | |
| 84 | + | |
| 85 | + | |
| 86 | +def fig_occupation(D): | |
| 87 | + g = D["grille_occupation"]; occ = D["financement"]["occupation"] | |
| 88 | + fig, ax = plt.subplots(figsize=(LARGEUR, 3.1)) | |
| 89 | + for i, (lib, lab) in enumerate((("minimale", "Mise de fonds minimale (7,3 %, assuré)"), ("10 %", "Mise de fonds de 10 % (assuré)"), ("15 %", "Mise de fonds de 15 % (assuré)"), ("20 %", "Mise de fonds de 20 % (non assuré, +46 pb)"))): | |
| 90 | + s = g[g["mise"] == lib]; ax.plot(s["taux"] * 100, s["hors_capital"], color=palette.cat[i], label=lab) | |
| 91 | + ax.axhline(occ["loyer_marche_logement"], color=palette.ink, lw=1.2, ls="--"); ax.text(3.15, occ["loyer_marche_logement"] + 25, "Loyer de marché du rez-de-chaussée (1 430 $)", fontsize=7.5, color=palette.ink) | |
| 92 | + ax.axvline(ch.C.TAUX_ASSURE * 100, color=palette.ink2, lw=0.8, ls=":"); ax.text(ch.C.TAUX_ASSURE * 100 + 0.05, 1080, "Taux assuré, août 2026 (4,12 %)", fontsize=7, color=palette.ink2) | |
| 93 | + ax.set_xlabel("Taux hypothécaire nominal (%, composition semestrielle)"); ax.set_ylabel("Coût économique mensuel hors capital ($)") | |
| 94 | + ax.yaxis.set_major_formatter(FuncFormatter(fmt_dollars)); ax.set_xlim(3.1, 7.2); ax.set_ylim(1000, 3600); ax.legend(loc="upper left", fontsize=7.2) | |
| 95 | + savefig(fig, "ch46/ch46-fig-occupation.pdf") | |
| 96 | + | |
| 97 | + | |
| 98 | +def fig_fourchettes(D): | |
| 99 | + rec = D["reconciliation"]; rev = D["revenu"]; gr = D["grille"]; co = D["cout"]; cs = D["cout_sensibilite"]; ch32 = D["ch32"] | |
| 100 | + lignes = [ | |
| 101 | + ("Comparaison — prix par logement ajusté (5 comp.)", gr["valeur"], gr["reconciliation"]["min"] * 3, gr["reconciliation"]["max"] * 3, 0.40), | |
| 102 | + ("Comparaison — MRB médian des comparables (22,05)", rev["v_mrb_comp"], float(D["comparables5"]["mrb"].min()) * D["base"]["rbp_contrat"], float(D["comparables5"]["mrb"].max()) * D["base"]["rbp_contrat"], 0.35), | |
| 103 | + ("Revenu — capitalisation au taux ajusté (3,02 %)", rev["v_cap_ajuste"], rev["v_cap_q3"], rev["v_cap_q1"], 0.25), | |
| 104 | + ("Repère — 127 ventes brutes (ch. 32)", ch32["v_log_127"], ch32["v_cap_127"], ch32["v_log_127"], 0.0), | |
| 105 | + ("Repère — ch. 32 (médianes des comparables)", 872425.7581, 729549.19, 922395.6, 0.0), | |
| 106 | + ("Contrôle — coût déprécié (hypothèses déclarées)", co["valeur"], float(cs["valeur"].min()), float(cs["valeur"].max()), 0.0), | |
| 107 | + ] | |
| 108 | + fig, ax = plt.subplots(figsize=(LARGEUR, 3.1)) | |
| 109 | + for i, (lib, v, lo, hi, w) in enumerate(lignes): | |
| 110 | + y = len(lignes) - 1 - i; col = palette.cat[0] if w > 0 else palette.ink2 | |
| 111 | + ax.plot([lo, hi], [y, y], color=col, lw=1.4, alpha=0.6); ax.scatter([v], [y], s=46 if w > 0 else 26, color=col, zorder=3, marker="o" if w > 0 else "s") | |
| 112 | + ax.text(hi + 12000, y, fmt_dollars(v) + (f" (poids {int(w*100)} %)" if w > 0 else ""), va="center", fontsize=7.2, color=palette.ink) | |
| 113 | + ax.axvspan(rec["fourchette_conclue"][0], rec["fourchette_conclue"][1], color=palette.cat[1], alpha=0.12, lw=0) | |
| 114 | + ax.axvline(rec["valeur_finale"], color=palette.ink, lw=1.5, ls="--"); ax.text(rec["valeur_finale"], len(lignes) - 0.45, f"Valeur conclue {fmt_dollars(rec['valeur_finale'])}\nfourchette {fmt_dollars(rec['fourchette_conclue'][0])} – {fmt_dollars(rec['fourchette_conclue'][1])}", ha="center", fontsize=7.5, color=palette.ink) | |
| 115 | + ax.axvline(D["sujet"]["valeur_role_2025"], color=palette.cat[3], lw=1, ls=":"); ax.text(D["sujet"]["valeur_role_2025"], -0.75, "Rôle 2025-2027\n(réf. 2023-07-01)", ha="center", fontsize=7, color=palette.cat[3]) | |
| 116 | + ax.set_yticks(range(len(lignes))); ax.set_yticklabels([l[0] for l in reversed(lignes)], fontsize=7.2) | |
| 117 | + ax.xaxis.set_major_formatter(FuncFormatter(fmt_dollars)); ax.set_xlim(330000, 1480000); ax.set_ylim(-1.1, len(lignes) + 0.3); ax.grid(axis="y", visible=False) | |
| 118 | + ax.set_xlabel("Indication de valeur ($)") | |
| 119 | + savefig(fig, "ch46/ch46-fig-fourchettes.pdf") | |
| 120 | + | |
| 121 | + | |
| 122 | +if __name__ == "__main__": | |
| 123 | + setup(); D = ch.dossier() | |
| 124 | + fig_loyers(D); fig_cascade(D); fig_mrb(D); fig_occupation(D); fig_fourchettes(D) | |
| 125 | + print("Figures écrites dans assets/figures/ch46/") | |
added
analysis/python/figures/ch48_portefeuille_figures.py
+151 −0
@@ -0,0 +1,151 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""Chapitre 48 — figures du dossier « portefeuille multirésidentiel interrégional » (DONNÉES SYNTHÉTIQUES, graine 20261031). | |
| 3 | +Produit assets/figures/ch48/ch48-fig-{carte,rmr,rne,taux,montecarlo,dette,frontiere}.pdf | |
| 4 | +Usage (racine) : PYTHONPATH=analysis/python python3 analysis/python/figures/ch48_portefeuille_figures.py""" | |
| 5 | +from __future__ import annotations | |
| 6 | +import sys, pathlib, math | |
| 7 | +import numpy as np, pandas as pd | |
| 8 | +import matplotlib.pyplot as plt | |
| 9 | +from matplotlib.ticker import FuncFormatter | |
| 10 | +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) | |
| 11 | +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "cases")) | |
| 12 | +from traite.style import setup, palette, savefig, LARGEUR, fmt_dollars, fmt_milliers | |
| 13 | +import ch48_portefeuille as ch | |
| 14 | + | |
| 15 | +RMR = ch.RMR | |
| 16 | +COUL = {r: palette.cat[i] for i, r in enumerate(RMR)} | |
| 17 | +pct2 = FuncFormatter(lambda v, p: f"{v*100:.2f}".replace(".", ",") + " %") | |
| 18 | +pct1 = FuncFormatter(lambda v, p: f"{v*100:.1f}".replace(".", ",") + " %") | |
| 19 | +mdol = FuncFormatter(lambda v, p: f"{v/1e6:.0f} M$") | |
| 20 | +kdol = FuncFormatter(lambda v, p: f"{v/1e3:.0f} k$") | |
| 21 | +# Coordonnées des centres (Base de données toponymiques du Canada, RNCan, geonames, consulté le 2026-10-31 ; mêmes valeurs que ch04_carte.py) | |
| 22 | +CENTRES = {"Montréal": (45.508822, -73.554077), "Québec": (46.813819, -71.207997), "Gatineau": (45.427929, -75.710938), | |
| 23 | + "Sherbrooke": (45.404047, -71.893171), "Trois-Rivières": (46.343077, -72.543383), "Saguenay": (48.428221, -71.062202)} | |
| 24 | +DECAL = {"Montréal": (-1.35, -0.05), "Québec": (0.55, 0.25), "Gatineau": (0.0, 0.45), "Sherbrooke": (0.85, -0.12), "Trois-Rivières": (-0.05, 0.40), "Saguenay": (0.0, 0.40)} | |
| 25 | + | |
| 26 | +def fig_carte(D): | |
| 27 | + pf = D["portefeuille"]; g = D["sommaire"]["par_rmr"]; k = math.cos(math.radians(47.0)) | |
| 28 | + fig, ax = plt.subplots(figsize=(LARGEUR, 4.0)) | |
| 29 | + pts = [CENTRES["Montréal"], CENTRES["Trois-Rivières"], CENTRES["Québec"]] | |
| 30 | + ax.plot([p[1] * k for p in pts], [p[0] for p in pts], color=palette.grid, lw=5, alpha=0.9, zorder=1, solid_capstyle="round") | |
| 31 | + ref = g["valeur"].max() | |
| 32 | + for r in RMR: | |
| 33 | + lat, lon = CENTRES[r]; x, y = lon * k, lat | |
| 34 | + ax.scatter([x], [y], s=max(2600 * g.loc[r, "valeur"] / ref, 40), color=COUL[r], alpha=0.30, edgecolor=COUL[r], lw=1.0, zorder=2) | |
| 35 | + s = pf[pf["rmr"] == r] | |
| 36 | + ax.scatter(s["lon"] * k, s["lat"], s=6 + s["nb_logements"] * 0.9, color=COUL[r], edgecolor="white", lw=0.4, zorder=4, alpha=0.9) | |
| 37 | + dx, dy = DECAL[r] | |
| 38 | + ax.text(x + dx, y + dy, f"{r}\n{int(g.loc[r, 'n'])} imm. · {int(g.loc[r, 'logements'])} log.\n{g.loc[r, 'valeur']/1e6:.1f} M$ · {g.loc[r, 'taux_pondere']*100:.2f} %".replace(".", ","), ha="center", va="center", fontsize=7, color=palette.ink, zorder=5) | |
| 39 | + z = pf[pf["zone_inondable"] == 1]; ax.scatter(z["lon"] * k, z["lat"], s=90, facecolor="none", edgecolor=palette.cat[3], lw=1.3, zorder=6, label="zone inondable (3 immeubles)") | |
| 40 | + c = pf[pf["contamination_sol"] == 1]; ax.scatter(c["lon"] * k, c["lat"], s=90, facecolor="none", edgecolor=palette.ink, lw=1.0, ls="--", zorder=6, label="contamination du sol (2 immeubles)") | |
| 41 | + ax.set_xlim(-76.2 * k - 0.9, -70.5 * k + 0.9); ax.set_ylim(44.35, 49.3) | |
| 42 | + ax.set_xticks([lon * k for lon in (-76, -75, -74, -73, -72, -71)]); ax.set_xticklabels([f"{abs(l)}° O" for l in (-76, -75, -74, -73, -72, -71)]) | |
| 43 | + ax.set_yticks([45, 46, 47, 48, 49]); ax.set_yticklabels([f"{v}° N" for v in (45, 46, 47, 48, 49)]) | |
| 44 | + ax.set_aspect("equal"); ax.grid(True, lw=0.4); ax.legend(loc="upper left", fontsize=7) | |
| 45 | + ax.text(-76.2 * k - 0.8, 44.45, "Cercles : valeur 2026 agrégée par RMR (surface proportionnelle) ; points : immeubles (surface ∝ logements) ; schéma sans fond de carte", fontsize=6.8, color=palette.ink2) | |
| 46 | + return savefig(fig, "ch48/ch48-fig-carte.pdf") | |
| 47 | + | |
| 48 | +def fig_rmr(D): | |
| 49 | + g = D["sommaire"]["par_rmr"]; pf = D["portefeuille"]; x = np.arange(len(RMR)) | |
| 50 | + fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(LARGEUR, 3.3), gridspec_kw={"wspace": 0.42}) | |
| 51 | + ax1.bar(x, g["valeur_par_logement"], 0.62, color=[COUL[r] for r in RMR], alpha=0.85) | |
| 52 | + for i, r in enumerate(RMR): | |
| 53 | + ax1.text(i, g.loc[r, "valeur_par_logement"] + 4000, fmt_milliers(round(g.loc[r, "valeur_par_logement"], -3)), ha="center", fontsize=7) | |
| 54 | + ax1.text(i, 6000, f"RPV {g.loc[r, 'rpv']*100:.0f} %", ha="center", fontsize=6.5, color="white", rotation=90, va="bottom") | |
| 55 | + ax1.axhline(D["sommaire"]["total"]["valeur_par_logement"], color=palette.ink, ls=":", lw=1); ax1.text(5.45, D["sommaire"]["total"]["valeur_par_logement"] + 3000, "portefeuille", fontsize=6.5, ha="right") | |
| 56 | + ax1.set_xticks(x); ax1.set_xticklabels(RMR, rotation=28, ha="right", fontsize=7.5); ax1.yaxis.set_major_formatter(kdol); ax1.set_ylim(0, 240_000); ax1.set_title("Valeur 2026 par logement", loc="left") | |
| 57 | + # taux : étendue, médiane, pondéré ; fourchettes CBRE (Montréal, Québec, basse hauteur B, T3 2026) | |
| 58 | + cbre = {"Montréal": (0.0425, 0.0475), "Québec": (0.05, 0.06)} | |
| 59 | + for i, r in enumerate(RMR): | |
| 60 | + s = pf[pf["rmr"] == r] | |
| 61 | + ax2.vlines(i, s["taux_global"].min(), s["taux_global"].max(), color=palette.ink2, lw=1) | |
| 62 | + ax2.scatter(np.full(len(s), i) + np.linspace(-0.12, 0.12, len(s)), s["taux_global"], s=14, color=COUL[r], zorder=3, alpha=0.9) | |
| 63 | + ax2.scatter([i], [g.loc[r, "taux_pondere"]], marker="_", s=300, color=palette.ink, linewidths=2.2, zorder=4) | |
| 64 | + if r in cbre: ax2.add_patch(plt.Rectangle((i - 0.32, cbre[r][0]), 0.64, cbre[r][1] - cbre[r][0], color=palette.grid, alpha=0.9, zorder=1)) | |
| 65 | + ax2.scatter([], [], marker="_", s=120, color=palette.ink, linewidths=2.2, label="taux pondéré (RNE / valeur)"); ax2.scatter([], [], s=14, color=palette.ink2, label="immeubles") | |
| 66 | + ax2.add_patch(plt.Rectangle((0, 0), 0, 0, color=palette.grid, label="fourchette CBRE T3 2026 (basse hauteur B)")) | |
| 67 | + ax2.set_xticks(x); ax2.set_xticklabels(RMR, rotation=28, ha="right", fontsize=7.5); ax2.yaxis.set_major_formatter(pct1); ax2.set_ylim(0.035, 0.075) | |
| 68 | + ax2.set_title("Taux global par RMR", loc="left"); ax2.legend(loc="upper left", fontsize=6.5) | |
| 69 | + fig.text(0.99, -0.06, "DONNÉES SYNTHÉTIQUES — cas 4 (48 immeubles, 6 RMR) ; CBRE : repères publiés, Montréal et Québec seulement", ha="right", fontsize=6.8, color=palette.ink2) | |
| 70 | + return savefig(fig, "ch48/ch48-fig-rmr.pdf") | |
| 71 | + | |
| 72 | +def fig_rne(D): | |
| 73 | + dyn = D["dynamique"]; agg = dyn["agregats_rmr"]; tot = dyn["agregat_total"] | |
| 74 | + idx = [p.to_timestamp() for p in agg.index] | |
| 75 | + fig, ax = plt.subplots(figsize=(LARGEUR, 3.3)) | |
| 76 | + ax.axvspan(pd.Timestamp("2020-04-01"), pd.Timestamp("2021-06-30"), color=palette.grid, alpha=0.7, lw=0, label="choc pandémique (2020T2-2021T2)") | |
| 77 | + for r in RMR: | |
| 78 | + ax.plot(idx, 100 * agg[r] / agg[r].iloc[0], color=COUL[r], lw=1.2, label=f"{r} (g = {dyn['par_rmr'].loc[r, 'croissance_agregee']*100:.1f} %/an)".replace(".", ",")) | |
| 79 | + ax.plot(idx, 100 * tot / tot.iloc[0], color=palette.ink, lw=2.2, label=f"Portefeuille (g = {dyn['total']['croissance_agregee']*100:.1f} %/an)".replace(".", ",")) | |
| 80 | + ax.set_ylabel("RNE trimestriel agrégé, indice 2016T1 = 100"); ax.set_ylim(90, 170); ax.legend(loc="upper left", fontsize=6.8, ncol=2) | |
| 81 | + ax.text(pd.Timestamp("2026-05-01"), 92, "DONNÉES SYNTHÉTIQUES", ha="right", fontsize=6.8, color=palette.ink2) | |
| 82 | + return savefig(fig, "ch48/ch48-fig-rne.pdf") | |
| 83 | + | |
| 84 | +def fig_taux(D): | |
| 85 | + t = D["predites"]; reg = D["regression"] | |
| 86 | + fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(LARGEUR, 3.3), gridspec_kw={"wspace": 0.35, "width_ratios": [1.25, 1]}) | |
| 87 | + for i, r in enumerate(RMR): | |
| 88 | + s = t[t["rmr"] == r].sort_values("taux_global"); off = np.linspace(-0.2, 0.2, len(s)) | |
| 89 | + ax1.scatter(i + off, s["taux_global"], s=20, color=COUL[r], zorder=3, label="taux retenu (évaluation individuelle)" if i == 0 else None) | |
| 90 | + ax1.scatter(i + off, s["taux_predit"], s=26, facecolor="none", edgecolor=palette.ink, lw=0.8, zorder=4, label="taux prédit par le modèle de masse" if i == 0 else None) | |
| 91 | + for o, a, b in zip(off, s["taux_global"], s["taux_predit"]): ax1.plot([i + o, i + o], [a, b], color=palette.ink2, lw=0.5, zorder=2) | |
| 92 | + ax1.set_xticks(range(6)); ax1.set_xticklabels(RMR, rotation=28, ha="right", fontsize=7.5); ax1.yaxis.set_major_formatter(pct1); ax1.set_ylim(0.035, 0.075) | |
| 93 | + ax1.set_title("Taux retenus et taux prédits", loc="left"); ax1.legend(loc="upper left", fontsize=6.8) | |
| 94 | + ax2.hist(reg["residus"] * 1e4 * 0.0520, bins=14, color=palette.cat[0], alpha=0.85, edgecolor="white") | |
| 95 | + ax2.axvline(0, color=palette.ink, lw=1) | |
| 96 | + ax2.set_xlabel("Résidu du modèle, en points de base\n(au taux pondéré de 5,20 %)"); ax2.set_ylabel("Nombre d'immeubles"); ax2.set_title(f"Résidus (σ = {reg['sigma']:.3f} en log, R² = {reg['r2']:.2f})".replace(".", ","), loc="left") | |
| 97 | + fig.text(0.99, -0.08, "DONNÉES SYNTHÉTIQUES — régression semi-logarithmique du taux global (traite.cas4.effets_regionaux), n = 48", ha="right", fontsize=6.8, color=palette.ink2) | |
| 98 | + return savefig(fig, "ch48/ch48-fig-taux.pdf") | |
| 99 | + | |
| 100 | +def fig_montecarlo(D): | |
| 101 | + fig, ax = plt.subplots(figsize=(LARGEUR, 3.3)) | |
| 102 | + V0 = D["mc"]["stats"]["V0"] / 1e6; bins = np.linspace(110, 185, 76) | |
| 103 | + for nom, lab, c, ls in [("mc_independant", "immeubles indépendants (ρ = 0)", palette.cat[2], "-"), ("mc", "corrélation déclarée (taux : 0,50 / 0,70 ; RNE : 0,17)", palette.cat[0], "-"), ("mc_parfait", "corrélation parfaite (ρ = 1)", palette.cat[3], "-")]: | |
| 104 | + m = D[nom]; V = m["valeur_portefeuille"] / 1e6 | |
| 105 | + ax.hist(V, bins=bins, histtype="stepfilled" if nom == "mc" else "step", color=c, alpha=0.55 if nom == "mc" else 1.0, lw=1.4, density=True, label=f"{lab} — IC 90 % : {m['stats']['q05']/1e6:.1f} à {m['stats']['q95']/1e6:.1f} M$".replace(".", ",")) | |
| 106 | + st = D["mc"]["stats"] | |
| 107 | + for q, lab in [(st["q05"], "q 5 %"), (st["q95"], "q 95 %")]: | |
| 108 | + ax.axvline(q / 1e6, color=palette.ink, ls="--", lw=0.9); ax.text(q / 1e6, ax.get_ylim()[1] * 0.45, lab, ha="center", fontsize=7, backgroundcolor=palette.surface) | |
| 109 | + ax.axvline(V0, color=palette.ink, lw=1.4); ax.annotate(f"somme des indications\nindividuelles : {V0:.1f} M$".replace(".", ","), (V0, ax.get_ylim()[1] * 0.30), xytext=(158, ax.get_ylim()[1] * 0.42), fontsize=7, arrowprops=dict(arrowstyle="-", color=palette.ink2, lw=0.6)) | |
| 110 | + ax.set_xlabel("Valeur simulée du portefeuille (M$)"); ax.set_ylabel("Densité"); ax.set_yticks([]); ax.set_ylim(0, ax.get_ylim()[1] * 1.35); ax.legend(loc="upper left", fontsize=6.6) | |
| 111 | + ax.text(184, ax.get_ylim()[1] * 0.02, "10 000 tirages, graine 20261031 — DONNÉES SYNTHÉTIQUES", ha="right", fontsize=6.8, color=palette.ink2) | |
| 112 | + return savefig(fig, "ch48/ch48-fig-montecarlo.pdf") | |
| 113 | + | |
| 114 | +def fig_dette(D): | |
| 115 | + det = D["dette"]; ech = det["echeancier"]; er = det["echeancier_rmr"]; x = np.arange(len(ech.index)) | |
| 116 | + fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(LARGEUR, 3.3), gridspec_kw={"wspace": 0.38}) | |
| 117 | + bas = np.zeros(len(x)) | |
| 118 | + for r in RMR: | |
| 119 | + v = er[r].reindex(ech.index).fillna(0).values / 1e6 | |
| 120 | + ax1.bar(x, v, 0.62, bottom=bas, color=COUL[r], label=r); bas += v | |
| 121 | + for i, e in enumerate(ech.index): ax1.text(i, bas[i] + 0.5, f"{ech.loc[e, 'dette']/1e6:.1f} M$\n({ech.loc[e, 'part_dette']*100:.0f} %)".replace(".", ","), ha="center", fontsize=6.8) | |
| 122 | + ax1.set_xticks(x); ax1.set_xticklabels([str(e) for e in ech.index]); ax1.set_ylabel("Dette arrivant à échéance (M$)"); ax1.set_ylim(0, 46); ax1.set_title("Échéancier de la dette par RMR", loc="left"); ax1.legend(fontsize=6.3, ncol=3, loc="upper center") | |
| 123 | + for pb, c, mk in [(0, palette.cat[2], "o"), (100, palette.cat[0], "s"), (200, palette.cat[3], "D")]: | |
| 124 | + ax2.plot(x, ech[f"rcd_+{pb}"], marker=mk, ms=5, color=c, lw=1.3, label=f"renouvellement au taux +{pb} pb") | |
| 125 | + ax2.axhline(1.20, color=palette.ink, ls="--", lw=0.9); ax2.text(4.1, 1.21, "covenant 1,20", fontsize=6.8, ha="right") | |
| 126 | + ax2.axhline(1.0, color=palette.ink, ls=":", lw=0.9); ax2.text(4.1, 1.01, "RCD = 1,00", fontsize=6.8, ha="right") | |
| 127 | + ax2.set_xticks(x); ax2.set_xticklabels([str(e) for e in ech.index]); ax2.set_ylim(0.9, 2.3); ax2.set_ylabel("RCD agrégé des prêts de l'échéance"); ax2.set_title("Couverture au renouvellement (RNE 2026 constant)", loc="left", fontsize=8.5); ax2.legend(fontsize=6.6, loc="upper left") | |
| 128 | + fig.text(0.99, -0.06, "DONNÉES SYNTHÉTIQUES — amortissement 480 mois ; solde refinancé sur l'amortissement résiduel", ha="right", fontsize=6.8, color=palette.ink2) | |
| 129 | + return savefig(fig, "ch48/ch48-fig-dette.pdf") | |
| 130 | + | |
| 131 | +def fig_frontiere(D): | |
| 132 | + fr = D["allocation"]["frontiere"] | |
| 133 | + fig, ax = plt.subplots(figsize=(LARGEUR, 3.4)) | |
| 134 | + pts = fr["frontiere"]; ax.plot([p["sigma"] for p in pts], [p["mu"] for p in pts], color=palette.ink, lw=1.4, label="frontière (6 RMR, sans vente à découvert)") | |
| 135 | + for i, r in enumerate(RMR): | |
| 136 | + ax.scatter(fr["sigma"][i], fr["mu"][i], s=40, color=COUL[r], zorder=4); ax.annotate(r, (fr["sigma"][i], fr["mu"][i]), xytext=(5, 3), textcoords="offset points", fontsize=7) | |
| 137 | + ax.scatter(fr["sigma_actuel"], fr["mu_actuel"], s=110, marker="*", color=palette.cat[3], edgecolor=palette.ink, lw=0.6, zorder=5, label=f"portefeuille actuel (μ = {fr['mu_actuel']*100:.2f} %, σ = {fr['sigma_actuel']*100:.2f} %)".replace(".", ",")) | |
| 138 | + ax.scatter(fr["sigma_min_var"], fr["mu_min_var"], s=60, marker="s", color=palette.cat[2], edgecolor=palette.ink, lw=0.6, zorder=5, label="variance minimale") | |
| 139 | + ax.scatter(fr["sigma_meme_mu"], fr["mu_actuel"], s=60, marker="^", color=palette.cat[1], edgecolor=palette.ink, lw=0.6, zorder=5, label="même rendement attendu, risque minimal") | |
| 140 | + ax.scatter(fr["sigma_actuel"], fr["mu_meme_sigma"], s=60, marker="v", color=palette.cat[4], edgecolor=palette.ink, lw=0.6, zorder=5, label="même risque, rendement maximal") | |
| 141 | + ax.xaxis.set_major_formatter(pct2); ax.yaxis.set_major_formatter(pct1); ax.set_xlabel("Risque : écart-type du log de la valeur régionale simulée (un an)"); ax.set_ylabel("Rendement attendu : taux pondéré + croissance du RNE") | |
| 142 | + ax.set_xlim(0.0415, 0.0545); ax.set_ylim(0.070, 0.106); ax.legend(loc="upper left", fontsize=6.6) | |
| 143 | + ax.text(0.0417, 0.0705, "DONNÉES SYNTHÉTIQUES — covariances : simulation Monte-Carlo (10 000 tirages)", fontsize=6.8, color=palette.ink2) | |
| 144 | + return savefig(fig, "ch48/ch48-fig-frontiere.pdf") | |
| 145 | + | |
| 146 | +def main(): | |
| 147 | + setup(); D = ch.dossier() | |
| 148 | + for f in [fig_carte, fig_rmr, fig_rne, fig_taux, fig_montecarlo, fig_dette, fig_frontiere]: print("OK", f(D)) | |
| 149 | + | |
| 150 | +if __name__ == "__main__": | |
| 151 | + main() | |
added
analysis/python/tests/test_ch37.py
+169 −0
@@ -0,0 +1,169 @@ | ||
| 1 | +"""Chapitre 37 — Apprentissage automatique et explicabilité : reproductibilité et bornes de performance des chiffres cités dans le | |
| 2 | +texte, les tableaux et les solutions. Modèle hédonique de référence : traite.cas1.hedonique (non redéfini). Tolérances déclarées.""" | |
| 3 | +import sys, pathlib, math | |
| 4 | +import numpy as np, pytest | |
| 5 | +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "cases")) | |
| 6 | +import ch37_ml as c37 | |
| 7 | +import ch18_hedonique as c18 | |
| 8 | +from traite import cas1 | |
| 9 | + | |
| 10 | +@pytest.fixture(scope="module") | |
| 11 | +def res(): | |
| 12 | + return c37.main(verbose=False) | |
| 13 | + | |
| 14 | +def test_plis_identiques_au_chapitre_18(): | |
| 15 | + """Les dix plis aléatoires sont exactement ceux de ch18_hedonique.validation_croisee (même graine, même découpage).""" | |
| 16 | + v = cas1.charger()["ventes"]; pa = c37.plis_aleatoires(len(v)) | |
| 17 | + rng = np.random.default_rng(c18.GRAINE); attendu = np.array_split(rng.permutation(len(v)), 10) | |
| 18 | + assert all(np.array_equal(a, b) for a, b in zip(pa, attendu)) and sum(len(f) for f in pa) == 600 | |
| 19 | + assert c37.GRAINE == c18.GRAINE == 20261031 | |
| 20 | + | |
| 21 | +def test_reproductibilite_et_reference(res): | |
| 22 | + """Le modèle hédonique validé ici reproduit le ch. 18 : RMSE 40 888 $, COD 6,83 en validation croisée ; prédiction du sujet 637 953 $.""" | |
| 23 | + m = res["comparaison"]["metriques"]["Hédonique (référence)"]["aleatoire"] | |
| 24 | + assert m["COD"] == pytest.approx(6.83, abs=0.01) and m["rmse_log"] == pytest.approx(0.0839, abs=0.0005) and m["PRD"] == pytest.approx(1.007, abs=0.001) | |
| 25 | + assert m["mdape"] == pytest.approx(5.68, abs=0.05) and m["r2_hors"] == pytest.approx(0.846, abs=0.001) | |
| 26 | + assert res["modele_ref"]["r2"] == pytest.approx(0.853, abs=0.001) and res["modele_ref"]["sigma"] == pytest.approx(0.083, abs=0.0005) | |
| 27 | + s = res["sujet_pred"] | |
| 28 | + assert s.loc["Hédonique (référence)", "prediction_cas1"] == pytest.approx(637_953, abs=1) | |
| 29 | + assert s.loc["Hédonique (référence)", "ln_pred"] == pytest.approx(13.3627, abs=0.0001) | |
| 30 | + # une seconde exécution des modèles stochastiques donne les mêmes prédictions (graine fixée) | |
| 31 | + Xm = res["Xm"]; y = res["y"]; f = res["comparaison"]["plis"]["aleatoire"][0]; tr = np.setdiff1d(np.arange(600), f) | |
| 32 | + p1 = c37._boosting().fit(Xm.iloc[tr], y[tr]).predict(Xm.iloc[f]); p2 = c37._boosting().fit(Xm.iloc[tr], y[tr]).predict(Xm.iloc[f]) | |
| 33 | + assert np.allclose(p1, p2) and np.allclose(p1, res["comparaison"]["pred"]["Gradient boosting"]["aleatoire"][f]) | |
| 34 | + | |
| 35 | +def test_comparaison_aleatoire(res): | |
| 36 | + """Sur un jeu engendré par une loi semi-log additive, l'hédonique bien spécifié domine ; le boosting est le meilleur des modèles d'arbres.""" | |
| 37 | + M = {k: v["aleatoire"] for k, v in res["comparaison"]["metriques"].items()} | |
| 38 | + attendu = {"Hédonique (référence)": (0.0839, 6.83, 5.68), "Arbre (profondeur 3)": (0.1468, 11.63, 9.85), "Forêt aléatoire": (0.1067, 8.64, 7.52), | |
| 39 | + "Gradient boosting": (0.0931, 7.42, 6.22), "XGBoost": (0.0921, 7.33, 6.09)} | |
| 40 | + for nom, (rmse, cod, mdape) in attendu.items(): | |
| 41 | + assert M[nom]["rmse_log"] == pytest.approx(rmse, abs=0.0015), nom | |
| 42 | + assert M[nom]["COD"] == pytest.approx(cod, abs=0.12), nom | |
| 43 | + assert M[nom]["mdape"] == pytest.approx(mdape, abs=0.15), nom | |
| 44 | + assert M["Hédonique (référence)"]["rmse_log"] < M["XGBoost"]["rmse_log"] < M["Gradient boosting"]["rmse_log"] < M["Forêt aléatoire"]["rmse_log"] < M["Arbre (profondeur 3)"]["rmse_log"] | |
| 45 | + # bornes : tous les modèles d'ensemble restent dans la plage IAAO 5-10 ; l'arbre seul en sort | |
| 46 | + assert 5.0 < M["Gradient boosting"]["COD"] < 10.0 and 5.0 < M["Forêt aléatoire"]["COD"] < 10.0 and M["Arbre (profondeur 3)"]["COD"] > 10.0 | |
| 47 | + # régressivité des arbres : PRB plus négatif que l'hédonique, PRD plus grand | |
| 48 | + assert M["Forêt aléatoire"]["PRB"] < -0.15 and M["Forêt aléatoire"]["PRD"] > M["Hédonique (référence)"]["PRD"] | |
| 49 | + assert M["Forêt aléatoire"]["PRB"] == pytest.approx(-0.196, abs=0.01) and M["Gradient boosting"]["PRB"] == pytest.approx(-0.088, abs=0.01) | |
| 50 | + # niveau : médianes des ratios ≈ 1 pour tous | |
| 51 | + assert all(0.99 < m["ratio_median"] < 1.015 for m in M.values()) | |
| 52 | + # RMSE en dollars | |
| 53 | + assert M["Hédonique (référence)"]["rmse_dollars"] == pytest.approx(40_889, abs=30) and M["Gradient boosting"]["rmse_dollars"] == pytest.approx(45_644, abs=300) | |
| 54 | + | |
| 55 | +def test_validation_spatiale_et_temporelle(res): | |
| 56 | + M = res["comparaison"]["metriques"] | |
| 57 | + assert M["Hédonique (référence)"]["spatial"]["COD"] == pytest.approx(7.00, abs=0.1) and M["Gradient boosting"]["spatial"]["COD"] == pytest.approx(7.62, abs=0.12) | |
| 58 | + assert M["Forêt aléatoire"]["spatial"]["COD"] == pytest.approx(8.96, abs=0.12) and M["XGBoost"]["spatial"]["COD"] == pytest.approx(7.53, abs=0.12) | |
| 59 | + # la validation spatiale est toujours plus sévère que la validation aléatoire | |
| 60 | + for nom in M: assert M[nom]["spatial"]["rmse_log"] >= M[nom]["aleatoire"]["rmse_log"] - 1e-9, nom | |
| 61 | + bloc = res["comparaison"]["plis"]["bloc_spatial"]; assert len(np.unique(bloc)) == 10 and np.bincount(bloc).min() >= 9 | |
| 62 | + # temporel : 101 ventes de 2026 ; les arbres sous-prédisent (pas d'extrapolation de la tendance), l'hédonique presque pas | |
| 63 | + T = {k: v["temporel"] for k, v in M.items()} | |
| 64 | + assert T["Hédonique (référence)"]["n"] == 101 | |
| 65 | + assert T["Hédonique (référence)"]["biais_log"] == pytest.approx(-0.0102, abs=0.001) and T["Gradient boosting"]["biais_log"] == pytest.approx(-0.034, abs=0.004) | |
| 66 | + assert T["Forêt aléatoire"]["biais_log"] == pytest.approx(-0.060, abs=0.005) and T["Arbre (profondeur 3)"]["biais_log"] == pytest.approx(-0.085, abs=0.006) | |
| 67 | + assert T["Gradient boosting"]["ratio_median"] == pytest.approx(0.966, abs=0.005) and T["Hédonique (référence)"]["ratio_median"] == pytest.approx(0.988, abs=0.003) | |
| 68 | + d = res["detendance"] | |
| 69 | + assert d["metriques_detendance"]["biais_log"] == pytest.approx(-0.018, abs=0.004) and d["metriques_detendance"]["COD"] == pytest.approx(7.44, abs=0.15) | |
| 70 | + assert d["metriques_feuille1"]["biais_log"] == pytest.approx(-0.134, abs=0.01) and d["n_entrainement_apres_mois_17"] == 14 | |
| 71 | + assert d["beta_mois_entrainement"] == pytest.approx(0.00342, abs=0.00005) | |
| 72 | + # le biais du boosting brut s'aggrave au fil des mois de 2026 | |
| 73 | + b = res["comparaison"]["biais_mensuel_2026"]["Gradient boosting"]; assert b[25.0] < b[19.0] and b[25.0] == pytest.approx(-0.076, abs=0.01) | |
| 74 | + | |
| 75 | +def test_un_secteur_exclu(res): | |
| 76 | + S = res["secteurs"] | |
| 77 | + assert S["metriques"]["Hédonique (référence)"]["COD"] == pytest.approx(10.44, abs=0.1) and S["metriques"]["Gradient boosting"]["COD"] == pytest.approx(8.59, abs=0.15) | |
| 78 | + assert S["metriques"]["Forêt aléatoire"]["COD"] == pytest.approx(10.21, abs=0.15) | |
| 79 | + bh = S["biais_secteur"]["Hédonique (référence)"] | |
| 80 | + assert bh["Buckingham"] == pytest.approx(0.234, abs=0.005) and bh["Masson-Angers"] == pytest.approx(0.261, abs=0.005) and abs(bh["Gatineau"]) < 0.02 | |
| 81 | + # le biais de l'hédonique sur un secteur exclu est (au bruit près) la prime de secteur omise | |
| 82 | + assert bh["Buckingham"] == pytest.approx(-c37.VRAIS["sect"]["Buckingham"], abs=0.06) and bh["Masson-Angers"] == pytest.approx(-c37.VRAIS["sect"]["Masson-Angers"], abs=0.06) | |
| 83 | + bg = S["biais_secteur"]["Gradient boosting"] | |
| 84 | + assert abs(bg["Buckingham"]) < 0.02 and bg["Masson-Angers"] == pytest.approx(0.072, abs=0.01) and bg["Gatineau"] == pytest.approx(-0.068, abs=0.01) | |
| 85 | + | |
| 86 | +def test_scenario_non_lineaire(res): | |
| 87 | + """Perturbation déclarée : le boosting bat l'hédonique mal spécifié, et l'hédonique respécifié reprend l'avantage.""" | |
| 88 | + sc = res["scenario"]; M = sc["metriques"] | |
| 89 | + assert sc["n_prime"] == 76 and sc["n_mur"] == 235 | |
| 90 | + assert M["Hédonique (référence)"]["COD"] == pytest.approx(8.15, abs=0.1) and M["Gradient boosting"]["COD"] == pytest.approx(7.46, abs=0.12) | |
| 91 | + assert M["XGBoost"]["COD"] == pytest.approx(7.40, abs=0.12) and M["Hédonique respécifié"]["COD"] == pytest.approx(6.85, abs=0.1) and M["Forêt aléatoire"]["COD"] == pytest.approx(9.17, abs=0.15) | |
| 92 | + assert M["Gradient boosting"]["rmse_log"] < M["Hédonique (référence)"]["rmse_log"] and M["Hédonique respécifié"]["rmse_log"] < M["Gradient boosting"]["rmse_log"] | |
| 93 | + assert M["Hédonique (référence)"]["rmse_log"] == pytest.approx(0.1003, abs=0.001) and M["Gradient boosting"]["rmse_log"] == pytest.approx(0.0944, abs=0.0015) | |
| 94 | + c = sc["coef_respecifie"] | |
| 95 | + assert c["prime"] == pytest.approx(0.129, abs=0.001) and c["mur"] == pytest.approx(-0.092, abs=0.001) and c["sigma"] == pytest.approx(0.0831, abs=0.0005) | |
| 96 | + assert abs(c["prime"] - c37.SCENARIO["prime_qualite_age"]) < 1.96 * c["se_prime"] and abs(c["mur"] - c37.SCENARIO["mur"]) < 1.96 * c["se_mur"] | |
| 97 | + # l'interaction âge × qualité devient la première interaction SHAP dans le scénario, et n'apparaît pas en tête dans le cas de base | |
| 98 | + assert res["shap"]["scenario_interactions"].index[0] == ("age", "qualite") and res["shap"]["scenario_interactions"].iloc[0] == pytest.approx(0.0068, abs=0.001) | |
| 99 | + assert res["shap"]["interactions"].index[0] != ("age", "qualite") and res["shap"]["interactions"].iloc[0] < 0.004 | |
| 100 | + | |
| 101 | +def test_shap(res): | |
| 102 | + sh = res["shap"]; Xm = res["Xm"] | |
| 103 | + assert sh["efficacite_max_err"] < 1e-9 and sh["efficacite_sujet_err"] < 1e-9 # axiome d'efficacité (TreeSHAP exact) | |
| 104 | + assert sh["base"] == pytest.approx(13.0732, abs=0.0005) and sh["base"] == pytest.approx(res["y"].mean(), abs=0.001) | |
| 105 | + assert math.exp(sh["base"]) == pytest.approx(476_010, rel=0.001) | |
| 106 | + imp = sh["importance"] | |
| 107 | + assert list(imp.index[:3]) == ["superficie", "age", "mois"] and imp["superficie"] == pytest.approx(0.111, abs=0.002) and imp["age"] == pytest.approx(0.0432, abs=0.002) | |
| 108 | + assert imp[[f"sect_{s}" for s in cas1.SECTEURS]].max() < 0.002 # secteurs absorbés par lat/lon/distance | |
| 109 | + s = sh["sujet"] | |
| 110 | + assert s["mois"] == pytest.approx(0.0986, abs=0.002) and s["superficie"] == pytest.approx(0.0871, abs=0.002) and s["age"] == pytest.approx(0.0322, abs=0.002) | |
| 111 | + assert s["terrain"] == pytest.approx(-0.0127, abs=0.002) and s.sum() + sh["base"] == pytest.approx(res["sujet_pred"].loc["Gradient boosting", "ln_pred"], abs=1e-9) | |
| 112 | + # symétrie/efficacité : la somme des |φ| moyens n'excède pas la dispersion de y | |
| 113 | + assert imp.sum() < np.std(res["y"]) * 2 | |
| 114 | + # importance par permutation : même tête de classement | |
| 115 | + p = res["permutation"]["Gradient boosting"] | |
| 116 | + assert p.idxmax() == "superficie" and p["superficie"] == pytest.approx(0.0348, abs=0.003) and p["age"] == pytest.approx(0.0061, abs=0.0015) | |
| 117 | + assert res["permutation"]["Gradient boosting (EQM de base)"] == pytest.approx(0.00867, abs=0.0002) | |
| 118 | + | |
| 119 | +def test_sujet_et_pdp(res): | |
| 120 | + s = res["sujet_pred"] | |
| 121 | + assert s.loc["Gradient boosting", "prediction"] == pytest.approx(605_825, rel=0.003) and s.loc["XGBoost", "prediction"] == pytest.approx(613_976, rel=0.003) | |
| 122 | + assert s.loc["Forêt aléatoire", "prediction"] == pytest.approx(586_818, rel=0.003) and s.loc["Arbre (profondeur 3)", "prediction"] == pytest.approx(549_573, rel=0.003) | |
| 123 | + assert s.loc["Hédonique (référence)", "prediction"] == pytest.approx(638_011, abs=50) | |
| 124 | + # tous les modèles d'arbres prédisent le sujet sous l'hédonique (compression vers la moyenne) | |
| 125 | + assert (s["prediction"].drop("Hédonique (référence)") < s.loc["Hédonique (référence)", "prediction"]).all() | |
| 126 | + assert s.loc["Gradient boosting", "ln_pred"] - s.loc["Gradient boosting", "ln_pred"] == 0 and math.exp(s.loc["Gradient boosting", "ln_pred"]) == pytest.approx(604_595, rel=0.003) | |
| 127 | + pdp = res["pdp"] | |
| 128 | + # la PDP de la superficie du boosting suit la loi de génération : corrélation > 0,99 et écart maximal < 4 points | |
| 129 | + for nom in ("Gradient boosting", "Hédonique (référence)"): | |
| 130 | + a = pdp["superficie"][nom] - pdp["superficie"][nom].mean(); b = pdp["superficie"]["vraie"] - pdp["superficie"]["vraie"].mean() | |
| 131 | + assert np.corrcoef(a, b)[0, 1] > 0.99 and np.abs(a - b).max() < 0.04, nom | |
| 132 | + # scénario : la PDP de l'âge du boosting reproduit le mur (creux entre 25 et 45 ans) que le polynôme hédonique ne peut pas suivre | |
| 133 | + g = pdp["age_scenario"]["grille"]; gb = pdp["age_scenario"]["Gradient boosting"]; hd = pdp["age_scenario"]["Hédonique (référence)"]; vr = pdp["age_scenario"]["vraie"] | |
| 134 | + m = (g >= 25) & (g <= 45) | |
| 135 | + assert np.abs((gb - gb.mean()) - (vr - vr.mean())).max() < np.abs((hd - hd.mean()) - (vr - vr.mean())).max() | |
| 136 | + assert np.mean(gb[m]) < np.mean(gb[(g > 46) & (g < 60)]) | |
| 137 | + | |
| 138 | +def test_courbes_et_sensibilite(res): | |
| 139 | + c = res["courbes"] | |
| 140 | + assert c[4]["optimum"] < c[2]["optimum"] <= c[1]["optimum"] # plus l'arbre est profond, plus tôt il surapprend | |
| 141 | + assert c[4]["entrainement"][-1] < 0.02 and c[4]["validation"][-1] > c[4]["rmse_min"] # erreur d'entraînement quasi nulle, validation remonte | |
| 142 | + assert c[1]["rmse_min"] == pytest.approx(0.0887, abs=0.001) and c[2]["rmse_min"] == pytest.approx(0.0923, abs=0.001) and c[4]["rmse_min"] == pytest.approx(0.0966, abs=0.0015) | |
| 143 | + assert c[2]["optimum"] == pytest.approx(730, abs=60) and c[4]["optimum"] == pytest.approx(367, abs=60) | |
| 144 | + s = res["sensibilite"].set_index(["modele", "profondeur", "arbres", "nu", "feuille_min", "sous_ech"]) | |
| 145 | + g = s.loc["Gradient boosting"] | |
| 146 | + assert g.loc[(1, 1200, 0.03, 5, 0.8), "rmse_log"] == pytest.approx(0.0887, abs=0.001) # souches additives : le meilleur boosting | |
| 147 | + assert g.loc[(2, 1200, 0.03, 5, 0.8), "rmse_log"] == pytest.approx(0.0931, abs=0.001) | |
| 148 | + assert g.loc[(4, 1200, 0.03, 5, 0.8), "rmse_log"] == pytest.approx(0.0984, abs=0.0015) and g.loc[(2, 1200, 0.10, 5, 0.8), "rmse_log"] == pytest.approx(0.0988, abs=0.0015) | |
| 149 | + assert g.loc[(2, 300, 0.03, 5, 0.8), "rmse_log"] == pytest.approx(0.0977, abs=0.0015) and g.loc[(2, 3000, 0.03, 5, 0.8), "rmse_log"] == pytest.approx(0.0962, abs=0.0015) | |
| 150 | + f = s.loc["Forêt aléatoire"] | |
| 151 | + assert f["rmse_log"].between(0.105, 0.117).all() and f.loc[("max", 100, slice(None), 2, "bootstrap"), "rmse_log"].iloc[0] == pytest.approx(0.1065, abs=0.001) | |
| 152 | + | |
| 153 | +def test_solutions_exercices(res): | |
| 154 | + e = res["exercices"] | |
| 155 | + # 37.1 : meilleure division des huit ventes | |
| 156 | + b = e["1"]["best"]; assert b["seuil"] == 113.5 and b["k"] == 2 and b["gain"] == pytest.approx(0.338, abs=0.001) and e["1"]["sse0"] == pytest.approx(0.462, abs=0.001) | |
| 157 | + assert b["c_gauche"] == pytest.approx(12.611, abs=0.001) and b["c_droite"] == pytest.approx(13.0857, abs=0.001) | |
| 158 | + assert e["1"]["S"] == [70.0, 99.0, 128.0, 156.0, 160.0, 170.0, 221.0, 241.0] | |
| 159 | + # 37.2 : variance d'une moyenne d'arbres corrélés | |
| 160 | + assert e["2"]["B1"] == pytest.approx(0.0144) and e["2"]["B100"] == pytest.approx(0.004421, abs=1e-6) and e["2"]["limite"] == pytest.approx(0.00432) and e["2"]["ecart_type_limite"] == pytest.approx(0.0657, abs=0.0001) | |
| 161 | + # 37.3 : boosting à la main | |
| 162 | + assert e["3"]["F"][-1] == pytest.approx(12.8542, abs=1e-4) and e["3"]["residus"][-1] == pytest.approx(0.1458, abs=1e-4) and e["3"]["m_1pct"] == pytest.approx(43.7, abs=0.05) | |
| 163 | + # 37.4 : Shapley à trois joueurs | |
| 164 | + phi = e["4"]["phi"]; assert phi["S"] == pytest.approx(70.833, abs=0.001) and phi["Q"] == pytest.approx(35.833, abs=0.001) and phi["L"] == pytest.approx(23.333, abs=0.001) and e["4"]["somme"] == pytest.approx(130.0) | |
| 165 | + # 37.5 : dix prédictions d'un AVM | |
| 166 | + r = e["5"]; assert r["rmse_log"] == pytest.approx(0.0503, abs=0.0001) and r["mdape"] == pytest.approx(4.51, abs=0.01) and r["COD"] == pytest.approx(4.42, abs=0.01) | |
| 167 | + assert r["PRD"] == pytest.approx(1.006, abs=0.001) and r["PRB"] == pytest.approx(-0.034, abs=0.001) and r["ratio_median"] == pytest.approx(0.971, abs=0.001) and r["mape"] == pytest.approx(4.82, abs=0.01) | |
| 168 | + # 37.6 et 37.7 : chiffres cohérents avec les tableaux | |
| 169 | + assert e["6"]["Hédonique (référence)"]["secteur"] == pytest.approx(10.44, abs=0.1) and e["7"]["Hédonique respécifié"] == pytest.approx(6.85, abs=0.1) | |
added
analysis/python/tests/test_ch39.py
+173 −0
@@ -0,0 +1,173 @@ | ||
| 1 | +"""Chapitre 39 — Modèles bayésiens et intervalles : vérification des identités, des diagnostics et des chiffres cités dans le texte, | |
| 2 | +les tableaux et les solutions. Tolérances déclarées dans chaque assertion. Tout est numpy/scipy (pymc et arviz absents).""" | |
| 3 | +import sys, pathlib, math | |
| 4 | +import numpy as np, pytest | |
| 5 | +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "cases")) | |
| 6 | +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) | |
| 7 | +import ch39_bayes as c39 | |
| 8 | +from traite import cas1 | |
| 9 | + | |
| 10 | +@pytest.fixture(scope="module") | |
| 11 | +def res(): | |
| 12 | + return c39.main(verbose=False) | |
| 13 | + | |
| 14 | +# ----------------------------------------------------------------------------------------------------------------- conjugué | |
| 15 | +def test_nig_reference_coincide_avec_mco(res): | |
| 16 | + ex = res["conjugue"]; p = ex["p600"]; m = res["modele"] | |
| 17 | + assert np.allclose(p["m_n"], m["beta"], atol=1e-9) # a priori de référence : m_n = β̂ | |
| 18 | + assert p["a_n"] == pytest.approx(292.0) and p["nu"] == pytest.approx(584.0) # (n − k)/2 = (600 − 16)/2 | |
| 19 | + assert p["b_n"] * 2 == pytest.approx(float(m["residus"] @ m["residus"]), rel=1e-6) # b_n = SCR/2 (différence d'arrondi y'y − m'V⁻¹m) | |
| 20 | + assert p["sigma_moy"] == pytest.approx(0.0832, abs=0.0002) | |
| 21 | + t = ex["tab600"]; assert int(t["couvre"].sum()) == 15 and int(t["vrai"].notna().sum()) == 15 | |
| 22 | + assert t.loc["ln_S", "post_sd"] == pytest.approx(0.0138, abs=0.0002) | |
| 23 | + assert t.loc["sect_Masson-Angers", "post_moy"] == pytest.approx(-0.2631, abs=0.0005) | |
| 24 | + | |
| 25 | +def test_prior_propre_vague(res): | |
| 26 | + ex = res["conjugue"]; pp = ex["p600_propre"] | |
| 27 | + assert np.abs(pp["m_n"] - res["modele"]["beta"]).max() < 1e-4 | |
| 28 | + assert pp["a_n"] == pytest.approx(300.001, abs=1e-6) and pp["sigma_moy"] == pytest.approx(0.0821, abs=0.0002) | |
| 29 | + assert ex["pred600_propre"]["bas95"] == pytest.approx(540_678, abs=5) and ex["pred600_propre"]["haut95"] == pytest.approx(747_707, abs=5) | |
| 30 | + | |
| 31 | +def test_predictive_student_vs_ic_frequentiste(res): | |
| 32 | + pr = res["conjugue"]["pred600"] | |
| 33 | + assert pr["mediane"] == pytest.approx(635_821, abs=2) | |
| 34 | + assert pr["echelle"] == pytest.approx(0.08364, abs=0.00005) and pr["part_coefficients"] * 100 == pytest.approx(1.49, abs=0.02) | |
| 35 | + assert pr["bas95"] == pytest.approx(539_504, abs=5) and pr["haut95"] == pytest.approx(749_334, abs=5) | |
| 36 | + assert pr["bas90"] == pytest.approx(553_979, abs=5) and pr["haut90"] == pytest.approx(729_754, abs=5) | |
| 37 | + # écart avec l'intervalle fréquentiste du ch. 18 (HC1, quantile normal) inférieur à 0,1 % | |
| 38 | + assert abs(pr["bas95"] / c39.IC18["bas95"] - 1) < 0.001 and abs(pr["haut95"] / c39.IC18["haut95"] - 1) < 0.001 | |
| 39 | + # identité : quantile retransformé = exp(loc ± t·échelle) | |
| 40 | + from scipy import stats | |
| 41 | + q = stats.t.ppf(0.975, pr["nu"]) * pr["echelle"] | |
| 42 | + assert math.exp(pr["loc"] - q) == pytest.approx(pr["bas95"], rel=1e-12) | |
| 43 | + | |
| 44 | +def test_coherence_sequentielle(res): | |
| 45 | + ex = res["conjugue"]; p = ex["p600"]; ps = ex["p_rec_seq"] | |
| 46 | + assert ex["n_anc"] + ex["n_rec"] == 600 | |
| 47 | + assert np.abs(ps["m_n"] - p["m_n"]).max() < 1e-8 # postérieure(2023-24) comme a priori + ventes 2025-26 = postérieure(600) | |
| 48 | + assert ps["a_n"] == pytest.approx(p["a_n"], abs=1e-9) and ps["b_n"] == pytest.approx(p["b_n"], rel=1e-6) | |
| 49 | + assert np.allclose(ps["V_n"], p["V_n"], atol=1e-10) | |
| 50 | + | |
| 51 | +def test_petit_echantillon_et_prior_informatif(res): | |
| 52 | + ex = res["conjugue"]; cols = ex["cols_micro"]; i = cols.index("ln_S") | |
| 53 | + assert ex["n_micro"] == 40 and len(cols) == 12 | |
| 54 | + f = ex["petits"]["faible"]; k1 = ex["petits"]["kappa_1"]; k4 = ex["petits"]["kappa_4"]; k16 = ex["petits"]["kappa_16"] | |
| 55 | + assert f["post"]["m_n"][i] == pytest.approx(0.611, abs=0.002) and f["post"]["sd"][i] == pytest.approx(0.067, abs=0.002) | |
| 56 | + assert k1["post"]["m_n"][i] == pytest.approx(0.543, abs=0.002) and k1["post"]["sd"][i] == pytest.approx(0.016, abs=0.001) | |
| 57 | + assert k4["post"]["sd"][i] == pytest.approx(0.028, abs=0.001) and k16["post"]["sd"][i] == pytest.approx(0.042, abs=0.001) | |
| 58 | + assert f["pred"]["mediane"] == pytest.approx(650_965, abs=5) and k1["pred"]["mediane"] == pytest.approx(630_978, abs=5) | |
| 59 | + assert f["largeur95_pct"] == pytest.approx(39.5, abs=0.1) and k1["largeur95_pct"] == pytest.approx(33.2, abs=0.1) | |
| 60 | + # plus κ est grand, moins l'a priori pèse : écart-type croissant, moyenne qui se rapproche de l'estimation plate | |
| 61 | + assert k1["post"]["sd"][i] < k4["post"]["sd"][i] < k16["post"]["sd"][i] < f["post"]["sd"][i] | |
| 62 | + assert ex["pred_hull"]["mediane"] == pytest.approx(623_894, abs=5) and ex["p_hull"]["n"] == 186 | |
| 63 | + | |
| 64 | +def test_loo_exact_nig_petit(): | |
| 65 | + """LOO exact : sur un petit jeu simulé, la prédictive LOO calculée par réestimation égale celle d'une réestimation explicite.""" | |
| 66 | + rng = np.random.default_rng(1); X = np.column_stack([np.ones(30), rng.normal(size=30)]); y = 1 + 0.5 * X[:, 1] + rng.normal(0, 0.3, 30) | |
| 67 | + loo = c39.loo_exact_nig(X, y, c39.prior_reference(["const", "x"])) | |
| 68 | + from scipy import stats | |
| 69 | + p = c39.nig_posterieure(X[1:], y[1:], c39.prior_reference(["const", "x"])) | |
| 70 | + s2 = p["b_n"] / p["a_n"] * (1 + X[0] @ p["V_n"] @ X[0]) | |
| 71 | + assert loo["pointwise"][0] == pytest.approx(stats.t.logpdf(y[0], p["nu"], loc=X[0] @ p["m_n"], scale=math.sqrt(s2)), rel=1e-10) | |
| 72 | + | |
| 73 | +# ----------------------------------------------------------------------------------------------------------------- hiérarchique | |
| 74 | +def test_gibbs_diagnostics(res): | |
| 75 | + d = res["hier"]["diag"] | |
| 76 | + assert (d["rhat_bloc"] < 1.01).all() and (d["ess_bloc"] > 2000).all() | |
| 77 | + assert d.loc["dist_centre", "ess_naif"] < 300 and d.loc["dist_centre", "rhat_naif"] > 1.03 # colinéarité distance/secteurs : mélange lent | |
| 78 | + assert d.loc["alpha_Masson-Angers", "ess_naif"] < 300 and d.loc["alpha_Masson-Angers", "ess_bloc"] > 4000 | |
| 79 | + | |
| 80 | +def test_hierarchique_retrouve_les_lois(res): | |
| 81 | + hi = res["hier"]; r = hi["resume"]; rel = hi["relatifs"] | |
| 82 | + assert r.loc["sigma", "moyenne"] == pytest.approx(0.0832, abs=0.0015) | |
| 83 | + assert r.loc["ln_S", "moyenne"] == pytest.approx(0.5395, abs=0.003) | |
| 84 | + for s in rel.index: # chaque prime de secteur vraie est dans l'intervalle de crédibilité à 95 % | |
| 85 | + assert rel.loc[s, "q025"] <= rel.loc[s, "vrai"] <= rel.loc[s, "q975"] | |
| 86 | + assert rel.loc["Masson-Angers", "moyenne"] == pytest.approx(-0.224, abs=0.01) | |
| 87 | + assert math.sqrt(hi["tau2_med"]) == pytest.approx(0.18, abs=0.02) | |
| 88 | + # rétrécissement : l'effet aléatoire est moins extrême que l'effet fixe pour les secteurs périphériques | |
| 89 | + for s in ["Buckingham", "Masson-Angers"]: | |
| 90 | + assert rel.loc[s, "moyenne"] > rel.loc[s, "fixe"] | |
| 91 | + | |
| 92 | +def test_formule_retrecissement_et_experience(res): | |
| 93 | + hi = res["hier"]; e = hi["experience"] | |
| 94 | + assert c39.facteur_retrecissement(5, 0.083 ** 2, 0.15 ** 2) == pytest.approx(0.9423, abs=0.0005) | |
| 95 | + assert c39.facteur_retrecissement(5, 0.083 ** 2, 0.05 ** 2) == pytest.approx(0.6447, abs=0.0005) | |
| 96 | + assert list(e.index) == [5, 10, 20, 47] | |
| 97 | + a0_rel = float(hi["g"]["alpha0"].mean() - hi["g"]["alpha"][:, :, 0].mean()) | |
| 98 | + for n in e.index: | |
| 99 | + assert e.loc[n, "hier"] > e.loc[n, "fixe"] # rétrécissement vers la moyenne des secteurs | |
| 100 | + assert abs(e.loc[n, "hier"] - a0_rel) < abs(e.loc[n, "fixe"] - a0_rel) | |
| 101 | + assert abs(e.loc[n, "hier"] - e.loc[n, "vrai"]) < abs(e.loc[n, "fixe"] - e.loc[n, "vrai"]) # ici, plus proche du vrai | |
| 102 | + assert e.loc[n, "hier_rhat"] < 1.01 | |
| 103 | + assert e.loc[5, "fixe"] == pytest.approx(-0.342, abs=0.003) and e.loc[5, "hier"] == pytest.approx(-0.300, abs=0.01) | |
| 104 | + assert e.loc[5, "B"] == pytest.approx(0.970, abs=0.005) | |
| 105 | + assert e.loc[5, "largeur_f_pct"] == pytest.approx(36.1, abs=0.3) and e.loc[47, "largeur_f_pct"] == pytest.approx(33.4, abs=0.3) | |
| 106 | + # la part du bruit de transaction domine : la largeur ne varie que de quelques points entre 5 et 47 ventes | |
| 107 | + assert e.loc[5, "largeur_h_pct"] - e.loc[47, "largeur_h_pct"] < 5 | |
| 108 | + | |
| 109 | +def test_predictive_hierarchique(res): | |
| 110 | + pr = res["hier"]["pred"] | |
| 111 | + assert pr["mediane"] == pytest.approx(634_748, rel=0.004) and pr["moyenne"] == pytest.approx(637_727, rel=0.004) | |
| 112 | + assert pr["bas95"] == pytest.approx(540_309, rel=0.006) and pr["haut95"] == pytest.approx(749_431, rel=0.006) | |
| 113 | + assert pr["sd_log"] == pytest.approx(0.0834, abs=0.002) and pr["sd_moyenne_log"] == pytest.approx(0.0103, abs=0.002) | |
| 114 | + # la moyenne a posteriori en dollars est proche de la prédiction de Duan du ch. 18 (637 953 $) : la retransformation est implicite | |
| 115 | + assert abs(pr["moyenne"] / c39.IC18["point"] - 1) < 0.005 | |
| 116 | + | |
| 117 | +def test_sensibilite_prior_tau(res): | |
| 118 | + s = res["hier"]["sensibilite"] | |
| 119 | + assert len(s) == 4 and s["tau_med_600"].between(0.12, 0.20).all() | |
| 120 | + assert (s["ma_5"].max() - s["ma_5"].min()) < 0.05 # l'effet à 5 ventes bouge de moins de 0,05 selon l'a priori sur τ | |
| 121 | + assert (s["ma_600"].max() - s["ma_600"].min()) < 0.03 | |
| 122 | + | |
| 123 | +def test_criteres(res): | |
| 124 | + c = res["criteres"] | |
| 125 | + pool, fixe, hier = c.index | |
| 126 | + assert c.loc[fixe, "elpd_waic"] - c.loc[pool, "elpd_waic"] > 100 | |
| 127 | + assert abs(c.loc[fixe, "elpd_waic"] - c.loc[hier, "elpd_waic"]) < 2 | |
| 128 | + for m in (pool, fixe): # WAIC ≈ LOO exact ≈ LOO tronqué pour les modèles conjugués | |
| 129 | + assert abs(c.loc[m, "elpd_waic"] - c.loc[m, "elpd_loo_exact"]) < 1.0 | |
| 130 | + assert abs(c.loc[m, "elpd_loo_tronque"] - c.loc[m, "elpd_loo_exact"]) < 0.5 | |
| 131 | + assert 10 < c.loc[fixe, "p_waic"] < 20 and c.loc[fixe, "waic"] == pytest.approx(-1266.3, abs=1.0) | |
| 132 | + | |
| 133 | +# ----------------------------------------------------------------------------------------------------------------- simulation | |
| 134 | +def test_recuperation_sur_donnees_simulees(res): | |
| 135 | + """Données engendrées par les lois du générateur (README synthétique) : le modèle conjugué couvre ≥ 13 des 15 paramètres et le | |
| 136 | + modèle hiérarchique retrouve σ et les primes de secteur dans ses intervalles de crédibilité.""" | |
| 137 | + v = res["ventes"]; d = c39.simuler_depuis_generateur(v, 600, graine=3) | |
| 138 | + p = c39.ajuster_nig(d); cols = p["colonnes"] | |
| 139 | + vrais = np.array([c39.VRAIS.get(c, np.nan) for c in cols]); m = ~np.isnan(vrais) | |
| 140 | + couv = ((vrais[m] >= p["ic95_bas"][m]) & (vrais[m] <= p["ic95_haut"][m])).sum() | |
| 141 | + assert couv >= 13 and p["sigma_moy"] == pytest.approx(0.085, abs=0.007) | |
| 142 | + Xc, moy, z = c39.matrice_hier(d); y = np.log(d["prix_vente"].astype(float)).values | |
| 143 | + g = c39.gibbs_hierarchique(y, Xc.values, z, n_iter=1500, brulage=500, chaines=2, graine=11) | |
| 144 | + rel = c39.effets_relatifs(g); r = c39.resume_gibbs(g, list(Xc.columns)) | |
| 145 | + assert r.loc["sigma", "moyenne"] == pytest.approx(0.085, abs=0.007) | |
| 146 | + ok = sum(rel.loc[s, "q025"] <= c39.VRAIS[f"sect_{s}"] <= rel.loc[s, "q975"] for s in rel.index) | |
| 147 | + assert ok >= 3 and (r["rhat"] < 1.05).all() | |
| 148 | + | |
| 149 | +def test_rhat_et_ess_sur_chaines_independantes(): | |
| 150 | + rng = np.random.default_rng(3); ch = rng.normal(size=(4, 2000)) | |
| 151 | + assert 0.99 < c39.rhat_scinde(ch) < 1.02 and c39.ess(ch) > 0.7 * 8000 | |
| 152 | + ar = np.empty((4, 2000)); ar[:, 0] = rng.normal(size=4) # AR(1) à φ = 0,9 : ESS ≈ mn (1−φ)/(1+φ) ≈ 420 | |
| 153 | + for t in range(1, 2000): ar[:, t] = 0.9 * ar[:, t - 1] + rng.normal(size=4) * math.sqrt(1 - 0.81) | |
| 154 | + assert 200 < c39.ess(ar) < 800 and c39.rhat_scinde(ar) < 1.1 | |
| 155 | + assert c39.rhat_scinde(ar + np.array([[0.0], [0.0], [3.0], [0.0]])) > 1.5 # une chaîne décalée : R̂ explose | |
| 156 | + | |
| 157 | +def test_waic_identites(): | |
| 158 | + rng = np.random.default_rng(5); lp = rng.normal(-1, 0.1, size=(500, 50)) | |
| 159 | + w = c39.waic(lp) | |
| 160 | + assert w["elpd_waic"] == pytest.approx(w["lppd"] - w["p_waic"]) and w["waic"] == pytest.approx(-2 * w["elpd_waic"]) | |
| 161 | + assert w["p_waic"] == pytest.approx(50 * 0.01, rel=0.2) | |
| 162 | + | |
| 163 | +# ----------------------------------------------------------------------------------------------------------------- exercices | |
| 164 | +def test_exercices(res): | |
| 165 | + e = res["exercices"] | |
| 166 | + assert e["ex1"]["m_n"] == pytest.approx(3440.8, abs=0.1) and e["ex1"]["s_n"] == pytest.approx(120.0, abs=0.01) | |
| 167 | + assert e["ex1"]["poids_donnees"] == pytest.approx(0.64, abs=0.001) and e["ex1"]["n_equivalent_prior"] == pytest.approx(2.25) | |
| 168 | + assert e["ex2"]["p_sup_650"] == pytest.approx(0.396, abs=0.002) and e["ex2"]["p_inf_550"] == pytest.approx(0.042, abs=0.002) | |
| 169 | + assert e["ex3"][5] == pytest.approx(0.9423, abs=0.0005) and e["ex3"]["alpha_5"] == pytest.approx(-0.2873, abs=0.0005) | |
| 170 | + assert e["ex3"]["sd_cond_5"] == pytest.approx(0.0360, abs=0.0002) and e["ex3"]["sd_fixe_5"] == pytest.approx(0.0371, abs=0.0002) | |
| 171 | + assert e["ex4"]["t975"] == pytest.approx(1.9640, abs=0.0002) and e["ex4"]["t975_20"] == pytest.approx(2.0860, abs=0.0002) | |
| 172 | + assert e["ex5"]["rhat"] == pytest.approx(1.0077, abs=0.0002) and e["ex5"]["rhat_divergent"] == pytest.approx(1.2855, abs=0.001) | |
| 173 | + assert e["ex7"]["m13.0_s1.0"]["sd"] == pytest.approx(0.081, abs=0.003) and e["ex7"]["m13.0_s0.05"]["sd"] == pytest.approx(0.0425, abs=0.002) | |
added
analysis/python/tests/test_ch41.py
+229 −0
@@ -0,0 +1,229 @@ | ||
| 1 | +"""Tests du chapitre 41 — PHMU, terrains et développement (DONNÉES SYNTHÉTIQUES du cas 1 ; zonage FICTIF ; hypothèses déclarées). | |
| 2 | +Tolérances : 1 $ sur les montants cités arrondis au dollar ; 0,05 point sur les pourcentages à une décimale ; 1e-9 sur les identités. | |
| 3 | +Les valeurs « citées » sont celles de manuscript/chapters/ch41.tex et de solutions/ch41.tex.""" | |
| 4 | +import sys, pathlib, math | |
| 5 | +import numpy as np, pytest | |
| 6 | +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "cases")) | |
| 7 | +import ch41_phmu as cas | |
| 8 | +import ch08_zonage as c08 | |
| 9 | +from traite import finance as fin | |
| 10 | + | |
| 11 | +TOL = 1.0 | |
| 12 | + | |
| 13 | + | |
| 14 | +@pytest.fixture(scope="module") | |
| 15 | +def R(): | |
| 16 | + return cas.exemple_complet() | |
| 17 | + | |
| 18 | + | |
| 19 | +class TestIntrants: | |
| 20 | + def test_couts_ch21(self, R): | |
| 21 | + cu = R["couts"] | |
| 22 | + assert cu["direct"] == pytest.approx(298826.17, abs=0.01) and cu["indirects"] == pytest.approx(62598.68, abs=0.01) | |
| 23 | + assert cu["cout_neuf"] == pytest.approx(397567.33, abs=0.01) | |
| 24 | + assert cu["di_m2_maison"] == pytest.approx(2190.45, abs=0.01) | |
| 25 | + assert cu["ratio_ipcb_plex"] == pytest.approx(118.5 / 116.5, rel=1e-12) and round(cu["ratio_ipcb_plex"], 4) == 1.0172 | |
| 26 | + assert cu["di_m2_plex"] == pytest.approx(2228.06, abs=0.01) | |
| 27 | + | |
| 28 | + def test_prix_sortie(self, R): | |
| 29 | + u = R["usages"] | |
| 30 | + assert u["S0"]["vente"]["prix_isolee_equiv"] == pytest.approx(637953, abs=TOL) # prédiction hédonique du sujet (ch. 18) | |
| 31 | + assert u["U"]["vente"]["gdv"] == pytest.approx(720727, abs=TOL) | |
| 32 | + assert u["D"]["vente"]["prix_isolee_equiv"] == pytest.approx(550726, abs=TOL) and u["D"]["vente"]["prix_unite"] == pytest.approx(495654, abs=TOL) | |
| 33 | + assert u["D"]["vente"]["gdv"] == pytest.approx(991307, abs=TOL) | |
| 34 | + assert u["T"]["vente"]["prix_isolee_equiv"] == pytest.approx(480573, abs=TOL) and u["T"]["vente"]["prix_unite"] == pytest.approx(408487, abs=TOL) | |
| 35 | + assert u["T"]["vente"]["gdv"] == pytest.approx(1225460, abs=TOL) | |
| 36 | + assert u["Q"]["vente"]["gdv"] == pytest.approx(1371384, abs=TOL) | |
| 37 | + assert u["D"]["vente"]["prix_unite"] == pytest.approx(0.90 * u["D"]["vente"]["prix_isolee_equiv"], rel=1e-12) | |
| 38 | + assert u["T"]["vente"]["prix_unite"] == pytest.approx(0.85 * u["T"]["vente"]["prix_isolee_equiv"], rel=1e-12) | |
| 39 | + | |
| 40 | + def test_en_bloc_cas2(self, R): | |
| 41 | + tb = R["usages"]["T_bloc"] | |
| 42 | + assert tb["prix_par_logement_triplex"] == pytest.approx(281000, abs=TOL) and tb["gdv"] == pytest.approx(843000, abs=TOL) | |
| 43 | + assert tb["promoteur"]["valeur_residuelle"] == pytest.approx(-28102, abs=TOL) and tb["promoteur"]["reste"] < 0 | |
| 44 | + | |
| 45 | + | |
| 46 | +class TestQuatreTests: | |
| 47 | + def test_legal(self, R): | |
| 48 | + u = R["usages"] | |
| 49 | + assert u["U"]["legal"]["logements_admissibles"] == 3 and u["U"]["legal"]["aire_max_m2"] == pytest.approx(372.0) | |
| 50 | + assert u["D"]["legal"]["aire_brute_m2"] == pytest.approx(240 / 0.85) and u["D"]["legal"]["permis"] | |
| 51 | + assert u["T"]["legal"]["aire_brute_m2"] == pytest.approx(300 / 0.85) and u["T"]["legal"]["permis"] | |
| 52 | + assert not u["Q"]["legal"]["permis"] and not u["Q"]["legal"]["ok_cos"] and not u["Q"]["legal"]["ok_logements"] | |
| 53 | + assert c08.densite_admissible(620, 150, 3, 0.60, 450)["logements_par_densite"] == 4 # la densité seule admettrait 4 ; le plafond de la grille (3) l'emporte | |
| 54 | + | |
| 55 | + def test_physique(self, R): | |
| 56 | + for k in ("U", "D", "T"): | |
| 57 | + tp = R["usages"][k]["physique"] | |
| 58 | + assert tp["possible"] and tp["etages_requis"] <= 3 and tp["empreinte_max_m2"] == pytest.approx(16 * 16) | |
| 59 | + | |
| 60 | + | |
| 61 | +class TestResiduel: | |
| 62 | + def test_valeurs_citees_promoteur(self, R): | |
| 63 | + u = R["usages"] | |
| 64 | + attendu = {"U": (158849, 185550, 6814, 108109, 28829), "D": (190541, 225936, 12290, 148696, 39652), "T": (229265, 275967, 18239, 183819, 49018), "Q": (244935, 299288, 23994, 205708, 54855)} | |
| 65 | + for k, (vl, reste, F, marge, frais) in attendu.items(): | |
| 66 | + r = u[k]["promoteur"] | |
| 67 | + assert r["valeur_residuelle"] == pytest.approx(vl, abs=TOL) and r["reste"] == pytest.approx(reste, abs=TOL) | |
| 68 | + assert r["financement"] == pytest.approx(F, abs=TOL) and r["marge"] == pytest.approx(marge, abs=TOL) and r["frais_vente"] == pytest.approx(frais, abs=TOL) | |
| 69 | + assert u["T"]["promoteur"]["couts_construction"] == pytest.approx(668417, abs=TOL) and u["T"]["promoteur"]["contribution_parcs"] == pytest.approx(22927, abs=TOL) | |
| 70 | + assert u["T"]["promoteur"]["v_l_statique"] == pytest.approx(250879, abs=TOL) and u["T"]["promoteur"]["facteur_temps"] == pytest.approx(1.0619 ** 1.5, rel=1e-12) | |
| 71 | + assert u["D"]["promoteur"]["couts_construction"] == pytest.approx(534734, abs=TOL) and u["U"]["promoteur"]["couts_construction"] == pytest.approx(361425, abs=TOL) | |
| 72 | + | |
| 73 | + def test_valeurs_citees_occupant(self, R): | |
| 74 | + u = R["usages"] | |
| 75 | + assert u["U"]["occupant"]["valeur_residuelle"] == pytest.approx(266187, abs=TOL) and u["U"]["occupant"]["marge"] == pytest.approx(39824, abs=TOL) | |
| 76 | + assert u["D"]["occupant"]["valeur_residuelle"] == pytest.approx(330792, abs=TOL) and u["T"]["occupant"]["valeur_residuelle"] == pytest.approx(399479, abs=TOL) | |
| 77 | + # marge sur le coût = 10 % de (C + D + F) | |
| 78 | + r = u["U"]["occupant"]; assert r["marge"] == pytest.approx(0.10 * (r["couts_construction"] + r["demolition"] + r["financement"]), rel=1e-12) | |
| 79 | + | |
| 80 | + def test_identites(self, R): | |
| 81 | + r = R["usages"]["T"]["promoteur"]; h = cas.HYP | |
| 82 | + # reste = GDV − frais − C − D − F − marge ; V_L(1+τ)(1+r)^T = reste ; parcs = τ V_L | |
| 83 | + assert r["reste"] == pytest.approx(r["gdv"] - r["frais_vente"] - r["couts_construction"] - r["demolition"] - r["financement"] - r["marge"], rel=1e-12) | |
| 84 | + assert r["valeur_residuelle"] * (1 + h["taux_parcs"]) * r["facteur_temps"] == pytest.approx(r["reste"], rel=1e-12) | |
| 85 | + assert r["contribution_parcs"] == pytest.approx(h["taux_parcs"] * r["valeur_residuelle"], rel=1e-12) | |
| 86 | + # intérêts intérimaires : I = L i T_eff / 2 avec T_eff = T(1 − part mise de fonds) | |
| 87 | + L = h["part_pret"] * (r["couts_construction"] + r["demolition"]); T_eff = 1.5 * (1 - (1 - h["part_pret"])) | |
| 88 | + assert r["financement"] == pytest.approx(L * h["taux_pret"] * T_eff / 2, rel=1e-12) | |
| 89 | + # forme linéaire V_L = a·GDV − b′ et seuil | |
| 90 | + a, b_prime = cas._a_b(r, h) | |
| 91 | + assert a * r["gdv"] - b_prime == pytest.approx(r["valeur_residuelle"], rel=1e-10) | |
| 92 | + s = cas.seuil_gdv(r, R["v_act"]); r2 = cas.residuel_developpement(s, 300.0, R["couts"]["di_m2_plex"], 1.5, h) | |
| 93 | + assert r2["valeur_residuelle"] == pytest.approx(R["v_act"], rel=1e-9) | |
| 94 | + | |
| 95 | + def test_version_statique_egale_ch08(self, R): | |
| 96 | + """Avec r = 0, marge et financement en % du GDV et frais de vente nuls, la fonction retrouve valeur_residuelle_redeveloppement du ch. 8.""" | |
| 97 | + gdv, s_tot, di = 1_000_000.0, 300.0, 2000.0 | |
| 98 | + h = {**cas.HYP, "frais_vente": 0.0, "r_portage": 0.0} | |
| 99 | + r = cas.residuel_developpement(gdv, s_tot, di, 1.0, h) | |
| 100 | + ref = c08.valeur_residuelle_redeveloppement(3, 100.0, gdv / 300.0, di, 0.0, h["demolition"], h["marge"], r["financement"] / gdv, h["taux_parcs"]) | |
| 101 | + assert r["valeur_residuelle"] == pytest.approx(ref["valeur_residuelle"], rel=1e-10) | |
| 102 | + | |
| 103 | + def test_seuils_et_conclusion(self, R): | |
| 104 | + assert R["seuil_gdv_T"] == pytest.approx(1776390, abs=TOL) and R["hausse_requise_T"] * 100 == pytest.approx(45.0, abs=0.05) | |
| 105 | + assert R["seuil_gdv_T_marche"] == pytest.approx(1296396, abs=TOL) and R["hausse_requise_T_marche"] * 100 == pytest.approx(5.8, abs=0.05) | |
| 106 | + assert R["phmu_vacant"] == "U" and R["phmu_vacant_promoteur"] == "T" and R["phmu_ameliore"] == "S0" | |
| 107 | + assert R["contribution_batiment"] == pytest.approx(323000) and all(p == 0 for p in R["primes"].values()) | |
| 108 | + | |
| 109 | + def test_sensibilite_citee(self, R): | |
| 110 | + s = R["sensibilite"].set_index("parametre") | |
| 111 | + assert s.loc["Prix des unités", "bas"] == pytest.approx(105569, abs=TOL) and s.loc["Prix des unités", "haut"] == pytest.approx(352962, abs=TOL) | |
| 112 | + assert s.loc["Coût de construction", "bas"] == pytest.approx(286246, abs=TOL) and s.loc["Coût de construction", "haut"] == pytest.approx(172285, abs=TOL) | |
| 113 | + assert s.loc["Marge du promoteur", "bas"] == pytest.approx(280169, abs=TOL) and s.loc["Marge du promoteur", "haut"] == pytest.approx(178362, abs=TOL) | |
| 114 | + assert s.loc["Durée totale", "bas"] == pytest.approx(241459, abs=TOL) and s.loc["Durée totale", "haut"] == pytest.approx(217582, abs=TOL) | |
| 115 | + assert s.loc["Taux (prêt et portage)", "bas"] == pytest.approx(236137, abs=TOL) and s.loc["Taux (prêt et portage)", "haut"] == pytest.approx(221939, abs=TOL) | |
| 116 | + assert s.loc["Contribution pour fins de parcs", "bas"] == pytest.approx(252192, abs=TOL) | |
| 117 | + assert s.loc["Frais de vente", "bas"] == pytest.approx(249627, abs=TOL) and s.loc["Frais de vente", "haut"] == pytest.approx(208904, abs=TOL) | |
| 118 | + # la hausse de prix de 15 % fait passer le triplex (promoteur) au-dessus de la maison neuve (occupant) : bascule de la PHMU du terrain vacant | |
| 119 | + assert s.loc["Prix des unités", "haut"] > R["usages"]["U"]["occupant"]["valeur_residuelle"] > s.loc["Prix des unités", "base"] | |
| 120 | + | |
| 121 | + | |
| 122 | +class TestOption: | |
| 123 | + def test_parametres(self, R): | |
| 124 | + o = R["option"] | |
| 125 | + assert o["u"] == pytest.approx(math.exp(0.10 * math.sqrt(5)), rel=1e-12) and o["d"] == pytest.approx(1 / o["u"], rel=1e-12) | |
| 126 | + assert round(o["u"], 4) == 1.2506 and round(o["d"], 4) == 0.7996 and round(o["F"], 4) == 0.8860 and round(o["q"], 4) == 0.1915 and round(o["disc"], 4) == 0.9057 | |
| 127 | + assert 0 < o["q"] < 1 and o["F"] == pytest.approx(((1.02) / 1.045) ** 5, rel=1e-12) | |
| 128 | + assert round(R["a"], 4) == 0.6729 and R["b"] == pytest.approx(1195378, abs=TOL) | |
| 129 | + | |
| 130 | + def test_noeuds_cites(self, R): | |
| 131 | + n = R["option"]["noeuds"] | |
| 132 | + assert n[(1, 1)]["gdv"] == pytest.approx(1532535, abs=TOL) and n[(1, 0)]["gdv"] == pytest.approx(979914, abs=TOL) | |
| 133 | + assert n[(2, 2)]["gdv"] == pytest.approx(1916556, abs=TOL) and n[(2, 1)]["gdv"] == pytest.approx(1225460, abs=TOL) and n[(2, 0)]["gdv"] == pytest.approx(783568, abs=TOL) | |
| 134 | + assert n[(2, 2)]["exercice"] == pytest.approx(94322, abs=TOL) and n[(2, 1)]["exercice"] == 0 and n[(2, 0)]["exercice"] == 0 | |
| 135 | + assert n[(1, 1)]["continuation"] == pytest.approx(16357, abs=TOL) and n[(1, 1)]["exercice"] == 0 and not n[(1, 1)]["exercer"] | |
| 136 | + assert n[(0, 0)]["valeur"] == pytest.approx(2837, abs=TOL) | |
| 137 | + | |
| 138 | + def test_prime_et_identites(self, R): | |
| 139 | + o = R["option"] | |
| 140 | + assert o["prime_americaine"] == pytest.approx(2837, abs=TOL) and o["prime_europeenne"] == pytest.approx(o["prime_americaine"], rel=1e-12) | |
| 141 | + assert o["van_immediate"] == 0.0 and o["prime_americaine"] >= o["van_immediate"] | |
| 142 | + # valeur actualisée risque-neutre du seul nœud dans la monnaie : q² × 94 322 × disc² | |
| 143 | + assert o["prime_americaine"] == pytest.approx(o["q"] ** 2 * o["noeuds"][(2, 2)]["exercice"] * o["disc"] ** 2, rel=1e-10) | |
| 144 | + assert o["gdv_seuil_van"] == pytest.approx(R["seuil_gdv_T"], rel=1e-10) | |
| 145 | + assert R["frontiere_t1"] == pytest.approx(1874959, abs=TOL) and R["frontiere_t1"] > o["gdv_seuil_van"] | |
| 146 | + assert o["prime_americaine"] / R["v_act"] * 100 == pytest.approx(0.47, abs=0.005) | |
| 147 | + | |
| 148 | + def test_sensibilite_option(self, R): | |
| 149 | + s = R["sens_option"] | |
| 150 | + assert s[600000.0][0.05] == 0.0 and s[600000.0][0.10] == pytest.approx(2837, abs=TOL) and s[600000.0][0.15] == pytest.approx(21420, abs=TOL) | |
| 151 | + assert s[600000.0][0.20] == pytest.approx(47939, abs=TOL) and s[600000.0][0.25] == pytest.approx(77556, abs=TOL) | |
| 152 | + assert s[450000.0][0.10] == pytest.approx(7348, abs=TOL) and s[300000.0][0.10] == pytest.approx(23568, abs=TOL) and s[300000.0][0.25] == pytest.approx(132208, abs=TOL) | |
| 153 | + for v in s: # la prime croît avec σ et décroît avec la valeur en usage | |
| 154 | + vals = list(s[v].values()); assert all(b >= a for a, b in zip(vals, vals[1:])) | |
| 155 | + assert s[300000.0][0.15] > s[450000.0][0.15] > s[600000.0][0.15] | |
| 156 | + | |
| 157 | + def test_americaine_superieure_europeenne_quand_exercice_anticipe(self): | |
| 158 | + """Avec un rendement de détention élevé et une forte volatilité, l'exercice anticipé a de la valeur.""" | |
| 159 | + o = cas.option_redeveloppement(1_000_000, 0.7, 500_000, 0.30, 5.0, 0.02, 0.08, 2) | |
| 160 | + assert o["prime_americaine"] >= o["prime_europeenne"] and o["prime_americaine"] >= o["van_immediate"] | |
| 161 | + assert any(nd.get("exercer") for nd in o["noeuds"].values()) | |
| 162 | + | |
| 163 | + def test_frontiere_coherente(self): | |
| 164 | + a, b = 0.7, 1_000_000.0 | |
| 165 | + f = cas.frontiere_exercice_t1(a, b, 0.20, 5.0, 0.02, 0.045) | |
| 166 | + p = cas.parametres_binomiaux(0.20, 5.0, 0.02, 0.045) | |
| 167 | + cont = p["disc"] * (p["q"] * max(a * f * p["u"] - b, 0) + (1 - p["q"]) * max(a * f * p["d"] - b, 0)) | |
| 168 | + assert a * f - b == pytest.approx(cont, rel=1e-6) and f >= b / a | |
| 169 | + | |
| 170 | + | |
| 171 | +class TestCapozzaHelsley: | |
| 172 | + def test_valeurs_citees(self, R): | |
| 173 | + ch = R["capozza"]; cr = ch["resultat"] | |
| 174 | + assert ch["R_A"] == pytest.approx(27000, abs=TOL) and ch["R_T"] == pytest.approx(55146, abs=TOL) and ch["C"] == pytest.approx(716656, abs=TOL) | |
| 175 | + assert cr["R_certitude"] == pytest.approx(59250, abs=TOL) and cr["prime_irreversibilite"] == pytest.approx(13249, abs=TOL) and cr["R_seuil"] == pytest.approx(72498, abs=TOL) | |
| 176 | + assert ch["hausse_requise_certitude"] * 100 == pytest.approx(7.4, abs=0.05) and ch["hausse_requise_incertitude"] * 100 == pytest.approx(31.5, abs=0.05) | |
| 177 | + | |
| 178 | + def test_limite_certitude_et_racine(self): | |
| 179 | + r, g, s = 0.045, 551.0, 5515.0 | |
| 180 | + out = cas.seuil_capozza_helsley(27000, 716656, r, g, s); al = out["alpha"] | |
| 181 | + assert 0.5 * s * s * al * al + g * al - r == pytest.approx(0, abs=1e-9) # α racine positive de ½σ²α² + gα − r = 0 | |
| 182 | + assert cas.seuil_capozza_helsley(27000, 716656, r, g, 0.0)["R_seuil"] == pytest.approx(27000 + r * 716656, rel=1e-12) | |
| 183 | + assert cas.seuil_capozza_helsley(27000, 716656, r, g, 1e-3)["prime_irreversibilite"] == pytest.approx(0.0, abs=1e-6) | |
| 184 | + assert out["prime_irreversibilite"] > 0 and cas.seuil_capozza_helsley(27000, 716656, r, g, 2 * s)["prime_irreversibilite"] > out["prime_irreversibilite"] | |
| 185 | + | |
| 186 | + | |
| 187 | +class TestExercices: | |
| 188 | + """Chiffres des solutions (solutions/ch41.tex).""" | |
| 189 | + def test_ex2_residuel_statique(self): | |
| 190 | + # 6 logements de 95 m² à 4 900 $/m² ; coûts 2 300 $/m² ; indirects 15 % ; démolition 45 000 $ ; marge 15 % ; financement 3 % ; parcs 10 % | |
| 191 | + r = c08.valeur_residuelle_redeveloppement(6, 95.0, 4900.0, 2300.0, 0.15, 45000.0, 0.15, 0.03, 0.10) | |
| 192 | + assert r["gdv"] == pytest.approx(2793000) and r["couts_directs"] == pytest.approx(1311000) and r["couts_indirects"] == pytest.approx(196650) | |
| 193 | + assert r["reste_avant_parcs"] == pytest.approx(737610) and r["valeur_residuelle"] == pytest.approx(670554.5, abs=0.1) | |
| 194 | + # (b) sans parcs ni financement : 737 610 + 83 790 = 821 400 | |
| 195 | + r2 = c08.valeur_residuelle_redeveloppement(6, 95.0, 4900.0, 2300.0, 0.15, 45000.0, 0.15, 0.0, 0.0) | |
| 196 | + assert r2["valeur_residuelle"] == pytest.approx(821400) | |
| 197 | + | |
| 198 | + def test_ex3_calendrier(self): | |
| 199 | + h = {**cas.HYP, "demolition": 0.0} | |
| 200 | + r = cas.residuel_developpement(1_800_000, 400.0, 2300.0, 2.0, h) | |
| 201 | + assert r["couts_construction"] == pytest.approx(920000) and r["pret"] == pytest.approx(690000) | |
| 202 | + assert r["financement"] == pytest.approx(690000 * 0.0619 * 1.5 / 2, rel=1e-12) and r["financement"] == pytest.approx(32033.25, abs=0.01) | |
| 203 | + assert r["reste"] == pytest.approx(1800000 - 72000 - 920000 - 32033.25 - 270000, abs=0.01) | |
| 204 | + assert r["valeur_residuelle"] == pytest.approx(505966.75 / (1.1 * 1.0619 ** 2), rel=1e-9) and r["valeur_residuelle"] == pytest.approx(407908, abs=TOL) | |
| 205 | + r0 = cas.residuel_developpement(1_800_000, 400.0, 2300.0, 2.0, {**h, "r_portage": 0.0}) | |
| 206 | + assert r0["valeur_residuelle"] == pytest.approx(459970, abs=TOL) | |
| 207 | + | |
| 208 | + def test_ex5_binomial(self): | |
| 209 | + o = cas.option_redeveloppement(1_000_000, 0.70, 800_000, 0.15, 5.0, 0.02, 0.04, 2) | |
| 210 | + assert round(o["u"], 4) == 1.3985 and round(o["d"], 4) == 0.7150 and round(o["q"], 4) == 0.2815 and round(o["F"], 4) == 0.9075 | |
| 211 | + assert o["noeuds"][(2, 2)]["exercice"] == pytest.approx(0.70 * 1_000_000 * o["u"] ** 2 - 800_000, rel=1e-12) and o["noeuds"][(2, 2)]["exercice"] == pytest.approx(569089, abs=TOL) | |
| 212 | + assert o["prime_americaine"] == pytest.approx(45636, abs=TOL) and o["prime_europeenne"] == pytest.approx(37007, abs=TOL) and o["van_immediate"] == 0.0 | |
| 213 | + assert o["noeuds"][(1, 1)]["exercice"] == pytest.approx(178960, abs=TOL) and o["noeuds"][(1, 1)]["continuation"] == pytest.approx(145121, abs=TOL) | |
| 214 | + assert o["noeuds"][(1, 1)]["exercer"] # exercer vaut plus qu'attendre au nœud haut : option américaine > européenne | |
| 215 | + assert o["gdv_seuil_van"] == pytest.approx(800000 / 0.70, rel=1e-12) | |
| 216 | + | |
| 217 | + def test_ex6_capozza(self): | |
| 218 | + out = cas.seuil_capozza_helsley(18000, 600000, 0.05, 400, 4000) | |
| 219 | + assert out["R_certitude"] == pytest.approx(48000) and out["prime_irreversibilite"] == pytest.approx(9266, abs=TOL) and out["R_seuil"] == pytest.approx(57266, abs=TOL) | |
| 220 | + assert out["R_seuil"] / 42000 - 1 == pytest.approx(0.3635, abs=5e-4) # rente courante de 42 000 $ : hausse requise de 36,3 % | |
| 221 | + | |
| 222 | + def test_ex4_teardown(self): | |
| 223 | + # prix 455 000 $ ; démolition 28 000 $ ; valeur du terrain extraite = 483 000 $ ; contribution du bâtiment −28 000 $ | |
| 224 | + assert 455000 + 28000 == 483000 | |
| 225 | + # ratio V_L / P = 1,06 > 1 ; part du bâtiment négative | |
| 226 | + assert (455000 - 483000) / 455000 == pytest.approx(-0.0615, abs=1e-4) | |
| 227 | + | |
| 228 | + def test_marges(self): | |
| 229 | + assert fin.marge_sur_prix(0.10) == pytest.approx(1 / 11) and fin.marge_sur_cout(0.15) == pytest.approx(0.15 / 0.85) | |
added
analysis/python/tests/test_ch43.py
+241 −0
@@ -0,0 +1,241 @@ | ||
| 1 | +"""Tests du chapitre 43 — risques environnementaux et sinistres (DONNÉES SYNTHÉTIQUES, graine 20261031). | |
| 2 | +Tolérances : 0,5 $ sur les montants arrondis au dollar ; 0,05 point de pourcentage sur les pourcentages cités avec deux | |
| 3 | +décimales ; 5e-5 sur les coefficients en logarithme cités avec quatre décimales ; 1e-9 sur les identités algébriques. | |
| 4 | +Les valeurs « citées » sont celles de manuscript/chapters/ch43.tex et de solutions/ch43.tex.""" | |
| 5 | +import sys, pathlib, math | |
| 6 | +import numpy as np, pandas as pd, pytest | |
| 7 | +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "cases")) | |
| 8 | +import ch43_risques as cas | |
| 9 | +from traite import cas4, comparables as cp, hedonique as h | |
| 10 | + | |
| 11 | +TOL, TOLP, TOLL = 0.5, 0.05, 5e-5 | |
| 12 | + | |
| 13 | + | |
| 14 | +@pytest.fixture(scope="module") | |
| 15 | +def res(): | |
| 16 | + return cas.main(ecrire=False, verbeux=False) | |
| 17 | + | |
| 18 | + | |
| 19 | +class TestPrimeTaux: | |
| 20 | + def test_structure_cas4(self, res): | |
| 21 | + pr = res["prime"] | |
| 22 | + assert pr["n"] == 48 and pr["n_inondable"] == 3 and pr["n_sol"] == 2 | |
| 23 | + assert set(pr["immeubles_inondables"]["id_immeuble"]) == {"P36", "P41", "P43"} | |
| 24 | + assert set(pr["immeubles_sol"]["id_immeuble"]) == {"P20", "P21"} | |
| 25 | + assert pr["part_valeur_inondable"] * 100 == pytest.approx(6.66, abs=TOLP) | |
| 26 | + | |
| 27 | + def test_coefficients_cites(self, res): | |
| 28 | + e = res["prime"]["inondable"] | |
| 29 | + assert e["coef"] == pytest.approx(0.0756, abs=TOLL) and e["se"] == pytest.approx(0.0347, abs=TOLL) | |
| 30 | + assert e["effet_pct"] * 100 == pytest.approx(7.86, abs=TOLP) | |
| 31 | + assert e["ic_bas_pct"] * 100 == pytest.approx(0.77, abs=TOLP) and e["ic_haut_pct"] * 100 == pytest.approx(15.44, abs=TOLP) | |
| 32 | + assert e["effet_kennedy_pct"] * 100 == pytest.approx(7.79, abs=TOLP) and e["t_vs_declare"] == pytest.approx(0.77, abs=0.005) | |
| 33 | + s = res["prime"]["sol"] | |
| 34 | + assert s["coef"] == pytest.approx(0.0536, abs=TOLL) and s["se"] == pytest.approx(0.0288, abs=TOLL) | |
| 35 | + assert s["effet_pct"] * 100 == pytest.approx(5.50, abs=TOLP) | |
| 36 | + assert res["prime"]["decote_valeur_inondable_pct"] * 100 == pytest.approx(-7.29, abs=TOLP) | |
| 37 | + assert res["prime"]["decote_valeur_declaree_pct"] * 100 == pytest.approx(-4.76, abs=TOLP) | |
| 38 | + | |
| 39 | + def test_identites(self, res): | |
| 40 | + e = res["prime"]["inondable"] | |
| 41 | + # effet % = exp(coef) − 1 ; IC symétrique en log ; décote de valeur = exp(−coef) − 1 = −effet/(1+effet) | |
| 42 | + assert e["effet_pct"] == pytest.approx(math.exp(e["coef"]) - 1, rel=1e-12) | |
| 43 | + assert math.log(1 + e["ic_haut_pct"]) - e["coef"] == pytest.approx(e["coef"] - math.log(1 + e["ic_bas_pct"]), rel=1e-9) | |
| 44 | + assert res["prime"]["decote_valeur_inondable_pct"] == pytest.approx(-e["effet_pct"] / (1 + e["effet_pct"]), rel=1e-12) | |
| 45 | + # le coefficient provient bien de cas4.effets_regionaux | |
| 46 | + eff = cas4.effets_regionaux(cas4.charger()["portefeuille"]) | |
| 47 | + assert e["coef"] == pytest.approx(float(eff.loc["inondable", "coef"]), rel=1e-12) | |
| 48 | + | |
| 49 | + | |
| 50 | +class TestDiDCas4: | |
| 51 | + def test_choc_injecte(self, res): | |
| 52 | + pan = res["panel"] | |
| 53 | + tr = pan[pan["traite"] == 1] | |
| 54 | + assert set(tr["id_immeuble"]) == {"P36", "P41", "P43"} and len(pan) == 48 * 42 | |
| 55 | + assert tr.loc[tr["trimestre"] == "2019Q2", "choc"].unique().tolist() == [-0.25] | |
| 56 | + assert tr.loc[tr["trimestre"] == "2021Q1", "choc"].unique().tolist() == [-0.05] | |
| 57 | + assert (pan.loc[pan["traite"] == 0, "choc"] == 0).all() and (tr.loc[tr["trimestre"] < "2019Q2", "choc"] == 0).all() | |
| 58 | + assert (pan["ln_rne"] - pan["ln_rne_sans_choc"] - pan["choc"]).abs().max() < 1e-12 | |
| 59 | + | |
| 60 | + def test_valeurs_citees(self, res): | |
| 61 | + d = res["did"] | |
| 62 | + c = d["cellules"] | |
| 63 | + assert c.loc[0, 0] == pytest.approx(10.0063, abs=TOLL) and c.loc[0, 1] == pytest.approx(10.1607, abs=TOLL) | |
| 64 | + assert c.loc[1, 0] == pytest.approx(10.0995, abs=TOLL) and c.loc[1, 1] == pytest.approx(10.1409, abs=TOLL) | |
| 65 | + assert d["estimateur_2x2"] == pytest.approx(-0.1129, abs=TOLL) and d["se_hc1"] == pytest.approx(0.0252, abs=TOLL) | |
| 66 | + assert d["estimateur_strate"] == pytest.approx(-0.0794, abs=TOLL) | |
| 67 | + assert d["n_combinaisons"] == 17296 and d["n_traites"] == 3 and d["n_temoins"] == 45 | |
| 68 | + assert d["p_ri"] == pytest.approx(0.0052, abs=5e-5) and d["p_ri_strate"] == pytest.approx(0.0240, abs=5e-5) | |
| 69 | + tw = d["twfe"] | |
| 70 | + assert tw.loc["traite_transitoire", "coef"] == pytest.approx(-0.1653, abs=TOLL) and tw.loc["traite_transitoire", "se_grappes"] == pytest.approx(0.0263, abs=TOLL) | |
| 71 | + assert tw.loc["traite_permanent", "coef"] == pytest.approx(-0.0888, abs=TOLL) and tw.loc["traite_permanent", "se_grappes"] == pytest.approx(0.0466, abs=TOLL) | |
| 72 | + es = d["event_study"] | |
| 73 | + assert es.loc[0, "coef"] == pytest.approx(-0.2625, abs=TOLL) and es.loc[1, "coef"] == pytest.approx(-0.1278, abs=TOLL) | |
| 74 | + assert es.loc[2, "coef"] == pytest.approx(-0.0620, abs=TOLL) and es.loc[-7, "coef"] == pytest.approx(0.0208, abs=TOLL) | |
| 75 | + assert es.loc[12, "coef"] == pytest.approx(-0.0912, abs=TOLL) and es.loc[-1, "coef"] == 0.0 | |
| 76 | + assert d["effet_valeur_estime_pct"] * 100 == pytest.approx(-15.16, abs=TOLP) and d["effet_valeur_declare_pct"] * 100 == pytest.approx(-9.41, abs=TOLP) | |
| 77 | + assert d["verite_permanente_pct"] * 100 == pytest.approx(-4.88, abs=TOLP) | |
| 78 | + | |
| 79 | + def test_identites(self, res): | |
| 80 | + d = res["did"]; c = d["cellules"] | |
| 81 | + assert d["estimateur_2x2"] == pytest.approx((c.loc[1, 1] - c.loc[1, 0]) - (c.loc[0, 1] - c.loc[0, 0]), rel=1e-12) | |
| 82 | + # la régression des différences longues redonne exactement l'estimateur 2 × 2 (panel cylindré) | |
| 83 | + assert d["coef_reg"] == pytest.approx(d["estimateur_2x2"], abs=1e-9) | |
| 84 | + # l'observé est l'un des placebos ; p = part des |placebos| ≥ |observé| | |
| 85 | + assert np.any(np.isclose(d["placebo_dist"], d["estimateur_2x2"])) | |
| 86 | + assert d["p_ri"] == pytest.approx(np.mean(np.abs(d["placebo_dist"]) >= abs(d["estimateur_2x2"]) - 1e-12), rel=1e-12) | |
| 87 | + # la vérité permanente est dans l'IC à 95 % en grappes du TWFE | |
| 88 | + tw = d["twfe"].loc["traite_permanent"] | |
| 89 | + assert tw["coef"] - 1.96 * tw["se_grappes"] <= cas.CHOC_PERMANENT <= tw["coef"] + 1.96 * tw["se_grappes"] | |
| 90 | + # effet valeur = Δln RNE − Δln R | |
| 91 | + assert d["effet_valeur_estime_log"] == pytest.approx(tw["coef"] - res["prime"]["inondable"]["coef"], rel=1e-12) | |
| 92 | + assert d["effet_valeur_declare_log"] == pytest.approx(cas.CHOC_PERMANENT - math.log(1.05), rel=1e-12) | |
| 93 | + | |
| 94 | + | |
| 95 | +class TestRiviereFictive: | |
| 96 | + def test_structure(self, res): | |
| 97 | + lots, v = res["lots"], res["ventes"] | |
| 98 | + assert len(lots) == 625 and int(lots["expose"].sum()) == 175 | |
| 99 | + assert lots["classe"].value_counts().to_dict() == {"hors zone": 450, "élevée": 50, "modérée": 50, "faible": 50, "très élevée": 25} | |
| 100 | + assert int(lots["rive"].sum()) == 75 + 75 - 9 # 3 rangées × 25 + 3 colonnes × 25 − 9 lots communs | |
| 101 | + e = res["hedoniques"]["effectifs"] | |
| 102 | + assert e == {"ventes": 1014, "exposees": 302, "rive": 241, "apres": 695} | |
| 103 | + assert (v["prix_vente"] % 500 == 0).all() | |
| 104 | + | |
| 105 | + def test_reproductibilite(self): | |
| 106 | + l1, v1 = cas.generer_riverain(); l2, v2 = cas.generer_riverain() | |
| 107 | + assert v1.equals(v2) and l1.equals(l2) | |
| 108 | + | |
| 109 | + def test_verites(self, res): | |
| 110 | + ver = res["hedoniques"]["verite"] | |
| 111 | + assert ver["decote_avant_log"] == pytest.approx(-0.0499, abs=TOLL) and ver["decote_apres_log"] == pytest.approx(-0.0763, abs=TOLL) | |
| 112 | + assert ver["stigmate_log"] == pytest.approx(-0.0263, abs=TOLL) | |
| 113 | + assert ver["stigmate_log"] == pytest.approx(ver["decote_apres_log"] - ver["decote_avant_log"], rel=1e-12) | |
| 114 | + assert cas.DECOTE_AVANT["très élevée"] * (1 + cas.STIGMATE) == pytest.approx(-0.15) | |
| 115 | + | |
| 116 | + def test_paires(self, res): | |
| 117 | + rp = res["resume_paires"]; p = res["paires"] | |
| 118 | + assert rp["n"] == 206 and rp["moyenne"] == pytest.approx(-19113, abs=TOL) | |
| 119 | + assert rp["moyenne_pct"] * 100 == pytest.approx(-4.83, abs=TOLP) and rp["mediane_pct"] * 100 == pytest.approx(-5.35, abs=TOLP) | |
| 120 | + assert rp["ic95_pct"][0] * 100 == pytest.approx(-6.15, abs=TOLP) and rp["ic95_pct"][1] * 100 == pytest.approx(-3.51, abs=TOLP) | |
| 121 | + assert rp["verite_pct"] * 100 == pytest.approx(-7.26, abs=TOLP) | |
| 122 | + pc = rp["par_classe"] | |
| 123 | + assert pc.loc["très élevée", "n"] == 25 and pc.loc["très élevée", "moyenne_pct"] * 100 == pytest.approx(-8.47, abs=TOLP) | |
| 124 | + assert pc.loc["élevée", "moyenne_pct"] * 100 == pytest.approx(-5.97, abs=TOLP) and pc.loc["faible", "moyenne_pct"] * 100 == pytest.approx(-1.30, abs=TOLP) | |
| 125 | + pr = rp["par_rive"] | |
| 126 | + assert pr.loc[0, "moyenne_pct"] * 100 == pytest.approx(-3.62, abs=TOLP) and pr.loc[1, "moyenne_pct"] * 100 == pytest.approx(-5.91, abs=TOLP) | |
| 127 | + # identités : différence = exposé − témoin ajusté ; témoin ajusté = (témoin + ajust. temps) × facteur physique ; témoins uniques | |
| 128 | + assert (p["difference"] - (p["prix_expose"] - p["prix_temoin_ajuste"])).abs().max() < 1e-6 | |
| 129 | + assert (p["prix_temoin_ajuste"] - (p["prix_temoin"] + p["ajust_temps"]) * p["facteur_physique"]).abs().max() < 1e-6 | |
| 130 | + assert p["id_temoin"].is_unique and p["id_expose"].is_unique | |
| 131 | + r = cp.ventes_appariees(p["prix_expose"].tolist(), p["prix_temoin_ajuste"].tolist()) | |
| 132 | + assert r["moyenne"] == pytest.approx(rp["moyenne"], rel=1e-12) | |
| 133 | + | |
| 134 | + def test_hedoniques(self, res): | |
| 135 | + he = res["hedoniques"] | |
| 136 | + assert he["a_sans_rive"]["coef"] == pytest.approx(-0.0447, abs=TOLL) and he["a_sans_rive"]["se"] == pytest.approx(0.0052, abs=TOLL) | |
| 137 | + assert he["b_avec_rive"]["coef"] == pytest.approx(-0.0501, abs=TOLL) and he["b_avec_rive"]["se"] == pytest.approx(0.0059, abs=TOLL) | |
| 138 | + assert he["b_avec_rive"]["coef_rive"] == pytest.approx(0.0132, abs=TOLL) and he["b_avec_rive"]["sigma"] == pytest.approx(0.0713, abs=TOLL) | |
| 139 | + assert he["b_avec_rive"]["r2"] == pytest.approx(0.874, abs=0.0005) | |
| 140 | + c = he["c_classes"] | |
| 141 | + attendu = {"très élevée": (-0.1035, 0.0183), "élevée": (-0.0564, 0.0149), "modérée": (-0.0426, 0.0158), "faible": (-0.0043, 0.0122), | |
| 142 | + "très élevée × après": (-0.0218, 0.0223), "élevée × après": (-0.0430, 0.0154), "modérée × après": (-0.0193, 0.0184), "faible × après": (-0.0095, 0.0163), | |
| 143 | + "rive": (0.0417, 0.0074)} | |
| 144 | + for k, (b, s) in attendu.items(): | |
| 145 | + assert c.loc[k, "coef"] == pytest.approx(b, abs=TOLL) and c.loc[k, "se"] == pytest.approx(s, abs=TOLL) | |
| 146 | + assert c["r2"].iloc[0] == pytest.approx(0.882, abs=0.0005) | |
| 147 | + d = he["d_did_reg"] | |
| 148 | + assert d["coef_expose"] == pytest.approx(-0.0316, abs=TOLL) and d["se_expose"] == pytest.approx(0.0092, abs=TOLL) | |
| 149 | + assert d["coef_did"] == pytest.approx(-0.0269, abs=TOLL) and d["se_did"] == pytest.approx(0.0107, abs=TOLL) | |
| 150 | + assert he["d_did_2x2"]["estimateur"] == pytest.approx(-0.0237, abs=TOLL) | |
| 151 | + assert he["d_did_2x2"]["effectifs"] == {(0, 0): 223, (0, 1): 489, (1, 0): 96, (1, 1): 206} | |
| 152 | + | |
| 153 | + def test_identites_hedoniques(self, res): | |
| 154 | + he = res["hedoniques"]; m = he["d_did_2x2"]["moyennes"] | |
| 155 | + assert he["d_did_2x2"]["estimateur"] == pytest.approx((m[(1, 1)] - m[(1, 0)]) - (m[(0, 1)] - m[(0, 0)]), rel=1e-12) | |
| 156 | + c = he["c_classes"] | |
| 157 | + # la vérité de chaque classe est dans l'IC à 95 % (sauf mention au texte : « très élevée × après » et « faible » sont à la limite) | |
| 158 | + for k in ["très élevée", "élevée", "modérée", "élevée × après", "modérée × après", "faible × après", "rive"]: | |
| 159 | + assert abs(c.loc[k, "coef"] - c.loc[k, "verite"]) <= 1.96 * c.loc[k, "se"] + 1e-12, k | |
| 160 | + t = res["effets_methodes"] | |
| 161 | + assert len(t) == 7 and (t["effet_pct"] - (np.exp(t["coef"]) - 1)).abs().max() < 1e-12 | |
| 162 | + assert t.loc[5, "coef"] == pytest.approx(t.loc[3, "coef"] + t.loc[4, "coef"], rel=1e-12) | |
| 163 | + | |
| 164 | + | |
| 165 | +class TestModelesValeur: | |
| 166 | + def test_contamination(self, res): | |
| 167 | + ct = res["contamination"]; b = ct["base"] | |
| 168 | + assert ct["cout_total"] == pytest.approx(206500) and b["valeur"] == pytest.approx(1595816, abs=TOL) | |
| 169 | + assert b["va_propre_stigmatisee"] == pytest.approx(1843979, abs=TOL) and b["va_cout"] == pytest.approx(248163, abs=TOL) | |
| 170 | + assert b["cout_delai"] == pytest.approx(151021, abs=TOL) and b["cout_stigmate"] == pytest.approx(105000, abs=TOL) | |
| 171 | + assert b["ecart_pct"] * 100 == pytest.approx(-24.01, abs=TOLP) | |
| 172 | + # identité : V_c = VA propre stigmatisée − VA coût − VA pertes ; et écart = V_p − V_c | |
| 173 | + assert b["valeur"] == pytest.approx(b["va_propre_stigmatisee"] - b["va_cout"] - b["va_perte_revenu"], rel=1e-12) | |
| 174 | + assert b["ecart_total"] == pytest.approx(ct["hyp"]["v_propre"] - b["valeur"], rel=1e-12) | |
| 175 | + s = ct["sensibilite"] | |
| 176 | + assert s[(0.0, 6)] == pytest.approx(1780814, abs=TOL) and s[(0.15, 24)] == pytest.approx(1331391, abs=TOL) and s[(0.05, 15)] == pytest.approx(b["valeur"], rel=1e-12) | |
| 177 | + assert ct["sensibilite_cout"][2.0] == pytest.approx(1347653, abs=TOL) | |
| 178 | + # sans délai, sans stigmate, sans contingence : V_c = V_p − C | |
| 179 | + z = cas.valeur_terrain_contamine(2_100_000, 206_500, 0, 0.065, 0.0) | |
| 180 | + assert z["valeur"] == pytest.approx(2_100_000 - 206_500, rel=1e-12) | |
| 181 | + | |
| 182 | + def test_sinistre(self, res): | |
| 183 | + sn = res["sinistre"]; b = sn["base"] | |
| 184 | + assert sn["hyp"]["v_avant"] == 1_714_000 and sn["hyp"]["nb_logements"] == 8 | |
| 185 | + assert sn["loyer_perdu_mensuel"] == pytest.approx(5608) and sn["indemnite"] == pytest.approx(194000) | |
| 186 | + assert b["va_perte_usage"] == pytest.approx(27603, abs=TOL) and b["stigmate_dollars"] == pytest.approx(85700, abs=TOL) | |
| 187 | + assert b["valeur"] == pytest.approx(1360697, abs=TOL) and b["perte_pct"] * 100 == pytest.approx(-20.61, abs=TOLP) | |
| 188 | + assert b["position_assure"] == pytest.approx(1554697, abs=TOL) and b["perte_nette_assure"] == pytest.approx(159303, abs=TOL) | |
| 189 | + assert b["part_non_assuree"] * 100 == pytest.approx(45.1, abs=0.05) | |
| 190 | + assert b["valeur"] == pytest.approx(sn["hyp"]["v_avant"] - sn["hyp"]["cout_remise"] - b["va_perte_usage"] - b["stigmate_dollars"], rel=1e-12) | |
| 191 | + assert sn["sensibilite_stigmate"][0.0] == pytest.approx(1446397, abs=TOL) and sn["sensibilite_stigmate"][0.15] == pytest.approx(1189297, abs=TOL) | |
| 192 | + # le stigmate n'est jamais indemnisé : perte nette assuré − stigmate = part non couverte des coûts + perte d'usage | |
| 193 | + assert b["perte_nette_assure"] - b["stigmate_dollars"] == pytest.approx(sn["hyp"]["cout_remise"] - sn["indemnite"] + b["va_perte_usage"], rel=1e-9) | |
| 194 | + | |
| 195 | + def test_pyrite(self, res): | |
| 196 | + py = res["pyrite"] | |
| 197 | + assert py["curable"]["retenu"] == pytest.approx(27000) and py["incurable"]["retenu"] == pytest.approx(1800 / 0.055, rel=1e-12) | |
| 198 | + assert py["incurable"]["retenu"] == pytest.approx(32727, abs=TOL) | |
| 199 | + | |
| 200 | + | |
| 201 | +class TestExercices: | |
| 202 | + """Chiffres des solutions (solutions/ch43.tex).""" | |
| 203 | + | |
| 204 | + def test_ex1_classes(self): | |
| 205 | + # exercice 1 : lecture du coefficient d'une indicatrice : exp(−0,083) − 1 et Kennedy | |
| 206 | + assert (math.exp(-0.083) - 1) * 100 == pytest.approx(-7.96, abs=0.005) | |
| 207 | + assert (math.exp(-0.083 - 0.5 * 0.021 ** 2) - 1) * 100 == pytest.approx(-7.99, abs=0.005) | |
| 208 | + | |
| 209 | + def test_ex2_prime_taux(self): | |
| 210 | + # exercice 2 : RNE 120 000 $, R hors zone 5,25 %, prime +7,86 % du taux | |
| 211 | + v0 = 120_000 / 0.0525; v1 = 120_000 / (0.0525 * 1.0786) | |
| 212 | + assert v0 == pytest.approx(2_285_714, abs=TOL) and v1 == pytest.approx(2_119_149, abs=TOL) | |
| 213 | + assert (v1 / v0 - 1) * 100 == pytest.approx(-7.29, abs=0.005) | |
| 214 | + | |
| 215 | + def test_ex3_contamination(self): | |
| 216 | + r = cas.valeur_terrain_contamine(850_000, 95_000, 12, 0.07, 0.08, 0.20) | |
| 217 | + assert r["va_propre_stigmatisee"] == pytest.approx(730_841, abs=TOL) and r["va_cout"] == pytest.approx(110_208, abs=TOL) | |
| 218 | + assert r["valeur"] == pytest.approx(620_633, abs=TOL) and r["ecart_pct"] * 100 == pytest.approx(-26.98, abs=0.005) | |
| 219 | + | |
| 220 | + def test_ex4_sinistre(self): | |
| 221 | + r = cas.valeur_apres_sinistre(980_000, 140_000, 3_600, 4, 0.06, 0.04, 0.80 * 140_000 - 5_000) | |
| 222 | + assert r["va_perte_usage"] == pytest.approx(14_226, abs=TOL) and r["stigmate_dollars"] == pytest.approx(39_200) | |
| 223 | + assert r["valeur"] == pytest.approx(786_574, abs=TOL) and r["position_assure"] == pytest.approx(893_574, abs=TOL) | |
| 224 | + assert r["perte_nette_assure"] == pytest.approx(86_426, abs=TOL) | |
| 225 | + | |
| 226 | + def test_ex5_did(self): | |
| 227 | + m = {(0, 0): 12.90, (0, 1): 12.98, (1, 0): 12.84, (1, 1): 12.86} | |
| 228 | + delta = (m[(1, 1)] - m[(1, 0)]) - (m[(0, 1)] - m[(0, 0)]) | |
| 229 | + assert delta == pytest.approx(-0.06, abs=1e-9) and (math.exp(delta) - 1) * 100 == pytest.approx(-5.82, abs=0.005) | |
| 230 | + | |
| 231 | + def test_ex6_randomisation(self, res): | |
| 232 | + # exercice 6 : p exact = part des |placebos| ≥ |observé| ; nombre de combinaisons C(48,3) | |
| 233 | + d = res["did"] | |
| 234 | + assert math.comb(48, 3) == 17296 == d["n_combinaisons"] | |
| 235 | + assert d["p_ri"] == pytest.approx(np.mean(np.abs(d["placebo_dist"]) >= abs(d["estimateur_2x2"]) - 1e-12)) | |
| 236 | + | |
| 237 | + def test_ex7_pyrite_vei(self): | |
| 238 | + # exercice 7 : seuil p* = t/C, et désuétude curable vs incurable | |
| 239 | + assert 1200 / 25000 == pytest.approx(0.048) | |
| 240 | + assert cas.desuetude_vs_correction(25_000, 2_000, 0, 0.055, True)["retenu"] == 27_000 | |
| 241 | + assert cas.desuetude_vs_correction(0, 0, 1_800, 0.055, False)["retenu"] == pytest.approx(32_727.27, abs=0.01) | |
added
analysis/python/tests/test_ch44.py
+184 −0
@@ -0,0 +1,184 @@ | ||
| 1 | +"""Tests du chapitre 44 — rénovation et efficacité énergétique (sujet du cas 1, DONNÉES SYNTHÉTIQUES). | |
| 2 | +Tolérances : 0,01 $ sur les montants recalculés ; 5e-4 sur les taux cités en % à deux décimales ; 1e-9 sur les identités.""" | |
| 3 | +import sys, pathlib, math | |
| 4 | +import numpy as np, pytest | |
| 5 | +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "cases")) | |
| 6 | +import ch44_renovation as cas | |
| 7 | +from traite import finance as fin, cas1 | |
| 8 | + | |
| 9 | + | |
| 10 | +@pytest.fixture(scope="module") | |
| 11 | +def data(): | |
| 12 | + mm = cas.mesures() | |
| 13 | + d = cas1.charger(); mod = cas1.hedonique(d["ventes"]); vs = float(cas1.predire(mod, cas1.sujet_df(d["sujet"]))[0]) | |
| 14 | + return {"mm": mm, "fA": cas.flux_mesure(mm["A"]), "fB": cas.flux_mesure(mm["B"]), "fAB": cas.flux_mesure(mm["AB"]), | |
| 15 | + "fI": cas.flux_mesure(mm["A_incr"]), "ventes": d["ventes"], "modele": mod, "vs": vs} | |
| 16 | + | |
| 17 | + | |
| 18 | +class TestTarifD: | |
| 19 | + def test_structure_verifiee(self): | |
| 20 | + assert cas.FRAIS_ACCES_JOUR == 0.46154 and cas.PRIX_TRANCHE_1 == 0.07065 and cas.PRIX_TRANCHE_2 == 0.11142 and cas.SEUIL_KWH_JOUR == 40 | |
| 21 | + | |
| 22 | + def test_facture_sujet(self): | |
| 23 | + F = cas.facture_tarif_d(22000.0) | |
| 24 | + assert F["acces"] == pytest.approx(168.46, abs=0.005) and F["tranche1_kwh"] == 14600 and F["tranche2_kwh"] == 7400 | |
| 25 | + assert F["energie_t1"] == pytest.approx(1031.49, abs=0.005) and F["energie_t2"] == pytest.approx(824.51, abs=0.005) | |
| 26 | + assert F["total"] == pytest.approx(2024.46, abs=0.005) | |
| 27 | + assert F["total"] == pytest.approx(F["acces"] + F["energie_t1"] + F["energie_t2"], abs=1e-9) | |
| 28 | + assert F["prix_moyen"] == pytest.approx(0.0920, abs=5e-5) and F["prix_marginal"] == 0.11142 | |
| 29 | + | |
| 30 | + def test_economie_marginale(self): | |
| 31 | + # 1 000 kWh retranchés au-dessus du seuil valent 111,42 $ ; à cheval sur le seuil (15 000 → 14 000), 86,96 $ | |
| 32 | + assert cas.economie_annuelle(26000.0, 25000.0) == pytest.approx(111.42, abs=0.005) | |
| 33 | + assert cas.economie_annuelle(15000.0, 14000.0) == pytest.approx(86.96, abs=0.005) | |
| 34 | + assert cas.economie_annuelle(22000.0, 18700.0) == pytest.approx(3300 * 0.11142, abs=1e-9) | |
| 35 | + | |
| 36 | + | |
| 37 | +class TestMesures: | |
| 38 | + def test_thermopompe(self, data): | |
| 39 | + m, f = data["mm"]["A"], data["fA"] | |
| 40 | + assert m["cout"] == 16500.0 and m["subvention"] == pytest.approx(4320.0) and f["cout_net"] == pytest.approx(12180.0) | |
| 41 | + assert m["kwh"] == pytest.approx(3300.0) and f["economie_courante"] == pytest.approx(367.69, abs=0.005) | |
| 42 | + assert f["economie_an1"] == pytest.approx(378.72, abs=0.005) | |
| 43 | + assert f["van"] == pytest.approx(-7286.54, abs=0.01) and f["tri"] == pytest.approx(-0.0584, abs=5e-5) | |
| 44 | + assert f["va_economies"] == pytest.approx(4893.46, abs=0.01) and f["indice_rentabilite"] == pytest.approx(0.402, abs=5e-4) | |
| 45 | + assert f["annuite_cout_net"] == pytest.approx(1140.37, abs=0.01) | |
| 46 | + assert f["cee_net"] == pytest.approx(0.3456, abs=5e-5) and f["cee_brut"] == pytest.approx(0.4681, abs=5e-5) | |
| 47 | + assert f["delai_simple"] is None and f["delai_actualise"] is None | |
| 48 | + | |
| 49 | + def test_isolation(self, data): | |
| 50 | + m, f = data["mm"]["B"], data["fB"] | |
| 51 | + assert m["cout"] == pytest.approx(4180.0) and f["cout_net"] == pytest.approx(2180.0) and m["kwh"] == pytest.approx(880.0) | |
| 52 | + assert f["economie_courante"] == pytest.approx(98.05, abs=0.005) and f["n"] == 31 | |
| 53 | + assert f["van"] == pytest.approx(224.60, abs=0.01) and f["tri"] == pytest.approx(0.0529, abs=5e-5) | |
| 54 | + assert f["delai_simple"] == pytest.approx(16.89, abs=0.005) and f["delai_actualise"] == pytest.approx(27.40, abs=0.005) | |
| 55 | + assert f["va_economies"] == pytest.approx(2404.60, abs=0.01) and f["cee_net"] == pytest.approx(0.1512, abs=5e-5) | |
| 56 | + assert f["cee_brut"] == pytest.approx(0.2899, abs=5e-5) | |
| 57 | + | |
| 58 | + def test_combinaison_non_additive(self, data): | |
| 59 | + mm = data["mm"] | |
| 60 | + assert mm["chauffage_kwh"] == pytest.approx(11000.0) and mm["eco_additive_kwh"] == pytest.approx(4180.0) | |
| 61 | + assert mm["AB"]["kwh"] == pytest.approx(3916.0) and mm["AB"]["kwh"] < mm["eco_additive_kwh"] | |
| 62 | + assert data["fAB"]["van"] == pytest.approx(-8553.09, abs=0.01) and data["fAB"]["va_economies"] == pytest.approx(5806.91, abs=0.01) | |
| 63 | + | |
| 64 | + def test_decision_incrementale(self, data): | |
| 65 | + mm, f = data["mm"], data["fI"] | |
| 66 | + assert mm["cout_tp_standard"] == pytest.approx(13944.66, abs=0.01) and mm["subvention_tp_standard"] == pytest.approx(1800.0) | |
| 67 | + assert mm["surcout_climat_froid"] == pytest.approx(2555.34, abs=0.01) and mm["surcout_net_climat_froid"] == pytest.approx(35.34, abs=0.01) | |
| 68 | + assert f["van"] == pytest.approx(3226.97, abs=0.01) and f["va_economies"] == pytest.approx(3262.31, abs=0.01) | |
| 69 | + assert f["van"] > 0 and data["fA"]["van"] < 0 | |
| 70 | + | |
| 71 | + def test_identites(self, data): | |
| 72 | + for f in (data["fA"], data["fB"], data["fAB"], data["fI"]): | |
| 73 | + # VAN = −coût net + VA des économies ; cumul actualisé final = VAN ; VAN au TRI = 0 | |
| 74 | + assert f["van"] == pytest.approx(-f["cout_net"] + f["va_economies"], abs=1e-9) | |
| 75 | + assert f["cumul_actualise"][-1] == pytest.approx(f["van"], abs=1e-9) | |
| 76 | + if not math.isnan(f["tri"]): assert fin.van(f["flux"], f["tri"]) == pytest.approx(0.0, abs=1e-6) | |
| 77 | + # économies croissantes au taux g | |
| 78 | + assert f["flux"][2] / f["flux"][1] == pytest.approx(1 + f["g"], abs=1e-12) | |
| 79 | + # annuité : coût net × CRF ; coût de l'énergie économisée = annuité / kWh | |
| 80 | + assert f["annuite_cout_net"] == pytest.approx(f["cout_net"] * fin.facteur_recouvrement(f["r"], f["n"]), abs=1e-9) | |
| 81 | + | |
| 82 | + | |
| 83 | +class TestSeuilsEtSensibilite: | |
| 84 | + def test_seuils_thermopompe(self, data): | |
| 85 | + s = cas.seuils(data["mm"]["A"]) | |
| 86 | + assert s["cout_net_equilibre"] == pytest.approx(data["fA"]["va_economies"], abs=1e-9) | |
| 87 | + assert s["subvention_equilibre"] == pytest.approx(11606.54, abs=0.01) | |
| 88 | + assert s["kwh_equilibre"] == pytest.approx(8683.0, abs=1.0) and s["fraction_chauffage_equilibre"] == pytest.approx(0.789, abs=5e-4) | |
| 89 | + assert s["g_equilibre"] == pytest.approx(0.1440, abs=5e-4) | |
| 90 | + assert cas.flux_mesure(data["mm"]["A"], g=s["g_equilibre"])["van"] == pytest.approx(0.0, abs=0.05) | |
| 91 | + | |
| 92 | + def test_grille(self, data): | |
| 93 | + G = cas.grille_van(data["mm"]["A"]) | |
| 94 | + assert G.loc[0.0458, 0.03] == pytest.approx(data["fA"]["van"], abs=1e-9) | |
| 95 | + # VAN décroissante dans le taux, croissante dans la croissance du prix | |
| 96 | + assert (np.diff(G.values, axis=0) < 0).all() and (np.diff(G.values, axis=1) > 0).all() | |
| 97 | + GB = cas.grille_van(data["mm"]["B"]) | |
| 98 | + assert GB.loc[0.0458, 0.03] == pytest.approx(224.60, abs=0.01) and GB.loc[0.02, 0.0] > 0 and GB.loc[0.10, 0.06] < 0 | |
| 99 | + | |
| 100 | + def test_duree_et_tornade(self, data): | |
| 101 | + D = cas.sensibilite_duree(data["mm"]["A"]) | |
| 102 | + assert D.loc[15, 0.0458] == pytest.approx(data["fA"]["van"], abs=1e-9) and (np.diff(D.values, axis=0) > 0).all() | |
| 103 | + T = cas.sensibilite_tornade(data["mm"]["A"]) | |
| 104 | + assert len(T) == 7 and all(o["base"] == pytest.approx(data["fA"]["van"], abs=1e-9) for o in T) | |
| 105 | + sub = [o for o in T if o["param"].startswith("Subvention")][0] | |
| 106 | + assert sub["bas"] == pytest.approx(-11606.54, abs=0.01) and sub["haut"] == pytest.approx(-4906.54, abs=0.01) | |
| 107 | + | |
| 108 | + | |
| 109 | +class TestContribution: | |
| 110 | + def test_lectures(self, data): | |
| 111 | + L = cas.lectures_contribution(data["mm"]["A"], data["fA"], data["vs"]) | |
| 112 | + assert data["vs"] == pytest.approx(637952.85, abs=0.01) | |
| 113 | + assert L["ratio_va_cout"] == pytest.approx(0.297, abs=5e-4) and L["ratio_va_cout_net"] == pytest.approx(0.402, abs=5e-4) | |
| 114 | + assert L["primes_dollars"]["Europe (méta-analyse)"] == pytest.approx(14800.51, abs=0.01) | |
| 115 | + assert L["primes_dollars"]["Monde (méta-analyse)"] == pytest.approx(26794.02, abs=0.01) | |
| 116 | + assert L["primes_dollars"]["Amérique du Nord (méta-analyse)"] == pytest.approx(34194.27, abs=0.01) | |
| 117 | + LB = cas.lectures_contribution(data["mm"]["B"], data["fB"], data["vs"]) | |
| 118 | + assert LB["ratio_va_cout_net"] == pytest.approx(1.103, abs=5e-4) | |
| 119 | + | |
| 120 | + def test_economies_restantes(self, data): | |
| 121 | + m = data["mm"]["A"] | |
| 122 | + assert cas.va_economies_restantes(m, 0) == pytest.approx(data["fA"]["va_economies"], abs=1e-9) | |
| 123 | + assert cas.va_economies_restantes(m, 5) == pytest.approx(3923.88, abs=0.01) | |
| 124 | + assert cas.va_economies_restantes(m, 10) == pytest.approx(2360.94, abs=0.01) | |
| 125 | + assert cas.va_economies_restantes(m, 15) == pytest.approx(0.0, abs=1e-9) | |
| 126 | + | |
| 127 | + def test_paires_qualite(self, data): | |
| 128 | + P = cas.paires_qualite(data["ventes"], data["modele"]) | |
| 129 | + assert P["resultat"]["n"] == 30 and len(P["paires"]) == 30 | |
| 130 | + assert P["mediane_pct"] == pytest.approx(0.0578, abs=5e-5) and P["moyenne_pct"] == pytest.approx(0.0465, abs=5e-5) | |
| 131 | + assert P["ecart_type_pct"] == pytest.approx(0.1364, abs=5e-5) and P["erreur_type_pct"] == pytest.approx(0.0249, abs=5e-5) | |
| 132 | + assert P["effet_hedonique_pct"] == pytest.approx(0.0630, abs=5e-5) and P["beta_qualite"] == pytest.approx(0.06108, abs=5e-6) | |
| 133 | + # chaque vente n'entre que dans une paire ; différence = prix haut − prix bas ajusté | |
| 134 | + assert P["paires"]["id_bas"].is_unique and P["paires"]["id_haut"].is_unique | |
| 135 | + assert np.allclose(P["paires"]["difference"], P["paires"]["prix_haut"] - P["paires"]["prix_bas_ajuste"]) | |
| 136 | + C = cas.contribution_renovation(P, data["vs"]) | |
| 137 | + assert C["contribution_paires"] == pytest.approx(36891.23, abs=0.01) and C["contribution_hedonique"] == pytest.approx(40181.62, abs=0.01) | |
| 138 | + assert C["ratio_paires"] == pytest.approx(0.568, abs=5e-4) and C["ratio_hedonique"] == pytest.approx(0.618, abs=5e-4) | |
| 139 | + assert C["van_renovation"] == pytest.approx(-28108.77, abs=0.01) | |
| 140 | + | |
| 141 | + def test_fin_de_vie(self): | |
| 142 | + F = cas.composantes_fin_de_vie() | |
| 143 | + assert len(F) == 5 and list(F["composante"][:2]) == ["Ventilateur récupérateur de chaleur (VRC)", "Entrée asphaltée"] | |
| 144 | + assert (F["ratio"] >= 0.90).all() and F["ratio"].iloc[0] == pytest.approx(1.0) | |
| 145 | + | |
| 146 | + | |
| 147 | +@pytest.fixture(scope="module") | |
| 148 | +def ex(): | |
| 149 | + return cas.exercices() | |
| 150 | + | |
| 151 | + | |
| 152 | +class TestExercices: | |
| 153 | + | |
| 154 | + def test_ex1(self, ex): | |
| 155 | + assert ex["ex1"][18000.0]["total"] == pytest.approx(1578.78, abs=0.005) and ex["ex1"][26000.0]["total"] == pytest.approx(2470.14, abs=0.005) | |
| 156 | + assert ex["ex1"][18000.0]["prix_moyen"] == pytest.approx(0.0877, abs=5e-5) and ex["ex1"][26000.0]["prix_moyen"] == pytest.approx(0.0950, abs=5e-5) | |
| 157 | + assert ex["ex1"]["economie_1000_kwh_a_26000"] == pytest.approx(111.42, abs=0.005) and ex["ex1"]["economie_1000_kwh_a_15000"] == pytest.approx(86.96, abs=0.005) | |
| 158 | + | |
| 159 | + def test_ex2(self, ex): | |
| 160 | + assert ex["ex2"]["standard"] == 1200.0 and ex["ex2"]["froid"] == 2880.0 and ex["ex2"]["froid_60000"] == 6700.0 | |
| 161 | + assert ex["ex2"]["capacite_plafond_btu"] == pytest.approx(55833.33, abs=0.01) | |
| 162 | + | |
| 163 | + def test_ex3_fenetres(self, ex): | |
| 164 | + f = ex["ex3"]["flux"] | |
| 165 | + assert ex["ex3"]["mesure"]["subvention"] == 4500.0 and f["cout_net"] == pytest.approx(17811.0) | |
| 166 | + assert f["van"] == pytest.approx(-16213.20, abs=0.01) and f["va_economies"] == pytest.approx(1597.80, abs=0.01) | |
| 167 | + assert f["cee_net"] == pytest.approx(1.8396, abs=5e-5) and ex["ex3"]["sans_subvention"]["van"] == pytest.approx(-20713.20, abs=0.01) | |
| 168 | + | |
| 169 | + def test_ex4(self, ex): | |
| 170 | + assert ex["ex4"]["r458"]["cee_net"] == pytest.approx(0.1512, abs=5e-5) and ex["ex4"]["r458"]["cee_brut"] == pytest.approx(0.2899, abs=5e-5) | |
| 171 | + assert ex["ex4"]["r8"]["cee_net"] == pytest.approx(0.2183, abs=5e-5) and ex["ex4"]["sans_sub"]["van"] == pytest.approx(-1775.40, abs=0.01) | |
| 172 | + | |
| 173 | + def test_ex5(self, ex): | |
| 174 | + e = ex["ex5"] | |
| 175 | + assert e["europe"] == pytest.approx(14800.51, abs=0.01) and e["amerique"] == pytest.approx(34194.27, abs=0.01) | |
| 176 | + assert e["va_economies_AB"] == pytest.approx(5806.91, abs=0.01) and e["va_economies_AB_31"] == pytest.approx(10700.48, abs=0.01) | |
| 177 | + | |
| 178 | + def test_ex6(self, ex): | |
| 179 | + assert ex["ex6"]["g_equilibre"] == pytest.approx(0.1440, abs=5e-4) and ex["ex6"]["kwh_equilibre"] == pytest.approx(8683.0, abs=1.0) | |
| 180 | + | |
| 181 | + def test_ex7(self, ex): | |
| 182 | + assert ex["ex7"]["base"]["resultat"]["n"] == 30 and ex["ex7"]["large"]["resultat"]["n"] == 59 and ex["ex7"]["serre"]["resultat"]["n"] == 4 | |
| 183 | + assert ex["ex7"]["large"]["mediane_pct"] == pytest.approx(0.0604, abs=5e-5) and ex["ex7"]["serre"]["mediane_pct"] == pytest.approx(0.0092, abs=5e-5) | |
| 184 | + assert ex["ex7"]["kennedy"] == pytest.approx(0.06298, abs=5e-6) | |
added
analysis/python/tests/test_ch46.py
+127 −0
@@ -0,0 +1,127 @@ | ||
| 1 | +"""Tests du chapitre 46 — Cas 2 : triplex à Québec (dossier complet, données SYNTHÉTIQUES). | |
| 2 | +Identités (RNE, grille d'ajustements, hypothèque), cohérence avec traite.cas2 et ch32_plex (mêmes comparables, mêmes indications | |
| 3 | +de référence), valeurs citées dans le texte (tolérances : montants ±1 $ sauf mention, taux ±1e-4), exercices, présence des figures.""" | |
| 4 | +import pathlib, sys | |
| 5 | +import numpy as np, pytest | |
| 6 | + | |
| 7 | +ROOT = pathlib.Path(__file__).resolve().parents[3] | |
| 8 | +sys.path.insert(0, str(ROOT / "analysis/python/cases")) | |
| 9 | +import ch46_dossier as ch # noqa: E402 | |
| 10 | +import ch32_plex as C # noqa: E402 | |
| 11 | +from traite import cas2, finance as fin # noqa: E402 | |
| 12 | + | |
| 13 | + | |
| 14 | +@pytest.fixture(scope="module") | |
| 15 | +def D(): | |
| 16 | + return ch.dossier() | |
| 17 | + | |
| 18 | + | |
| 19 | +# ----------------------------------------------------------------------------- cohérence avec le module partagé et le ch. 32 | |
| 20 | +def test_coherence_cas2_et_ch32(D): | |
| 21 | + r = cas2.tout(); b = D["base"] | |
| 22 | + assert b["rbp_contrat"] == pytest.approx(41832.0) and b["rbp_marche"] == pytest.approx(49332.0) and b["rbe"] == pytest.approx(40577.04) | |
| 23 | + assert b["rne_declare"] == pytest.approx(r["base"]["rne"], abs=1e-6) # RNE « déclaré » = RNE du ch. 32 / traite.cas2 | |
| 24 | + assert b["rne_declare"] == pytest.approx(22033.9584, abs=0.01) | |
| 25 | + assert list(D["comparables5"]["id_vente"]) == ["Q0187", "Q0222", "Q0243", "Q0207", "Q0235"] # mêmes cinq comparables que le ch. 32 | |
| 26 | + assert D["revenu"]["v_mrb_comp"] == pytest.approx(922395.6, abs=0.01) | |
| 27 | + assert D["revenu"]["taux_ajuste"] == pytest.approx(0.0302, abs=1e-4) | |
| 28 | + assert D["revenu"]["v_cap_ajuste_rne_declare"] == pytest.approx(729549.19, abs=0.01) # indication du ch. 32 retrouvée | |
| 29 | + assert D["ch32"]["valeur_arrondie"] == 870000 | |
| 30 | + | |
| 31 | + | |
| 32 | +# ----------------------------------------------------------------------------- 46.5 taxes 2026 et RNE normalisé | |
| 33 | +def test_taxes_2026_et_rne(D): | |
| 34 | + b = D["base"]; t = b["taxes"] | |
| 35 | + assert t["fonciere"] == pytest.approx(495000 * 0.007464) and t["tarifs"] == pytest.approx(3 * (386 + 195)) | |
| 36 | + assert t["municipale"] == pytest.approx(5437.68, abs=0.01) and t["scolaire"] == pytest.approx((495000 - 25000) * 0.0007899, abs=0.01) | |
| 37 | + assert b["ecart_taxes"] == pytest.approx(6370 - 5437.68 - 371.253, abs=0.01) | |
| 38 | + assert b["rne"] == pytest.approx(b["rne_declare"] + b["ecart_taxes"], abs=1e-6) | |
| 39 | + assert b["rne"] == pytest.approx(22595.03, abs=0.01) | |
| 40 | + assert b["rne"] == pytest.approx(b["rbe"] - b["depenses_hors_reserve"] - b["reserve"]) | |
| 41 | + det = b["detail"]; assert det.loc[det["type"] == "imputée", "normalise"].iloc[0] == pytest.approx(0.04 * b["rbe"]) | |
| 42 | + assert b["ratio_depenses"] == pytest.approx(0.443, abs=0.001) | |
| 43 | + assert b["rne_marche"] == pytest.approx(29579.03, abs=0.01) | |
| 44 | + | |
| 45 | + | |
| 46 | +# ----------------------------------------------------------------------------- 46.6 régression auxiliaire et grille | |
| 47 | +def test_regression_et_grille(D): | |
| 48 | + reg = D["regression"]; g = D["grille"]; t = g["tableau"] | |
| 49 | + assert reg["n"] == 127 and 0.70 < reg["r2"] < 0.75 | |
| 50 | + assert reg["taux_annuel_marche"] == pytest.approx(np.exp(-12 * reg["beta"]["mois"]) - 1) | |
| 51 | + assert abs(reg["t"]["loyer_relatif"]) < ch.T_MIN and (t["loyer_relatif"] == 0).all() # coefficient non significatif → pas d'ajustement | |
| 52 | + assert (t["stationnement"] == 0).all() # tous les comparables ont une case, comme le sujet | |
| 53 | + for _, r in t.iterrows(): # identité : prix ajusté = prix + Σ ajustements ; brut = Σ|A|/P | |
| 54 | + aj = [r[k] for k in ("temps", "superficie", "age", "etat", "stationnement", "loyer_marche", "loyer_relatif")] | |
| 55 | + assert r["prix_ajuste"] == pytest.approx(r["prix_logement"] + sum(aj)) | |
| 56 | + assert r["brut_pct"] == pytest.approx(sum(abs(a) for a in aj) / r["prix_logement"]) | |
| 57 | + assert sum(g["poids"]) == pytest.approx(1.0) | |
| 58 | + assert g["valeur"] == pytest.approx(3 * sum(w * p for w, p in zip(g["poids"], t["prix_ajuste"]))) | |
| 59 | + assert g["valeur"] == pytest.approx(1020366.74, abs=0.01) and g["valeur_par_logement"] == pytest.approx(340122.25, abs=0.01) | |
| 60 | + q = t[t["id_vente"] == "Q0243"].iloc[0] | |
| 61 | + assert q["prix_ajuste"] == pytest.approx(383849.14, abs=0.01) and q["brut_pct"] == pytest.approx(0.126, abs=0.001) | |
| 62 | + | |
| 63 | + | |
| 64 | +# ----------------------------------------------------------------------------- 46.7 revenu | |
| 65 | +def test_revenu(D): | |
| 66 | + rev = D["revenu"]; b = D["base"] | |
| 67 | + assert rev["v_cap_ajuste"] == pytest.approx(b["rne"] / rev["taux_ajuste"]) and rev["v_cap_ajuste"] == pytest.approx(748126.24, abs=0.01) | |
| 68 | + assert rev["v_cap_median"] == pytest.approx(b["rne"] / 0.0317) and rev["v_cap_comp"] == pytest.approx(b["rne"] / 0.0297) | |
| 69 | + assert rev["taux_implicite_comparaison"] == pytest.approx(b["rne"] / D["grille"]["valeur"]) | |
| 70 | + assert rev["mrb_comp_ajuste_rho"] == pytest.approx(24.38, abs=0.01) | |
| 71 | + | |
| 72 | + | |
| 73 | +# ----------------------------------------------------------------------------- 46.8 coût (contrôle) | |
| 74 | +def test_cout(D): | |
| 75 | + co = D["cout"] | |
| 76 | + assert co["cu_taille"] == pytest.approx(2409 * (262 / 165) ** -0.10) and co["cu"] == pytest.approx(co["cu_taille"] * 118.5 / 116.5) | |
| 77 | + assert co["cout_neuf"] == pytest.approx(co["cu"] * 262) and co["age_effectif"] == pytest.approx(0.5 * 78 + 0.5 * 0.5 * 90) | |
| 78 | + assert co["valeur"] == pytest.approx(co["terrain"] + co["cout_neuf"] * (1 - co["age_effectif"] / 90)) | |
| 79 | + assert co["valeur"] == pytest.approx(473111.44, abs=0.01) | |
| 80 | + assert D["depreciation_extraite"]["depreciation"] < 0 # le marché implique une dépréciation négative : hypothèses de coût rejetées | |
| 81 | + | |
| 82 | + | |
| 83 | +# ----------------------------------------------------------------------------- 46.10 réconciliation | |
| 84 | +def test_reconciliation(D): | |
| 85 | + rec = D["reconciliation"]; ret = rec["retenues"] | |
| 86 | + assert sum(rec["poids"].values()) == pytest.approx(1.0) | |
| 87 | + assert rec["valeur_ponderee"] == pytest.approx(0.40 * ret["par_logement_ajuste"] + 0.35 * ret["mrb"] + 0.25 * ret["capitalisation"]) | |
| 88 | + assert rec["valeur_ponderee"] == pytest.approx(918016.72, abs=0.01) and rec["valeur_finale"] == 920000 | |
| 89 | + assert rec["fourchette_conclue"] == (870000.0, 970000.0) | |
| 90 | + assert rec["taux_implicite"] == pytest.approx(D["base"]["rne"] / 920000) and rec["mrb_implicite"] == pytest.approx(920000 / 41832) | |
| 91 | + assert rec["ratio_role"] == pytest.approx(920000 / 495000) | |
| 92 | + assert rec["ecart_ch32"] == pytest.approx(50000) | |
| 93 | + | |
| 94 | + | |
| 95 | +# ----------------------------------------------------------------------------- 46.9 financement | |
| 96 | +def test_financement(D): | |
| 97 | + f = D["financement"]; h = fin.Hypotheque(f["pret"], 0.0412, 300) | |
| 98 | + assert f["mise_de_fonds"] == pytest.approx(0.05 * 500000 + 0.10 * (920000 - 500000)) and f["mise_de_fonds"] == pytest.approx(C.mise_de_fonds_minimale_assuree(920000)) | |
| 99 | + assert f["pret"] == pytest.approx(920000 - f["mise_de_fonds"]) | |
| 100 | + assert h.taux_periodique == pytest.approx((1 + 0.0412 / 2) ** (1 / 6) - 1) and f["paiement_mensuel"] == pytest.approx(h.paiement) | |
| 101 | + assert h.solde(300) == pytest.approx(0.0, abs=1e-6) | |
| 102 | + assert f["interets_an1"] + f["capital_an1"] == pytest.approx(f["service_dette"], abs=1e-6) | |
| 103 | + assert f["taux_admissible"] == pytest.approx(0.0612) and f["paiement_mensuel_tam"] == pytest.approx(fin.Hypotheque(f["pret"], 0.0612, 300).paiement) | |
| 104 | + o = f["occupation"] | |
| 105 | + assert o["cout_net_annuel"] == pytest.approx(f["service_dette"] + o["depenses_hors_gestion"] + o["cout_opportunite"] - o["loyers_percus_nets"]) | |
| 106 | + assert o["loyers_percus_nets"] == pytest.approx((1188 + 1251) * 12 * 0.97) | |
| 107 | + assert o["cout_net_mensuel_hors_capital"] == pytest.approx(o["cout_net_mensuel"] - f["capital_rembourse_5ans"] / 60) | |
| 108 | + m = f["mutation"]; assert m["base"] == 920000 | |
| 109 | + assert m["droits"] == pytest.approx(0.005 * 62900 + 0.01 * (315000 - 62900) + 0.015 * 185000 + 0.025 * 250000 + 0.03 * (920000 - 750000)) | |
| 110 | + assert 0.5 < f["part_paiement_couverte_brute"] < 0.6 and f["ratio_couverture_investisseur"] < 0.5 | |
| 111 | + | |
| 112 | + | |
| 113 | +def test_reprise(D): | |
| 114 | + r = D["reprise"] | |
| 115 | + assert r["delta1"] == pytest.approx((1430 - 1047) * 12 * 0.97) | |
| 116 | + assert r["va_rotation"] == pytest.approx(r["delta1"] / (0.06 - r["g_ecart"] + 0.13 * (1 + r["g_ecart"]))) | |
| 117 | + assert r["va_10ans_sans_rotation"] == pytest.approx(0.97 * 40791.4685, abs=0.5) # ex. 32.7 du ch. 32 (sans la provision d'inoccupation) | |
| 118 | + assert r["valeur_faculte_reprise"] == pytest.approx(r["va_10ans_sans_rotation"] - r["va_rotation"]) | |
| 119 | + | |
| 120 | + | |
| 121 | +def test_exercices_et_figures(D): | |
| 122 | + E = ch.exercices(D) | |
| 123 | + assert E["1"]["v_cap_2026"] - E["1"]["v_cap_declare"] == pytest.approx(E["1"]["ecart"] / D["revenu"]["taux_ajuste"]) | |
| 124 | + assert E["5"]["cout_net_2e"] > E["5"]["cout_net_rc"] | |
| 125 | + assert min(E["6"].values()) < 920000 < max(E["6"].values()) | |
| 126 | + for slug in ("loyers", "cascade", "mrb", "occupation", "fourchettes"): | |
| 127 | + assert (ROOT / f"assets/figures/ch46/ch46-fig-{slug}.pdf").exists() | |
added
analysis/python/tests/test_ch48.py
+96 −0
@@ -0,0 +1,96 @@ | ||
| 1 | +"""Tests du chapitre 48 (cas 4, portefeuille interrégional — données synthétiques) : identités du sommaire, cohérence du modèle de masse | |
| 2 | +avec traite.cas4.effets_regionaux et avec le ch. 33 (P45), identités financières (RCD, soldes), propriétés de la simulation (reproductibilité, | |
| 3 | +diversification : VaR portefeuille ≤ somme des VaR, cas parfaitement corrélé ≈ additif, cas indépendant très inférieur), frontière, valeurs citées. | |
| 4 | +Tolérances déclarées : montants ±1 $ (valeurs citées au dollar), taux ±1e-6, Monte-Carlo ±1 $ (même graine).""" | |
| 5 | +import sys, pathlib | |
| 6 | +import numpy as np, pytest | |
| 7 | +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "cases")) | |
| 8 | +from traite import cas4, finance as fin | |
| 9 | +import ch48_portefeuille as ch | |
| 10 | + | |
| 11 | +@pytest.fixture(scope="module") | |
| 12 | +def D(): | |
| 13 | + return ch.dossier(n_mc=10_000, graine=ch.GRAINE) | |
| 14 | + | |
| 15 | +def test_sommaire_identites(D): | |
| 16 | + s = D["sommaire"]; g = s["par_rmr"]; t = s["total"]; pf = D["portefeuille"] | |
| 17 | + assert t["n"] == 48 and t["logements"] == 888 and g["n"].sum() == 48 and g["logements"].sum() == 888 | |
| 18 | + assert t["valeur"] == pytest.approx(143_363_000) and t["rne"] == pytest.approx(7_447_030) and t["dette"] == pytest.approx(85_766_000) | |
| 19 | + assert t["taux_pondere"] == pytest.approx(t["rne"] / t["valeur"]) and abs(t["taux_pondere"] - 0.0519) < 5e-4 | |
| 20 | + assert t["rpv"] == pytest.approx(0.5982, abs=5e-4) and t["part_zone_inondable"] == pytest.approx(0.0666, abs=5e-4) | |
| 21 | + assert (g["valeur"] / g["logements"]).loc["Montréal"] == pytest.approx(206_237.34, abs=0.01) | |
| 22 | + assert g["part_valeur"].sum() == pytest.approx(1.0) and g["valeur"].sum() == pytest.approx(t["valeur"]) | |
| 23 | + # taux pondéré ≠ moyenne simple des taux (Sherbrooke, exercice 1) | |
| 24 | + e1 = D["exercices"]["ex1"]; assert e1["taux_pondere"] != pytest.approx(e1["taux_moyen_simple"], abs=1e-4) | |
| 25 | + assert t["valeur_par_logement"] == pytest.approx(161_444.82, abs=0.01) | |
| 26 | + | |
| 27 | +def test_modele_de_masse_coherent_avec_cas4_et_ch33(D): | |
| 28 | + reg = D["regression"]; eff = cas4.effets_regionaux(D["portefeuille"]) | |
| 29 | + assert np.allclose(reg["beta"], eff["coef"].values) | |
| 30 | + assert reg["table"].loc["rmr_Gatineau", "effet_pct"] == pytest.approx(0.1962, abs=5e-4) # +19,6 % (ch. 33) | |
| 31 | + assert reg["table"].loc["ln_logements", "coef"] == pytest.approx(-0.0315, abs=5e-4) # ln n −0,0315 (ch. 33) | |
| 32 | + assert reg["duan"] == pytest.approx(1.0011, abs=5e-5) # facteur de Duan (ch. 33) | |
| 33 | + # P45 : taux de régression 5,461 % → 6 716 534 $ (ch. 33) | |
| 34 | + R = ch.taux_predit(reg, "Gatineau", 2, 36, 72) | |
| 35 | + assert R == pytest.approx(0.05461, abs=5e-6) and 366_800 / R == pytest.approx(6_716_534, abs=1) | |
| 36 | + t = D["predites"]; assert t.set_index("id_immeuble").loc["P45", "valeur_predite"] == pytest.approx(6_716_534, abs=1) | |
| 37 | + # prédiction = exp(Xβ) × Duan pour tous les immeubles ; somme prédite vs individuelle | |
| 38 | + assert np.allclose(t["valeur_predite"], t["rne_2026"] / (np.exp(reg["X"] @ reg["beta"]) * reg["duan"])) | |
| 39 | + assert D["iaao"]["ecart_agrege_pct"] == pytest.approx(-0.0055, abs=5e-4) | |
| 40 | + ia = D["iaao"]["portefeuille"]; assert ia["n"] == 48 and 0.99 < ia["ratio_median"] < 1.01 and ia["COD"] < 5 and 0.98 < ia["PRD"] < 1.03 and abs(ia["PRB"]) < 0.05 | |
| 41 | + | |
| 42 | +def test_dynamique_rne(D): | |
| 43 | + dyn = D["dynamique"]; pf = D["portefeuille"] | |
| 44 | + assert len(dyn["par_immeuble"]) == 48 and dyn["total"]["rne_2026T2_annualise"] == pytest.approx(pf["rne_2026"].sum(), abs=10) | |
| 45 | + assert 0.025 < dyn["total"]["croissance_agregee"] < 0.035 | |
| 46 | + assert dyn["par_rmr"].loc["Gatineau", "croissance_agregee"] > dyn["par_rmr"].loc["Saguenay", "croissance_agregee"] | |
| 47 | + assert -0.05 < dyn["pandemie"]["Portefeuille"] < 0 # choc négatif | |
| 48 | + c = dyn["correlations"]; assert 0.1 < c["inter_rmr_moyenne"] < 0.25 and abs(c["inter_hors_pandemie"]) < 0.05 # corrélation due au choc commun | |
| 49 | + | |
| 50 | +def test_dette_identites(D): | |
| 51 | + det = D["dette"]; d = det["par_immeuble"]; pf = D["portefeuille"].set_index("id_immeuble") | |
| 52 | + r = d.set_index("id_immeuble").loc["P45"]; hyp = fin.Hypotheque(5_416_000, 0.0517, 480) | |
| 53 | + assert r["service_dette"] == pytest.approx(hyp.service_dette_annuel) and r["rcd"] == pytest.approx(366_800 / hyp.service_dette_annuel) | |
| 54 | + assert r["rcd"] == pytest.approx(1.15, abs=5e-3) # ch. 33 : RCD 1,15 sur 40 ans | |
| 55 | + assert D["dette_alt"]["par_immeuble"].set_index("id_immeuble").loc["P45", "rcd"] == pytest.approx(0.95, abs=5e-3) # ch. 33 : 0,95 sur 25 ans | |
| 56 | + assert r["solde_echeance"] == pytest.approx(hyp.solde(24)) and det["total"]["rcd_agrege"] == pytest.approx(pf["rne_2026"].sum() / d["service_dette"].sum()) | |
| 57 | + assert det["echeancier"]["dette"].sum() == pytest.approx(85_766_000) and det["echeancier_rmr"].values.sum() == pytest.approx(85_766_000) | |
| 58 | + # RCD décroît avec la hausse de taux ; capacité RCD = RNE / (1,20 f') | |
| 59 | + assert (d["rcd_+0"] >= d["rcd_+100"]).all() and (d["rcd_+100"] >= d["rcd_+200"]).all() | |
| 60 | + assert det["total"]["rcd_agrege_+200"] < det["total"]["rcd_agrege_+100"] < det["total"]["rcd_agrege_+0"] | |
| 61 | + assert (d["injection_+200"] >= d["injection_+0"]).all() | |
| 62 | + e3 = D["exercices"]["ex3"]; assert e3["injection"] == pytest.approx(max(e3["solde_2028"] - e3["capacite"], 0)) | |
| 63 | + | |
| 64 | +def test_monte_carlo_reproductible_et_diversification(D): | |
| 65 | + mc = D["mc"]; m2 = ch.simuler_portefeuille(D["portefeuille"], D["dynamique"], D["regression"], 10_000, ch.GRAINE) | |
| 66 | + assert mc["stats"]["mediane"] == pytest.approx(m2["stats"]["mediane"], abs=1) and mc["var95_portefeuille"] == pytest.approx(m2["var95_portefeuille"], abs=1) | |
| 67 | + assert mc["valeurs"].shape == (10_000, 48) and np.allclose(mc["valeurs"].sum(1), mc["valeur_portefeuille"]) | |
| 68 | + # espérance ≈ V0 à l'ordre des termes de convexité ; médiane proche de V0 | |
| 69 | + assert abs(mc["stats"]["mediane"] / mc["stats"]["V0"] - 1) < 0.01 | |
| 70 | + # diversification : VaR portefeuille ≤ somme des VaR ; parfait ≈ additif ; indépendant ≪ déclaré | |
| 71 | + assert mc["var95_portefeuille"] <= mc["somme_var95_individuelles"] | |
| 72 | + assert D["mc_parfait"]["ratio_diversification"] == pytest.approx(1.0, abs=0.01) | |
| 73 | + assert D["mc_independant"]["ratio_diversification"] < mc["ratio_diversification"] < 1.0 | |
| 74 | + assert D["mc_independant"]["stats"]["ecart_type"] < mc["stats"]["ecart_type"] < D["mc_parfait"]["stats"]["ecart_type"] | |
| 75 | + # matrice de corrélation à deux facteurs : symétrique, diagonale 1, définie positive | |
| 76 | + S = ch.matrice_correlation(D["portefeuille"]["rmr"].values, 0.5, 0.7) | |
| 77 | + assert np.allclose(S, S.T) and np.allclose(np.diag(S), 1) and np.linalg.eigvalsh(S).min() > 0 | |
| 78 | + # version analytique normale : indépendant < portefeuille < parfait | |
| 79 | + a = D["analytique"]; assert a["sigma_independant"] < a["sigma_portefeuille"] < a["sigma_parfait"] | |
| 80 | + | |
| 81 | +def test_frontiere_et_allocation(D): | |
| 82 | + fr = D["allocation"]["frontiere"] | |
| 83 | + assert fr["w_actuel"].sum() == pytest.approx(1.0) and fr["w_min_var"].sum() == pytest.approx(1.0, abs=1e-6) | |
| 84 | + assert fr["sigma_min_var"] <= fr["sigma_actuel"] + 1e-9 and fr["sigma_meme_mu"] <= fr["sigma_actuel"] + 1e-9 and fr["mu_meme_sigma"] >= fr["mu_actuel"] - 1e-9 | |
| 85 | + assert fr["mu_actuel"] == pytest.approx(float(fr["w_actuel"] @ fr["mu"])) | |
| 86 | + al = D["allocation"]; assert len(al["vendre"]) == 6 and len(al["conserver"]) == 6 and not set(al["vendre"]["id_immeuble"]) & set(al["conserver"]["id_immeuble"]) | |
| 87 | + ap = al["apres_cession"]; assert ap["n"] == 42 and ap["valeur"] == pytest.approx(143_363_000 - ap["valeur_cedee"]) | |
| 88 | + | |
| 89 | +def test_risques_et_reconciliation(D): | |
| 90 | + ris = D["risques"]; rec = D["reconciliation"] | |
| 91 | + assert ris["part_inondable"] == pytest.approx(0.0666, abs=5e-4) and len(ris["inondables"]) == 3 and len(ris["sols"]) == 2 | |
| 92 | + assert ris["effet_inondable_pct_taux"] == pytest.approx(np.exp(D["regression"]["beta"][3]) - 1) | |
| 93 | + assert ris["decote_inondable_agregee"] == pytest.approx(-ris["effet_inondable_pct_taux"] / (1 + ris["effet_inondable_pct_taux"]), abs=1e-9) # V/V' − 1 = 1/(1+e) − 1 | |
| 94 | + assert rec["valeur_retenue"] == 143_400_000 and rec["rpv"] == pytest.approx(85_766_000 / 143_400_000) | |
| 95 | + assert rec["fourchette_mc"][0] < rec["somme_individuelle"] < rec["fourchette_mc"][1] | |
| 96 | + e6 = D["exercices"]["ex6"]; assert e6["apres_valeur"] == pytest.approx(143_363_000 - 9_546_000) and e6["ids"] == ["P36", "P41", "P43"] | |
added
assets/figures/ch41/ch41-fig-frontiere-option.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch41/ch41-fig-residuel-densite.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch41/ch41-fig-surface-prix-cout.pdf
+0 −0
Binary file not shown.
modified
assets/figures/ch42/ch42-fig-grille-fraction.pdf
+0 −0
Binary file not shown.
modified
assets/figures/ch43/ch43-fig-carte-riverain.pdf
+0 −0
Binary file not shown.
modified
assets/figures/ch43/ch43-fig-effets-methodes.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch44/ch44-fig-capitalisation.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch44/ch44-fig-flux.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch44/ch44-fig-heatmap-van.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch44/ch44-fig-prime-verte.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch46/ch46-fig-cascade.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch46/ch46-fig-fourchettes.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch46/ch46-fig-loyers.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch46/ch46-fig-mrb.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch46/ch46-fig-occupation.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch48/ch48-fig-carte.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch48/ch48-fig-dette.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch48/ch48-fig-frontiere.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch48/ch48-fig-montecarlo.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch48/ch48-fig-rmr.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch48/ch48-fig-rne.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch48/ch48-fig-taux.pdf
+0 −0
Binary file not shown.
deleted
manuscript/_wrap_ch20.tex
+0 −12
@@ -1,12 +0,0 @@ | ||
| 1 | −\providecommand{\BUILDVARIANT}{uqo}\providecommand{\BUILDMEDIA}{numerique} | |
| 2 | −\documentclass[10pt,twoside,openright]{book} | |
| 3 | −\newif\ifLogoAutorise\LogoAutorisefalse\def\PALETTE{uqo} | |
| 4 | −\usepackage{traite-qc} | |
| 5 | −\input{../manuscript/frontmatter/metadata.tex} | |
| 6 | −\input{/Users/simon-pierreboucher/Desktop/UQO/Traite-Evaluation-QC/build/ch20/_labels.tex} | |
| 7 | −\begin{document} | |
| 8 | −\setcounter{chapter}{19} | |
| 9 | −\input{chapters/ch20.tex} | |
| 10 | −\appendix\chapter{Solutions du chapitre}\input{../solutions/ch20.tex} | |
| 11 | −\printbibliography | |
| 12 | −\end{document} | |
modified
manuscript/chapters/ch20.tex
+16 −16
@@ -18,7 +18,7 @@ Les chapitres précédents ont laissé le lecteur devant plusieurs chiffres pour | ||
| 18 | 18 | |
| 19 | 19 | \subsection{Ce que la réconciliation n'est pas} |
| 20 | 20 | |
| 21 | −La \motcle{réconciliation} (en anglais \emph{reconciliation}, parfois \emph{correlation} dans les textes anciens) est l'étape par laquelle l'évaluateur passe des indications de valeur fournies par les méthodes qu'il a appliquées à une conclusion unique, accompagnée de son raisonnement. Les manuels professionnels la décrivent comme un jugement fondé sur trois critères : la quantité et la qualité des données dont chaque méthode a disposé, la pertinence de la méthode pour le type de bien et pour la fin de l'évaluation, et la cohérence interne de chaque indication avec les autres et avec le marché \parencite{appraisal2026,appraisalCdn}. Les Normes internationales d'évaluation en vigueur sont plus explicites encore sur ce que la réconciliation n'est pas : lorsque plusieurs approches ou méthodes sont utilisées, « the process of analysing and reconciling the differing values into a single conclusion, without averaging, should be described by the valuer in the report » \parencite[IVS~103, §~10.07]{ivs2025}. La proscription de la moyenne simple n'est pas une coquetterie : une moyenne non pondérée traite comme équivalentes une indication tirée de six cents ventes et une indication tirée de trois hypothèses non vérifiées, et la \cref{sec:20:combinaison} montrera, sur le cas 1, qu'elle conduit à une conclusion que personne ne défendrait. | |
| 21 | +La \motcle{réconciliation} (en anglais \emph{reconciliation}, parfois \emph{correlation} dans les textes anciens) est l'étape par laquelle l'évaluateur passe des indications de valeur fournies par les méthodes qu'il a appliquées à une conclusion unique, accompagnée de son raisonnement. Les manuels professionnels la décrivent comme un jugement fondé sur trois critères : la quantité et la qualité des données dont chaque méthode a disposé, la pertinence de la méthode pour le type de bien et pour la fin de l'évaluation, et la cohérence interne de chaque indication avec les autres et avec le marché \parencite{appraisal2026,appraisalCdn}. Les Normes internationales d'évaluation en vigueur sont plus explicites encore sur ce que la réconciliation n'est pas : lorsque plusieurs approches ou méthodes sont utilisées, « the process of analysing and reconciling the differing values into a single conclusion, without averaging, should be described by the valuer in the report » \parencite[IVS~103, §~10.07]{ivs2025}. La proscription de la moyenne simple n'est pas une coquetterie : une moyenne non pondérée traite comme équivalentes une indication tirée de six cents ventes et une indication tirée de trois hypothèses non vérifiées, et la \cref{sec:20:exemple} montrera, sur le cas 1, qu'elle conduit à une conclusion que personne ne défendrait. | |
| 22 | 22 | |
| 23 | 23 | \begin{definition}{Indication de valeur et réconciliation}{indication} |
| 24 | 24 | Une \motcle{indication de valeur} est l'estimation de la valeur recherchée produite par une méthode donnée, appliquée à des données données, sous des hypothèses déclarées ; elle est entachée d'une erreur dont la variance et, éventuellement, le biais dépendent de la méthode et des données. La réconciliation est l'opération qui, à partir de plusieurs indications et de ce que l'on sait de leurs erreurs, produit une conclusion de valeur, ponctuelle ou en fourchette, et le raisonnement qui la justifie. |
@@ -169,7 +169,7 @@ arrondie à \dollars{580000} au \cref{ch:24}. La lecture ventilée de la dépré | ||
| 169 | 169 | \begin{table}[htbp] |
| 170 | 170 | \caption{Indication par le coût du sujet du cas 1 reprise du chapitre 24 : composantes, lecture écartée, contre-exemple et sensibilité}\label{tab:20:cout} |
| 171 | 171 | \centering\small |
| 172 | −\begin{tabularx}{\textwidth}{@{}YS[table-format=6.0]S[table-format=6.0]@{}} | |
| 172 | +\begin{tabularx}{\textwidth}{@{}YS[table-format=-6.0]S[table-format=6.0]@{}} | |
| 173 | 173 | \toprule |
| 174 | 174 | Composante ou variante & {Montant (\$)} & {Indication (\$)} \\ |
| 175 | 175 | \midrule |
@@ -183,10 +183,10 @@ Lecture ventilée de la dépréciation physique (57,5 \%), écartée & -221090 & | ||
| 183 | 183 | Contre-exemple : allocation du terrain à 30 \% de la comparaison et âge-vie 28/60 & & 384705 \\ |
| 184 | 184 | \addlinespace |
| 185 | 185 | \multicolumn{3}{@{}l}{\textit{Sensibilité (tornade du chapitre 24, borne basse -- borne haute de l'indication)}} \\ |
| 186 | −Valeur du terrain $\pm 15$ \% & & {537951 -- 621051} \\ | |
| 187 | −Coût neuf $\pm 8{,}6$ \% (une année d'IPCB) & & {554944 -- 606170} \\ | |
| 188 | −Paramètres d'extraction (fenêtre, élasticité, qualité) & & {558807 -- 588716} \\ | |
| 189 | −Désuétude fonctionnelle de 0 à 2 fois & & {571896 -- 587106} \\ | |
| 186 | +Valeur du terrain $\pm 15$ \% & \multicolumn{2}{r@{}}{537\,951 -- 621\,051} \\ | |
| 187 | +Coût neuf $\pm 8{,}6$ \% (une année d'IPCB) & \multicolumn{2}{r@{}}{554\,944 -- 606\,170} \\ | |
| 188 | +Paramètres d'extraction (fenêtre, élasticité, qualité) & \multicolumn{2}{r@{}}{558\,807 -- 588\,716} \\ | |
| 189 | +Désuétude fonctionnelle de 0 à 2 fois & \multicolumn{2}{r@{}}{571\,896 -- 587\,106} \\ | |
| 190 | 190 | \bottomrule |
| 191 | 191 | \end{tabularx} |
| 192 | 192 | \tabsource{\synth{analysis/python/cases/ch24\_depreciation.py, repris par ch20\_reconciliation.py (fonction \texttt{cout})} ; valeur par le coût : \texttt{traite.cout.valeur\_par\_cout}.} |
@@ -194,9 +194,9 @@ Désuétude fonctionnelle de 0 à 2 fois & & {571896 -- 587106} \\ | ||
| 194 | 194 | |
| 195 | 195 | Le \cref{ch:24} a choisi l'extraction de marché plutôt que les hypothèses d'usage, et le contre-exemple du \cref{tab:20:cout} montre ce qui était en jeu. Si l'on avait attribué au terrain une part conventionnelle de 30 \% de l'indication par comparaison et appliqué une dépréciation âge-vie de 28 ans sur 60, l'indication aurait été de \dollars{384705}, inférieure d'un tiers aux deux autres ; la dépréciation extraite du marché avec ce terrain aurait été \emph{négative}, $-\dollars{67716}$ à la prédiction hédonique, ce qui aurait signalé non pas que le bâtiment s'apprécie mais que l'une des hypothèses était fausse, en l'occurrence celle du terrain. Avec le terrain extrait de \dollars{277000}, la méthode d'extraction appliquée aux deux autres indications devient un instrument de cohérence : |
| 196 | 196 | \begin{equation}\label{eq:20:extraction} |
| 197 | −D_{\text{marché}} = C_N - (\hat V - V_L) = 397\,567 - (575\,565 - 277\,000) = \dollars{99002} \quad\text{(24,9 \% de } C_N\text{) à l'indication par comparaison,} | |
| 197 | +D_{\text{marché}} = C_N - (\hat V - V_L) = 397\,567 - (575\,565 - 277\,000) = \dollars{99002}, | |
| 198 | 198 | \end{equation} |
| 199 | −et \dollars{36614}, soit 9,2 \%, à la prédiction hédonique. La dépréciation que la comparaison implique, 24,9 \%, est presque celle que le \cref{ch:24} a extraite de l'ensemble du marché, 23,9 \% ; celle que le modèle implique, 9,2 \%, est beaucoup plus faible. Le coût et la comparaison sont donc cohérents entre eux, et tous deux s'écartent du modèle dans le même sens, ce qui n'est pas indépendant du fait que le terrain et la dépréciation ont été extraits de ventes ramenées à la date par la tendance du même modèle et que la méthode d'extraction, par construction, aligne le coût déprécié sur les prix observés. La \cref{sec:20:exemple} mesurera cette dépendance par le bootstrap plutôt que de la supposer. | |
| 199 | +soit 24,9 \% de $C_N$ à l'indication par comparaison, et \dollars{36614}, soit 9,2 \%, à la prédiction hédonique. La dépréciation que la comparaison implique, 24,9 \%, est presque celle que le \cref{ch:24} a extraite de l'ensemble du marché, 23,9 \% ; celle que le modèle implique, 9,2 \%, est beaucoup plus faible. Le coût et la comparaison sont donc cohérents entre eux, et tous deux s'écartent du modèle dans le même sens, ce qui n'est pas indépendant du fait que le terrain et la dépréciation ont été extraits de ventes ramenées à la date par la tendance du même modèle et que la méthode d'extraction, par construction, aligne le coût déprécié sur les prix observés. La \cref{sec:20:exemple} mesurera cette dépendance par le bootstrap plutôt que de la supposer. | |
| 200 | 200 | |
| 201 | 201 | \section{Exemple intégral : la réconciliation du cas 1}\label{sec:20:exemple} |
| 202 | 202 | |
@@ -259,10 +259,10 @@ G0559 & 7.01 & 528000 & 648027 & 23.4 & 1.9 & non \\ | ||
| 259 | 259 | |
| 260 | 260 | \begin{table}[htbp] |
| 261 | 261 | \caption{Bootstrap de l'indication par comparaison et de la prédiction hédonique du cas 1, $B = 2\,000$ réplications, et énumération exhaustive des sous-ensembles de cinq candidats (données synthétiques)}\label{tab:20:bootstrap} |
| 262 | −\centering\footnotesize\setlength{\tabcolsep}{3.5pt} | |
| 263 | −\begin{tabular}{@{}lS[table-format=6.0]S[table-format=5.0]S[table-format=6.0]S[table-format=6.0]S[table-format=6.0]@{}} | |
| 262 | +\centering\footnotesize\setlength{\tabcolsep}{3pt} | |
| 263 | +\begin{tabular}{@{}L{46mm}S[table-format=6.0]S[table-format=5.0]S[table-format=6.0]S[table-format=6.0]S[table-format=6.0]@{}} | |
| 264 | 264 | \toprule |
| 265 | −Rééchantillonnage & {Moyenne (\$)} & {Écart-type (\$)} & {Quantile 5 \% (\$)} & {Médiane (\$)} & {Quantile 95 \% (\$)} \\ | |
| 265 | +Rééchantillonnage & {Moyenne (\$)} & {É.-t. (\$)} & {Quant. 5 \% (\$)} & {Médiane (\$)} & {Quant. 95 \% (\$)} \\ | |
| 266 | 266 | \midrule |
| 267 | 267 | (A) Seize candidats, puis sélection des cinq plus proches & 577823 & 18160 & 552872 & 575322 & 611283 \\ |
| 268 | 268 | (B) 600 ventes, réestimation du modèle, comparables fixes & 575410 & 2672 & 570836 & 575423 & 579797 \\ |
@@ -285,14 +285,14 @@ Prédiction hédonique du sujet, rééchantillonnage (B) & 637736 & 6531 & 62700 | ||
| 285 | 285 | |
| 286 | 286 | \begin{table}[htbp] |
| 287 | 287 | \caption{Les trois indications du cas 1, leur écart-type et la source de cet écart-type}\label{tab:20:indications} |
| 288 | −\centering\small | |
| 289 | −\begin{tabularx}{\textwidth}{@{}lS[table-format=6.0]S[table-format=5.0]YS[table-format=6.0]S[table-format=6.0]@{}} | |
| 288 | +\centering\footnotesize\setlength{\tabcolsep}{3pt} | |
| 289 | +\begin{tabularx}{\textwidth}{@{}L{24mm}S[table-format=6.0]S[table-format=5.0]YS[table-format=6.0]S[table-format=6.0]@{}} | |
| 290 | 290 | \toprule |
| 291 | −Indication & {Valeur (\$)} & {Écart-type (\$)} & Source de l'écart-type & {Borne 5 \% (\$)} & {Borne 95 \% (\$)} \\ | |
| 291 | +Indication & {Valeur (\$)} & {É.-t. (\$)} & Source de l'écart-type & {Borne 5 \% (\$)} & {Borne 95 \% (\$)} \\ | |
| 292 | 292 | \midrule |
| 293 | 293 | Comparaison, 5 comparables (\cref{ch:17}) & 575565 & 18385 & bootstrap conjoint (C) & 552095 & 611779 \\ |
| 294 | −Hédonique (\cref{ch:18}) & 637953 & 11462 & estimation (bootstrap, \dollars{6531}) et spécification (quatre formes, \dollars{9420}) en quadrature & 619097 & 656809 \\ | |
| 295 | −Coût (\cref{ch:24}, \cref{sec:20:cout}) & 579501 & 23695 & bootstrap sur les mêmes 600 ventes rééchantillonnées (\dollars{23575}) et variantes d'extraction (\dollars{2377}) en quadrature & 552142 & 628078 \\ | |
| 294 | +Hédonique (\cref{ch:18}) & 637953 & 11462 & estimation (bootstrap, \dollars{6531}) et spécification (quatre formes, \dollars{9420}), en quadrature & 619097 & 656809 \\ | |
| 295 | +Coût (\cref{ch:24}, \cref{sec:20:cout}) & 579501 & 23695 & bootstrap sur les mêmes rééchantillons des 600 ventes (\dollars{23575}) et variantes d'extraction (\dollars{2377}), en quadrature & 552142 & 628078 \\ | |
| 296 | 296 | \bottomrule |
| 297 | 297 | \end{tabularx} |
| 298 | 298 | \tabsource{\synth{analysis/python/cases/ch20\_reconciliation.py} ; bornes : quantiles bootstrap à 5 et 95 \% pour la comparaison et le coût, $\pm 1{,}645$ écart-type pour l'hédonique ; corrélations : comparaison--hédonique $0{,}088$, coût--hédonique $0{,}225$, coût--comparaison $0{,}018$.} |
modified
manuscript/chapters/ch36.tex
+350 −0
@@ -214,3 +214,353 @@ Lorsqu'une seule unité est traitée, ou lorsque le traité est un agrégat (un | ||
| 214 | 214 | et l'effet à la période $t > T_0$ est l'écart $y^1_t - \sum_j \hat w_j Y^0_{jt}$. Les deux contraintes sont la substance de la méthode : la positivité et la somme unitaire interdisent l'extrapolation, de sorte que le contrefactuel reste à l'intérieur de l'enveloppe des trajectoires observées et que les poids se lisent comme la composition d'un portefeuille de témoins, interprétable et discutable. La version originale ajoute des prédicteurs (caractéristiques moyennes pré-traitement) à l'ajustement ; la version réduite à la trajectoire suffit lorsque $T_0$ est long, ce qui est notre cas avec 26 trimestres. Lorsque les niveaux des unités diffèrent fortement, comme les revenus d'immeubles de 6 et de 72 logements, on ajuste les séries centrées sur leur moyenne pré-traitement, ce qui revient à ajouter une constante au modèle et à ne demander aux donneurs que de reproduire la \emph{forme} de la trajectoire. |
| 215 | 215 | |
| 216 | 216 | L'inférence ne passe pas par une erreur-type classique mais par des \motcle{placebos dans l'espace} : on applique la méthode à chaque donneur comme s'il avait été traité à la même date, et l'on compare l'écart post-traitement du vrai traité à la distribution des écarts obtenus pour les donneurs. La statistique usuelle est le rapport de l'erreur quadratique moyenne post-traitement à celle de la période pré-traitement (RMSPE), qui pénalise les unités mal ajustées avant le traitement ; si le traité a le plus grand rapport parmi $J + 1$ unités, la valeur $p$ de permutation est $1/(J + 1)$. La méthode exige un bassin de donneurs qui ne soient pas eux-mêmes affectés par le traitement, directement ou par débordement, et dont l'enveloppe contienne le traité ; un ajustement pré-traitement médiocre, visible au RMSPE, est le premier signe que l'exercice n'est pas fiable. |
| 217 | + | |
| 218 | +\section{Exemple intégral : un choc simulé sur le portefeuille du cas 4}\label{sec:36:exemple} | |
| 219 | + | |
| 220 | +\begin{synthetique} | |
| 221 | +Les séries trimestrielles de revenu net d'exploitation des 48 immeubles du cas 4 (\texttt{data/synthetic/cas4\_portefeuille\_series.csv}, 2016T1 à 2026T2) sont fictives : \texttt{analysis/python/traite/synth.py} (graine 20261031) les engendre comme une tendance de croissance propre à chaque RMR, une marche aléatoire d'écart-type 1,5 \% par trimestre et un choc pandémique de $-3$ \% de 2020T2 à 2021T2, selon les lois déclarées dans \texttt{data/synthetic/README.md}. Le présent chapitre les prend telles quelles comme résultats potentiels \emph{sans} traitement, $Y_{it}(0)$, et leur superpose un traitement dont l'effet est déclaré : huit immeubles de la RMR de Montréal (P04, P06, P11, P14, P20, P21, P28 et P42, tirés au hasard parmi les dix-sept) sont réputés desservis par une ligne de transport collectif fictive mise en service au troisième trimestre de 2022 ; cinq immeubles de la RMR de Gatineau (P10, P24, P29, P33 et P37, parmi huit) le sont par un second tronçon fictif au troisième trimestre de 2024 ; les 35 autres ne sont jamais traités. L'effet sur le logarithme du revenu net est, pour les deux cohortes, $\delta(k) = 0{,}01\,(k + 1)$ pour $k = 0, \dots, 3$ trimestres après la mise en service, puis $\delta(k) = 0{,}04 + 0{,}0025\,(k - 3)$ pour $k \geq 4$ : le régime de $+4$ \% est atteint après un an, au rythme du renouvellement des baux, puis l'effet croît lentement avec la valorisation du voisinage. Ces chiffres sont des hypothèses pédagogiques, non des mesures d'un projet réel ; aucune ligne, aucun tronçon ni aucun immeuble n'existe. Tous les résultats de cette section sont imprimés par \texttt{analysis/python/cases/ch36\_panel.py}, fondés sur les fonctions de \texttt{traite.panel}, et vérifiés par \texttt{analysis/python/tests/test\_ch36.py} ; le contrôle croisé avec \texttt{linearmodels} et \texttt{statsmodels} (option \texttt{-{}-controle}) reproduit coefficients et erreurs-types à $10^{-6}$ près pour les coefficients et à $1$ \% près pour les erreurs-types en grappes (différence de correction d'échantillon fini). | |
| 222 | +\end{synthetique} | |
| 223 | + | |
| 224 | +\begin{figure}[htbp] | |
| 225 | +\centering | |
| 226 | +\begin{tikzpicture}[x=2.9mm, y=1cm, every node/.style={font=\sffamily\footnotesize}] | |
| 227 | +% axe du temps : t = 0 (2016T1) … 41 (2026T2) | |
| 228 | +\draw[->, thick, brandGray] (0,0) -- (43,0) node[right, text=brandGray] {trimestre}; | |
| 229 | +\foreach \t/\l in {0/2016T1, 8/2018T1, 16/2020T1, 24/2022T1, 32/2024T1, 40/2026T1} {\draw[brandGray] (\t,0.08) -- (\t,-0.08) node[below, text=brandGray] {\l};} | |
| 230 | +% bandes de traitement | |
| 231 | +\fill[brandPrimary!25] (26,0.55) rectangle (42,1.05); \draw[brandPrimary, thick] (26,0.55) -- (26,1.05); | |
| 232 | +\node[left] at (0,0.8) {Cohorte A (8 immeubles, Montréal)}; | |
| 233 | +\node at (34,0.8) {traitée dès 2022T3 ($k = 0$ à $15$)}; | |
| 234 | +\fill[brandSecondary!35] (34,1.25) rectangle (42,1.75); \draw[brandSecondary!80!black, thick] (34,1.25) -- (34,1.75); | |
| 235 | +\node[left] at (0,1.5) {Cohorte B (5 immeubles, Gatineau)}; | |
| 236 | +\node at (38,1.5) {dès 2024T3}; | |
| 237 | +\node[left] at (0,2.2) {Jamais traités (35 immeubles, 6 RMR)}; | |
| 238 | +\draw[brandGray, dashed] (0,2.2) -- (42,2.2); | |
| 239 | +% fenêtres du 2 x 2 | |
| 240 | +\draw[decorate, decoration={brace, amplitude=3pt}, brandDark] (22,-0.75) -- (25.9,-0.75) node[midway, below=3pt, text=brandDark] {avant (2021T3--2022T2)}; | |
| 241 | +\draw[decorate, decoration={brace, amplitude=3pt}, brandDark] (30,-0.75) -- (33.9,-0.75) node[midway, below=3pt, text=brandDark] {après (2023T3--2024T2)}; | |
| 242 | +\draw[decorate, decoration={brace, amplitude=3pt, mirror}, brandGray] (10,0.3) -- (17.9,0.3) node[midway, above=3pt, text=brandGray] {placebo (2019T3)}; | |
| 243 | +\end{tikzpicture} | |
| 244 | +\caption{Chronologie du plan simulé : deux cohortes traitées à des dates différentes, un groupe jamais traité, et les fenêtres retenues pour l'estimateur $2 \times 2$ (deuxième année après la mise en service, lorsque le régime déclaré est atteint) et pour le placebo temporel.}\label{fig:36:chronologie} | |
| 245 | +\figsource{Schéma de l'auteur ; plan décrit dans \texttt{analysis/python/cases/ch36\_panel.py}.} | |
| 246 | +\end{figure} | |
| 247 | + | |
| 248 | +\begin{exemple}{Cinq estimateurs confrontés à un effet connu}{choc-cas4} | |
| 249 | +L'exemple procède en huit étapes : description du plan et des trajectoires ; doubles différences $2 \times 2$ et placebo ; effets fixes bidirectionnels et erreurs-types ; étude d'événement ; décomposition de Goodman-Bacon ; estimateur de Callaway et Sant'Anna ; contrôle synthétique ; discontinuité spatiale sur une simulation annexe. Le \cref{tab:36:bilan} rassemble les résultats. | |
| 250 | + | |
| 251 | +\paragraph{Étape 1 : le plan et la vérité.} La \cref{fig:36:chronologie} résume le plan. L'effet moyen vrai sur les observations traitées, c'est-à-dire l'ATT de l'\cref{eq:36:att} pris sur l'ensemble des trimestres post-traitement des deux cohortes, vaut $0{,}0454$ en logarithme : $0{,}0484$ pour la cohorte A, qui compte seize trimestres de traitement et atteint $\delta(15) = 0{,}07$ au dernier, et $0{,}0356$ pour la cohorte B, qui n'en compte que huit. Sur la fenêtre « après » de l'estimateur $2 \times 2$, la deuxième année suivant la mise en service de la cohorte A ($k = 4$ à $7$), l'effet vrai moyen est de $0{,}0462$. La \cref{fig:36:trajectoires} montre les trajectoires moyennes : le logarithme du revenu net des immeubles traités de Montréal se détache de celui des neuf immeubles témoins de la même RMR après 2022T3, et la courbe en pointillé, qui retranche l'effet déclaré, indique ce que les traités auraient connu sans traitement ; le lecteur voit que ce contrefactuel n'est pas exactement parallèle aux témoins, parce que les marches aléatoires de huit immeubles et de neuf autres ne s'annulent pas, et c'est tout le problème. | |
| 252 | + | |
| 253 | +\begin{figure}[htbp] | |
| 254 | +\centering | |
| 255 | +\includegraphics[width=\textwidth]{ch36/ch36-fig-trajectoires.pdf} | |
| 256 | +\caption{Logarithme moyen du revenu net d'exploitation, en écart à 2016T1, des immeubles traités et des témoins jamais traités de la même RMR. (a) Montréal, cohorte A traitée dès 2022T3 ; (b) Gatineau, cohorte B traitée dès 2024T3. Le pointillé est la trajectoire des traités débarrassée de l'effet déclaré, c'est-à-dire le contrefactuel $Y(0)$ que seul un jeu simulé permet de tracer. Les groupes ne sont pas rigoureusement parallèles : les marches aléatoires idiosyncrasiques ne s'annulent pas sur huit ou neuf immeubles.}\label{fig:36:trajectoires} | |
| 257 | +\figsource{\synth{script \texttt{analysis/python/figures/ch36\_figures.py}, graine 20261031}} | |
| 258 | +\end{figure} | |
| 259 | + | |
| 260 | +\paragraph{Étape 2 : doubles différences $2 \times 2$ et placebo.} Le \cref{tab:36:did} donne les quatre moyennes de cellule de l'\cref{eq:8:did} pour la cohorte A, avec pour témoins les neuf immeubles jamais traités de Montréal. Le logarithme moyen du revenu net des traités passe de $10{,}1379$ à $10{,}2450$ entre les deux fenêtres, soit $+0{,}1071$ ; celui des témoins de $10{,}3696$ à $10{,}4234$, soit $+0{,}0538$. La double différence vaut $0{,}0534$, à comparer au vrai $0{,}0462$ : l'erreur, $0{,}0072$, est bien inférieure à l'erreur-type de $0{,}0201$ obtenue par la régression des différences longues de l'\cref{eq:36:did-twfe} (dix-sept observations, HC1). En pourcentage, l'estimation est de $5{,}5$ \% pour un effet vrai de $4{,}7$ \%. Avec les 35 témoins de toutes les RMR, l'estimateur tombe à $0{,}0468$, plus proche encore de la vérité, mais pour une mauvaise raison que la \cref{sec:36:sensibilite} démêle : les tendances régionales des autres RMR ne sont pas celles de Montréal, et deux biais de signe opposé se compensent partiellement. Le placebo temporel, qui place une fausse mise en service en 2019T3 et compare 2018T3--2019T2 à 2019T3--2020T2, donne $0{,}0155$ avec une erreur-type de $0{,}0101$ : non significatif ($t = 1{,}5$), mais pas négligeable, et le signe positif annonce ce que l'étude d'événement va préciser. | |
| 261 | + | |
| 262 | +\begin{table}[htbp] | |
| 263 | +\caption{Doubles différences $2 \times 2$ pour la cohorte A : moyennes de cellule du logarithme du revenu net d'exploitation (données synthétiques)}\label{tab:36:did} | |
| 264 | +\centering | |
| 265 | +\small | |
| 266 | +\begin{tabular}{lS[table-format=2.4]S[table-format=2.4]S[table-format=1.4]} | |
| 267 | +\toprule | |
| 268 | +Groupe & {Avant (2021T3--2022T2)} & {Après (2023T3--2024T2)} & {Différence} \\ | |
| 269 | +\midrule | |
| 270 | +Traités, Montréal ($n = 8$) & 10.1379 & 10.2450 & 0.1071 \\ | |
| 271 | +Témoins jamais traités, Montréal ($n = 9$) & 10.3696 & 10.4234 & 0.0538 \\ | |
| 272 | +\midrule | |
| 273 | +Double différence $\hat\delta_{2\times 2}$ & & & 0.0534 \\ | |
| 274 | +Erreur-type (différences longues, HC1) & & & 0.0201 \\ | |
| 275 | +Effet vrai moyen sur la fenêtre & & & 0.0462 \\ | |
| 276 | +\midrule | |
| 277 | +\multicolumn{4}{l}{\emph{Variante :} témoins jamais traités des six RMR ($n = 35$) : $\hat\delta = 0{,}0468$ (ET $0{,}0177$)} \\ | |
| 278 | +\multicolumn{4}{l}{\emph{Placebo temporel :} fausse mise en service en 2019T3, témoins de Montréal : $\hat\delta = 0{,}0155$ (ET $0{,}0101$)} \\ | |
| 279 | +\bottomrule | |
| 280 | +\end{tabular} | |
| 281 | +\tabsource{\synth{\texttt{analysis/python/cases/ch36\_panel.py}, fonctions \texttt{did\_deux\_periodes} et \texttt{placebo\_temporel} ; \texttt{traite.panel.did\_2x2}}.} | |
| 282 | +\end{table} | |
| 283 | + | |
| 284 | +\paragraph{Étape 3 : effets fixes bidirectionnels et erreurs-types.} Le \cref{tab:36:twfe} estime l'\cref{eq:36:did-twfe} sur les 2 016 observations avec l'indicatrice $D_{it}$ des deux cohortes. Avec des effets de période communs à toutes les RMR, le coefficient vaut $0{,}0905$, le double de l'effet vrai de $0{,}0454$ ; la transformation within bidirectionnelle de l'\cref{eq:36:within} donne exactement le même chiffre, ce qui vérifie la \cref{prop:36-within}. L'écart n'est pas un accident d'échantillonnage : les deux cohortes sont dans les RMR dont le générateur fait croître les revenus le plus vite, Gatineau et Montréal, et un effet de période commun attribue à $D_{it}$ la différence entre leur tendance et la tendance moyenne des six RMR, accumulée sur dix ans. Remplacer les effets de période par des effets RMR $\times$ trimestre, 246 indicatrices qui laissent chaque région suivre sa propre trajectoire, ramène le coefficient à $0{,}0702$. Il reste supérieur à la vérité de $0{,}025$, mais l'erreur-type en grappes est de $0{,}0264$ : l'écart est de l'ordre d'un écart-type, et l'étude d'événement en donnera la raison. Les trois colonnes d'erreurs-types sont la leçon la plus importante du tableau. L'erreur-type classique, $0{,}0054$, et l'erreur-type HC1, $0{,}0064$, supposent 2 016 observations indépendantes ; l'erreur-type en grappes par immeuble, $0{,}0264$, est $4{,}9$ fois plus grande, un rapport conforme à l'\cref{eq:36:moulton} pour $m = 42$ et une corrélation intra-immeuble de l'ordre de $0{,}5$. Quiconque aurait rapporté le premier chiffre aurait annoncé un effet mesuré à $\pm 1$ \% près alors que l'incertitude réelle est de $\pm 5$ \%. | |
| 285 | + | |
| 286 | +\begin{table}[htbp] | |
| 287 | +\caption{Estimateur à effets fixes bidirectionnels de l'effet statique du traitement, selon les effets de période et le calcul des erreurs-types (données synthétiques ; effet vrai moyen $0{,}0454$)}\label{tab:36:twfe} | |
| 288 | +\centering | |
| 289 | +\small | |
| 290 | +\begin{tabular}{lS[table-format=3.0]S[table-format=1.4]S[table-format=1.4]S[table-format=1.4]S[table-format=1.4]} | |
| 291 | +\toprule | |
| 292 | +Spécification & {Paramètres} & {$\hat\delta$} & {ET classique} & {ET HC1} & {ET grappes} \\ | |
| 293 | +\midrule | |
| 294 | +Effets d'immeuble + trimestre (communs) & 90 & 0.0905 & 0.0047 & 0.0053 & 0.0198 \\ | |
| 295 | +Within bidirectionnel (vérification) & & 0.0905 & & & \\ | |
| 296 | +Effets d'immeuble + RMR $\times$ trimestre & 295 & 0.0702 & 0.0054 & 0.0064 & 0.0264 \\ | |
| 297 | +\bottomrule | |
| 298 | +\end{tabular} | |
| 299 | +\tabsource{\synth{\texttt{analysis/python/cases/ch36\_panel.py}, fonction \texttt{twfe\_statique} ; \texttt{traite.panel.twfe} et \texttt{mco\_grappes} (CR1, 48 grappes)} ; contrôle croisé \texttt{linearmodels.PanelOLS} et \texttt{statsmodels} (coefficient identique, erreurs-types en grappes $0{,}0196$ et $0{,}0198$).} | |
| 300 | +\end{table} | |
| 301 | + | |
| 302 | +\paragraph{Étape 4 : étude d'événement.} La \cref{fig:36:eventstudy}(a) et le \cref{tab:36:event} présentent l'\cref{eq:36:event} avec effets RMR $\times$ trimestre, fenêtre de $-8$ à $+8$ trimestres (les deux extrémités regroupant les observations plus lointaines) et référence $k = -1$. Les coefficients post-traitement suivent le profil déclaré de près : $0{,}0088$ à $k = 0$ pour un vrai $0{,}01$, $0{,}0235$ pour $0{,}02$, $0{,}0312$ pour $0{,}03$, $0{,}0410$ pour $0{,}04$, puis $0{,}0354$, $0{,}0439$, $0{,}0476$ et $0{,}0561$ pour des vrais de $0{,}0425$ à $0{,}05$ ; l'indicatrice regroupée $k \geq 8$, qui ne concerne que la cohorte A, donne $0{,}0817$ pour une vraie moyenne de $0{,}0612$, avec une erreur-type de $0{,}027$ qui traduit l'accumulation des marches aléatoires. La montée en régime sur quatre trimestres, que l'estimateur statique ne pouvait pas voir, est parfaitement lisible. Les coefficients pré-traitement sont tous négatifs, entre $-0{,}007$ et $-0{,}021$, aucun n'est individuellement significatif, et ils n'ont pas de pente marquée : le profil est compatible avec des tendances parallèles, mais il révèle que le trimestre de référence, 2022T2 pour la cohorte A, est un point relativement haut des traités, puisque tous les trimestres antérieurs leur sont inférieurs d'environ $0{,}015$ une fois la tendance régionale retirée. C'est le hasard du tirage des marches aléatoires, et c'est lui qui explique que l'estimateur statique du \cref{tab:36:twfe}, qui compare la moyenne de \emph{tous} les trimestres post-traitement à celle de \emph{tous} les trimestres antérieurs, excède la vérité de $0{,}02$ environ : il hérite du creux pré-traitement. L'étude d'événement, qui mesure tout par rapport à $k = -1$, n'en souffre pas, mais elle en souffrirait si le hasard avait placé le creux en $k = -1$. La leçon est qu'il faut regarder les deux et comprendre d'où vient leur écart, non choisir celle qui arrange. | |
| 303 | + | |
| 304 | +\begin{figure}[htbp] | |
| 305 | +\centering | |
| 306 | +\includegraphics[width=\textwidth]{ch36/ch36-fig-event-study.pdf} | |
| 307 | +\caption{Dynamique de l'effet estimé et effet déclaré. (a) Étude d'événement par effets fixes d'immeuble et RMR $\times$ trimestre, référence $k = -1$, bandes à 95 \% par erreurs-types en grappes ; les coefficients de $k = -8$ et $k = +8$ regroupent les observations plus éloignées. (b) Agrégation dynamique de l'estimateur de Callaway et Sant'Anna avec témoins jamais traités de la même RMR, bandes par bootstrap de 499 tirages par blocs d'immeubles. Dans les deux panneaux, la montée en régime sur quatre trimestres est retrouvée ; les coefficients pré-traitement négatifs signalent que le trimestre de référence est un point haut des traités.}\label{fig:36:eventstudy} | |
| 308 | +\figsource{\synth{script \texttt{analysis/python/figures/ch36\_figures.py}, graine 20261031}} | |
| 309 | +\end{figure} | |
| 310 | + | |
| 311 | +\begin{table}[htbp] | |
| 312 | +\caption{Coefficients de l'étude d'événement (effets d'immeuble et RMR $\times$ trimestre) et effet déclaré, par trimestre écoulé depuis la mise en service (données synthétiques)}\label{tab:36:event} | |
| 313 | +\centering | |
| 314 | +\small | |
| 315 | +\begin{tabular}{S[table-format=+1.0]S[table-format=+1.4]S[table-format=1.4]S[table-format=1.4]cS[table-format=+1.0]S[table-format=+1.4]S[table-format=1.4]S[table-format=1.4]} | |
| 316 | +\toprule | |
| 317 | +{$k$} & {$\hat\delta_k$} & {ET grappes} & {Vrai} & & {$k$} & {$\hat\delta_k$} & {ET grappes} & {Vrai} \\ | |
| 318 | +\midrule | |
| 319 | +{$\leq -8$} & -0.0175 & 0.0204 & 0 & & 0 & 0.0088 & 0.0060 & 0.0100 \\ | |
| 320 | +-7 & -0.0169 & 0.0173 & 0 & & 1 & 0.0235 & 0.0084 & 0.0200 \\ | |
| 321 | +-6 & -0.0182 & 0.0148 & 0 & & 2 & 0.0312 & 0.0108 & 0.0300 \\ | |
| 322 | +-5 & -0.0121 & 0.0131 & 0 & & 3 & 0.0410 & 0.0138 & 0.0400 \\ | |
| 323 | +-4 & -0.0214 & 0.0140 & 0 & & 4 & 0.0354 & 0.0141 & 0.0425 \\ | |
| 324 | +-3 & -0.0183 & 0.0116 & 0 & & 5 & 0.0439 & 0.0149 & 0.0450 \\ | |
| 325 | +-2 & -0.0073 & 0.0074 & 0 & & 6 & 0.0476 & 0.0155 & 0.0475 \\ | |
| 326 | +-1 & 0 & {(référence)} & 0 & & 7 & 0.0561 & 0.0158 & 0.0500 \\ | |
| 327 | + & & & & & {$\geq 8$} & 0.0817 & 0.0270 & 0.0612 \\ | |
| 328 | +\bottomrule | |
| 329 | +\end{tabular} | |
| 330 | +\tabsource{\synth{\texttt{analysis/python/cases/ch36\_panel.py}, fonction \texttt{etude\_evenement} ; \texttt{traite.panel.event\_study}} ; la colonne « vrai » est la moyenne de $\delta(k)$ sur les observations regroupées dans chaque indicatrice.} | |
| 331 | +\end{table} | |
| 332 | + | |
| 333 | +\paragraph{Étape 5 : décomposition de Goodman-Bacon.} Le \cref{tab:36:bacon} décompose l'estimateur TWFE à effets de période communs, $0{,}0905$, selon l'\cref{eq:36:bacon}. Les quatre comparaisons $2 \times 2$ se recombinent exactement : $0{,}6658 \times 0{,}0844 + 0{,}2721 \times 0{,}1224 + 0{,}0476 \times 0{,}0080 + 0{,}0146 \times 0{,}0403 = 0{,}0905$, et les poids somment à un, ce que le test unitaire vérifie à $10^{-10}$. La lecture est instructive. Les deux comparaisons contre les jamais traités dominent (94 \% du poids) et sont toutes deux très au-dessus de la vérité, à $0{,}084$ et $0{,}122$, parce qu'elles comparent des immeubles de Montréal et de Gatineau à des témoins de six RMR sur toute la décennie : c'est le biais de tendance régionale déjà identifié. La comparaison « précoce contre tardive pas encore traitée » oppose Montréal à Gatineau avant 2024T3 et donne $0{,}008$, parce que Gatineau croît plus vite que Montréal dans le générateur et que cette différence de tendance s'oppose à l'effet. La comparaison interdite, « tardive contre précoce déjà traitée », donne $0{,}040$ pour une vérité de $0{,}036$ ; son poids est faible ($1{,}5$ \%) et son biais ici modeste, parce que l'effet de la cohorte A n'augmente que de $0{,}0025$ par trimestre pendant que la cohorte B est traitée. La \cref{fig:36:bacon}(a) place ces quatre points dans le plan poids--estimation. Sur le cas 4, la décomposition montre donc que le défaut du TWFE tient d'abord aux tendances régionales, non à l'échelonnement ; pour isoler ce dernier, le panneau (b) et le \cref{tab:36:labo} recourent à un laboratoire. | |
| 334 | + | |
| 335 | +\begin{table}[htbp] | |
| 336 | +\caption{Décomposition de Goodman-Bacon de l'estimateur TWFE à effets de période communs ($\hat\delta = 0{,}0905$), cas 4 (données synthétiques)}\label{tab:36:bacon} | |
| 337 | +\centering | |
| 338 | +\small | |
| 339 | +\begin{tabular}{llS[table-format=1.4]S[table-format=1.4]S[table-format=1.4]} | |
| 340 | +\toprule | |
| 341 | +Groupe traité & Groupe témoin (fenêtre) & {Poids $s$} & {$\hat\beta_{2\times 2}$} & {Vrai} \\ | |
| 342 | +\midrule | |
| 343 | +Cohorte A (2022T3) & Jamais traités (toute la période) & 0.6658 & 0.0844 & 0.0484 \\ | |
| 344 | +Cohorte B (2024T3) & Jamais traités (toute la période) & 0.2721 & 0.1224 & 0.0356 \\ | |
| 345 | +Cohorte A & Cohorte B avant 2024T3 (pas encore traitée) & 0.0476 & 0.0080 & 0.0280 \\ | |
| 346 | +Cohorte B & Cohorte A dès 2022T3 (déjà traitée) & 0.0146 & 0.0403 & 0.0356 \\ | |
| 347 | +\midrule | |
| 348 | +Somme des poids / moyenne pondérée & & 1.0000 & 0.0905 & 0.0454 \\ | |
| 349 | +\bottomrule | |
| 350 | +\end{tabular} | |
| 351 | +\tabsource{\synth{\texttt{traite.panel.bacon} appliqué au panel de \texttt{ch36\_panel.py}} ; la colonne « vrai » est l'effet moyen déclaré du groupe traité sur la fenêtre de chaque comparaison.} | |
| 352 | +\end{table} | |
| 353 | + | |
| 354 | +\begin{figure}[htbp] | |
| 355 | +\centering | |
| 356 | +\includegraphics[width=\textwidth]{ch36/ch36-fig-bacon.pdf} | |
| 357 | +\caption{Décomposition de Goodman-Bacon : chaque point est une comparaison $2 \times 2$, placée selon son poids et son estimation ; le trait plein est l'estimateur TWFE, moyenne pondérée des points, et le tireté l'effet moyen vrai. (a) Cas 4 : les comparaisons contre les jamais traités dominent et portent le biais de tendance régionale. (b) Laboratoire à tendance commune et effets croissants ($\delta(k) = 0{,}005\,(k+1)$) : la comparaison « tardive contre précoce déjà traitée » est négative et pèse un tiers, et le TWFE tombe à un tiers de l'effet vrai.}\label{fig:36:bacon} | |
| 358 | +\figsource{\synth{script \texttt{analysis/python/figures/ch36\_figures.py}, graine 20261031}} | |
| 359 | +\end{figure} | |
| 360 | + | |
| 361 | +Le laboratoire du \cref{tab:36:labo} est un panel artificiel de 50 unités et 42 périodes, sans tendance régionale ($y_{it} = \alpha_i + \lambda_t + \varepsilon_{it}$, bruit d'écart-type $0{,}02$), avec deux cohortes de vingt unités traitées aux périodes 10 et 26, dix unités jamais traitées et un effet qui croît sans plafond, $\delta(k) = 0{,}005\,(k + 1)$. L'effet moyen vrai est de $0{,}0692$ ($0{,}0825$ pour la cohorte précoce, $0{,}0425$ pour la tardive). Le TWFE statique donne $0{,}0233$, un tiers de la vérité, avec une erreur-type en grappes de $0{,}0061$ qui exclut la vérité à plus de sept écarts-types : l'estimateur est précis et faux. La décomposition l'explique : la comparaison interdite vaut $-0{,}0356$ et pèse $0{,}33$, parce que l'effet de la cohorte précoce passe en moyenne de $0{,}0425$ pendant la fenêtre où elle sert de « pré » à $0{,}1225$ pendant la fenêtre « post », une hausse de $0{,}08$ que l'\cref{eq:36:interdite} retranche de l'ATT de la cohorte tardive. Les trois autres comparaisons sont proches de leurs vérités respectives. L'estimateur de Callaway et Sant'Anna, qui n'utilise que les dix unités jamais traitées comme témoins, donne $0{,}0893$ et $0{,}0310$ par cohorte et $0{,}0601$ au total : l'écart à la vérité ($-0{,}009$) tient à ce que la période de base $g - 1$ est bruitée et que ce bruit se transmet à tous les $\widehat{\mathrm{ATT}}(g, t)$ de la cohorte, un défaut connu de la base universelle, mais l'estimateur est dans l'intervalle de confiance et du bon ordre de grandeur, là où le TWFE est hors de tout. | |
| 362 | + | |
| 363 | +\begin{table}[htbp] | |
| 364 | +\caption{Laboratoire à adoption échelonnée et effets croissants : TWFE statique, comparaisons de Goodman-Bacon et estimateur de Callaway et Sant'Anna (panel artificiel)}\label{tab:36:labo} | |
| 365 | +\centering | |
| 366 | +\small | |
| 367 | +\begin{tabular}{lS[table-format=+1.4]S[table-format=1.4]S[table-format=1.4]} | |
| 368 | +\toprule | |
| 369 | +Quantité & {Estimation} & {ET grappes / bootstrap} & {Vrai} \\ | |
| 370 | +\midrule | |
| 371 | +TWFE statique & 0.0233 & 0.0061 & 0.0692 \\ | |
| 372 | +\quad comparaison précoce vs jamais traités (poids 0,204) & 0.0785 & & 0.0825 \\ | |
| 373 | +\quad comparaison tardive vs jamais traités (poids 0,265) & 0.0402 & & 0.0425 \\ | |
| 374 | +\quad comparaison précoce vs tardive pas encore traitée (poids 0,204) & 0.0404 & & 0.0425 \\ | |
| 375 | +\quad comparaison tardive vs précoce déjà traitée (poids 0,327) & -0.0356 & & 0.0425 \\ | |
| 376 | +Callaway--Sant'Anna, cohorte précoce & 0.0893 & 0.0086 & 0.0825 \\ | |
| 377 | +Callaway--Sant'Anna, cohorte tardive & 0.0310 & 0.0105 & 0.0425 \\ | |
| 378 | +Callaway--Sant'Anna, global & 0.0601 & 0.0073 & 0.0692 \\ | |
| 379 | +\bottomrule | |
| 380 | +\end{tabular} | |
| 381 | +\tabsource{\synth{\texttt{analysis/python/cases/ch36\_panel.py}, fonction \texttt{laboratoire} (graine 20261031 + 36)}.} | |
| 382 | +\end{table} | |
| 383 | + | |
| 384 | +\paragraph{Étape 6 : Callaway et Sant'Anna sur le cas 4.} Revenons au portefeuille. Le \cref{tab:36:cs} applique l'\cref{eq:36:cs} avec, pour chaque cohorte, les témoins jamais traités de sa propre RMR, neuf à Montréal et trois à Gatineau, ce qui est la façon la plus directe d'imposer des tendances parallèles \emph{régionales}. L'effet de la cohorte A est estimé à $0{,}0528$ (vrai $0{,}0484$), celui de la cohorte B à $0{,}0406$ (vrai $0{,}0356$), et l'effet global, pondéré par les tailles des cohortes, à $0{,}0481$ pour un vrai de $0{,}0454$ : l'erreur est de $0{,}003$, le quart de l'erreur-type bootstrap de $0{,}0116$. L'agrégation dynamique, \cref{fig:36:eventstudy}(b), reproduit le profil de l'étude d'événement avec une précision comparable et le prolonge jusqu'à $k = 15$ pour la seule cohorte A, où l'on voit l'estimation suivre la pente de $0{,}0025$ par trimestre avec un bruit croissant. Avec les 35 témoins de toutes les RMR, l'estimateur donne $0{,}0509$ pour A mais $0{,}0521$ pour B, dont l'effet vrai n'est que de $0{,}0356$ : Gatineau croît plus vite que la moyenne des autres RMR et la cohorte B s'en trouve créditée, exactement comme dans la comparaison correspondante du \cref{tab:36:bacon}. Le choix des témoins est une hypothèse d'identification à part entière, et la restriction à la même RMR en est une plus crédible que l'assemblage de six marchés. | |
| 385 | + | |
| 386 | +\begin{table}[htbp] | |
| 387 | +\caption{Estimateur de Callaway et Sant'Anna par cohorte et global, selon le groupe témoin, cas 4 (données synthétiques)}\label{tab:36:cs} | |
| 388 | +\centering | |
| 389 | +\small | |
| 390 | +\begin{tabular}{lS[table-format=1.4]S[table-format=1.4]S[table-format=1.4]S[table-format=1.4]S[table-format=1.4]} | |
| 391 | +\toprule | |
| 392 | +& \multicolumn{2}{c}{Témoins de la même RMR} & \multicolumn{2}{c}{Témoins de toutes les RMR} & \\ | |
| 393 | +\cmidrule(lr){2-3}\cmidrule(lr){4-5} | |
| 394 | +Agrégat & {$\widehat{\mathrm{ATT}}$} & {ET bootstrap} & {$\widehat{\mathrm{ATT}}$} & {ET bootstrap} & {Vrai} \\ | |
| 395 | +\midrule | |
| 396 | +Cohorte A (Montréal, 2022T3, 16 trimestres) & 0.0528 & 0.0154 & 0.0509 & 0.0145 & 0.0484 \\ | |
| 397 | +Cohorte B (Gatineau, 2024T3, 8 trimestres) & 0.0406 & 0.0183 & 0.0521 & 0.0133 & 0.0356 \\ | |
| 398 | +Global (pondéré par la taille des cohortes) & 0.0481 & 0.0116 & 0.0514 & & 0.0454 \\ | |
| 399 | +\bottomrule | |
| 400 | +\end{tabular} | |
| 401 | +\tabsource{\synth{\texttt{traite.panel.callaway\_santanna} ; base universelle $g - 1$, témoins jamais traités, bootstrap stratifié de 499 tirages (99 pour la variante)}.} | |
| 402 | +\end{table} | |
| 403 | + | |
| 404 | +\paragraph{Étape 7 : contrôle synthétique.} La cohorte A est ici traitée comme une seule unité, la moyenne des logarithmes de ses huit immeubles, et le bassin de donneurs est constitué des 35 immeubles jamais traités, chaque série étant centrée sur sa moyenne des 26 trimestres pré-traitement. Les poids de l'\cref{eq:36:synth} se concentrent sur onze donneurs (\cref{fig:36:synthetique}(c)) : P02 ($0{,}142$) et P45 ($0{,}105$) à Gatineau, P09 ($0{,}136$), P16 ($0{,}123$) et P38 ($0{,}027$) à Montréal, P25 ($0{,}120$) et P19 ($0{,}114$) à Trois-Rivières, P43 ($0{,}110$) au Saguenay, P03 ($0{,}071$), P35 et P05. L'ajustement pré-traitement est excellent, avec un RMSPE de $0{,}0031$, ce qui n'étonne pas lorsque 35 donneurs servent à reproduire une moyenne de huit séries lisses. Après la mise en service, l'écart moyen entre la cohorte et son contrôle synthétique est de $0{,}0363$ pour un effet vrai moyen de $0{,}0484$ sur les seize trimestres : le contrôle synthétique sous-estime de $0{,}012$, là où les estimateurs par régression surestimaient. La raison se lit sur le panneau (a) : la méthode ajuste toute la trajectoire pré-traitement, y compris les derniers trimestres où les traités sont, par hasard, au-dessus de leur tendance ; le contrefactuel synthétique prolonge ce point haut, alors que les traités retombent ensuite vers leur tendance, et la différence est portée au débit du traitement. L'inférence par placebos (panneau (b)) est néanmoins nette : le rapport des RMSPE post et pré de la cohorte vaut $12{,}9$, le plus élevé des 36 unités, soit une valeur $p$ de permutation de $1/36 = 0{,}028$. Le contrôle synthétique confirme qu'il s'est passé quelque chose, situe l'effet dans le bon ordre de grandeur, et montre par les poids quels immeubles ont servi de témoins ; il ne remplace pas l'étude d'événement pour la dynamique, et il est, comme elle, à la merci de la configuration des derniers trimestres pré-traitement. | |
| 405 | + | |
| 406 | +\begin{figure}[htbp] | |
| 407 | +\centering | |
| 408 | +\includegraphics[width=\textwidth]{ch36/ch36-fig-synthetique.pdf} | |
| 409 | +\caption{Contrôle synthétique de la cohorte A (moyenne des huit immeubles traités de Montréal, logarithme du revenu net centré sur la moyenne pré-traitement). (a) Trajectoire traitée, contrôle synthétique et contrefactuel déclaré. (b) Écart traité moins synthétique et écarts des 35 placebos obtenus en traitant fictivement chaque donneur ; le rapport des RMSPE post et pré du traité est le plus élevé des 36. (c) Poids des onze donneurs qui dépassent 0,5 \%.}\label{fig:36:synthetique} | |
| 410 | +\figsource{\synth{script \texttt{analysis/python/figures/ch36\_figures.py}, graine 20261031}} | |
| 411 | +\end{figure} | |
| 412 | + | |
| 413 | +\paragraph{Étape 8 : discontinuité spatiale.} Le portefeuille du cas 4 ne comporte pas de frontière ; la \cref{fig:36:discontinuite} repose donc sur une simulation transversale annexe, déclarée dans le script : 400 immeubles fictifs répartis uniformément à moins de deux kilomètres d'une frontière de zone, dont le logarithme de la valeur suit $12{,}9 + 0{,}03\,x + 0{,}05\,\ind[x \geq 0] + \varepsilon$, avec $x$ la distance signée en kilomètres (positive du côté de la zone de densité supérieure) et $\varepsilon$ normal d'écart-type $0{,}08$. La pente de $0{,}03$ par kilomètre représente un gradient continu de localisation, le saut de $0{,}05$ l'effet de la règle. La comparaison naïve des deux côtés donne $0{,}114$, plus du double de la vérité, parce qu'elle attribue à la règle tout le gradient. L'\cref{eq:36:rdd} donne $0{,}034$ (erreur-type $0{,}034$, 93 immeubles effectifs) pour une bande de 500 mètres, $0{,}032$ ($0{,}025$, 202 immeubles) pour un kilomètre et $0{,}058$ ($0{,}018$, 400 immeubles) pour deux kilomètres. Les trois estimations encadrent la vérité à moins d'un écart-type, et la progression des erreurs-types illustre l'arbitrage : la bande de deux kilomètres est la plus précise mais elle repose sur l'hypothèse, ici vraie par construction, que la fonction de prix est linéaire sur toute la largeur. Sur des données réelles, la linéarité n'est jamais garantie, et l'usage est de rapporter la fourchette. | |
| 414 | + | |
| 415 | +\begin{figure}[htbp] | |
| 416 | +\centering | |
| 417 | +\includegraphics[width=\textwidth]{ch36/ch36-fig-discontinuite.pdf} | |
| 418 | +\caption{Discontinuité spatiale à une frontière de zone (simulation transversale fictive) : logarithme de la valeur selon la distance signée à la frontière, moyennes par tranche de 200 mètres et ajustement local linéaire à noyau triangulaire de bande 1 km de part et d'autre. Le saut à la frontière est l'effet de la règle ; le gradient continu de part et d'autre est la localisation, que la comparaison naïve des deux côtés confondrait avec lui.}\label{fig:36:discontinuite} | |
| 419 | +\figsource{\synth{script \texttt{analysis/python/figures/ch36\_figures.py}, graine 20261031 ; données engendrées par \texttt{ch36\_panel.discontinuite}}} | |
| 420 | +\end{figure} | |
| 421 | + | |
| 422 | +\paragraph{Bilan.} Le \cref{tab:36:bilan} aligne les estimateurs sur la vérité correspondante. Trois constats. Les estimateurs qui imposent des tendances parallèles \emph{régionales} (2 $\times$ 2 avec témoins de Montréal, étude d'événement et TWFE avec effets RMR $\times$ trimestre, Callaway--Sant'Anna par RMR) sont tous à moins d'un écart-type de la vérité ; ceux qui mélangent les régions ne le sont que par compensation d'erreurs. L'estimateur statique, quel que soit son habillage, est le plus exposé aux accidents de trajectoire pré-traitement, parce qu'il moyenne sur toute la période ; l'étude d'événement et l'estimateur par cohortes, qui raisonnent par rapport à une base datée, rendent ces accidents visibles. Enfin, aucun estimateur n'est précis : avec huit immeubles traités et des revenus qui suivent des marches aléatoires, les erreurs-types honnêtes sont de l'ordre de $0{,}015$ à $0{,}025$, soit $\pm 3$ à $\pm 5$ points de pourcentage autour d'un effet de $4$ à $5$ \%. C'est le réalisme qu'un rapport doit afficher : l'effet est positif, de l'ordre de quelques pour cent, et sa valeur exacte n'est pas connue. | |
| 423 | +\end{exemple} | |
| 424 | + | |
| 425 | +\begin{table}[htbp] | |
| 426 | +\caption{Bilan : estimateurs de l'effet du traitement sur le logarithme du revenu net d'exploitation et vérité correspondante (données synthétiques)}\label{tab:36:bilan} | |
| 427 | +\centering | |
| 428 | +\small | |
| 429 | +\begin{tabular}{lS[table-format=1.4]S[table-format=1.4]S[table-format=1.4]S[table-format=+1.4]} | |
| 430 | +\toprule | |
| 431 | +Estimateur (cible) & {Estimation} & {Erreur-type} & {Vrai} & {Écart} \\ | |
| 432 | +\midrule | |
| 433 | +$2 \times 2$, témoins Montréal (cohorte A, 2\textsuperscript{e} année) & 0.0534 & 0.0201 & 0.0462 & 0.0072 \\ | |
| 434 | +$2 \times 2$, témoins six RMR (idem) & 0.0468 & 0.0177 & 0.0462 & 0.0006 \\ | |
| 435 | +TWFE statique, effets communs (ATT global) & 0.0905 & 0.0198 & 0.0454 & 0.0451 \\ | |
| 436 | +TWFE statique, RMR $\times$ trimestre (ATT global) & 0.0702 & 0.0264 & 0.0454 & 0.0248 \\ | |
| 437 | +Étude d'événement, $k = 3$ (effet à un an) & 0.0410 & 0.0138 & 0.0400 & 0.0010 \\ | |
| 438 | +Callaway--Sant'Anna, même RMR (ATT global) & 0.0481 & 0.0116 & 0.0454 & 0.0027 \\ | |
| 439 | +Contrôle synthétique (cohorte A, 16 trimestres) & 0.0363 & {$p = 0{,}028$} & 0.0484 & -0.0121 \\ | |
| 440 | +Discontinuité, bande 1 km (saut à la frontière) & 0.0316 & 0.0247 & 0.0500 & -0.0184 \\ | |
| 441 | +\bottomrule | |
| 442 | +\end{tabular} | |
| 443 | +\tabsource{\synth{\texttt{analysis/python/cases/ch36\_panel.py}} ; erreurs-types HC1 sur différences longues ($2 \times 2$), en grappes par immeuble (TWFE, étude d'événement), bootstrap par blocs (Callaway--Sant'Anna), valeur $p$ de permutation (contrôle synthétique), HC1 pondérée (discontinuité).} | |
| 444 | +\end{table} | |
| 445 | + | |
| 446 | +\section{Analyse de sensibilité}\label{sec:36:sensibilite} | |
| 447 | + | |
| 448 | +\subsection{Lorsque les tendances parallèles sont violées} | |
| 449 | + | |
| 450 | +L'hypothèse de l'\cref{eq:36:paralleles} est la clé de voûte, et il faut savoir ce qui se passe quand elle cède. Le script réestime tout après avoir ajouté aux immeubles traités, sur toute la période depuis 2016, une croissance supplémentaire d'un point de pourcentage par an : c'est le scénario où la ligne a été tracée là où les loyers montaient déjà plus vite, une sélection sur la tendance plutôt que sur le niveau. La \cref{fig:36:sensibilite} et le \cref{tab:36:sensibilite} montrent le dégât. L'estimateur $2 \times 2$ passe de $0{,}0534$ à $0{,}0734$ : les deux fenêtres étant distantes de deux ans en moyenne, la tendance différentielle y ajoute $2 \times 0{,}01 = 0{,}02$, exactement. Le TWFE à effets RMR $\times$ trimestre passe de $0{,}0702$ à $0{,}1227$, parce que la tendance s'accumule sur les dix ans que la comparaison « tous les trimestres après contre tous les trimestres avant » embrasse. L'étude d'événement, elle, \emph{montre} le problème : ses coefficients pré-traitement descendent régulièrement vers le passé, de $-0{,}061$ à $k \leq -8$ jusqu'à $-0{,}007$ à $k = -2$, avec une pente de $0{,}0036$ par trimestre contre $0{,}0011$ dans le scénario de référence, et les coefficients post-traitement montent trop vite, $0{,}076$ à $k = 7$ contre $0{,}056$. C'est tout l'intérêt du graphique : il ne corrige rien, mais il rend la violation visible, alors que le coefficient statique l'absorbe en silence. | |
| 451 | + | |
| 452 | +\begin{figure}[htbp] | |
| 453 | +\centering | |
| 454 | +\includegraphics[width=\textwidth]{ch36/ch36-fig-sensibilite.pdf} | |
| 455 | +\caption{Étude d'événement dans le scénario de référence et dans le scénario où les immeubles traités croissent d'un point de pourcentage de plus par an depuis 2016 (tendances parallèles violées). Les coefficients pré-traitement, plats dans le premier cas, dessinent une pente dans le second ; les coefficients post-traitement sont surestimés d'un montant qui croît avec $k$.}\label{fig:36:sensibilite} | |
| 456 | +\figsource{\synth{script \texttt{analysis/python/figures/ch36\_figures.py}, graine 20261031}} | |
| 457 | +\end{figure} | |
| 458 | + | |
| 459 | +Deux corrections sont possibles lorsque la pente pré-traitement est jugée réelle. La première ajoute au modèle une tendance linéaire propre au groupe traité ; sur le scénario violé, le TWFE avec cette tendance donne $0{,}0609$, qui retire l'essentiel du biais mais reste au-dessus de la vérité, parce qu'une tendance linéaire estimée sur toute la période absorbe aussi une partie de l'effet, lui-même croissant après le traitement. La seconde, plus transparente, extrapole la tendance pré-traitement de chaque immeuble et détendance les différences longues avant le $2 \times 2$ : on obtient $0{,}0426$ pour une vérité de $0{,}0462$. Ni l'une ni l'autre n'est une garantie, parce qu'elles remplacent l'hypothèse de tendances parallèles par celle, tout aussi invérifiable, d'une divergence \emph{linéaire} qui se serait poursuivie. \textcite{roth2023} recensent les approches qui bornent l'effet sous des hypothèses explicites sur la violation possible, et c'est dans cet esprit qu'un rapport doit présenter la fourchette plutôt qu'un chiffre. | |
| 460 | + | |
| 461 | +\begin{table}[htbp] | |
| 462 | +\caption{Sensibilité aux hypothèses d'identification : tendances parallèles violées ($+1$ point de croissance par an pour les traités) et choix du groupe témoin (données synthétiques)}\label{tab:36:sensibilite} | |
| 463 | +\centering | |
| 464 | +\small | |
| 465 | +\begin{tabular}{lS[table-format=1.4]S[table-format=1.4]S[table-format=1.4]} | |
| 466 | +\toprule | |
| 467 | +Scénario et estimateur & {Référence} & {Violé} & {Vrai} \\ | |
| 468 | +\midrule | |
| 469 | +$2 \times 2$, témoins Montréal & 0.0534 & 0.0734 & 0.0462 \\ | |
| 470 | +TWFE statique, RMR $\times$ trimestre & 0.0702 & 0.1227 & 0.0454 \\ | |
| 471 | +Pente des coefficients pré-traitement ($k = -7$ à $-2$, par trimestre) & 0.0011 & 0.0036 & 0 \\ | |
| 472 | +Étude d'événement, $k = 7$ & 0.0561 & 0.0761 & 0.0500 \\ | |
| 473 | +TWFE avec tendance linéaire du groupe traité & & 0.0609 & 0.0454 \\ | |
| 474 | +$2 \times 2$ détendancé (tendance pré-traitement par immeuble) & & 0.0426 & 0.0462 \\ | |
| 475 | +\midrule | |
| 476 | +\multicolumn{4}{l}{\emph{Choix des témoins, $2 \times 2$ de la cohorte A (scénario de référence, vrai $0{,}0462$) :}} \\ | |
| 477 | +\quad 35 jamais traités, six RMR & 0.0468 & & \\ | |
| 478 | +\quad 9 jamais traités, Montréal & 0.0534 & & \\ | |
| 479 | +\quad 26 jamais traités, hors Montréal & 0.0445 & & \\ | |
| 480 | +\quad 5 pas encore traités (cohorte B) & 0.0308 & & \\ | |
| 481 | +\bottomrule | |
| 482 | +\end{tabular} | |
| 483 | +\tabsource{\synth{\texttt{analysis/python/cases/ch36\_panel.py}, fonctions \texttt{sensibilite} et \texttt{groupes\_temoins}}.} | |
| 484 | +\end{table} | |
| 485 | + | |
| 486 | +\subsection{Le choix du groupe témoin} | |
| 487 | + | |
| 488 | +La seconde moitié du \cref{tab:36:sensibilite} fait varier le groupe témoin de l'estimateur $2 \times 2$. Les neuf immeubles de Montréal donnent $0{,}0534$ ; les 26 immeubles des cinq autres RMR, $0{,}0445$ ; les 35 réunis, $0{,}0468$ ; les cinq immeubles de la cohorte B, pas encore traités dans la fenêtre, $0{,}0308$. L'étendue, de $0{,}031$ à $0{,}053$, est du même ordre que les erreurs-types, ce qui ne permet pas de dire qu'un groupe est « meilleur » au vu des seuls résultats ; c'est l'argument d'identification qui doit trancher, avant de regarder les chiffres. Les témoins de Gatineau sont peu nombreux et portés par une tendance régionale plus rapide ; les témoins hors Montréal mêlent six marchés ; les témoins de Montréal partagent le marché des traités mais ne sont que neuf. La bonne pratique est de retenir le groupe le plus défendable par le mécanisme (ici, la même RMR), de rapporter les autres en sensibilité, et de vérifier que les tendances pré-traitement de chaque groupe candidat sont compatibles avec celles des traités, ce que l'étude d'événement fait groupe par groupe. Lorsque le groupe témoin pourrait être lui-même affecté par le traitement, par exemple des immeubles à deux kilomètres de la station qui perdent des locataires au profit des immeubles desservis, l'estimateur mesure la différence des deux effets et non l'effet sur les traités ; ce \motcle{débordement} (\emph{spillover}) doit être écarté par le choix d'un groupe suffisamment éloigné, ou modélisé, comme le font \textcite{currie2015} avec des anneaux de distance successifs. | |
| 489 | + | |
| 490 | +\subsection{Le traitement de l'autocorrélation} | |
| 491 | + | |
| 492 | +Le \cref{tab:36:twfe} a montré que les erreurs-types en grappes sont quatre à cinq fois plus grandes que les erreurs-types classiques sur ce panel. Le facteur dépend de la persistance des séries : des revenus nets qui suivent des marches aléatoires, comme ici et comme dans la plupart des immeubles réels où les baux se renouvellent lentement, ont une corrélation intra-immeuble élevée. Les deux remèdes, erreurs-types en grappes et agrégation en différences longues, donnent des résultats cohérents ($0{,}0264$ pour l'un, $0{,}0201$ pour l'autre sur des cibles voisines), et tous deux condamnent l'usage des erreurs-types classiques ou HC1 sur des données de panel. Lorsque le traitement est assigné par groupe (îlot, municipalité) et que ces groupes sont peu nombreux, aucune des deux méthodes n'est fiable ; il faut alors recourir à des procédures de rééchantillonnage adaptées aux petits nombres de grappes, ou reconnaître que l'inférence est fragile. | |
| 493 | + | |
| 494 | +\section{Applications : infrastructure, inondation, rezonage, réglementation}\label{sec:36:applications} | |
| 495 | + | |
| 496 | +Les méthodes de ce chapitre ont été appliquées à chacune des questions qui ouvrent le \cref{sec:36:pourquoi}, et la littérature fournit des repères sur la manière de s'y prendre, sinon sur les chiffres, qui dépendent du marché. Pour les \emph{infrastructures de transport}, le plan canonique est celui de \textcite{dubeLegrosTheriaultDesrosiers2014} sur le train de banlieue de la Rive-Sud de Montréal : les ventes de maisons à distance de marche des gares sont comparées, avant et après le changement de desserte, aux ventes plus éloignées, dans un estimateur de doubles différences qui tient compte en outre de la dépendance spatio-temporelle entre les ventes voisines (\cref{ch:19}), et que \textcite{dubeLegrosTheriaultDesrosiers2017} ont ensuite généralisé ; \textcite{dube2024} proposent un cadre causal intégré pour mesurer la plus-value foncière d'un changement de transport collectif, qui combine la définition de la zone traitée, le choix des témoins et la dynamique de l'effet. Deux leçons de ces travaux sont transposables à tout mandat : l'effet s'installe avant la mise en service, dès l'annonce, de sorte que la date de « traitement » doit être réfléchie et que les trimestres d'annonce doivent être exclus de la fenêtre pré-traitement ; et l'effet décroît avec la distance, de sorte que la définition du groupe traité par un seuil de distance est une hypothèse à tester en la faisant varier. | |
| 497 | + | |
| 498 | +Pour les \emph{risques environnementaux}, le \cref{ch:43} traite en détail de la décote des zones inondables et des sites contaminés ; les outils causaux y servent à distinguer la décote attachée au risque lui-même de celle qui tient aux attributs des terrains riverains. Le plan de \textcite{currie2015} sur les usines toxiques est le modèle : les ouvertures et les fermetures fournissent des événements datés, les anneaux de distance des groupes traités et témoins, et l'étude d'événement la dynamique. Un sinistre réel, une inondation printanière, est un événement du même type, mais il frappe des immeubles sélectionnés par leur situation ; le groupe témoin naturel est formé des immeubles de la même zone cartographiée qui n'ont pas été touchés, ou de ceux d'une zone comparable épargnée cette année-là, et la publication d'une nouvelle cartographie réglementaire est en soi un traitement informationnel dont l'effet se mesure par doubles différences autour de la date de publication. Pour le \emph{rezonage}, le \cref{sec:8:exemple} a appliqué les ventes appariées et les doubles différences à un îlot fictif ; le présent chapitre ajoute que la frontière de l'îlot est une discontinuité exploitable, que la date d'entrée en vigueur est précédée d'une période de consultation où l'anticipation agit, et que l'effet dépend de l'option de redéveloppement et donc de l'âge du bâti, ce qui en fait un effet hétérogène dont la moyenne doit être interprétée avec soin. Pour la \emph{réglementation}, enfin, l'effet d'une règle qui s'applique à tous les immeubles d'un territoire à la même date ne peut être mesuré que par comparaison avec un territoire voisin non soumis, ce qui ramène à la discontinuité spatiale ou au contrôle synthétique à l'échelle de municipalités ; le \cref{ch:34} discute des scénarios d'encadrement locatif, dont l'évaluation causale est, au Québec, un chantier plutôt qu'un acquis. | |
| 499 | + | |
| 500 | +\begin{quebec}[Données de panel disponibles au Québec] | |
| 501 | +Les panels d'immeubles sont rares dans les données publiques québécoises, mais ils se construisent. Les rôles d'évaluation foncière diffusés par le ministère des Affaires municipales et de l'Habitation sur Données Québec \parencite{dqroles}, sous licence Creative Commons Attribution 4.0, couvrent les 1 140 municipalités avec un identifiant d'unité d'évaluation stable d'un rôle triennal à l'autre, ce qui permet de suivre la valeur au rôle, les caractéristiques physiques et l'usage de chaque unité à travers les rôles successifs ; les noms et adresses postales des propriétaires y sont caviardés. Une difficulté propre : la valeur au rôle est une estimation à la date de référence du rôle (art. 46 de la Loi sur la fiscalité municipale, \cref{ch:09}), non un prix, et un panel de valeurs au rôle mesure la réaction de l'évaluateur municipal au changement autant que celle du marché. Les ventes répétées du registre foncier et des bases de courtage fournissent des panels de prix non cylindrés, ceux du \cref{ch:35}. Pour les revenus, l'Enquête sur les logements locatifs de la SCHL suit les loyers et l'inoccupation par zone et par taille de logement d'une année à l'autre, ce qui constitue un panel de zones plutôt que d'immeubles. Enfin, la cartographie des zones inondables du cadre réglementaire en vigueur depuis le 1\textsuperscript{er} mars 2026 \parencite{zonesInond2026} est publiée progressivement par secteur, ce qui crée, pour qui voudra l'étudier, un plan à adoption échelonnée dont les cohortes sont les secteurs cartographiés et dont le traitement est l'information nouvelle sur le risque ; les méthodes de la \cref{sec:36:echelonne} y sont directement applicables, et les mises en garde aussi. Sources consultées le 31 octobre 2026. | |
| 502 | +\end{quebec} | |
| 503 | + | |
| 504 | +\begin{erreurs} | |
| 505 | +\begin{itemize} | |
| 506 | +\item Lire une différence entre immeubles exposés et non exposés, à une date donnée, comme l'effet de l'exposition : c'est oublier le biais de sélection de l'\cref{eq:36:selection}. Les infrastructures, les zones et les règlements ne sont jamais placés au hasard. | |
| 507 | +\item Lire une variation avant--après du même immeuble comme l'effet du changement : c'est attribuer au traitement la tendance du marché. Il faut un groupe témoin, et le défendre. | |
| 508 | +\item Rapporter des erreurs-types classiques ou HC1 sur un panel : la corrélation intra-immeuble les rend quatre à cinq fois trop petites dans l'exemple. Les grappes sont au niveau où le traitement varie. | |
| 509 | +\item Estimer un effet statique par TWFE sur un plan échelonné et le présenter comme un effet moyen : les poids ne sont pas ceux de l'échantillon et les comparaisons interdites peuvent inverser le signe. Décomposer, ou estimer par cohortes. | |
| 510 | +\item Prendre un test de pré-tendance non significatif pour une preuve de tendances parallèles : il a peu de puissance, et le scénario de la \cref{sec:36:sensibilite} montre une violation qui ne se voit qu'en regardant la pente. | |
| 511 | +\item Choisir le groupe témoin après avoir vu les résultats, ou mélanger des marchés dont les tendances diffèrent : l'identification se décide sur le mécanisme, les variantes se rapportent en sensibilité. | |
| 512 | +\item Dater le traitement à la mise en service alors que le marché a réagi à l'annonce, ou inclure les trimestres d'anticipation dans la fenêtre « avant ». | |
| 513 | +\item Convertir un coefficient en logarithme en pourcentage par simple multiplication par cent, ou appliquer un effet moyen à un sujet dont l'exposition est atypique (distance, âge, type), alors que l'effet est hétérogène. | |
| 514 | +\end{itemize} | |
| 515 | +\end{erreurs} | |
| 516 | + | |
| 517 | +\begin{limites} | |
| 518 | +Les méthodes de ce chapitre estiment des effets \emph{moyens} sur des groupes ; l'évaluateur a devant lui un immeuble. Passer de l'un à l'autre exige de montrer que le sujet appartient bien à la population traitée au sens du plan (même distance, même type, même date d'exposition) et de reconnaître que la dispersion individuelle des effets, que la régression n'estime pas, s'ajoute à l'incertitude. L'exemple intégral est favorable à bien des égards : le panel est cylindré, les cohortes sont nettes, le traitement est binaire et permanent, le groupe jamais traité est large, les tendances régionales sont les seules à différer et l'effet est connu. Sur des données réelles, les ventes ne forment pas un panel cylindré, le traitement est souvent continu (une distance, une intensité de risque), les effets s'installent dès l'annonce, les témoins peuvent être affectés par débordement, et l'on ne connaît jamais la vérité ; la robustesse à ces écarts doit être examinée cas par cas. Les intervalles rapportés supposent un nombre suffisant de grappes et des bootstraps par blocs ; avec quelques dizaines d'immeubles traités, ils restent larges, et l'honnêteté commande de les afficher. Les mises en œuvre de \texttt{traite.panel} sont volontairement simplifiées (pas de covariables dans l'estimateur par cohortes, base universelle, contrôle synthétique sans prédicteurs) ; pour un dossier contentieux, on validera les résultats avec des bibliothèques de référence, comme le script le fait pour le TWFE. Enfin, l'effet d'un traitement sur le revenu net n'est pas son effet sur la valeur : il faut encore passer par le taux de capitalisation (\cref{ch:29}), qui peut lui aussi réagir au changement, et la question causale se pose alors deux fois. | |
| 519 | +\end{limites} | |
| 520 | + | |
| 521 | +\begin{synthese} | |
| 522 | +\begin{itemize} | |
| 523 | +\item Une question d'évaluation sur un changement est causale : elle oppose un résultat observé à un contrefactuel. L'ATT est la cible ; la différence brute entre exposés et non exposés s'en écarte du biais de sélection. | |
| 524 | +\item Les effets fixes d'immeuble éliminent toute différence permanente ; l'estimateur within équivaut aux indicatrices et exige l'exogénéité stricte. Sur un panel, les erreurs-types sont en grappes au niveau du traitement. | |
| 525 | +\item Les doubles différences identifient l'ATT sous tendances parallèles ; placebos et étude d'événement en éprouvent la plausibilité et révèlent la dynamique. La référence $k = -1$ et le choix des témoins font partie de l'hypothèse. | |
| 526 | +\item En adoption échelonnée, le TWFE statique est une moyenne de comparaisons $2 \times 2$ à poids opaques, dont certaines utilisent des unités déjà traitées ; l'estimateur par cohortes de Callaway et Sant'Anna agrège explicitement des comparaisons propres. | |
| 527 | +\item La discontinuité spatiale exploite une frontière ; le contrôle synthétique construit un témoin à poids positifs et s'infère par permutation. Chacun a son hypothèse et ses diagnostics. | |
| 528 | +\item Sur le choc simulé du cas 4, les estimateurs à tendances parallèles régionales retrouvent l'effet déclaré à moins d'un écart-type ; les erreurs-types honnêtes sont de $\pm 3$ à $\pm 5$ points, et le rapport doit le dire. | |
| 529 | +\end{itemize} | |
| 530 | +\end{synthese} | |
| 531 | + | |
| 532 | +\section*{Exercices} | |
| 533 | +\addcontentsline{toc}{section}{Exercices} | |
| 534 | + | |
| 535 | +\begin{exercice}[1]{Doubles différences à la main}{ex:36:1} | |
| 536 | +Dans un quartier desservi par une nouvelle gare, le logarithme moyen du loyer des immeubles situés à moins de 800 m est passé de $11{,}52$ avant la mise en service à $11{,}64$ après ; celui des immeubles situés entre 1,5 et 3 km, de $11{,}40$ à $11{,}47$. (a) Calculez l'estimateur $2 \times 2$ de l'\cref{eq:8:did} et convertissez-le en pourcentage. (b) Énoncez l'hypothèse qui en fait un effet causal et proposez un test placebo. (c) Pourquoi les immeubles situés entre 800 m et 1,5 km ont-ils été exclus ? | |
| 537 | +\end{exercice} | |
| 538 | + | |
| 539 | +\begin{exercice}[1]{Transformation within sur un petit panel}{ex:36:2} | |
| 540 | +Trois immeubles sont observés deux années. Le logarithme du revenu net et l'indicatrice d'une rénovation majeure sont : immeuble 1, $(11{,}0 ; 0)$ puis $(11{,}3 ; 1)$ ; immeuble 2, $(11{,}6 ; 0)$ puis $(11{,}7 ; 1)$ ; immeuble 3, $(10{,}8 ; 0)$ puis $(10{,}9 ; 0)$. (a) Calculez les données transformées within de l'\cref{eq:36:within} et l'estimateur de l'\cref{eq:36:within-est} (sans effets de période). (b) Comparez-le à la pente de la régression des moindres carrés ordinaires de $y$ sur $x$ sur les six observations, et expliquez l'écart par la corrélation entre l'effet fixe et la rénovation. (c) Que mesure encore l'estimateur within si les deux immeubles rénovés l'ont été parce que leurs revenus stagnaient ? | |
| 541 | +\end{exercice} | |
| 542 | + | |
| 543 | +\begin{exercice}[2]{Grappes et facteur de Moulton}{ex:36:3} | |
| 544 | +(a) À l'aide de l'\cref{eq:36:moulton}, calculez le facteur par lequel multiplier une erreur-type classique pour un panel de 42 trimestres par immeuble et une corrélation intra-immeuble de $0{,}5$, puis de $0{,}9$ ; refaites le calcul pour 8 trimestres et $0{,}5$. (b) Dans le \cref{tab:36:twfe}, le rapport observé est de $4{,}9$ pour la spécification RMR $\times$ trimestre : quelle corrélation intra-immeuble implicite cela suggère-t-il ? (c) Un traitement est attribué à l'échelle de quatre municipalités, chacune comptant 200 immeubles observés 10 ans. À quel niveau faut-il définir les grappes, et pourquoi la formule de l'\cref{eq:36:grappes} est-elle alors peu fiable ? | |
| 545 | +\end{exercice} | |
| 546 | + | |
| 547 | +\begin{exercice}[2]{Lire une étude d'événement}{ex:36:4} | |
| 548 | +Une étude d'événement sur l'effet d'une nouvelle cartographie de zone inondable sur les prix donne, par rapport à l'année précédant la publication, des coefficients de $-0{,}01$, $-0{,}02$ et $-0{,}03$ pour $k = -4, -3, -2$, puis $-0{,}06$, $-0{,}07$, $-0{,}07$ et $-0{,}08$ pour $k = 0, \dots, 3$, avec des erreurs-types en grappes de $0{,}02$. (a) Les coefficients pré-traitement sont-ils individuellement significatifs ? Dessinent-ils une pente, et dans quel sens ? (b) Qu'implique cette pente pour l'interprétation des coefficients post-traitement, et quelle correction la \cref{sec:36:sensibilite} suggère-t-elle ? (c) Si la cartographie avait été annoncée deux ans avant sa publication, comment faudrait-il redéfinir la date de l'événement ? | |
| 549 | +\end{exercice} | |
| 550 | + | |
| 551 | +\begin{exercice}[2]{Poids de Goodman-Bacon}{ex:36:5} | |
| 552 | +Un panel de $T = 20$ périodes compte deux cohortes de taille égale, traitées aux périodes $5$ et $15$, et aucune unité jamais traitée. (a) Calculez $\bar D_k$ et $\bar D_\ell$, puis la variance $\hat V^D$ de l'indicatrice de traitement doublement centrée (on pourra construire le tableau $2 \times 20$ des $D_{it}$). (b) Calculez les deux poids $s_{k\ell}$ et $s_{\ell k}$ de l'\cref{eq:36:bacon-poids} et vérifiez qu'ils somment à un. (c) L'effet du traitement croît linéairement, $\delta(k) = 0{,}01\,(k+1)$ ; calculez l'espérance de $\hat\beta_{\ell k}$ par l'\cref{eq:36:interdite}, celle de $\hat\beta_{k\ell}$ sous tendances parallèles, puis celle du TWFE, et comparez-la à l'ATT global. | |
| 553 | +\end{exercice} | |
| 554 | + | |
| 555 | +\begin{exercice}[3]{Callaway et Sant'Anna à la main}{ex:36:6} | |
| 556 | +Quatre périodes, une cohorte traitée à la période 3 formée de deux immeubles dont les logarithmes de revenu sont $(10{,}00 ; 10{,}05 ; 10{,}12 ; 10{,}20)$ et $(9{,}50 ; 9{,}56 ; 9{,}66 ; 9{,}72)$, et trois témoins jamais traités : $(10{,}20 ; 10{,}24 ; 10{,}27 ; 10{,}31)$, $(9{,}80 ; 9{,}85 ; 9{,}89 ; 9{,}92)$ et $(9{,}90 ; 9{,}93 ; 9{,}98 ; 10{,}01)$. (a) Calculez $\widehat{\mathrm{ATT}}(3, 3)$ et $\widehat{\mathrm{ATT}}(3, 4)$ par l'\cref{eq:36:cs}, puis l'effet moyen de la cohorte. (b) Calculez le placebo $\widehat{\mathrm{ATT}}(3, 1)$ avec la même base et commentez. (c) Montrez que $\widehat{\mathrm{ATT}}(3, 3)$ coïncide avec l'estimateur $2 \times 2$ des périodes 2 et 3, et expliquez en quoi la base universelle $g - 1$ diffère d'une base « variable » $t - 1$ pour les périodes pré-traitement. | |
| 557 | +\end{exercice} | |
| 558 | + | |
| 559 | +\begin{exercice}[3]{Contrôle synthétique et bassin de donneurs}{ex:36:7} | |
| 560 | +À l'aide de \texttt{analysis/python/cases/ch36\_panel.py} et de \texttt{traite.panel.controle\_synthetique}, refaites le contrôle synthétique de la cohorte A en restreignant le bassin aux donneurs de Montréal, puis aux donneurs hors Montréal, puis en déplaçant fictivement la mise en service à 2020T3 (placebo temporel). (a) Rapportez les poids, le RMSPE pré-traitement et l'effet moyen post-traitement de chaque variante et comparez-les au \cref{tab:36:bilan} et à la vérité. (b) Quel bassin donne l'écart le plus faible à la vérité, et pourquoi n'est-ce pas un critère de choix sur données réelles ? (c) Rédigez, en 200 mots, la section « méthode et limites » d'un rapport qui utiliserait ce résultat pour justifier une plus-value de voisinage. | |
| 561 | +\end{exercice} | |
| 562 | + | |
| 563 | +\section*{Pour aller plus loin} | |
| 564 | +\addcontentsline{toc}{section}{Pour aller plus loin} | |
| 565 | + | |
| 566 | +\textcite{angristPischke2009} reste l'introduction la plus vivante aux plans d'identification, avec un chapitre sur les données de panel et un autre sur les erreurs-types en grappes ; \textcite{cunningham2021} en est le complément pédagogique, avec du code et des exemples, qui couvre les doubles différences, la discontinuité et le contrôle synthétique ; \textcite{imbensRubin2015} fondent rigoureusement le cadre des résultats potentiels. Pour l'économétrie des panels, \textcite{wooldridge2010} est la référence : effets fixes et aléatoires, exogénéité stricte, test de Hausman, inférence robuste. La littérature récente sur l'adoption échelonnée se lit dans l'ordre suivant : \textcite{goodmanbacon2021} pour la décomposition, \textcite{callawaySantanna2021} pour l'estimateur par cohortes, \textcite{sunAbraham2021} pour la version en régression des études d'événement, \textcite{dechaisemartinDhaultfoeuille2020} pour les traitements réversibles, et \textcite{roth2023} pour la synthèse et les recommandations pratiques. Le contrôle synthétique est exposé dans \textcite{abadieDiamondHainmueller2010}, dont l'étude de cas californienne reste le modèle de présentation. Les applications immobilières à lire en premier sont \textcite{lindenRockoff2008} et \textcite{currie2015} pour la structure spatio-temporelle de l'exposition, puis, pour le Québec, \textcite{dubeLegrosTheriaultDesrosiers2014}, \textcite{dubeLegrosTheriaultDesrosiers2017} et \textcite{dube2024} sur la plus-value du transport collectif à Montréal, qui marient les doubles différences aux modèles spatiaux du \cref{ch:19} ; \textcite{elhorst2014} traite des panels spatiaux proprement dits. | |
modified
manuscript/chapters/ch38.tex
+544 −1
@@ -1,2 +1,545 @@ | ||
| 1 | 1 | \chapter{SIG et risques géospatiaux}\label{ch:38} |
| 2 | −\stubchapter | |
| 2 | + | |
| 3 | +Tout ce que l'évaluateur sait de la localisation d'un immeuble passe, tôt ou tard, par une carte. La distance à une école, l'appartenance à une zone inondable, la proximité d'une autoroute, la valeur médiane du quartier ne sont pas des attributs que l'on lit sur la fiche descriptive : ce sont des quantités que l'on \emph{calcule} en superposant la position du bien à d'autres couches d'information géographique, et la qualité de ces quantités dépend de la qualité de la superposition. Le présent chapitre expose les outils des systèmes d'information géographique (SIG) dont l'évaluateur a besoin pour produire ces variables de façon reproductible et pour en représenter les résultats. Il décrit d'abord les données géospatiales disponibles au Québec et les systèmes de référence dans lesquels elles sont exprimées, parce qu'un calcul de distance entre deux couches mal projetées est faux avant même de commencer. Il formalise ensuite les opérations élémentaires, projection, jointure spatiale, tampon, distance, agrégation et interpolation, chacune avec sa définition, son algorithme et sa mise en œuvre dans la bibliothèque \texttt{geopandas}. Il montre comment en dériver des indices d'accessibilité et des variables de risque, inondation, glissement de terrain, contamination, bruit, et comment les intégrer au modèle hédonique des \cref{ch:18,ch:19}. Il fixe les règles de la cartographie thématique. L'exemple intégral, conduit sur les 600 ventes du cas 1 avec des polygones entièrement fictifs, illustre une leçon qui vaut plus que toutes les cartes : le jeu de données n'ayant pas été engendré avec ces polygones, l'effet vrai est nul, et ce que les régressions trouvent quand même mesure la propension des variables spatiales à produire des faux positifs. Le portefeuille du cas 4 fournit, en contrepoint, une application à l'échelle du Québec. La mesure des décotes de risque elles-mêmes relève du \cref{ch:43} ; le présent chapitre fournit les variables et les cartes. | |
| 4 | + | |
| 5 | +\begin{objectifs} | |
| 6 | +\begin{itemize} | |
| 7 | +\item Distinguer données vectorielles et matricielles, systèmes de référence géodésiques et projections, et choisir le code EPSG approprié à une analyse au Québec (NAD83, NAD83(SCRS), MTM, UTM, Québec Lambert, WGS84). | |
| 8 | +\item Connaître les jeux de données géospatiales ouverts utiles à l'évaluation résidentielle au Québec, leur licence, leur format et leurs limites. | |
| 9 | +\item Définir et exécuter les opérations SIG élémentaires (projection, jointure spatiale, tampon, distances euclidienne et réseau, agrégation par zone, interpolation) avec leur formule ou leur algorithme et un exemple \texttt{geopandas}. | |
| 10 | +\item Construire des indices d'accessibilité par distance et par gravité, et en donner la dérivation économique. | |
| 11 | +\item Transformer les cartographies de risque (zones inondables selon le cadre de 2026, zones de contraintes, registre des terrains contaminés, corridors de transport) en variables de modèle hédonique, et connaître les pièges d'identification qu'elles posent. | |
| 12 | +\item Produire une carte thématique défendable : choix de la représentation, classification, palette séquentielle ou divergente, légende et source. | |
| 13 | +\item Mesurer, par une expérience sur des polygones aléatoires, la fréquence des faux positifs spatiaux et l'effet de la spécification de la localisation sur cette fréquence. | |
| 14 | +\end{itemize} | |
| 15 | +\end{objectifs} | |
| 16 | + | |
| 17 | +\section{Les données géospatiales et leurs systèmes de référence}\label{sec:38:donnees} | |
| 18 | + | |
| 19 | +\subsection{Vecteur et matrice} | |
| 20 | + | |
| 21 | +Une donnée géospatiale associe une position à un attribut. Deux modèles de représentation coexistent. Le modèle \motcle{vectoriel} décrit des objets discrets par leur géométrie, point, ligne ou polygone, définie par des coordonnées de sommets, et leur attache une table d'attributs : une vente est un point, une rue une ligne, un lot cadastral ou une zone inondable un polygone. Le modèle \motcle{matriciel} (\emph{raster}) découpe le territoire en cellules régulières et attribue une valeur à chaque cellule : une image satellitaire, un modèle numérique d'altitude, une carte de bruit ou de profondeur d'eau calculée par simulation hydraulique sont des matrices. L'évaluation résidentielle travaille presque exclusivement en vectoriel, parce que ses objets, les immeubles, sont discrets et que ses sources, rôles d'évaluation, registre foncier, zonage, le sont aussi ; elle emprunte au matriciel les variables d'environnement physique, pente, altitude, exposition au bruit, qu'elle extrait à la position de chaque immeuble et ramène ainsi dans la table vectorielle. Les formats courants sont, pour le vectoriel, le GeoPackage (GPKG, une base SQLite normalisée), le GeoJSON, le \emph{shapefile} (SHP, ancien format à fichiers multiples) et la géodatabase fichier (FGDB) ; pour le matriciel, le GeoTIFF. La bibliothèque \texttt{geopandas}, qui étend \texttt{pandas} d'une colonne de géométries manipulées par \texttt{shapely}, lit tous ces formats vectoriels et porte un système de référence avec chaque table. | |
| 22 | + | |
| 23 | +\subsection{Datums, projections et codes EPSG} | |
| 24 | + | |
| 25 | +Une coordonnée n'a de sens que dans un \motcle{système de référence de coordonnées} (SRC), qui précise d'abord la forme de la Terre adoptée, l'ellipsoïde, et son ancrage, le \motcle{datum}, puis, le cas échéant, la \motcle{projection} qui transforme les coordonnées géographiques, latitude et longitude en degrés, en coordonnées planes en mètres. Les deux niveaux sont indépendants et tous deux comptent. Au Canada, le datum de référence est le NAD83 (\emph{North American Datum of 1983}) et, dans sa réalisation moderne, le NAD83 (SCRS), Système canadien de référence spatiale, dont les versions successives corrigent la réalisation originale ; le WGS84 des récepteurs GPS et des services Web en est voisin, à une différence qui atteint aujourd'hui un à deux mètres dans l'est du Canada du fait de la dérive de la plaque nord-américaine. Pour une analyse d'évaluation, qui travaille à l'échelle de la parcelle et du quartier, cette différence est négligeable devant l'erreur de géocodage du \cref{ch:14} ; elle ne l'est pas pour un arpenteur-géomètre. | |
| 26 | + | |
| 27 | +La projection est l'étape qui permet de calculer des distances et des surfaces en mètres. Le Québec utilise, pour ses produits géomatiques à grande échelle, la projection \motcle{MTM} (\emph{Modified Transverse Mercator}), une transverse de Mercator à fuseaux de trois degrés de longitude, avec un facteur d'échelle de 0,9999 au méridien central et un faux est de \num{304800} mètres ; le territoire habité est couvert par les fuseaux 7 (méridien central $70{,}5^\circ$ O ; Québec, Saguenay, Sherbrooke), 8 ($73{,}5^\circ$ O ; Montréal, Trois-Rivières) et 9 ($76{,}5^\circ$ O ; Gatineau). La projection UTM, à fuseaux de six degrés et facteur 0,9996, est l'alternative universelle, le fuseau 18 couvrant Montréal et Gatineau et le fuseau 19 la région de Québec ; la projection conique conforme de Lambert du Québec sert aux cartes de l'ensemble de la province. Chaque combinaison datum-projection porte un code numérique du registre EPSG, qui est la façon la plus sûre de la désigner dans un script ; le \cref{tab:38:epsg} donne les codes utiles, vérifiés dans la base EPSG distribuée avec la bibliothèque PROJ. | |
| 28 | + | |
| 29 | +\begin{table}[htbp] | |
| 30 | +\caption{Systèmes de référence de coordonnées utiles à l'évaluation au Québec et codes EPSG}\label{tab:38:epsg} | |
| 31 | +\centering | |
| 32 | +\footnotesize\setlength{\tabcolsep}{3.5pt} | |
| 33 | +\begin{tabular}{llp{27mm}p{48mm}} | |
| 34 | +\toprule | |
| 35 | +Système & {EPSG} & Nature & Emploi et remarques \\ | |
| 36 | +\midrule | |
| 37 | +WGS 84 & 4326 & géographique (degrés) & GPS, services Web, GeoJSON ; pas de calcul de distance direct \\ | |
| 38 | +NAD83 & 4269 & géographique & datum nord-américain d'origine ; nombreux fichiers anciens \\ | |
| 39 | +NAD83(SCRS) & 4617 & géographique & réalisation canadienne moderne ; produits gouvernementaux récents \\ | |
| 40 | +NAD83 / MTM zone 7, 8, 9 & 32187, 32188, 32189 & projetée, mètres & fuseaux de 3°, $k_0 = 0{,}9999$, faux est \num{304800} m ; méridiens centraux $70{,}5$, $73{,}5$, $76{,}5^\circ$ O \\ | |
| 41 | +NAD83(SCRS) / MTM zone 7, 8, 9 & 2949, 2950, 2951 & projetée, mètres & mêmes paramètres sur le datum SCRS \\ | |
| 42 | +NAD83 / UTM zone 18N, 19N & 26918, 26919 & projetée, mètres & fuseaux de 6°, $k_0 = 0{,}9996$ ; 18N : $78$--$72^\circ$ O, 19N : $72$--$66^\circ$ O \\ | |
| 43 | +NAD83 / Québec Lambert & 32198 & projetée, mètres & conique conforme, parallèles $46^\circ$ et $60^\circ$, méridien $68{,}5^\circ$ O ; cartes provinciales \\ | |
| 44 | +WGS 84 / Pseudo-Mercator & 3857 & projetée, mètres & fonds de tuiles Web ; échelle fausse de 40 \% à $45^\circ$ de latitude : jamais pour mesurer \\ | |
| 45 | +\bottomrule | |
| 46 | +\end{tabular} | |
| 47 | +\tabsource{registre EPSG tel que distribué dans PROJ 9.5.1 (\texttt{pyproj} 3.7.2), interrogé le 31 octobre 2026 ; la fiche en ligne \url{https://epsg.org} refuse l'accès automatisé.} | |
| 48 | +\end{table} | |
| 49 | + | |
| 50 | +Deux approximations sont utiles pour raisonner sur les projections sans les calculer. La première est la \motcle{projection équirectangulaire locale} des \cref{ch:19} : autour d'un point de référence $(\varphi_0, \lambda_0)$, on pose | |
| 51 | +\begin{equation}\label{eq:38:equirect} | |
| 52 | +x = R\cos\varphi_0\,(\lambda - \lambda_0), \qquad y = R\,(\varphi - \varphi_0), | |
| 53 | +\end{equation} | |
| 54 | +avec $R = \qty{6371.0}{\kilo\metre}$ et les angles en radians. Elle est exacte au point de référence et son erreur relative sur une distance croît avec l'écart de latitude, de l'ordre de $\tfrac12(\varphi - \varphi_0)\tan\varphi_0$ au premier ordre ; sur les 42 kilomètres d'étendue du cas 1, l'écart maximal à la distance géodésique atteint 83 mètres, soit 0,5 \%, ce qui est tolérable pour une matrice de pondération et intolérable pour un calcul de superficie. La seconde approximation est le \motcle{facteur d'échelle} d'une projection transverse de Mercator, qui mesure de combien une distance au sol est étirée sur le plan : | |
| 55 | +\begin{equation}\label{eq:38:echelle} | |
| 56 | +k(\varphi, \lambda) \approx k_0\left[1 + \tfrac12\,(\lambda - \lambda_0)^2\cos^2\varphi\right], | |
| 57 | +\end{equation} | |
| 58 | +où $k_0$ est le facteur au méridien central. Le facteur vaut $k_0 < 1$ sur le méridien central, croît vers les bords du fuseau et dépasse l'unité à une distance d'environ 90 kilomètres du méridien ; les fuseaux de trois degrés du MTM bornent la distorsion à quelques dix-millièmes, ce qui explique leur usage cadastral. Pour le sujet du cas 1, situé à $0{,}76^\circ$ à l'est du méridien central du fuseau 9, l'\cref{eq:38:echelle} donne $k = 0{,}999943$ : une distance mesurée sur la carte MTM sous-estime la distance au sol de 0,006 \%, et la projection restitue les distances géodésiques à 1,2 mètre près sur l'ensemble du jeu. Le \cref{ex:38:1} fait refaire ce calcul pour d'autres positions. | |
| 59 | + | |
| 60 | +\begin{pratique}[Règles de travail sur les systèmes de référence] | |
| 61 | +Toute table géospatiale porte son SRC et on le vérifie à la lecture (\texttt{gdf.crs}) ; une table sans SRC déclaré est une table dont on ne sait rien. Avant toute mesure, on projette toutes les couches dans un même SRC métrique adapté au territoire, le fuseau MTM du lieu pour une agglomération, Québec Lambert pour la province ; on ne calcule jamais une distance ou une surface en degrés, ni en Pseudo-Mercator. On documente au dossier le code EPSG retenu, comme on documente l'unité des superficies. Le passage d'un SRC à l'autre se fait par \texttt{to\_crs}, jamais par une formule maison, sauf la projection locale de l'\cref{eq:38:equirect} dont on connaît les limites. | |
| 62 | +\end{pratique} | |
| 63 | + | |
| 64 | +\subsection{Les jeux de données québécois} | |
| 65 | + | |
| 66 | +Le \cref{ch:13} a dressé l'inventaire des sources de données de l'évaluation ; le \cref{tab:38:jeux} en retient celles qui ont une géométrie et ajoute les couches de risque et d'urbanisme. Trois faits structurent cet inventaire. D'abord, le rôle d'évaluation foncière en format ouvert, diffusé par le ministère des Affaires municipales et de l'Habitation sous licence Creative Commons Attribution 4.0, existe depuis 2024 en version géoréférencée, géodatabase fichier et GeoPackage pour 2025 et 2026, GeoJSON pour les années antérieures : chaque unité d'évaluation des \num{1140} municipalités y porte une géométrie, les cadastres, noms et adresses des propriétaires étant caviardés et leur réidentification interdite \parencite{dqroles,dqrepertoire}. C'est la couche de base de toute analyse d'évaluation de masse au Québec, celle qui fournit les centroïdes de parcelle du \cref{ch:14} et les attributs du bâti. Ensuite, les grandes villes publient leur zonage et, pour certaines, leurs zones inondables en données ouvertes : la Ville de Québec diffuse ses zones de zonage et ses permis \parencite{vqzonage,vqpermis}, Gatineau son zonage et une couche de zones inondables d'ancienne génération \parencite{gatinondables}, Montréal ses unités d'évaluation avec un découpage sans valeur légale \parencite{mtlunites}. Enfin, les couches de risque les plus importantes, les cartographies de zones inondables de nouvelle génération du cadre réglementaire en vigueur depuis le 1\textsuperscript{er} mars 2026, sont diffusées par le gouvernement du Québec dans une carte interactive et « publiées progressivement » \parencite{inondcartes} ; l'évaluateur doit donc vérifier, pour chaque territoire, quelle génération de cartographie est en vigueur, et les couches municipales antérieures ne remplacent pas la carte officielle. | |
| 67 | + | |
| 68 | +\begin{table}[htbp] | |
| 69 | +\caption{Jeux de données géospatiales ouverts utiles à l'évaluation résidentielle au Québec (état au 31 octobre 2026)}\label{tab:38:jeux} | |
| 70 | +\centering | |
| 71 | +\footnotesize\setlength{\tabcolsep}{3pt} | |
| 72 | +\begin{tabularx}{\textwidth}{p{30mm}p{20mm}p{17mm}X} | |
| 73 | +\toprule | |
| 74 | +Jeu de données & Organisme & Format, licence & Contenu et usage ; limites \\ | |
| 75 | +\midrule | |
| 76 | +Rôle d'évaluation foncière du Québec, géoréférencé 2025--2026 & MAMH, Données Québec & FGDB, GPKG ; GeoJSON (2013--2024) ; CC BY 4.0 & Géométrie des unités d'évaluation des \num{1140} municipalités, attributs du rôle (utilisation, logements, superficies, valeurs) ; propriétaires et cadastre caviardés ; mise à jour annuelle et index trimestriel \\ | |
| 77 | +Adresses Québec ; Référentiel québécois des adresses & MRNF, Données Québec & géobase, points, services Web ; CC BY 4.0 & Géocodage par point d'adresse et par tranche ; réseau routier avec odonymes ; transition vers le Référentiel jusqu'à la fin de 2026 (\cref{ch:14}) \\ | |
| 78 | +Cartographies des zones inondables et de mobilité en vigueur & Gouvernement du Québec (MELCCFP, MRNF) & carte interactive ; couches publiées progressivement & Classes d'intensité très élevée, élevée, modérée, faible ; zones de mobilité ; base du cadre du 1\textsuperscript{er} mars 2026 ; la couverture est incomplète et la correspondance avec l'ancienne génération n'est pas énoncée \\ | |
| 79 | +Zones inondables normé v1 ; zones de grand et faible courant & Ville de Gatineau, Données Québec & GeoJSON, SHP ; CC BY 4.0 & Cartographie d'ancienne génération (annexe du règlement 502-2005) ; utile pour l'historique, à confronter à la carte officielle \\ | |
| 80 | +Zones inondables réglementées & Ville de Québec, Données Québec & CSV, GeoJSON, KML, SHP ; CC BY 4.0 & Zones annexées au schéma d'aménagement ; métadonnées de 2023, antérieures au cadre de 2026 \\ | |
| 81 | +Zonage municipal (zones, grilles de spécifications) & Villes de Québec et de Gatineau, Données Québec & CSV, GeoJSON, SHP ; CC BY 4.0 & Polygones de zones et usages autorisés (\cref{ch:08}) ; jointure spatiale avec les ventes pour l'usage permis \\ | |
| 82 | +Unités d'évaluation foncière ; permis & Ville de Montréal & CSV, GeoJSON, SHP ; licence ouverte & Attributs du bâti et datation des travaux ; découpage sans valeur légale \\ | |
| 83 | +Zone agricole, cartographie & CPTAQ & cartographie en ligne (Déméter) & Appartenance d'un lot à la zone agricole protégée (\cref{ch:08}) ; pas de téléchargement libre vérifié \\ | |
| 84 | +Répertoire des terrains contaminés & MELCCFP & recherche en ligne par adresse ou municipalité & Terrains inscrits lorsque la contamination excède généralement le critère B ; non exhaustif ; pas de couche géométrique : géocodage requis \\ | |
| 85 | +\bottomrule | |
| 86 | +\end{tabularx} | |
| 87 | +\tabsource{fiches Données Québec lues par l'API CKAN le 31 octobre 2026 \parencite{dqroles,mrnfAdressesQuebec2026,mrnfRQA2026,gatinondables,vqzonage,vqpermis,mtlunites} ; pages gouvernementales \parencite{inondcartes,cptaq,repertoiretc} ; registre du chapitre (\texttt{verification/chapters/ch38-registry.csv}). Aucun fichier volumineux n'a été téléchargé pour cet ouvrage.} | |
| 88 | +\end{table} | |
| 89 | + | |
| 90 | +\begin{quebec}[Licences, caviardage et confidentialité] | |
| 91 | +La licence Creative Commons Attribution 4.0 adoptée par Données Québec autorise la reproduction, la transformation et la rediffusion des jeux de données à condition d'en citer la source ; c'est sous cette licence que le rôle géoréférencé, Adresses Québec et les couches municipales du \cref{tab:38:jeux} sont offerts (fiches consultées le 31 octobre 2026). La licence ne lève pas les obligations relatives aux renseignements personnels : la fiche du rôle en format ouvert précise que les noms et adresses postales des propriétaires sont caviardés et qu'il est interdit de les réidentifier \parencite{dqroles}. L'évaluateur qui enrichit ses ventes d'attributs tirés du rôle travaille donc sur des unités d'évaluation anonymes appariées par position ou par matricule (\cref{ch:14}), non sur des propriétaires. Les données de transaction qu'il y joint, elles, proviennent du registre foncier et des plateformes de courtage, dont les conditions d'utilisation sont décrites au \cref{ch:13} ; une carte publiée dans un rapport ne doit pas permettre de reconstituer le prix payé par une personne identifiable, ce qui conduit à agréger ou à décaler les points lorsque les ventes sont rares. | |
| 92 | +\end{quebec} | |
| 93 | + | |
| 94 | +\section{Les opérations élémentaires}\label{sec:38:operations} | |
| 95 | + | |
| 96 | +Les variables spatiales d'un modèle hédonique résultent d'un petit nombre d'opérations que la \cref{fig:38:operations} ordonne et que le \cref{tab:38:operations} résume. Chacune a une définition géométrique précise, un algorithme et une mise en œuvre dans \texttt{geopandas} ; chacune a aussi un piège caractéristique. | |
| 97 | + | |
| 98 | +\begin{figure}[htbp] | |
| 99 | +\centering | |
| 100 | +\resizebox{\textwidth}{!}{% | |
| 101 | +\begin{tikzpicture}[node distance=4mm and 5mm, every node/.style={font=\sffamily\footnotesize}, boite/.style={draw=brandPrimary, fill=brandLight, rounded corners=1pt, align=center, minimum height=9mm, text width=27mm}, op/.style={boite, fill=white, text width=24mm, minimum height=11mm}, sortie/.style={boite, fill=brandSecondary!30, text width=27mm}, fleche/.style={-{Stealth[length=2mm]}, brandGray, thick}] | |
| 102 | +\node[boite] (src) {\textbf{Couches sources}\\rôle GPKG, adresses, zonage, zones inondables, réseau, corridors}; | |
| 103 | +\node[boite, right=of src] (crs) {\textbf{Harmonisation}\\\texttt{to\_crs(32189)}\\un seul SRC métrique}; | |
| 104 | +\node[boite, right=of crs] (geo) {\textbf{Géocodage des ventes}\\point d'adresse, parcelle (\cref{ch:14})}; | |
| 105 | +\node[op, below left=of geo, xshift=-6mm] (j) {Jointure spatiale\\point $\in$ polygone\\\texttt{sjoin(within)}}; | |
| 106 | +\node[op, right=of j] (t) {Tampon\\$B_r(A)$\\\texttt{buffer(r)}}; | |
| 107 | +\node[op, right=of t] (d) {Distances\\euclidienne, réseau\\\texttt{distance()}}; | |
| 108 | +\node[op, right=of d] (a) {Agrégation\\par zone, par cellule\\\texttt{groupby}, surfacique}; | |
| 109 | +\node[op, right=of a] (i) {Interpolation\\IDW, krigeage (\cref{ch:19})}; | |
| 110 | +\node[sortie, below=9mm of t] (var) {\textbf{Variables}\\indicatrices de zone, distances, indices d'accessibilité}; | |
| 111 | +\node[sortie, right=of var, text width=30mm] (mod) {\textbf{Modèle hédonique}\\effets, interactions, diagnostics spatiaux (\cref{ch:18,ch:19})}; | |
| 112 | +\node[sortie, right=of mod] (carte) {\textbf{Cartes thématiques}\\points, choroplèthes, classes, palettes}; | |
| 113 | +\node[sortie, right=of carte, fill=brandLight] (doc) {\textbf{Documentation}\\source, date, SRC, précision, licence}; | |
| 114 | +\draw[fleche] (src) -- (crs); \draw[fleche] (crs) -- (geo); | |
| 115 | +\foreach \x in {j, t, d, a, i} \draw[fleche] (geo.south) -- (\x.north); | |
| 116 | +\foreach \x in {j, t, d, a, i} \draw[fleche] (\x.south) -- (var.north); | |
| 117 | +\draw[fleche] (var) -- (mod); \draw[fleche] (mod) -- (carte); \draw[fleche] (carte) -- (doc); | |
| 118 | +\end{tikzpicture}} | |
| 119 | +\caption{La chaîne des opérations SIG en évaluation : des couches sources harmonisées dans un même système de référence aux variables du modèle hédonique et aux cartes, chaque étape étant documentée. Les cinq opérations élémentaires sont détaillées au tableau~\ref{tab:38:operations}.}\label{fig:38:operations} | |
| 120 | +\figsource{schéma de l'auteur.} | |
| 121 | +\end{figure} | |
| 122 | + | |
| 123 | +\subsection{La jointure spatiale et le test point dans polygone} | |
| 124 | + | |
| 125 | +La \motcle{jointure spatiale} attache à chaque objet d'une couche les attributs des objets d'une autre couche qui lui sont liés par une relation géométrique : la vente \emph{est dans} la zone, le lot \emph{touche} le corridor, la parcelle \emph{intersecte} la zone inondable. La relation la plus utile en évaluation est l'appartenance d'un point à un polygone, qui donne à chaque vente la classe de la zone où elle se trouve. L'algorithme classique est le \motcle{lancer de rayon} : on trace depuis le point une demi-droite horizontale et l'on compte les arêtes du polygone qu'elle croise ; le point est intérieur si et seulement si ce nombre est impair. Pour un polygone de sommets $(x_1, y_1), \dots, (x_n, y_n)$ et un point $(x, y)$, | |
| 126 | +\begin{equation}\label{eq:38:pip} | |
| 127 | +\mathbb{1}\{(x, y) \in \mathcal{P}\} = \Big[\,\sum_{i=1}^{n} \mathbb{1}\{(y_i > y) \neq (y_{i+1} > y)\}\cdot\mathbb{1}\Big\{x < x_i + \frac{(y - y_i)(x_{i+1} - x_i)}{y_{i+1} - y_i}\Big\}\Big] \bmod 2 , | |
| 128 | +\end{equation} | |
| 129 | +avec $(x_{n+1}, y_{n+1}) = (x_1, y_1)$ ; la première indicatrice retient les arêtes dont l'étendue verticale contient $y$, la seconde celles dont l'intersection avec la droite horizontale est à droite du point. Le calcul est linéaire dans le nombre de sommets, et les bibliothèques géométriques l'accélèrent par un index spatial qui écarte d'emblée les polygones dont la boîte englobante ne contient pas le point. La fonction \texttt{point\_dans\_polygone} de la bibliothèque \texttt{traite.geo} implémente l'\cref{eq:38:pip} ; le script du chapitre vérifie qu'elle donne, sur les 600 ventes et la zone d'exposition fictive, exactement le même résultat que \texttt{shapely}. Dans \texttt{geopandas}, la jointure s'écrit en une ligne : | |
| 130 | +\begin{lstlisting} | |
| 131 | +ventes = ventes.to_crs(32189) # NAD83 / MTM zone 9, metres | |
| 132 | +zones = gpd.read_file("zones.gpkg").to_crs(ventes.crs) | |
| 133 | +j = gpd.sjoin(ventes, zones[["classe", "geometry"]], how="left", predicate="within") | |
| 134 | +\end{lstlisting} | |
| 135 | +Deux pièges guettent la jointure. Le premier est la \emph{multiplicité} : si des polygones se chevauchent, une vente peut recevoir plusieurs lignes, et le dénombrement qui suit la compte deux fois ; il faut soit dissoudre les polygones par classe, soit retenir la classe la plus sévère. Le second est la \emph{frontière} : le prédicat \texttt{within} exclut les points situés exactement sur le contour, \texttt{intersects} les inclut, et la différence n'est pas théorique lorsque les ventes ont été géocodées au centroïde de parcelles dont certaines bordent la zone. La précision du géocodage (\cref{ch:14}) borne la confiance que l'on peut accorder à l'appartenance d'une vente proche de la limite : une erreur de position de quarante mètres suffit à faire changer de classe les ventes situées à moins de quarante mètres de la frontière, et le \cref{sec:38:exemple} en mesurera la part. | |
| 136 | + | |
| 137 | +\subsection{Les tampons} | |
| 138 | + | |
| 139 | +Le \motcle{tampon} (\emph{buffer}) de rayon $r$ d'un ensemble $A$ est l'ensemble des points situés à une distance au plus $r$ de $A$, | |
| 140 | +\begin{equation}\label{eq:38:tampon} | |
| 141 | +B_r(A) = \{\,s \in \mathbb{R}^2 : d(s, A) \leq r\,\} = A \oplus D_r , | |
| 142 | +\end{equation} | |
| 143 | +c'est-à-dire la somme de Minkowski de $A$ et du disque fermé de rayon $r$. Le tampon d'un point est un disque, celui d'une ligne un ruban aux extrémités arrondies, celui d'un polygone une version dilatée de ce polygone ; un rayon négatif érode. Les tampons servent à transformer une géométrie linéaire ou ponctuelle en zone d'influence, puis à tester l'appartenance des ventes à cette zone : « à moins de 300 mètres de l'autoroute », « à moins de 500 mètres d'une station », « dans la bande de 15 mètres de la rive ». La couronne entre deux rayons, $B_{r_2}(A) \setminus B_{r_1}(A)$, définit des classes de distance emboîtées, et c'est ainsi que le script du chapitre construit ses deux classes d'exposition fictives autour d'un cours d'eau fictif. Le choix du rayon est une décision de modélisation, pas une donnée : la \cref{sec:38:sensibilite} montrera que le coefficient d'une indicatrice de tampon varie avec le rayon et que le nombre de ventes incluses, donc la précision du coefficient, en dépend fortement. En \texttt{geopandas}, \texttt{corridor.buffer(300)} produit la zone et \texttt{ventes.within(zone)} l'indicatrice. | |
| 144 | + | |
| 145 | +\subsection{Les distances : euclidienne, orthodromique, réseau} | |
| 146 | + | |
| 147 | +Dans un SRC projeté, la distance entre deux points est euclidienne et \texttt{GeoSeries.distance} la calcule, y compris d'un point à la ligne ou au polygone le plus proche. Sur la sphère, la distance orthodromique entre deux positions en degrés se calcule par la formule du \motcle{haversine}, | |
| 148 | +\begin{equation}\label{eq:38:haversine} | |
| 149 | +d = 2R\arcsin\sqrt{\sin^2\frac{\varphi_2 - \varphi_1}{2} + \cos\varphi_1\cos\varphi_2\sin^2\frac{\lambda_2 - \lambda_1}{2}} , | |
| 150 | +\end{equation} | |
| 151 | +numériquement stable aux courtes distances, contrairement à la formule de la loi des cosinus sphérique. Avec $R = \qty{6371.0}{\kilo\metre}$, elle s'écarte de la distance géodésique sur l'ellipsoïde WGS84 de 0,3 \% au plus à la latitude de Gatineau, ce qui est négligeable pour l'évaluation mais supérieur à l'écart de la projection MTM. Le \cref{ex:38:2} compare les trois mesures sur un cas simple. | |
| 152 | + | |
| 153 | +La distance qui compte pour un ménage n'est toutefois ni euclidienne ni orthodromique : c'est la \motcle{distance réseau}, longueur du plus court chemin sur le réseau routier ou de transport en commun, ou mieux encore le temps de parcours. La distance réseau est toujours supérieure ou égale à la distance euclidienne, et le rapport des deux, le \motcle{coefficient de détour}, mesure la sinuosité du réseau : | |
| 154 | +\begin{equation}\label{eq:38:reseau} | |
| 155 | +d_{\text{réseau}}(i, j) \geq d_{\text{eucl}}(i, j), \qquad \delta_{ij} = \frac{d_{\text{réseau}}(i, j)}{d_{\text{eucl}}(i, j)} \in [1, \infty) , | |
| 156 | +\end{equation} | |
| 157 | +avec, pour un réseau orthogonal idéal, la borne $\delta \leq \sqrt{2}$ atteinte lorsque le trajet est diagonal. Sur un quadrillage parfait, la distance réseau se confond avec la distance de Manhattan $\abs{\Delta x} + \abs{\Delta y}$ ; dans le jeu du cas 1, le rapport entre la distance de Manhattan et la distance euclidienne du sujet aux 600 ventes vaut 1,33 en moyenne et 1,37 en médiane, ce qui donne l'ordre de grandeur de l'erreur commise en substituant l'une à l'autre dans un tissu urbain régulier. Le calcul d'une vraie distance réseau exige un graphe routier, celui d'Adresses Québec ou d'OpenStreetMap, et un algorithme de plus court chemin ; il dépasse le cadre de ce chapitre, mais trois faits méritent d'être retenus. Les travaux québécois sur l'accessibilité, de \textcite{desrosiersTheriaultVilleneuve2000} à \textcite{dubeTheriaultDesrosiers2013}, utilisent des temps de parcours calculés sur réseau plutôt que des distances à vol d'oiseau, parce que les barrières physiques, rivières, voies ferrées, autoroutes sans traverse, rendent la distance euclidienne trompeuse précisément là où les écarts de valeur sont les plus grands. À l'inverse, la distance euclidienne reste la bonne mesure des nuisances physiques, bruit, vue, odeurs, qui ne suivent pas les rues. Enfin, pour un indice d'accessibilité à de nombreuses destinations, la distance euclidienne corrigée d'un coefficient de détour moyen donne souvent des résultats proches de la distance réseau, parce que les erreurs se compensent ; pour une distance à une seule destination, elle peut être très mauvaise. | |
| 158 | + | |
| 159 | +\subsection{L'agrégation par zone et la pondération surfacique} | |
| 160 | + | |
| 161 | +Agréger, c'est résumer les objets d'une zone par une statistique, effectif, médiane du prix au mètre carré, résidu moyen, qui devient l'attribut de la zone. Lorsque la zone est un polygone et les objets des points, l'agrégation est une jointure spatiale suivie d'un \texttt{groupby}. Le choix des zones n'est pas neutre : des unités administratives, secteurs, aires de diffusion, unités de voisinage du rôle, portent un sens mais ont des tailles inégales ; une grille régulière, carrée ou hexagonale, n'a aucun sens administratif mais produit des cellules comparables, et l'hexagone a l'avantage sur le carré d'avoir tous ses voisins à égale distance et de mieux épouser les formes courbes. Le résultat dépend des frontières retenues, c'est le \motcle{problème des unités spatiales modifiables} : une même distribution de points donne des cartes différentes selon la taille et le tracé des zones, et une corrélation calculée sur des agrégats ne vaut pas pour les individus. Les effets fixes de grille du \cref{ch:19} en sont une illustration. | |
| 162 | + | |
| 163 | +Un problème voisin se pose lorsqu'une variable connue par zones sources, la population d'une aire de diffusion par exemple, doit être transférée à des zones cibles qui ne s'emboîtent pas, les secteurs d'un évaluateur. La \motcle{pondération surfacique} suppose la variable uniformément répartie dans chaque zone source et attribue à la cible la part proportionnelle à l'aire d'intersection : | |
| 164 | +\begin{equation}\label{eq:38:surfacique} | |
| 165 | +\hat v_t = \sum_{s} v_s\,\frac{\operatorname{aire}(s \cap t)}{\operatorname{aire}(s)} . | |
| 166 | +\end{equation} | |
| 167 | +L'hypothèse d'uniformité est fausse dès que la variable se concentre, et l'erreur peut être grande pour de petites cibles. Le script du chapitre en donne la mesure : en transférant les effectifs de ventes des cellules hexagonales du cas 1 vers une grille carrée de quatre kilomètres, l'estimation surfacique s'écarte du comptage exact de 2,9 ventes en moyenne absolue par carré, et de 14,9 au plus, pour des carrés qui en comptent de 1 à 80 ; une part de la masse (1,7 vente sur 600) fuit vers des carrés sans aucune vente. Des méthodes plus fines, dasymétriques, utilisent une couche auxiliaire, l'occupation du sol ou les points d'adresses, pour redistribuer la variable là où elle est plausible ; le rôle géoréférencé est, au Québec, la couche auxiliaire idéale, puisqu'il donne le nombre de logements de chaque parcelle. | |
| 168 | + | |
| 169 | +\subsection{L'interpolation} | |
| 170 | + | |
| 171 | +Interpoler, c'est estimer la valeur d'une variable continue en un point où elle n'est pas observée à partir des observations voisines. L'\motcle{inverse de la distance} (IDW) est la méthode la plus simple : | |
| 172 | +\begin{equation}\label{eq:38:idw} | |
| 173 | +\hat z(s_0) = \frac{\sum_{i} w_i\,z_i}{\sum_i w_i}, \qquad w_i = d(s_0, s_i)^{-p} , | |
| 174 | +\end{equation} | |
| 175 | +où la puissance $p$ règle la localité : $p = 1$ lisse, $p = 3$ ne retient presque que les plus proches voisins. L'IDW n'a pas de modèle statistique, donc pas de variance d'estimation, et il donne aux observations regroupées un poids cumulé excessif ; le krigeage du \cref{ch:19} corrige ces deux défauts en tirant les poids d'un variogramme. L'exemple du cas 1 permet de comparer : l'IDW du résidu du modèle sans indicatrices de secteur au sujet vaut $-0{,}054$ avec $p = 1$, $-0{,}060$ avec $p = 2$ et $-0{,}066$ avec $p = 3$, à rapprocher du résidu krigé de $-0{,}060$ obtenu au \cref{ch:19} ; en validation croisée, l'erreur quadratique moyenne des résidus passe de 0,103 sans correction à 0,086 avec $p = 1$, 0,090 avec $p = 2$ et 0,095 avec $p = 3$, contre 0,088 pour le krigeage. Sur un processus par paliers comme celui du cas 1, un lissage prononcé fait aussi bien que le krigeage ; sur un processus continu, le krigeage l'emporte, et il fournit en outre l'incertitude. | |
| 176 | + | |
| 177 | +\begin{table}[htbp] | |
| 178 | +\caption{Les cinq opérations SIG élémentaires : définition, algorithme, mise en œuvre et piège caractéristique}\label{tab:38:operations} | |
| 179 | +\centering | |
| 180 | +\footnotesize\setlength{\tabcolsep}{3pt} | |
| 181 | +\begin{tabularx}{\textwidth}{p{22mm}XXp{36mm}} | |
| 182 | +\toprule | |
| 183 | +Opération & Définition et formule & \texttt{geopandas} / \texttt{shapely} & Piège \\ | |
| 184 | +\midrule | |
| 185 | +Projection & changement de SRC ; distorsion mesurée par le facteur d'échelle, \cref{eq:38:echelle} & \texttt{to\_crs(epsg)} ; \texttt{crs} & distances en degrés ; couches dans des SRC différents ; Pseudo-Mercator \\ | |
| 186 | +Jointure spatiale & attributs des polygones transférés aux points selon un prédicat ; point dans polygone par lancer de rayon, \cref{eq:38:pip} & \texttt{sjoin(predicate="within")} ; \texttt{within}, \texttt{intersects} & doublons (polygones chevauchants) ; points sur la frontière ; précision du géocodage \\ | |
| 187 | +Tampon & $B_r(A) = A \oplus D_r$, \cref{eq:38:tampon} ; couronnes $B_{r_2} \setminus B_{r_1}$ & \texttt{buffer(r)} ; \texttt{difference} & rayon arbitraire ; peu de ventes dans les petits tampons \\ | |
| 188 | +Distance & euclidienne dans un SRC projeté ; haversine en degrés, \cref{eq:38:haversine} ; réseau $\geq$ euclidienne, \cref{eq:38:reseau} & \texttt{distance(geom)} ; \texttt{sjoin\_nearest} & vol d'oiseau à travers une rivière ; distance à la géométrie la plus proche plutôt qu'à la pertinente \\ | |
| 189 | +Agrégation, interpolation & statistique par zone ; pondération surfacique, \cref{eq:38:surfacique} ; IDW, \cref{eq:38:idw} ; krigeage (\cref{ch:19}) & \texttt{sjoin} + \texttt{groupby} ; \texttt{overlay} ; \texttt{traite.geo.idw} & unités spatiales modifiables ; uniformité supposée ; cellules presque vides \\ | |
| 190 | +\bottomrule | |
| 191 | +\end{tabularx} | |
| 192 | +\tabsource{synthèse de l'auteur ; toutes les fonctions citées sont appelées dans \texttt{analysis/python/cases/ch38\_sig.py}.} | |
| 193 | +\end{table} | |
| 194 | + | |
| 195 | +\section{Accessibilité et aménités}\label{sec:38:accessibilite} | |
| 196 | + | |
| 197 | +\subsection{Des distances aux indices} | |
| 198 | + | |
| 199 | +La variable spatiale la plus ancienne des modèles de prix est la distance au centre, dont le \cref{ch:05} a tiré le gradient de rente. Une ville réelle a plusieurs centres, de nombreux équipements et des nuisances, et la liste des distances à chacun d'eux devient vite trop longue pour un modèle estimé sur quelques centaines de ventes ; les variables de distance sont de surcroît fortement corrélées entre elles. Trois formes résument l'information. La \emph{distance à l'équipement le plus proche} (école, parc, station) est simple et parlante, mais elle ignore le nombre et la taille des équipements ; \textcite{desrosiersLaganaTheriault2001} ont montré à Québec que la taille de l'école primaire et sa proximité jouent toutes deux, et dans des directions qui peuvent s'opposer, une école très proche apportant aussi du bruit et de la circulation. Les \emph{indicatrices de tampon} transforment la distance en classes, à moins de 300 mètres, de 300 à 800 mètres, ce qui laisse les données révéler une forme non linéaire au prix d'une perte d'information et d'un choix de seuils. L'\emph{indice gravitaire}, enfin, agrège toutes les destinations en une seule mesure. | |
| 200 | + | |
| 201 | +\subsection{L'indice gravitaire et sa dérivation} | |
| 202 | + | |
| 203 | +L'indice d'accessibilité de Hansen, déjà rencontré à l'\cref{eq:5:accessibilite}, pondère chaque destination $k$ par sa masse $E_k$ et par une fonction décroissante de la distance : | |
| 204 | +\begin{equation}\label{eq:38:gravite} | |
| 205 | +A_i = \sum_{k=1}^{K} E_k\, f(d_{ik}), \qquad f(d) = e^{-\lambda d}\ \text{ou}\ f(d) = d^{-\beta} . | |
| 206 | +\end{equation} | |
| 207 | +Le paramètre de friction $\lambda$ fixe la portée : avec $\lambda = 0{,}25$ par kilomètre, une destination à 4 kilomètres compte pour 37 \% de son poids, à 8 kilomètres pour 14 \% ; la forme en puissance décroît plus vite près de l'origine et plus lentement au loin. Cette forme n'est pas arbitraire : elle découle d'un modèle de choix discret. Supposons qu'un ménage situé en $i$ choisisse une destination $k$ dont l'utilité est $U_{ik} = \ln E_k - \lambda d_{ik} + \epsilon_{ik}$, la masse mesurant l'attrait, la distance le coût, et $\epsilon_{ik}$ un terme aléatoire de loi de Gumbel indépendant d'une destination à l'autre. L'utilité espérée du meilleur choix, la \motcle{somme logarithmique} du modèle logit, vaut alors | |
| 208 | +\begin{equation}\label{eq:38:logsum} | |
| 209 | +\E\Big[\max_k U_{ik}\Big] = \ln\sum_{k=1}^{K}\exp\big(\ln E_k - \lambda d_{ik}\big) + \gamma = \ln A_i + \gamma , | |
| 210 | +\end{equation} | |
| 211 | +où $\gamma \approx 0{,}5772$ est la constante d'Euler--Mascheroni. Le logarithme de l'indice gravitaire est donc, à une constante près, le surplus espéré qu'un ménage retire de la position $i$ ; c'est pourquoi la spécification naturelle du modèle hédonique est $\theta\ln A_i$, comme à l'\cref{eq:5:polycentrique}, et non $A_i$ lui-même. La dérivation dit aussi ce que mesure $\lambda$ : l'arbitrage entre un kilomètre de plus et le logarithme de la masse, autrement dit la valeur du temps relativement à la variété de l'offre. Sa valeur doit être estimée ou empruntée à une étude de déplacements ; on ne la devine pas. La bibliothèque \texttt{traite.geo} fournit \texttt{accessibilite\_gravitaire} pour les deux formes de $f$. | |
| 212 | + | |
| 213 | +\subsection{Aménités, désaménités et externalités} | |
| 214 | + | |
| 215 | +Les aménités, parcs, plans d'eau, vues, écoles réputées, commerces, et les désaménités, autoroutes, voies ferrées, lignes de transport d'électricité, sites industriels, sont des externalités de localisation que le marché capitalise dans les prix. Leur mesure par SIG suit toujours le même schéma : une couche de la source d'externalité, une opération de distance ou de tampon, une variable dans le modèle. La difficulté n'est pas le calcul, elle est l'identification. D'abord, la plupart des aménités sont \emph{coproduites} par la localisation : un parc est là où le quartier est ancien et central, une autoroute là où le terrain était bon marché ; la variable d'aménité capte alors une part de la prime de quartier, et le \cref{sec:38:exemple} montrera à quel point. Ensuite, l'effet d'une même source varie avec la distance et le sens : \textcite{dubeTheriaultDesrosiers2013} trouvent, pour les gares de train de banlieue de la Rive-Sud de Montréal, une prime d'accessibilité qui croît à mesure que l'on s'approche puis se retourne au voisinage immédiat de la gare, où le bruit et le stationnement dominent ; la spécification doit admettre cette non-monotonie, par des classes de distance ou par un polynôme. Enfin, les effets sont hétérogènes selon les acheteurs : \textcite{kestensTheriaultDesrosiers2006} montrent à Québec que les attributs de l'environnement, couvert végétal, usages du sol environnants, sont valorisés différemment selon le profil du ménage, et \textcite{theriaultDesrosiersVilleneuveKestens2003} que les prix implicites interagissent avec la localisation. Les ouvrages de \textcite{theriaultDesrosiers2011} font la synthèse de ces travaux, qui ont établi, sur des données québécoises, les pratiques de ce chapitre. | |
| 216 | + | |
| 217 | +\section{Les risques géospatiaux}\label{sec:38:risques} | |
| 218 | + | |
| 219 | +\subsection{Les zones inondables et le cadre de 2026} | |
| 220 | + | |
| 221 | +Le risque d'inondation est, au Québec, le risque géospatial le mieux cartographié et le plus réglementé. Le \cref{ch:08} a décrit le cadre réglementaire entré en vigueur le 1\textsuperscript{er} mars 2026 et modifié le 29 juillet 2026, fondé sur la définition des milieux hydriques de la Loi sur la qualité de l'environnement, qui inclut « les zones inondables d'un lac ou d'un cours d'eau ainsi que les zones de mobilité d'un cours d'eau » \parencite[art.~46.0.2 al.~3, par.~2.1°]{lqe}, et ses conséquences sur ce qu'une résidence peut être, construite, agrandie, reconstruite \parencite{zonesInond2026,inondtravaux}. Ce qui intéresse le présent chapitre est la structure de la cartographie. Les cartographies de nouvelle génération classent l'exposition en quatre \motcle{classes d'intensité}, très élevée, élevée, modérée et faible, en croisant la probabilité d'inondation sur un horizon de 25 ans, plus de 70 \%, de 70 à 20 \%, de 20 à 7 \%, et la profondeur d'eau attendue, plus de 60 centimètres, de 30 à 60, de 0 à 30, les zones d'embâcle étant classées très élevée \parencite{inondcartes}. Pour le modélisateur, cette grille a deux vertus. Elle est \emph{ordinale} : les classes se rangent et l'on peut tester que les coefficients sont monotones. Et elle est \emph{probabiliste} : les bornes de probabilité sur 25 ans se convertissent en probabilités annuelles d'au moins une occurrence, sous l'hypothèse d'indépendance d'une année à l'autre, | |
| 222 | +\begin{equation}\label{eq:38:annuelle} | |
| 223 | +p_1 = 1 - (1 - P_{25})^{1/25} , | |
| 224 | +\end{equation} | |
| 225 | +ce qui donne 4,7 \% par année pour la borne de 70 \%, 0,9 \% pour 20 \% et 0,29 \% pour 7 \%. L'ancienne cartographie en zones de grand courant (récurrence de 0 à 20 ans) et de faible courant (20 à 100 ans) raisonnait en périodes de retour, dont l'inverse est la probabilité annuelle ; la correspondance entre les deux générations n'est pas énoncée par les pages gouvernementales consultées, et l'évaluateur qui dispose d'une couche municipale d'ancienne génération, comme celle de Gatineau \parencite{gatinondables}, doit la traiter comme une information distincte, non comme une traduction des nouvelles classes. | |
| 226 | + | |
| 227 | +La probabilité annuelle permet un premier raisonnement sur la valeur, que le \cref{ch:43} développera. Si une inondation cause un dommage égal à une fraction $D$ de la valeur et survient avec la probabilité annuelle $p_1$, la perte annuelle espérée vaut $p_1 D V$ ; un acheteur qui la capitalise au taux $R$ en retranche la valeur présente, | |
| 228 | +\begin{equation}\label{eq:38:capitalisation} | |
| 229 | +\frac{\Delta V}{V} = -\frac{p_1\,D}{R} . | |
| 230 | +\end{equation} | |
| 231 | +Le \cref{tab:38:capitalisation} applique l'\cref{eq:38:capitalisation} aux trois bornes de probabilité du cadre de 2026, avec des fractions de dommage \emph{hypothétiques} de 15, 8 et 3 \% décroissant avec la profondeur, un taux de 5,5 \% et la valeur hédonique du sujet du cas 1. La décote capitalisée passe de 12,8 \% pour la borne haute à 0,16 \% pour la borne basse : l'essentiel de l'effet se concentre dans les classes fréquentes, et une classe faible ne devrait presque rien peser si le marché ne tarifait que la perte espérée. Or la littérature trouve davantage : \textcite{binPolasky2004} mesurent une décote qui s'accroît après un événement, \textcite{binKruseLandry2008} montrent qu'en zone côtière la décote de risque coexiste avec une prime d'aménité pour la proximité de l'eau, qui la masque si l'on ne les sépare pas, et \textcite{bernsteinGustafsonLewis2019} que des propriétés exposées à une élévation future du niveau de la mer se vendent déjà moins cher que des propriétés comparables non exposées, ce qui révèle une anticipation du risque au-delà de la perte espérée courante. La règle de 2026 ajoute à la perte espérée une restriction juridique, l'interdiction de reconstruire dans les classes très élevée, élevée et modérée hors conditions, dont la valeur d'option perdue s'additionne à la décote de l'\cref{eq:38:capitalisation} ; c'est l'objet du \cref{ch:43}. | |
| 232 | + | |
| 233 | +\begin{table}[htbp] | |
| 234 | +\caption{Décote capitalisée d'une perte annuelle espérée selon la borne de probabilité des classes d'intensité du cadre de 2026 (hypothèses pédagogiques de dommage ; $R = 5{,}5$ \% ; $V = \dollars{637953}$)}\label{tab:38:capitalisation} | |
| 235 | +\centering | |
| 236 | +\small | |
| 237 | +\begin{tabular}{S[table-format=2.0]S[table-format=1.2]S[table-format=2.0]S[table-format=1.2]S[table-format=2.2]S[table-format=5.0]} | |
| 238 | +\toprule | |
| 239 | +{$P_{25}$ (\%)} & {$p_1$ (\%)} & {Dommage $D$ (\%)} & {Perte annuelle (\% de $V$)} & {Décote $\Delta V / V$ (\%)} & {Décote (\$)} \\ | |
| 240 | +\midrule | |
| 241 | +70 & 4.70 & 15 & 0.71 & 12.82 & 81805 \\ | |
| 242 | +20 & 0.89 & 8 & 0.07 & 1.29 & 8246 \\ | |
| 243 | +7 & 0.29 & 3 & 0.01 & 0.16 & 1009 \\ | |
| 244 | +\bottomrule | |
| 245 | +\end{tabular} | |
| 246 | +\tabsource{\synth{analysis/python/cases/ch38\_sig.py (fonction \texttt{exemple\_capitalisation}) ; \cref{eq:38:annuelle,eq:38:capitalisation} ; bornes de probabilité d'après \textcite{inondcartes} ; les fractions de dommage sont des hypothèses, non des mesures}} | |
| 247 | +\end{table} | |
| 248 | + | |
| 249 | +\subsection{Glissements de terrain, contamination, bruit} | |
| 250 | + | |
| 251 | +Les autres risques sont moins bien dotés en couches ouvertes, et leur intégration passe davantage par le règlement de zonage et par le registre foncier que par une carte téléchargeable. La Loi sur l'aménagement et l'urbanisme permet aux municipalités de régir ou de prohiber les usages et constructions « compte tenu […] des dangers d'inondation, d'éboulis, de glissement de terrain » \parencite[art.~113 al.~2, par.~16°]{lau} ; les zones de contraintes relatives aux glissements de terrain figurent aux schémas d'aménagement des municipalités régionales de comté et aux règlements de zonage, et c'est dans ces documents, dont certains sont diffusés en format géospatial par les villes qui publient leur zonage, que l'évaluateur trouve les polygones. La jointure spatiale d'une vente avec la couche de zonage livre à la fois l'usage permis, dont le \cref{ch:08} a montré l'importance pour la plus haute et meilleure utilisation, et l'appartenance aux zones de contraintes ; aucune donnée de ce type n'a été téléchargée pour l'ouvrage, et la couverture réelle de ces couches n'a pas été vérifiée. | |
| 252 | + | |
| 253 | +La contamination des sols est un risque ponctuel, attaché à un terrain, et sa source principale n'est pas une carte mais le \emph{registre foncier} : la Loi sur la qualité de l'environnement oblige à requérir l'inscription d'un avis de contamination dès qu'une étude révèle un dépassement des valeurs limites, avis qui désigne le terrain, l'utilisation autorisée par le zonage et un résumé de l'étude signé par un professionnel, et l'avis de décontamination suit les travaux \parencite[art.~31.58 et 31.59]{lqe} ; les municipalités tiennent une liste publique des terrains contaminés et y subordonnent les permis \parencite[art.~31.68]{lqe}. Le Répertoire des terrains contaminés du ministère de l'Environnement recense les terrains dont la contamination excède généralement le critère B du Guide d'intervention, en avertissant qu'il ne s'agit pas d'un inventaire exhaustif \parencite{repertoiretc}. Pour en faire une variable, il faut géocoder les adresses du répertoire, puis calculer pour chaque vente la distance au terrain contaminé le plus proche ou l'appartenance à un tampon ; l'avis au registre foncier, lui, se consulte à l'unité pour le sujet et ses comparables (\cref{ch:13}). Le cas 4 porte une indicatrice de contamination pour deux de ses 48 immeubles ; le \cref{ch:43} traite de la mesure de la décote et des coûts de réhabilitation. | |
| 254 | + | |
| 255 | +Le bruit et les nuisances de transport sont des externalités linéaires : autoroutes, voies ferrées, corridors aériens, lignes électriques. La couche source est le tracé, que fournissent les géobases routières et ferroviaires ; l'opération est le tampon ou la distance ; la variable est une indicatrice de proximité ou le logarithme de la distance. Les cartes de bruit modélisées, lorsqu'elles existent, sont des matrices dont on extrait le niveau sonore à la position de chaque vente, ce qui vaut mieux qu'une distance parce que l'atténuation dépend du relief, des écrans et du trafic. Le signe attendu est négatif pour le bruit et positif pour l'accessibilité qu'apporte la même infrastructure, et les deux effets se superposent dans l'espace ; la spécification doit les séparer, par exemple par une distance à l'échangeur ou à la station pour l'accès et une distance à la chaussée ou à la voie pour la nuisance. | |
| 256 | + | |
| 257 | +\subsection{Intégrer les risques au modèle hédonique} | |
| 258 | + | |
| 259 | +La forme générale du modèle hédonique de l'ouvrage, $\ln P_i = \alpha + \mathbf{x}_i\T\boldsymbol\beta + \tau_{t(i)} + \mu_{s(i)} + \varepsilon_i$, accueille les variables géospatiales de trois manières : | |
| 260 | +\begin{equation}\label{eq:38:hedonique-risque} | |
| 261 | +\ln P_i = \alpha + \mathbf{x}_i\T\boldsymbol\beta + \tau_{t(i)} + \mu_{s(i)} + \sum_{c} \delta_c\,\mathbb{1}\{i \in \mathcal{Z}_c\} + \gamma\,g(d_i) + \theta\ln A_i + \sum_{c}\boldsymbol\eta_c\T\,\mathbf{x}_i\,\mathbb{1}\{i \in \mathcal{Z}_c\} + \varepsilon_i . | |
| 262 | +\end{equation} | |
| 263 | +Les indicatrices de classe $\mathbb{1}\{i \in \mathcal{Z}_c\}$ mesurent l'effet moyen d'appartenir à la zone $c$ ; la fonction de distance $g(d_i)$, typiquement $\ln(1 + d_i/d_0)$ pour une distance en mètres rapportée à une échelle $d_0$, mesure un effet continu ; le logarithme de l'accessibilité $\ln A_i$ porte l'effet de la position relative ; les interactions $\boldsymbol\eta_c$ laissent le prix implicite d'une caractéristique varier avec l'exposition, par exemple la valeur d'un sous-sol fini en zone inondable, et sont la façon la plus directe de représenter qu'un risque ne frappe pas toutes les composantes du bien de la même manière. Trois règles d'estimation découlent des \cref{ch:18,ch:19}. Les effets fixes de localisation $\mu_{s(i)}$ doivent être présents et fins, parce que les zones de risque sont elles-mêmes localisées et que leur coefficient absorbe sinon la prime de quartier omise ; \textcite{kuminoffParmeterPope2010} et \textcite{bishop2020} ont montré que c'est la spécification des effets fixes, plus que la forme fonctionnelle, qui décide de la crédibilité d'un prix implicite d'aménité. Les erreurs-types doivent tenir compte de la dépendance spatiale résiduelle, par les tests et les modèles du \cref{ch:19}, faute de quoi les variables de zone paraissent significatives alors qu'elles ne le sont pas. Et le nombre de ventes dans chaque zone borne ce que l'on peut détecter : pour un coefficient d'indicatrice, l'erreur-type est approximativement $\sigma / \sqrt{n\,\pi(1 - \pi)(1 - R^2_{z})}$, où $\pi$ est la part des ventes dans la zone et $R^2_z$ la part de variance de l'indicatrice expliquée par les autres régresseurs, de sorte que l'\motcle{effet minimal détectable} avec une puissance de 80 \% au seuil de 5 \% vaut | |
| 264 | +\begin{equation}\label{eq:38:emd} | |
| 265 | +\mathrm{EMD} = (z_{0{,}975} + z_{0{,}80})\,\widehat{\mathrm{se}}(\hat\delta) \approx 2{,}80\,\widehat{\mathrm{se}}(\hat\delta) . | |
| 266 | +\end{equation} | |
| 267 | +Avec 31 ventes sur 600 dans une zone et $\sigma = 0{,}083$, l'exemple donnera un EMD de 4,9 \% : une décote de 3 \% y est indétectable, et son absence de signification ne prouve pas son absence. Cette arithmétique devrait précéder toute tentative de mesurer un risque rare sur un échantillon local. | |
| 268 | + | |
| 269 | +\section{La cartographie thématique}\label{sec:38:cartographie} | |
| 270 | + | |
| 271 | +Une carte est un argument, et comme tout argument elle peut tromper. Quatre décisions la gouvernent. La \emph{représentation} : des points proportionnels pour des quantités attachées à des objets discrets (le prix d'une vente, la valeur d'un immeuble), une \motcle{choroplèthe}, c'est-à-dire des zones teintées, pour des rapports ou des densités attachés à des zones (prix médian au mètre carré, part de la valeur en zone inondable, résidu moyen), jamais une choroplèthe pour des effectifs bruts, parce que la grande zone paraîtrait toujours importante. La \emph{classification} : une variable continue est découpée en quatre à six classes dont les bornes se choisissent par intervalles égaux, lisibles mais sensibles aux valeurs extrêmes ; par quantiles, qui équilibrent les effectifs mais séparent des valeurs voisines et réunissent des valeurs éloignées ; ou par \motcle{ruptures naturelles} de Fisher--Jenks, qui minimisent la somme des carrés des écarts intra-classe, | |
| 272 | +\begin{equation}\label{eq:38:jenks} | |
| 273 | +\min_{b_1 < \dots < b_{k-1}} \sum_{c=1}^{k}\sum_{i \in c}(z_i - \bar z_c)^2, \qquad \mathrm{GVF} = 1 - \frac{\sum_c\sum_{i \in c}(z_i - \bar z_c)^2}{\sum_i (z_i - \bar z)^2} , | |
| 274 | +\end{equation} | |
| 275 | +le critère GVF (\emph{goodness of variance fit}) mesurant la part de variance que la classification restitue ; la programmation dynamique en donne la solution exacte, implémentée dans \texttt{traite.geo.bornes\_jenks}. La \emph{palette} : une variable ordonnée de même signe appelle une palette \emph{séquentielle}, du clair au foncé, celle de l'ouvrage étant \texttt{palette.seq} ; une variable qui s'écarte d'une référence dans deux directions, un résidu, un écart à la médiane, une variation, appelle une palette \emph{divergente} centrée sur la référence, \texttt{palette.div}, bleu pour le négatif, gris neutre au centre, rouge pour le positif ; une variable nominale, les secteurs, appelle des teintes catégorielles distinctes, au plus six. Un arc-en-ciel n'est jamais justifié, parce que l'œil n'y lit pas d'ordre. La \emph{légende et la source} : chaque classe est libellée par ses bornes avec unités, les zones non cartographiées (trop peu d'observations) sont distinguées par une hachure plutôt que laissées blanches, et la source, le système de référence et la date figurent sous la carte, comme pour tout tableau de l'ouvrage. Les cartes de ce chapitre ne portent aucun fond de tuiles, ni par choix esthétique ni par contrainte technique seulement : les coordonnées du cas 1 sont synthétiques, et un fond réel suggérerait des lieux qui n'existent pas. Sur des données réelles, un fond discret, limites administratives, hydrographie, réseau principal, aide à se repérer ; les tuiles en Pseudo-Mercator ne doivent servir qu'à l'affichage, jamais à la mesure. | |
| 276 | + | |
| 277 | +\section{Exemple intégral : polygones fictifs sur le cas 1 et faux positifs spatiaux}\label{sec:38:exemple} | |
| 278 | + | |
| 279 | +\begin{synthetique} | |
| 280 | +Le dossier du cas 1 est synthétique. Les 600 ventes de \texttt{data/synthetic/cas1\_gatineau\_ventes.csv} ont été générées par \texttt{analysis/python/traite/synth.py} (graine 20261031) selon les lois de \texttt{data/synthetic/README.md} : primes de secteur, gradient de distance au centre, tendance temporelle, bruit indépendant d'écart-type 0,085. Les coordonnées sont fictives et ne désignent aucun lieu. Toutes les géométries de cette section, le « cours d'eau fictif » et ses deux classes d'exposition, le « corridor de transport fictif » et ses tampons, les trois « pôles » de l'indice d'accessibilité, sont déclarées en dur dans \texttt{analysis/python/cases/ch38\_sig.py}, en coordonnées NAD83 / MTM zone 9, et ne représentent ni la rivière des Outaouais, ni une autoroute, ni un centre réel ; elles sont dessinées pour traverser les grappes de ventes. Le générateur ne contient \emph{aucune} de ces variables : leur effet vrai sur les prix est exactement nul, et c'est ce qui fait l'intérêt de l'exercice. Tous les chiffres sont reproduits par \texttt{ch38\_sig.py}, les figures par \texttt{analysis/python/figures/ch38\_figures.py}. | |
| 281 | +\end{synthetique} | |
| 282 | + | |
| 283 | +\subsection{Projection et géométries} | |
| 284 | + | |
| 285 | +Les 600 ventes et le sujet, fournis en latitude et longitude, sont projetés en NAD83 / MTM zone 9 (EPSG 32189) : le sujet se trouve en $x = \num{364316}$ mètres, $y = \num{5031566}$ mètres, le jeu s'étend sur 42,2 kilomètres d'est en ouest et 27,5 du sud au nord, et le facteur d'échelle au sujet vaut 0,999943. Les distances du sujet aux 600 ventes calculées dans cette projection s'écartent des distances géodésiques WGS84 de 1,2 mètre au plus ; celles de la projection équirectangulaire locale du \cref{ch:19} de 82,8 mètres (0,51 \%) et celles du haversine de 70,8 mètres (0,28 \%). Le cours d'eau fictif est une polyligne de 43,8 kilomètres qui traverse les secteurs d'Aylmer et de Hull dans leur partie sud ; sa classe d'exposition A, tampon de 350 mètres, couvre 31,0 kilomètres carrés et sa classe B, couronne de 350 à 900 mètres, 50,3 kilomètres carrés. Le corridor fictif est une polyligne de 24,5 kilomètres orientée du sud-ouest au nord-est entre Hull et le secteur Gatineau ; son tampon de référence de 300 mètres couvre 15,0 kilomètres carrés. Les trois pôles reçoivent des masses de 100, 40 et 30 et l'indice gravitaire est exponentiel avec $\lambda = 0{,}25$ par kilomètre. La \cref{fig:38:carte} situe le tout. | |
| 286 | + | |
| 287 | +\begin{figure}[htbp] | |
| 288 | +\centering | |
| 289 | +\includegraphics[width=\textwidth]{ch38/ch38-fig-carte-cas1.pdf} | |
| 290 | +\caption{Les 600 ventes du cas 1 projetées en NAD83 / MTM zone 9, avec les géométries fictives du chapitre : cours d'eau et ses deux classes d'exposition (tampons de 350 et 900 m), corridor de transport et son tampon de 300 m, pôles de l'indice d'accessibilité et sujet. Les ventes tombant dans les classes A et B sont distinguées. Aucune de ces géométries ne correspond à un objet réel.}\label{fig:38:carte} | |
| 291 | +\figsource{\synth{analysis/python/figures/ch38\_figures.py, graine 20261031}} | |
| 292 | +\end{figure} | |
| 293 | + | |
| 294 | +\subsection{Jointure spatiale, distances et agrégation} | |
| 295 | + | |
| 296 | +La jointure spatiale par le prédicat \texttt{within} attribue 31 ventes à la classe A et 57 à la classe B, toutes dans Hull (14 et 19) et Aylmer (17 et 38) ; les secteurs Gatineau, Buckingham et Masson-Angers n'en comptent aucune, par construction de la géométrie. Le tampon de 300 mètres du corridor contient 17 ventes, dont 14 à Hull. La distance médiane au corridor est de 2,0 kilomètres à Hull et de 16,8 kilomètres à Buckingham ; l'indice d'accessibilité moyen vaut 74 à Hull, où se trouve le pôle principal, 44 dans le secteur Gatineau, 26 à Aylmer et moins de 0,5 dans les deux secteurs périphériques, que leur éloignement rend presque invisibles à un indice exponentiel. Le \cref{tab:38:secteurs} rassemble ces résultats avec le prix médian au mètre carré de chaque secteur. | |
| 297 | + | |
| 298 | +\begin{table}[htbp] | |
| 299 | +\caption{Jointure spatiale et distances aux géométries fictives, par secteur du cas 1 (données synthétiques)}\label{tab:38:secteurs} | |
| 300 | +\centering | |
| 301 | +\small\setlength{\tabcolsep}{4pt} | |
| 302 | +\begin{tabular}{lS[table-format=3.0]S[table-format=2.0]S[table-format=2.0]S[table-format=2.0]S[table-format=2.1]S[table-format=2.1]S[table-format=4.0]} | |
| 303 | +\toprule | |
| 304 | +Secteur & {Ventes} & {Classe A} & {Classe B} & {Corridor $\leq$ 300 m} & {Dist. corridor méd. (km)} & {Accessibilité $A_i$ moy.} & {Prix méd. (\$/m\textsuperscript{2})} \\ | |
| 305 | +\midrule | |
| 306 | +Hull & 186 & 14 & 19 & 14 & 2.0 & 74.3 & 3526 \\ | |
| 307 | +Gatineau & 200 & 0 & 0 & 1 & 2.8 & 43.6 & 3451 \\ | |
| 308 | +Aylmer & 125 & 17 & 38 & 2 & 4.5 & 26.4 & 3663 \\ | |
| 309 | +Buckingham & 42 & 0 & 0 & 0 & 16.8 & 0.2 & 2667 \\ | |
| 310 | +Masson-Angers & 47 & 0 & 0 & 0 & 16.8 & 0.4 & 2686 \\ | |
| 311 | +\midrule | |
| 312 | +Ensemble & 600 & 31 & 57 & 17 & 3.1 & 43.1 & 3418 \\ | |
| 313 | +\bottomrule | |
| 314 | +\end{tabular} | |
| 315 | +\tabsource{\synth{analysis/python/cases/ch38\_sig.py ; prédicat \texttt{within} ; distances euclidiennes en NAD83 / MTM zone 9 ; indice gravitaire exponentiel, $\lambda = 0{,}25$/km, masses 100, 40 et 30}} | |
| 316 | +\end{table} | |
| 317 | + | |
| 318 | +L'agrégation par cellule hexagonale de 1,2 kilomètre de rayon (3,74 kilomètres carrés) couvre l'étendue du jeu de 374 cellules, dont 69 contiennent au moins une vente et 30 en contiennent au moins cinq, seuil en deçà duquel une médiane n'est pas cartographiée. La \cref{fig:38:choroplethe} présente la médiane du prix au mètre carré de ces 30 cellules selon les trois classifications de la \cref{sec:38:cartographie}, et le \cref{tab:38:classes} en donne les bornes. Les intervalles égaux, de \num{2546} à \parmsq{4106} par pas de \num{312}, laissent deux cellules seulement dans la deuxième classe et concentrent onze cellules dans la quatrième ; les quantiles mettent six cellules par classe mais tracent une frontière entre \num{3441} et \parmsq{3555}, soit dans l'épaisseur du bruit d'échantillonnage d'une médiane sur cinq à vingt ventes ; les ruptures naturelles isolent les deux cellules périphériques bon marché (moins de \parmsq{3004}) et les cellules chères d'Aylmer (plus de \parmsq{3925}) avec un GVF de 0,964, contre 0,952 et 0,909. La leçon n'est pas que Jenks l'emporte toujours ; c'est qu'une carte à quantiles et une carte à intervalles égaux de la même variable racontent des histoires différentes, et que le lecteur d'un rapport doit pouvoir savoir laquelle il regarde. Le quatrième panneau de la \cref{fig:38:choroplethe} montre l'usage de la palette divergente : le résidu moyen du modèle de référence par cellule, centré sur zéro, oscille entre $-0{,}050$ et $+0{,}050$ sans structure, ce qui est la contrepartie cartographique de la statistique de Moran nulle du \cref{ch:19}. | |
| 319 | + | |
| 320 | +\begin{figure}[htbp] | |
| 321 | +\centering | |
| 322 | +\includegraphics[width=\textwidth]{ch38/ch38-fig-choroplethe.pdf} | |
| 323 | +\caption{Cartes choroplèthes par cellule hexagonale des 600 ventes du cas 1 (coordonnées synthétiques). (a)--(c) Médiane du prix au mètre carré habitable des 30 cellules d'au moins cinq ventes, classée en cinq classes par intervalles égaux, par quantiles et par ruptures naturelles de Fisher--Jenks, palette séquentielle ; les cellules de moins de cinq ventes sont hachurées. (d) Résidu moyen du modèle hédonique de référence par cellule, palette divergente centrée sur zéro : aucune structure spatiale ne subsiste.}\label{fig:38:choroplethe} | |
| 324 | +\figsource{\synth{analysis/python/figures/ch38\_figures.py}} | |
| 325 | +\end{figure} | |
| 326 | + | |
| 327 | +\begin{table}[htbp] | |
| 328 | +\caption{Bornes des cinq classes de la médiane du prix au mètre carré par cellule (30 cellules) selon la méthode de classification, effectifs et GVF (données synthétiques)}\label{tab:38:classes} | |
| 329 | +\centering | |
| 330 | +\small | |
| 331 | +\begin{tabular}{lcccccc} | |
| 332 | +\toprule | |
| 333 | +Méthode & {Classe 1} & {Classe 2} & {Classe 3} & {Classe 4} & {Classe 5} & {GVF} \\ | |
| 334 | +\midrule | |
| 335 | +Intervalles égaux & 2 546 -- 2 858 & 2 858 -- 3 170 & 3 170 -- 3 482 & 3 482 -- 3 794 & 3 794 -- 4 106 & 0,952 \\ | |
| 336 | + & (4) & (2) & (9) & (11) & (4) & \\ | |
| 337 | +Quantiles & 2 546 -- 3 165 & 3 165 -- 3 441 & 3 441 -- 3 555 & 3 555 -- 3 650 & 3 650 -- 4 106 & 0,909 \\ | |
| 338 | + & (6) & (6) & (6) & (6) & (6) & \\ | |
| 339 | +Fisher--Jenks & 2 546 -- 3 004 & 3 004 -- 3 358 & 3 358 -- 3 548 & 3 548 -- 3 925 & 3 925 -- 4 106 & 0,964 \\ | |
| 340 | + & (4) & (5) & (8) & (9) & (4) & \\ | |
| 341 | +\bottomrule | |
| 342 | +\end{tabular} | |
| 343 | +\tabsource{\synth{analysis/python/cases/ch38\_sig.py ; bornes en \$/m\textsuperscript{2}, effectifs entre parenthèses ; \cref{eq:38:jenks}}} | |
| 344 | +\end{table} | |
| 345 | + | |
| 346 | +\subsection{Les variables géospatiales dans le modèle hédonique} | |
| 347 | + | |
| 348 | +Le \cref{tab:38:coefficients} ajoute au modèle de référence du cas 1 (\texttt{traite.cas1.hedonique}) les cinq variables géospatiales construites plus haut, indicatrices des classes A et B, indicatrice du tampon de 300 mètres, logarithme de la distance au corridor et logarithme de l'accessibilité, et répète l'estimation pour les deux spécifications dégradées du \cref{ch:19}, sans indicatrices de secteur, puis sans secteur ni gradient. Rappelons que l'effet vrai de chacune de ces variables est nul. | |
| 349 | + | |
| 350 | +\begin{exemple}{Cinq variables fictives, trois spécifications}{cas1-sig} | |
| 351 | +\emph{Avec le modèle de référence}, les cinq coefficients sont petits et aucun n'est significatif : $+0{,}5$ \% pour la classe A ($t = 0{,}31$), $-1{,}8$ \% pour la classe B ($t = -1{,}33$), $-3{,}0$ \% pour le tampon de 300 mètres ($t = -1{,}37$), $-0{,}6$ \% par unité de logarithme de distance au corridor et $+1{,}1$ \% par unité de logarithme d'accessibilité ; le test conjoint des cinq variables donne $F(5, 579) = 0{,}84$ avec une valeur $p$ de 0,52, et le $R^2$ passe de 0,8530 à 0,8541. Le modèle dit la vérité : ces polygones ne valent rien. Il dit aussi avec quelle précision il la dit : l'effet minimal détectable à 80 \% de puissance est de 4,9 \% pour la classe A (31 ventes), de 3,8 \% pour la classe B (57 ventes) et de 6,2 \% pour le tampon de 300 mètres (17 ventes). Une décote réelle de 3 \% sur la classe A serait passée inaperçue avec ces effectifs. | |
| 352 | + | |
| 353 | +\emph{Sans indicatrices de secteur}, tout change. La classe A reçoit un coefficient de $+6{,}3$ \% ($t = 3{,}78$), la classe B de $+4{,}3$ \% ($t = 2{,}99$), le logarithme de la distance au corridor devient significatif au seuil de 5 \% ($-1{,}6$ \%, $t = -1{,}98$) et le logarithme de l'accessibilité vaut $+14{,}6$ \% ($t = 10{,}9$) ; le test conjoint donne $F = 33{,}4$ et le $R^2$ bondit de 0,766 à 0,818. Rien de tout cela n'est un effet du cours d'eau, du corridor ou des pôles. La zone d'exposition fictive a été dessinée pour traverser Aylmer, le secteur à prime positive, et Hull ; 55 des 88 ventes exposées sont à Aylmer, et les indicatrices de classe captent la prime d'Aylmer que le modèle sans secteur a omise. L'indice d'accessibilité, maximal à Hull et presque nul à Buckingham et à Masson-Angers, est une fonction lisse de la position qui épouse la carte des primes de secteur, et le modèle lui attribue ce qu'il ne peut plus attribuer aux indicatrices. Une « zone inondable » fictive qui augmente les prix de 6 \% avec un $t$ de 3,8 est un faux positif de manuel, et il est produit par une spécification que bien des rapports jugeraient acceptable, puisqu'elle contient un gradient de distance au centre et onze caractéristiques physiques. | |
| 354 | + | |
| 355 | +\emph{Sans secteur ni gradient}, les coefficients des classes restent de $+5{,}8$ et $+5{,}2$ \% ; l'accessibilité tombe à $+6{,}0$ \% mais avec un $t$ de 14,1, parce qu'elle devient la seule variable de localisation et absorbe à elle seule le gradient omis. Les trois spécifications sont résumées au \cref{tab:38:coefficients}. | |
| 356 | +\end{exemple} | |
| 357 | + | |
| 358 | +\begin{table}[htbp] | |
| 359 | +\caption{Coefficients des cinq variables géospatiales fictives ajoutées au modèle hédonique du cas 1 selon la spécification de la localisation (données synthétiques ; effet vrai nul ; erreurs-types HC1 entre parenthèses)}\label{tab:38:coefficients} | |
| 360 | +\centering | |
| 361 | +\footnotesize\setlength{\tabcolsep}{3.5pt} | |
| 362 | +\begin{tabular}{lrrrrrrr} | |
| 363 | +\toprule | |
| 364 | + & \multicolumn{2}{c}{Référence (secteurs, gradient)} & \multicolumn{2}{c}{Sans indicatrices de secteur} & \multicolumn{2}{c}{Sans secteur ni gradient} & {EMD} \\ | |
| 365 | +\cmidrule(lr){2-3}\cmidrule(lr){4-5}\cmidrule(lr){6-7}\cmidrule(lr){8-8} | |
| 366 | +Variable & {Coef.} & {$t$} & {Coef.} & {$t$} & {Coef.} & {$t$} & {réf.} \\ | |
| 367 | +\midrule | |
| 368 | +Classe A (31 ventes) & 0,0054 (0,0174) & 0,31 & 0,0614 (0,0162) & 3,78 & 0,0578 (0,0179) & 3,22 & 0,049 \\ | |
| 369 | +Classe B (57 ventes) & $-0{,}0181$ (0,0136) & $-1{,}33$ & 0,0424 (0,0141) & 2,99 & 0,0502 (0,0149) & 3,38 & 0,038 \\ | |
| 370 | +Corridor $\leq 300$ m (17 ventes) & $-0{,}0306$ (0,0223) & $-1{,}37$ & $-0{,}0066$ (0,0242) & $-0{,}27$ & $-0{,}0003$ (0,0248) & $-0{,}01$ & 0,062 \\ | |
| 371 | +$\ln(1 + d_{\text{corridor}}/100)$ & $-0{,}0065$ (0,0071) & $-0{,}90$ & $-0{,}0159$ (0,0080) & $-1{,}98$ & 0,0034 (0,0081) & 0,42 & 0,020 \\ | |
| 372 | +$\ln A_i$ (accessibilité) & 0,0106 (0,0211) & 0,50 & 0,1359 (0,0125) & 10,90 & 0,0596 (0,0042) & 14,13 & 0,059 \\ | |
| 373 | +\midrule | |
| 374 | +$R^2$ (sans / avec les cinq variables) & \multicolumn{2}{c}{0,8530 / 0,8541} & \multicolumn{2}{c}{0,7661 / 0,8182} & \multicolumn{2}{c}{0,6372 / 0,8040} & \\ | |
| 375 | +Test $F$ conjoint (valeur $p$) & \multicolumn{2}{c}{0,84 (0,52)} & \multicolumn{2}{c}{33,4 ($< 0{,}001$)} & \multicolumn{2}{c}{99,4 ($< 0{,}001$)} & \\ | |
| 376 | +\bottomrule | |
| 377 | +\end{tabular} | |
| 378 | +\tabsource{\synth{analysis/python/cases/ch38\_sig.py (fonction \texttt{modeles\_augmentes}) ; MCO semi-log, \cref{eq:38:hedonique-risque} sans interactions ; EMD = effet minimal détectable à 80 \% de puissance, \cref{eq:38:emd}, dans le modèle de référence}} | |
| 379 | +\end{table} | |
| 380 | + | |
| 381 | +\subsection{Une expérience sur des polygones aléatoires} | |
| 382 | + | |
| 383 | +L'exemple précédent portait sur deux polygones dessinés par l'auteur. Pour mesurer la fréquence du phénomène, le script tire au hasard 500 disques de rayon 0,5, 1 et 2 kilomètres centrés sur des ventes, ne retient que ceux qui contiennent entre 10 et 590 ventes, soit 107, 346 et 484 disques, et ajoute à chaque spécification l'indicatrice d'appartenance au disque. Chaque disque est une « zone » qui n'a aucun effet ; la part des coefficients dont le $t$ dépasse 1,96 en valeur absolue mesure le taux de faux positifs. Le \cref{tab:38:faux-positifs} et la \cref{fig:38:faux-positifs} donnent les résultats, avec les erreurs-types classiques et robustes. | |
| 384 | + | |
| 385 | +\begin{table}[htbp] | |
| 386 | +\caption{Part des coefficients « significatifs » d'indicatrices de disques aléatoires sans aucun effet, selon le rayon, la spécification de la localisation et l'erreur-type (données synthétiques)}\label{tab:38:faux-positifs} | |
| 387 | +\centering | |
| 388 | +\footnotesize\setlength{\tabcolsep}{3pt} | |
| 389 | +\begin{tabular}{lS[table-format=3.0]S[table-format=2.1]S[table-format=2.1]S[table-format=2.1]S[table-format=2.1]S[table-format=2.1]S[table-format=1.2]} | |
| 390 | +\toprule | |
| 391 | + & & \multicolumn{2}{c}{$\abs{t} > 1{,}96$ (\%)} & \multicolumn{2}{c}{$\abs{t} > 2{,}58$ (\%)} & {Bonferroni (\%)} & {É.-t. des $t$} \\ | |
| 392 | +\cmidrule(lr){3-4}\cmidrule(lr){5-6}\cmidrule(lr){7-7}\cmidrule(lr){8-8} | |
| 393 | +Rayon et spécification & {Disques} & {classique} & {HC1} & {classique} & {HC1} & {classique} & {classique} \\ | |
| 394 | +\midrule | |
| 395 | +0,5 km, référence & 107 & 11.2 & 26.2 & 0.9 & 10.3 & 0.0 & 1.30 \\ | |
| 396 | +0,5 km, sans secteur & 107 & 25.2 & 33.6 & 8.4 & 26.2 & 0.0 & 1.39 \\ | |
| 397 | +0,5 km, sans secteur ni gradient & 107 & 29.0 & 43.9 & 0.0 & 30.8 & 0.0 & 1.41 \\ | |
| 398 | +1 km, référence & 346 & 2.9 & 7.2 & 0.0 & 0.0 & 0.0 & 1.01 \\ | |
| 399 | +1 km, sans secteur & 346 & 36.4 & 51.7 & 23.1 & 33.8 & 15.3 & 2.42 \\ | |
| 400 | +1 km, sans secteur ni gradient & 346 & 28.6 & 41.3 & 16.5 & 27.7 & 3.5 & 1.74 \\ | |
| 401 | +2 km, référence & 484 & 3.5 & 3.7 & 0.4 & 0.2 & 0.0 & 1.05 \\ | |
| 402 | +2 km, sans secteur & 484 & 73.6 & 78.7 & 52.9 & 65.5 & 29.3 & 4.42 \\ | |
| 403 | +2 km, sans secteur ni gradient & 484 & 48.3 & 52.5 & 40.9 & 47.5 & 28.5 & 3.51 \\ | |
| 404 | +\bottomrule | |
| 405 | +\end{tabular} | |
| 406 | +\tabsource{\synth{analysis/python/cases/ch38\_sig.py (fonction \texttt{faux\_positifs}), graine 20261031 ; 500 tirages par rayon, disques de 10 à 590 ventes conservés ; seuil de Bonferroni $\alpha/m$ avec $m$ = nombre de disques ; sous l'hypothèse nulle et une spécification correcte, les parts attendues sont 5 \% et 1 \% et l'écart-type des $t$ vaut 1}} | |
| 407 | +\end{table} | |
| 408 | + | |
| 409 | +\begin{figure}[htbp] | |
| 410 | +\centering | |
| 411 | +\includegraphics[width=\textwidth]{ch38/ch38-fig-faux-positifs.pdf} | |
| 412 | +\caption{Faux positifs spatiaux sur les 600 ventes du cas 1. (a) Distribution des statistiques $t$ (erreurs-types classiques) de l'indicatrice de 346 disques aléatoires de 1 km sans aucun effet, pour le modèle de référence et le modèle sans indicatrices de secteur, avec la densité normale centrée réduite ; (b) part des coefficients significatifs à 5 \% selon le rayon du disque et la spécification. Avec la localisation correctement spécifiée, la part reste au voisinage du seuil nominal ; sans elle, les zones aléatoires paraissent significatives dans un à trois cas sur quatre.}\label{fig:38:faux-positifs} | |
| 413 | +\figsource{\synth{analysis/python/figures/ch38\_figures.py}} | |
| 414 | +\end{figure} | |
| 415 | + | |
| 416 | +Trois résultats se dégagent. Avec le modèle de référence et les erreurs-types classiques, la part de faux positifs vaut 2,9 \% à un kilomètre et 3,5 \% à deux kilomètres, et l'écart-type des $t$ vaut 1,01 et 1,05 : la statistique se comporte comme la théorie le prédit, parce que les résidus du modèle de référence sont indépendants dans l'espace. À 0,5 kilomètre, elle monte à 11,2 \% sur 107 disques qui se recouvrent largement, un écart au seuil nominal compatible avec la dépendance entre les tests et la petitesse de l'échantillon. Sans indicatrices de secteur, la part de faux positifs atteint 36 \% à un kilomètre et 74 \% à deux kilomètres, l'écart-type des $t$ monte à 2,4 et 4,4, et plus d'un disque sur quatre survit même à la correction de Bonferroni à deux kilomètres : les résidus de ce modèle portent la prime de secteur omise, un disque de deux kilomètres tombe presque entièrement dans un seul secteur, et son indicatrice mesure la prime de ce secteur. Plus la zone est grande, plus elle ressemble à un secteur, et plus le faux positif est sûr de lui. Enfin, les erreurs-types HC1 aggravent le problème pour les petits tampons : à 0,5 kilomètre, elles font passer la part de faux positifs du modèle de référence de 11 à 26 \%, parce qu'une correction de White calculée sur une indicatrice qui ne vaut un que pour dix à vingt observations sous-estime fortement la variance ; la version HC3, qui pondère par le levier, ne corrige que partiellement (23 \% dans l'\cref{ex:38:6}), et c'est l'erreur-type classique, ou une inférence par permutation des zones, qui convient lorsque la variable d'intérêt est rare et les résidus homoscédastiques. | |
| 417 | + | |
| 418 | +La correction de Bonferroni, qui remplace le seuil $\alpha$ par $\alpha/m$ lorsque $m$ hypothèses sont testées, | |
| 419 | +\begin{equation}\label{eq:38:bonferroni} | |
| 420 | +\text{rejeter } H_0^{(j)} \text{ si } p_j < \frac{\alpha}{m}, \qquad \Pr[\text{au moins un faux rejet}] \leq \alpha , | |
| 421 | +\end{equation} | |
| 422 | +est l'outil minimal de l'analyste qui essaie plusieurs rayons, plusieurs zones ou plusieurs définitions d'une même variable avant d'en retenir une, ce que la pratique appelle, sans toujours l'avouer, la recherche de spécification ; elle ramène à zéro les faux positifs du modèle de référence, mais elle ne sauve pas une spécification dont les résidus sont spatialement corrélés, et c'est bien le message : \emph{la défense contre les faux positifs spatiaux n'est pas statistique, elle est de spécification}. Un diagnostic de Moran sur les résidus (\cref{ch:19}) avant d'ajouter la moindre variable de zone est la première étape ; des effets fixes de localisation fins, la seconde ; un modèle à erreur spatiale ou des erreurs-types qui tiennent compte de la dépendance, la troisième. | |
| 423 | + | |
| 424 | +\section{Le portefeuille du cas 4 : cartes et exposition}\label{sec:38:cas4} | |
| 425 | + | |
| 426 | +\begin{synthetique} | |
| 427 | +Le portefeuille du cas 4 est synthétique : 48 immeubles fictifs de 6 à 72 logements répartis dans six régions métropolitaines (\texttt{data/synthetic/cas4\_portefeuille.csv}, graine 20261031), avec des coordonnées fictives dispersées autour du centre de chaque RMR, une indicatrice de zone inondable pour trois immeubles et une indicatrice de contamination du sol pour deux ; le générateur relève le taux global de 5 \% en zone inondable et de 3 \% en cas de contamination (\texttt{data/synthetic/README.md}). Les chiffres proviennent de \texttt{traite.cas4} et de \texttt{ch38\_sig.py}. | |
| 428 | +\end{synthetique} | |
| 429 | + | |
| 430 | +Le changement d'échelle impose un changement de projection : les 48 immeubles, répartis de Gatineau à Saguenay, couvrent trois fuseaux MTM et deux fuseaux UTM, et c'est la projection conique conforme Québec Lambert (EPSG 32198) qui les représente sans distorsion notable ; l'étendue projetée est de 371 kilomètres d'est en ouest et de 335 du sud au nord. La \cref{fig:38:cas4} les situe, avec une taille proportionnelle à la valeur 2026 et des marques distinctes pour les immeubles exposés. Les distances géodésiques entre les centroïdes des RMR, calculées sur l'ellipsoïde, vont de 116 kilomètres (Québec--Trois-Rivières) à 478 kilomètres (Gatineau--Saguenay), avec 236 kilomètres entre Montréal et Québec ; à ces distances, les sous-marchés sont des univers séparés, et le \cref{ch:48} traite leur corrélation comme un paramètre de risque, non comme une dépendance spatiale au sens du \cref{ch:19}. | |
| 431 | + | |
| 432 | +\begin{figure}[htbp] | |
| 433 | +\centering | |
| 434 | +\includegraphics[width=\textwidth]{ch38/ch38-fig-cas4.pdf} | |
| 435 | +\caption{Le portefeuille du cas 4 (48 immeubles fictifs). (a) Position des immeubles en NAD83 / Québec Lambert, taille proportionnelle à la valeur 2026 ; les trois immeubles en zone inondable et les deux immeubles au sol contaminé sont cerclés. (b) Part de la valeur de chaque RMR située en zone inondable : 6,7 \% de la valeur du portefeuille, concentrée sur un immeuble de Québec.}\label{fig:38:cas4} | |
| 436 | +\figsource{\synth{analysis/python/figures/ch38\_figures.py}} | |
| 437 | +\end{figure} | |
| 438 | + | |
| 439 | +Le \cref{tab:38:cas4} décompose l'exposition. Trois immeubles sur 48 sont en zone inondable, mais leur poids dans la valeur, \num{6.66} \%, soit \dollars{9546000} sur \dollars{143363000}, est très inégalement réparti : un seul immeuble de 7,2 millions de dollars à Québec représente 28,8 \% de la valeur du portefeuille dans cette RMR et 5,0 points des 6,7 \% d'ensemble ; l'immeuble exposé de Saguenay pèse 18,5 \% de la valeur régionale mais 0,4 point du total ; celui de Montréal, 2,6 \% de la plus grosse région. La carte et le tableau disent la même chose sous deux formes : une part agrégée de 6,7 \% masque une concentration que seule la désagrégation par RMR et par immeuble révèle, et c'est l'immeuble de Québec, non la part d'ensemble, qui doit retenir l'attention du gestionnaire au \cref{ch:48}. Du côté des taux, la régression des effets régionaux de \texttt{traite.cas4} (logarithme du taux global sur l'état, la taille, l'âge, les deux indicatrices de risque et les indicatrices de RMR) donne à l'indicatrice de zone inondable un coefficient de 0,0756 (erreur-type 0,0347), soit un taux supérieur de 7,9 \% toutes choses égales d'ailleurs, à comparer aux 5 \% du générateur, dont il s'écarte de moins d'une erreur-type ; l'indicatrice de contamination donne $+5{,}5$ \% sur deux immeubles, pour un effet vrai de 3 \%. Avec trois et deux immeubles exposés, ces coefficients sont de l'ordre de grandeur de leur incertitude, et l'\cref{eq:38:emd} en dit la raison avant même l'estimation : on ne mesure pas une décote de risque sur un portefeuille, on l'y \emph{applique}, à partir de la littérature et des études de marché du \cref{ch:43}. | |
| 440 | + | |
| 441 | +\begin{table}[htbp] | |
| 442 | +\caption{Exposition du portefeuille du cas 4 aux zones inondables, par région métropolitaine (données synthétiques)}\label{tab:38:cas4} | |
| 443 | +\centering | |
| 444 | +\small\setlength{\tabcolsep}{4pt} | |
| 445 | +\begin{tabular}{lS[table-format=2.0]S[table-format=3.1]S[table-format=1.0]S[table-format=1.0]S[table-format=1.1]S[table-format=2.1]S[table-format=1.1]} | |
| 446 | +\toprule | |
| 447 | +RMR & {Immeubles} & {Valeur 2026 (M\$)} & {En zone inondable} & {Sol contaminé} & {Valeur exposée (M\$)} & {Part de la RMR (\%)} & {Part du total (\%)} \\ | |
| 448 | +\midrule | |
| 449 | +Montréal & 17 & 65.2 & 1 & 2 & 1.7 & 2.6 & 1.2 \\ | |
| 450 | +Québec & 6 & 25.1 & 1 & 0 & 7.2 & 28.8 & 5.0 \\ | |
| 451 | +Gatineau & 8 & 20.3 & 0 & 0 & 0.0 & 0.0 & 0.0 \\ | |
| 452 | +Sherbrooke & 7 & 18.1 & 0 & 0 & 0.0 & 0.0 & 0.0 \\ | |
| 453 | +Trois-Rivières & 6 & 11.4 & 0 & 0 & 0.0 & 0.0 & 0.0 \\ | |
| 454 | +Saguenay & 4 & 3.3 & 1 & 0 & 0.6 & 18.5 & 0.4 \\ | |
| 455 | +\midrule | |
| 456 | +Portefeuille & 48 & 143.4 & 3 & 2 & 9.5 & 6.7 & 6.7 \\ | |
| 457 | +\bottomrule | |
| 458 | +\end{tabular} | |
| 459 | +\tabsource{\synth{traite.cas4.sommaire et analysis/python/cases/ch38\_sig.py (fonction \texttt{cas4\_geo}) ; valeur exposée = valeur 2026 des immeubles dont \texttt{zone\_inondable} = 1}} | |
| 460 | +\end{table} | |
| 461 | + | |
| 462 | +\section{Analyse de sensibilité}\label{sec:38:sensibilite} | |
| 463 | + | |
| 464 | +Trois décisions de ce chapitre méritent une analyse de sensibilité explicite : le rayon d'un tampon, la méthode de classification d'une carte, déjà traitée au \cref{tab:38:classes}, et la spécification de la localisation, traitée au \cref{tab:38:faux-positifs}. La \cref{fig:38:tampon} complète l'ensemble en faisant varier le rayon du tampon du corridor fictif de 100 à \num{1000} mètres, ce qui fait passer le nombre de ventes incluses de 4 à 57. Dans le modèle de référence, le coefficient de l'indicatrice va de $-5{,}4$ \% à $+0{,}2$ \% selon le rayon, sans jamais être significatif, et son intervalle de confiance à 95 \% se rétrécit de $\pm 7{,}2$ points à 100 mètres à $\pm 2{,}2$ points à \num{1000} mètres : l'estimation d'un effet de proximité sur quatre ventes n'a aucun sens, et l'analyste qui aurait retenu le rayon de 100 mètres parce qu'il donne le coefficient le plus négatif aurait fait exactement ce que la \cref{eq:38:bonferroni} interdit. Dans le modèle sans secteur, le coefficient est négatif à tous les rayons et devient significatif à 300 mètres ($-3{,}9$ \%, $t = -2{,}13$), puis de nouveau à 750 et \num{1000} mètres ($-3{,}7$ et $-4{,}5$ \%, $t = -2{,}72$ et $-3{,}56$) : le corridor fictif traverse Hull et le secteur Gatineau, dont les primes sont inférieures à celle d'Aylmer, et le tampon large mesure cette différence. Un lecteur pressé y verrait une décote de nuisance croissant avec la largeur du tampon ; c'est un artefact dont le signe même est un hasard de la géométrie dessinée. | |
| 465 | + | |
| 466 | +\begin{figure}[htbp] | |
| 467 | +\centering | |
| 468 | +\includegraphics[width=\textwidth]{ch38/ch38-fig-tampon.pdf} | |
| 469 | +\caption{Coefficient de l'indicatrice « à moins de $r$ mètres du corridor fictif » dans le modèle hédonique du cas 1 selon le rayon $r$, avec intervalles de confiance à 95 \% (HC1), pour le modèle de référence et le modèle sans indicatrices de secteur ; le nombre de ventes dans le tampon figure en haut. L'effet vrai est nul : la précision croît avec le rayon, et le modèle sans secteur fabrique une décote significative aux grands rayons.}\label{fig:38:tampon} | |
| 470 | +\figsource{\synth{analysis/python/figures/ch38\_figures.py}} | |
| 471 | +\end{figure} | |
| 472 | + | |
| 473 | +La sensibilité à la précision du géocodage, enfin, se déduit du \cref{ch:14} : avec des erreurs de position de quarante mètres, caractéristiques de l'interpolation le long de la rue, les ventes situées à moins de quarante mètres d'une frontière de classe peuvent changer de classe. Dans le jeu du cas 1, la frontière de la classe A à 350 mètres du cours d'eau fictif et celle de la classe B à 900 mètres sont des courbes de 44 kilomètres chacune ; les ventes qui en sont à moins de quarante mètres sont peu nombreuses, mais leur réaffectation suffit à modifier de quelques unités les effectifs de 31 et 57 du \cref{tab:38:secteurs}, et donc les coefficients. Sur des données réelles géocodées au centroïde du code postal, l'appartenance à une zone inondable, dont la largeur se compte souvent en dizaines de mètres, devient presque aléatoire ; c'est pourquoi la jointure spatiale avec une couche de risque exige un géocodage au point d'adresse ou à la parcelle, et pourquoi la colonne de précision du géocodage recommandée au \cref{ch:14} doit accompagner toute variable de zone. | |
| 474 | + | |
| 475 | +\begin{quebec}[Ce que l'évaluateur fait de la carte des zones inondables] | |
| 476 | +Pour un immeuble riverain, la démarche minimale est la suivante : consulter la carte interactive des zones inondables et de mobilité en vigueur \parencite{inondcartes} et noter la génération de cartographie applicable au territoire, la classe d'intensité ou la zone de courant, la date de consultation ; vérifier au règlement de zonage et, le cas échéant, au schéma d'aménagement la zone de contraintes et les restrictions associées ; vérifier au registre foncier les avis de contamination et les servitudes ; consulter la liste municipale des terrains contaminés ; documenter au rapport la source, la date et la précision de la position utilisée pour la jointure. Les Normes de pratique professionnelle de l'OEAQ exigent que le rapport expose les données, l'analyse et le raisonnement qui mènent à la conclusion \parencite{oeaqNormes} ; une appartenance à une zone de risque est une donnée dont la source et la date doivent figurer, et les normes de l'IAAO sur l'évaluation de masse traitent la localisation, y compris les contraintes environnementales, comme une dimension à modéliser et à valider \parencite{iaaomass2025}. La Loi sur la fiscalité municipale prévoit par ailleurs la tenue à jour du rôle à la suite d'un sinistre ou d'une restriction juridique nouvelle (\cref{ch:09}), ce qui fait de l'entrée en vigueur d'une nouvelle cartographie un événement d'évaluation foncière autant que de marché. Sources consultées le 31 octobre 2026 ; les règles détaillées par classe d'intensité n'ont pas été extraites du règlement et doivent être lues dans sa version en vigueur. | |
| 477 | +\end{quebec} | |
| 478 | + | |
| 479 | +\begin{erreurs} | |
| 480 | +\begin{itemize} | |
| 481 | +\item Mesurer des distances en degrés, ou superposer deux couches dans des systèmes de référence différents sans les reprojeter : les écarts se comptent en centaines de mètres et les jointures échouent silencieusement. | |
| 482 | +\item Mesurer des surfaces ou des distances en Pseudo-Mercator (EPSG 3857) parce que le fond de tuiles l'exige : à $45^\circ$ de latitude, l'échelle est fausse de 40 \%. | |
| 483 | +\item Ajouter une indicatrice de zone à un modèle sans effets fixes de localisation fins : le \cref{tab:38:coefficients} montre une « zone inondable » fictive significative à $+6$ \%, et le \cref{tab:38:faux-positifs} que trois disques aléatoires sur quatre deviennent significatifs à deux kilomètres. | |
| 484 | +\item Choisir le rayon d'un tampon, ou la définition d'une variable, après avoir regardé les coefficients, sans corriger pour les tests multiples ni déclarer les variantes essayées. | |
| 485 | +\item Lire l'absence de signification d'un coefficient de risque comme une absence d'effet, sans calculer l'effet minimal détectable : avec 31 ventes exposées, une décote de 3 \% est invisible. | |
| 486 | +\item Utiliser des erreurs-types HC1 pour une indicatrice qui ne vaut un que pour une poignée d'observations : la part de faux positifs double. | |
| 487 | +\item Cartographier des effectifs bruts en choroplèthe, choisir une palette arc-en-ciel ou divergente pour une variable de même signe, omettre les bornes de classes, la source et le système de référence. | |
| 488 | +\item Joindre des ventes géocodées au centroïde du code postal à une couche de zones inondables : l'appartenance devient aléatoire. | |
| 489 | +\item Confondre une couche municipale d'ancienne génération (grand et faible courant) avec les classes d'intensité du cadre de 2026, dont la correspondance n'est pas énoncée. | |
| 490 | +\item Tenir le Répertoire des terrains contaminés pour exhaustif, ou pour un substitut à la consultation du registre foncier. | |
| 491 | +\end{itemize} | |
| 492 | +\end{erreurs} | |
| 493 | + | |
| 494 | +\begin{limites} | |
| 495 | +Les géométries de l'exemple intégral sont fictives et ont été dessinées pour traverser les grappes de ventes ; les coefficients qu'elles produisent dans les spécifications dégradées illustrent un mécanisme de confusion, non une mesure de la décote d'une zone inondable ou d'une nuisance réelle, qui relève du \cref{ch:43}. Les fractions de dommage du \cref{tab:38:capitalisation} sont des hypothèses pédagogiques sans source empirique. Le jeu du cas 1 ayant des résidus indépendants, le modèle de référence y est, par construction, exempt de faux positifs au-delà du seuil nominal ; sur des données réelles, aucune segmentation n'épuise la dépendance spatiale et la part de faux positifs d'un modèle « raisonnable » se situe quelque part entre les deux colonnes du \cref{tab:38:faux-positifs}. Les distances de ce chapitre sont euclidiennes ; aucune distance réseau n'a été calculée, et la distance de Manhattan n'en est qu'une approximation pour un tissu orthogonal. La couverture réelle des couches de zonage et de zones de contraintes relatives aux glissements de terrain n'a pas été vérifiée, aucun fichier géospatial volumineux n'a été téléchargé pour l'ouvrage, et la fiche en ligne du registre EPSG n'a pu être consultée que par la base distribuée avec PROJ. Les codes EPSG, les formats et les licences du \cref{tab:38:jeux} sont ceux du 31 octobre 2026 ; les cartographies de zones inondables de nouvelle génération sont publiées progressivement et leur état change. Enfin, les outils de ce chapitre produisent des variables ; ils ne dispensent ni de l'inspection, qui seule constate un sous-sol inondé ou un mur fissuré, ni de la lecture du règlement. | |
| 496 | +\end{limites} | |
| 497 | + | |
| 498 | +\begin{synthese} | |
| 499 | +\begin{itemize} | |
| 500 | +\item Toute donnée géospatiale porte un système de référence ; au Québec, les mesures se font en NAD83 ou NAD83(SCRS) avec la projection MTM du fuseau local (EPSG 32187--32189, 2949--2951) ou en Québec Lambert (32198) pour la province ; jamais en degrés ni en Pseudo-Mercator. Le facteur d'échelle MTM borne la distorsion à quelques dix-millièmes. | |
| 501 | +\item Le rôle d'évaluation géoréférencé (GPKG, CC BY 4.0), Adresses Québec, les zonages municipaux et les cartographies de zones inondables en vigueur sont les couches de base ; les propriétaires sont caviardés et le Répertoire des terrains contaminés n'est pas exhaustif. | |
| 502 | +\item Cinq opérations suffisent : projection, jointure spatiale par point dans polygone, tampon, distance (euclidienne, orthodromique, réseau), agrégation et interpolation ; chacune a un piège, et la précision du géocodage borne toutes les autres. | |
| 503 | +\item L'indice gravitaire est la somme logarithmique d'un modèle de choix discret ; c'est $\ln A_i$ qui entre dans le modèle hédonique, et le paramètre de friction s'estime, il ne se devine pas. | |
| 504 | +\item Les classes d'intensité du cadre de 2026 sont ordinales et probabilistes ; la probabilité sur 25 ans se convertit en probabilité annuelle, et la décote capitalisée d'une perte espérée se concentre dans les classes fréquentes ; le marché y ajoute la valeur des restrictions et l'anticipation. | |
| 505 | +\item Sur le cas 1, où l'effet vrai est nul, les variables fictives sont non significatives dans le modèle de référence et fortement significatives sans indicatrices de secteur ; des disques aléatoires de deux kilomètres paraissent significatifs dans 74 \% des cas sans secteur contre 3,5 \% avec. La défense contre les faux positifs spatiaux est la spécification de la localisation, pas la correction statistique. | |
| 506 | +\item Une carte thématique défendable normalise ce qu'elle teinte, déclare sa classification et son GVF, utilise une palette séquentielle ou divergente selon la nature de la variable, hachure ce qu'elle ne cartographie pas, et porte sa source, son SRC et sa date. | |
| 507 | +\item Sur le cas 4, 6,7 \% de la valeur est en zone inondable, mais un seul immeuble de Québec en représente les trois quarts : l'exposition se lit immeuble par immeuble. | |
| 508 | +\end{itemize} | |
| 509 | +\end{synthese} | |
| 510 | + | |
| 511 | +\section*{Exercices} | |
| 512 | +\addcontentsline{toc}{section}{Exercices} | |
| 513 | + | |
| 514 | +\begin{exercice}[1]{Choisir le système de référence}{ex:38:1} | |
| 515 | +Pour chacune des positions suivantes, indiquez le fuseau MTM et le fuseau UTM appropriés et le code EPSG correspondant sur le datum NAD83 : (a) Montréal, longitude $73{,}60^\circ$ O, latitude $45{,}51^\circ$ N ; (b) Québec, $71{,}19^\circ$ O, $46{,}82^\circ$ N ; (c) Gatineau, $75{,}74^\circ$ O, $45{,}42^\circ$ N ; (d) Saguenay, $71{,}10^\circ$ O, $48{,}38^\circ$ N. Calculez ensuite, par l'\cref{eq:38:echelle}, le facteur d'échelle MTM en chacune de ces positions et l'erreur relative commise en lisant une distance de 10 kilomètres sur la carte sans correction. Pourquoi la projection Québec Lambert est-elle préférable pour une carte qui réunit ces quatre villes ? | |
| 516 | +\end{exercice} | |
| 517 | + | |
| 518 | +\begin{exercice}[1]{Trois distances}{ex:38:2} | |
| 519 | +Le sujet du cas 1 est en latitude $45{,}4215^\circ$ N, longitude $75{,}7395^\circ$ O ; une vente se trouve en $45{,}4620^\circ$ N, $75{,}6612^\circ$ O. Calculez la distance entre les deux points (a) par la projection équirectangulaire locale de l'\cref{eq:38:equirect} centrée sur le sujet ; (b) par la formule du haversine \eqref{eq:38:haversine} avec $R = \qty{6371.0}{\kilo\metre}$ ; (c) par la distance « en degrés » $\sqrt{\Delta\varphi^2 + \Delta\lambda^2}$ convertie à raison de 111 kilomètres par degré. Commentez l'écart de la troisième à la première et expliquez son origine. | |
| 520 | +\end{exercice} | |
| 521 | + | |
| 522 | +\begin{exercice}[2]{Point dans polygone et tampon}{ex:38:3} | |
| 523 | +Un polygone a pour sommets, en kilomètres, $(0, 0)$, $(4, 0)$, $(4, 3)$, $(2, 1)$ et $(0, 3)$, dans cet ordre. Par le lancer de rayon de l'\cref{eq:38:pip}, déterminez si les points $P_1 = (1, 1)$, $P_2 = (2, 2)$ et $P_3 = (3, 2{,}5)$ sont intérieurs, en donnant pour chacun le nombre de croisements. Calculez ensuite l'aire du polygone par la formule du lacet, puis l'aire de son tampon de 0,5 kilomètre en décomposant le tampon en polygone, bandes rectangulaires le long des arêtes et secteurs circulaires aux sommets convexes (indication : la somme des secteurs aux sommets convexes d'un polygone simple vaut un disque entier, moins la part absorbée par les sommets rentrants ; on admettra qu'un seul sommet est rentrant et l'on raisonnera à partir de la formule $\operatorname{aire}(B_r(\mathcal{P})) = \operatorname{aire}(\mathcal{P}) + r\,\operatorname{périmètre}(\mathcal{P}) + \pi r^2$ valable pour un polygone convexe, en expliquant pourquoi elle surestime ici). | |
| 524 | +\end{exercice} | |
| 525 | + | |
| 526 | +\begin{exercice}[2]{Indice gravitaire}{ex:38:4} | |
| 527 | +Trois pôles ont des masses de 100, 40 et 30 et se trouvent respectivement à 1,5, 6 et 9 kilomètres d'une maison. Calculez l'indice d'accessibilité exponentiel \eqref{eq:38:gravite} pour $\lambda = 0{,}25$ puis $\lambda = 0{,}50$ par kilomètre, ainsi que son logarithme. Une seconde maison est à 4, 4 et 4 kilomètres des trois pôles : laquelle est la mieux située selon chaque valeur de $\lambda$ ? Interprétez le résultat à l'aide de la dérivation de l'\cref{eq:38:logsum} et dites pourquoi c'est $\ln A_i$ et non $A_i$ qui doit entrer dans le modèle hédonique. | |
| 528 | +\end{exercice} | |
| 529 | + | |
| 530 | +\begin{exercice}[2]{Capitalisation d'un risque d'inondation}{ex:38:5} | |
| 531 | +Une maison évaluée \dollars{520000} se trouve dans une zone dont la probabilité d'inondation sur 25 ans est estimée à 35 \%. Calculez la probabilité annuelle \eqref{eq:38:annuelle}, puis la décote capitalisée \eqref{eq:38:capitalisation} pour un dommage de 10 \% de la valeur par événement et un taux de 5 \%. Refaites le calcul pour une profondeur attendue qui porterait le dommage à 20 \%. Comparez à une décote observée de 8 \% dans une étude de marché et proposez deux explications de l'écart tirées de la \cref{sec:38:risques}. Enfin, si 24 ventes sur 500 sont dans la zone et que l'écart-type résiduel du modèle vaut 0,09, estimez l'erreur-type approximative du coefficient de l'indicatrice (en supposant $R^2_z = 0{,}1$) et l'effet minimal détectable \eqref{eq:38:emd} : la décote de 8 \% serait-elle détectable ? | |
| 532 | +\end{exercice} | |
| 533 | + | |
| 534 | +\begin{exercice}[3]{Faux positifs et tests multiples}{ex:38:6} | |
| 535 | +À l'aide de \texttt{analysis/python/cases/ch38\_sig.py} (fonction \texttt{faux\_positifs}), répétez l'expérience des disques aléatoires avec un rayon de 3 kilomètres et \num{1000} tirages pour les trois spécifications. Rapportez la part de coefficients significatifs à 5 \% et après correction de Bonferroni, ainsi que l'écart-type des statistiques $t$. Remplacez ensuite les erreurs-types HC1 par les erreurs-types classiques et par la version HC3 (à implémenter : $\hat\sigma_i^2 = e_i^2/(1 - h_{ii})^2$) pour le rayon de 0,5 kilomètre et comparez les parts de faux positifs du modèle de référence. Rédigez une note de 200 mots sur ce que ces résultats impliquent pour un rapport qui dérive une décote de zone d'un modèle hédonique. | |
| 536 | +\end{exercice} | |
| 537 | + | |
| 538 | +\begin{exercice}[3]{Carte choroplèthe et unités spatiales modifiables}{ex:38:7} | |
| 539 | +À partir de \texttt{grille\_et\_agregation} dans \texttt{ch38\_sig.py}, recalculez la carte de la médiane du prix au mètre carré du cas 1 avec des cellules hexagonales de 0,8 et de 2,0 kilomètres de rayon, puis avec une grille carrée de 2 kilomètres de côté. Pour chaque grille, indiquez le nombre de cellules d'au moins cinq ventes, les bornes de Fisher--Jenks à cinq classes et le GVF. Comparez la part de la variance totale des prix au mètre carré qui est intercellulaire (entre les médianes) et intracellulaire selon la grille, et expliquez en quoi c'est une manifestation du problème des unités spatiales modifiables. Quelle grille recommanderiez-vous pour un rapport d'évaluation de masse destiné à un conseil municipal, et pourquoi ? | |
| 540 | +\end{exercice} | |
| 541 | + | |
| 542 | +\section*{Pour aller plus loin} | |
| 543 | +\addcontentsline{toc}{section}{Pour aller plus loin} | |
| 544 | + | |
| 545 | +Sur la qualité du géocodage et ses conséquences pour les variables spatiales, \textcite{zandbergen2008} et \textcite{ratcliffe2001} restent les références, complétées par le \cref{ch:14}. Les manuels d'économétrie spatiale de \textcite{anselin1988}, \textcite{lesagePace2009} et, pour les microdonnées immobilières, \textcite{dubeLegros2014} traitent des matrices de voisinage et des distances qui les fondent ; \textcite{dubin1988,dubin1998} introduisent la dépendance spatiale continue que ce chapitre a effleurée par l'interpolation, et \textcite{banerjeeCarlinGelfand2014} en donnent le traitement hiérarchique bayésien qui ouvre sur le \cref{ch:39}. La régression géographiquement pondérée de \textcite{fotheringhamBrunsdonCharlton2002} est l'outil cartographique par excellence de l'hétérogénéité spatiale (\cref{ch:19}). Les travaux québécois sur les attributs d'environnement mesurés par SIG, \textcite{desrosiersTheriaultVilleneuve2000}, \textcite{desrosiersLaganaTheriault2001}, \textcite{theriaultDesrosiersVilleneuveKestens2003}, \textcite{kestensTheriaultDesrosiers2006} et la synthèse de \textcite{theriaultDesrosiers2011}, puis \textcite{dubeTheriaultDesrosiers2013} sur l'accessibilité ferroviaire, montrent comment des distances réseau et des couches d'usage du sol entrent dans un modèle hédonique estimé sur des données de Québec et de Montréal. Sur la spécification des effets fixes qui protège les prix implicites d'aménités, on lira \textcite{kuminoffParmeterPope2010} et \textcite{bishop2020}. Sur les risques cartographiés et leur capitalisation, \textcite{binPolasky2004}, \textcite{binKruseLandry2008} et \textcite{bernsteinGustafsonLewis2019} fournissent trois stratégies d'identification différentes, avant et après un événement, séparation du risque et de l'aménité, exposition future, que le \cref{ch:43} reprend. Le cadre réglementaire québécois des zones inondables se lit dans les pages gouvernementales \parencite{zonesInond2026,inondcartes,inondtravaux} et dans la Loi sur la qualité de l'environnement \parencite{lqe} ; le rôle d'évaluation géoréférencé \parencite{dqroles} et le Référentiel québécois des adresses \parencite{mrnfRQA2026} sont les deux jeux ouverts par lesquels commencer toute analyse géospatiale de l'immobilier au Québec. | |
modified
manuscript/chapters/ch42.tex
+297 −1
@@ -1,2 +1,298 @@ | ||
| 1 | 1 | \chapter{Copropriétés et conversions}\label{ch:42} |
| 2 | −\stubchapter | |
| 2 | + | |
| 3 | +La copropriété divise est devenue, en une génération, le mode d'accession à la propriété le plus répandu dans les quartiers centraux des villes québécoises, et la fraction de copropriété est aujourd'hui le bien que l'évaluateur résidentiel rencontre le plus souvent après la maison unifamiliale. Elle pose pourtant un problème d'évaluation qui lui est propre : ce que l'acheteur acquiert n'est pas un bâtiment, mais une partie privative, une quote-part indivise de parties communes qu'il ne contrôle pas seul, l'usage exclusif de quelques accessoires, et une part des engagements financiers, présents et futurs, d'une personne morale, le syndicat, dont la santé conditionne la valeur de chaque fraction. Le \cref{ch:07} a décrit l'architecture juridique de cette modalité de propriété et les documents que le syndicat produit depuis le 14 août 2025 ; le présent chapitre l'aborde du point de vue du marché. Il montre d'abord comment évaluer une fraction par comparaison, en distinguant les ventes conclues dans le même immeuble de celles conclues ailleurs, et en isolant les ajustements qui n'existent que pour ce type de bien : l'étage, l'orientation, le stationnement, les frais de copropriété et l'état du fonds de prévoyance. Il traite ensuite de la copropriété indivise, de la copropriété détenue pour la location, puis de la conversion d'un immeuble locatif en copropriété divise, opération strictement encadrée par la Loi sur le Tribunal administratif du logement et dont la faisabilité se mesure en comparant la valeur de l'immeuble en bloc à la somme des fractions, nette des coûts et des délais. L'immeuble fictif de huit fractions du \cref{ch:07} sert de fil conducteur à tous les exemples, de sorte que les chiffres des deux chapitres se répondent. | |
| 4 | + | |
| 5 | +\begin{objectifs} | |
| 6 | +À la fin de ce chapitre, le lecteur sait : | |
| 7 | +\begin{itemize} | |
| 8 | +\item définir l'objet évalué lorsqu'il s'agit d'une fraction de copropriété divise et dresser la liste des documents à obtenir du vendeur et du syndicat avant d'en estimer la valeur ; | |
| 9 | +\item organiser la recherche de comparables en deux cercles, les ventes de l'immeuble et celles d'immeubles concurrents, et expliquer pourquoi les premières éliminent une source d'erreur que les secondes ne peuvent qu'ajuster ; | |
| 10 | +\item quantifier les ajustements propres à la copropriété (étage, orientation, stationnement, superficie, frais de copropriété, situation financière du syndicat) et dériver la règle de capitalisation d'un écart de charges ; | |
| 11 | +\item traduire un déficit de capitalisation du fonds de prévoyance ou une cotisation spéciale en ajustement de valeur, de façon cohérente avec le \cref{ch:07} ; | |
| 12 | +\item distinguer la valeur marchande d'une fraction, fixée par les acheteurs-occupants, de sa valeur d'investissement pour un bailleur, et situer la copropriété locative dans les données de la Société canadienne d'hypothèques et de logement ; | |
| 13 | +\item décrire le cadre juridique de la conversion d'un immeuble locatif en copropriété divise et conduire une analyse de faisabilité qui compare la valeur en bloc à la somme des fractions, nette des coûts, des frais, des délais et des droits des locataires. | |
| 14 | +\end{itemize} | |
| 15 | +\end{objectifs} | |
| 16 | + | |
| 17 | +\section{L'objet évalué et son marché}\label{sec:42:objet} | |
| 18 | + | |
| 19 | +\subsection{La fraction, son syndicat et ce que le prix contient} | |
| 20 | + | |
| 21 | +La copropriété divise naît de la publication d'une déclaration qui divise l'immeuble en fractions, chacune comprenant une partie privative et une quote-part des parties communes \parencite[art.~1010, 1038, 1042--1043]{ccq} ; dès cette publication, la collectivité des copropriétaires forme une personne morale, le syndicat, chargée de la conservation de l'immeuble et tenue de veiller à ce que les travaux nécessaires soient effectués \parencite[art.~1039]{ccq}. Le \cref{ch:07} a détaillé ce que la déclaration contient, comment la valeur relative de chaque fraction est établie selon la nature, la destination, les dimensions et la situation de la partie privative, sans égard à son utilisation \parencite[art.~1041]{ccq}, et comment cette valeur relative, fixée une fois pour toutes, détermine la quote-part des charges et des voix \parencite[art.~1053, 1064]{ccq}. Il a aussi posé le cadre de décomposition de la valeur d'une fraction, l'\cref{eq:7:fraction}, qui additionne la valeur attribuable aux éléments propres de la fraction et sa part de la valeur des parties communes, puis en retranche sa part du passif latent du syndicat. Le présent chapitre part de ce cadre et le confronte au marché. | |
| 22 | + | |
| 23 | +Ce qu'un acheteur paie lorsqu'il achète une fraction est donc un faisceau qui comprend quatre choses. La première est la partie privative elle-même, avec ses dimensions, sa disposition, son étage, son orientation, sa vue et son état intérieur. La deuxième est l'usage exclusif des parties communes à usage restreint qui lui sont rattachées : balcon, terrasse, case de stationnement, espace de rangement, dont l'entretien courant incombe aux seuls usagers tandis que leur remplacement est mutualisé sauf clause contraire de la déclaration \parencite[art.~1064]{ccq} et selon l'interprétation de la Cour d'appel rappelée au \cref{ch:07} \parencite{almaca2014}. La troisième est la quote-part des parties communes générales, c'est-à-dire la part de l'enveloppe, de la structure, des équipements et du terrain, dont la qualité et l'état se lisent dans le carnet d'entretien. La quatrième, la plus souvent négligée, est la position financière de la fraction dans le syndicat : les charges communes qu'elle supportera, les sommes qu'elle devra verser au fonds de prévoyance et au fonds d'auto assurance, les cotisations spéciales votées ou prévisibles, et sa part des litiges et des dettes du syndicat, puisqu'un jugement contre le syndicat est exécutoire contre chaque copropriétaire en proportion de la valeur relative de sa fraction \parencite[art.~1078]{ccq}. Les trois premières composantes relèvent de la comparaison classique ; la quatrième exige des ajustements qui n'ont pas d'équivalent pour une maison et qui font l'objet de la \cref{sec:42:ajustements}. | |
| 24 | + | |
| 25 | +Deux clarifications s'imposent d'emblée, parce qu'elles sont la source des erreurs les plus fréquentes. D'abord, la \emph{valeur relative} n'est pas une valeur marchande et la quote-part n'est pas une clé de ventilation de la valeur de l'immeuble : appliquer la quote-part de 14,08~\% de la fraction F4 de l'immeuble fictif à la valeur de l'immeuble entier ne donne pas la valeur de F4, parce que la valeur relative ignore l'état intérieur, les améliorations et la conjoncture, et parce que la somme des valeurs marchandes des fractions n'égale pas la valeur de l'immeuble entier détenu par un seul propriétaire, ce que la \cref{sec:42:conversion} démontre et exploite. Ensuite, les \emph{frais de copropriété} ne sont pas un coût homogène : ils contiennent des charges courantes, qui sont une dépense, et des contributions au fonds de prévoyance et au fonds d'auto assurance, qui sont une épargne forcée destinée à des travaux dont l'acheteur bénéficiera. Comparer deux fractions par leurs frais mensuels bruts revient à pénaliser la copropriété bien gérée, et le \cref{ch:07} a montré sur l'immeuble fictif que la copropriété qui verse \dollars{12000} au fonds au lieu des \dollars{21891} que l'étude recommande affiche des frais plus bas et porte pourtant un déficit de capitalisation de \dollars{150908}. | |
| 26 | + | |
| 27 | +\subsection{Le marché québécois de la copropriété en 2026} | |
| 28 | + | |
| 29 | +La copropriété est un marché à part entière, avec ses prix, ses délais et sa conjoncture, qu'il faut situer avant d'évaluer. Le \cref{tab:42:marche} rassemble les indicateurs publics les plus récents à la date de rédaction, et la \cref{fig:42:medianes} compare les prix médians des copropriétés et des unifamiliales par territoire. | |
| 30 | + | |
| 31 | +\begin{table}[htbp] | |
| 32 | +\caption{Indicateurs publics du marché de la copropriété au Québec (état au 31 octobre 2026)}\label{tab:42:marche} | |
| 33 | +\centering\footnotesize | |
| 34 | +\begin{tabularx}{\textwidth}{L{34mm}YL{34mm}} | |
| 35 | +\toprule | |
| 36 | +Indicateur & Valeur & Source et période \\ | |
| 37 | +\midrule | |
| 38 | +Prix médian Centris, copropriété, T3 2026 & Province \dollars{400000} (0~\% a/a) ; RMR de Montréal \dollars{435000} ($+2$~\%) ; île de Montréal \dollars{477500} ($+1$~\%) ; RMR de Québec \dollars{334900} ($+6$~\%) ; RMR de Gatineau \dollars{297450} ($-5$~\%) ; RMR de Sherbrooke \dollars{327000} ($+4$~\%) ; RMR de Trois-Rivières \dollars{287000} & APCIQ, communiqué du 14 octobre 2026 \parencite{apciqt32026} \\ | |
| 39 | +Délais de vente médians, copropriété, T3 2026 & Province 58 jours ; Montréal 61 ; Québec 34 ; Gatineau 36 & APCIQ, T3 2026 \parencite{apciqt32026,apciqgatineaut32026} \\ | |
| 40 | +Prix médian par secteur, T3 2026 & Rosemont \dollars{563200} ; Villeray \dollars{586500} (petit échantillon) ; La Cité--Basse-Ville et Limoilou \dollars{320000} ($+18$~\%) ; ville de Gatineau \dollars{295000} & Baromètres FSMI \parencite{apciqbaromontreal2026,apciqbaroquebec2026,apciqgatineaut32026} \\ | |
| 41 | +Valeur moyenne au rôle, copropriété & Montréal \dollars{549600} (rôle 2026-2028, référence 1\ier{} juillet 2024) ; Gatineau \dollars{306300} $\to$ \dollars{320700} ($+4{,}70$~\%, rôle 2027-2029, référence 1\ier{} juillet 2025) ; agglomération de Québec \dollars{301539} ($+21{,}6$~\%, rôle 2025-2027, référence 1\ier{} juillet 2023) & Villes de Montréal, Gatineau et Québec \parencite{montrealrole2026,gatineaurole2027,quebecrole2025} \\ | |
| 42 | +Univers des copropriétés et part offerte en location, octobre 2025 & RMR de Montréal \num{233535} unités, 21,9~\% louées ; RMR de Québec \num{36816}, 15,5~\% ; Gatineau (partie québécoise) \num{10620}, 27,4~\% & SCHL, Enquête sur les logements locatifs, tableaux 4.3.1 \parencite{schlrmltables2025} \\ | |
| 43 | +Taux d'inoccupation, copropriétés offertes en location, octobre 2025 & Montréal 2,1~\% (1,4~\% en 2024) ; Québec 0,6~\% ; Gatineau 1,0~\% ; appartements locatifs traditionnels : 2,9~\%, 2,4~\% et 3,8~\% & SCHL, tableaux 4.1.1 \parencite{schlrmltables2025} \\ | |
| 44 | +Compte de taxes moyen d'une copropriété, Montréal, 2026 & \dollars{3301} (unifamiliale : \dollars{4939}) & La Presse, 19 octobre 2026, source secondaire\footnotemark \\ | |
| 45 | +\bottomrule | |
| 46 | +\end{tabularx} | |
| 47 | +\tabsource{compilation de l'auteur d'après les sources citées, consultées le 31 octobre 2026 ; les prix APCIQ sont des médianes des ventes conclues par courtier (système Centris) et non de l'ensemble des mutations.} | |
| 48 | +\end{table} | |
| 49 | +\footnotetext{Chiffre rapporté par La Presse (« Taxes locales à Montréal : un bond de près de 9~\% en 2027 », 19 octobre 2026) d'après les documents budgétaires de la Ville ; le document officiel n'a pas été lu (\texttt{sources/notes-E.md}, §~ch.~28).} | |
| 50 | + | |
| 51 | +\begin{figure}[htbp] | |
| 52 | +\centering | |
| 53 | +\includegraphics[width=\linewidth]{ch42/ch42-fig-medianes-rmr.pdf} | |
| 54 | +\caption{Prix médians des copropriétés et des unifamiliales vendues par l'entremise du système Centris au troisième trimestre de 2026, par territoire, avec la variation annuelle de la médiane des copropriétés et le rapport entre les deux médianes. La copropriété se vend entre 59~\% (île de Montréal) et 80~\% (province) du prix de l'unifamiliale, l'écart étant d'autant plus grand que le marché est cher.}\label{fig:42:medianes} | |
| 55 | +\figsource{APCIQ, Statistiques résidentielles Centris, 3\ieme{} trimestre 2026, communiqué du 14 octobre 2026 \parencite{apciqt32026} ; script \texttt{analysis/python/figures/ch42\_medianes.py} ; chiffres cités tels que publiés.} | |
| 56 | +\end{figure} | |
| 57 | + | |
| 58 | +Trois traits de ce tableau pèsent sur la pratique. D'abord, la copropriété est un marché de grandes villes : la RMR de Montréal concentre près d'un quart de million d'appartements en copropriété, dont plus d'une sur cinq est offerte en location, ce qui fait de la fraction louée un bien courant et de l'investisseur un acheteur significatif (\cref{sec:42:locative}). Ensuite, les prix régionaux diffèrent autant que ceux des maisons, et la fraction de \msq{98} qui se vend autour de \dollars{380000} dans l'exemple de ce chapitre serait, à caractéristiques égales, nettement plus chère dans un quartier central de l'île de Montréal et moins chère à Gatineau ; les médianes du \cref{tab:42:marche} ne sont donc pas des valeurs, mais des repères pour calibrer l'ordre de grandeur de l'exemple et pour vérifier qu'une conclusion n'est pas aberrante. Enfin, la copropriété est le segment où la conjoncture se lit le plus vite : les délais de vente de 2026 y sont plus longs que pour les unifamiliales partout sauf à Québec, et l'offre en forte hausse à Montréal signale un marché où l'acheteur a retrouvé du choix, ce qui affecte directement l'ajustement de conditions du marché et la durée d'exposition à retenir. | |
| 59 | + | |
| 60 | +\subsection{Les documents que l'évaluateur obtient} | |
| 61 | + | |
| 62 | +L'évaluation d'une fraction commence par un dossier documentaire plus lourd que celui d'une maison, et le droit en fixe désormais le contenu. Le vendeur doit remettre en temps utile au promettant acheteur une attestation du syndicat sur l'état de la copropriété, que le syndicat délivre dans les quinze jours \parencite[art.~1068.1]{ccq} ; le promettant acheteur peut demander au syndicat, à ses frais, les documents et renseignements qui lui permettent un consentement éclairé \parencite[art.~1068.2]{ccq} ; l'acquéreur, tenu des charges communes dues au moment de l'acquisition, peut obtenir un état des charges dues \parencite[art.~1069]{ccq}. Le contenu de l'attestation est fixé par règlement : solde du fonds de prévoyance et recommandation de l'étude, contributions exigées et payées sur trois exercices, liquidités, surplus ou déficit des trois derniers états financiers, budget, assurances et plus haute franchise, fonds d'auto assurance, inspections et sinistres des cinq dernières années, travaux majeurs réalisés depuis cinq ans et prévus pour les dix prochaines années avec leurs coûts, litiges en cours et modifications à la déclaration depuis trois ans \parencite[art.~10]{reglCoproprieteDivise}. Le \cref{tab:42:documents} relie chaque pièce à l'usage que l'évaluateur en fait et à la section du chapitre qui en traite. | |
| 63 | + | |
| 64 | +\begin{table}[htbp] | |
| 65 | +\caption{Pièces du dossier d'évaluation d'une fraction de copropriété divise et usage de chacune}\label{tab:42:documents} | |
| 66 | +\centering\footnotesize | |
| 67 | +\begin{tabularx}{\textwidth}{L{40mm}L{30mm}Y} | |
| 68 | +\toprule | |
| 69 | +Pièce & Fondement & Usage en évaluation \\ | |
| 70 | +\midrule | |
| 71 | +Déclaration de copropriété (acte constitutif, règlement, état descriptif) & art.~1052--1053 C.c.Q. & Destination (location, usages interdits, animaux), parties communes à usage restreint rattachées à la fraction, quote-part, clauses pénales ; bassin d'acheteurs ; charges \\ | |
| 72 | +Attestation du syndicat sur l'état de la copropriété & art.~1068.1 C.c.Q. ; CCQ, r.~8.01, art.~10 & Solde et recommandation du fonds, cotisations, déficits, sinistres, travaux prévus sur dix ans, litiges : intrants de l'ajustement de situation financière (\cref{sec:42:syndicat}) \\ | |
| 73 | +Carnet d'entretien et étude du fonds de prévoyance & art.~1070.2, 1071 C.c.Q. ; r.~8.01, art.~1--9 & État et durée de vie des composantes communes (dépréciation, \cref{ch:24}) ; cotisation nécessaire et déficit de capitalisation (\cref{ch:07}) \\ | |
| 74 | +Budget, états financiers, procès-verbaux récents & art.~1068.2, 1070 C.c.Q. & Décomposition des frais en charges courantes, fonds et auto assurance ; cotisations spéciales votées ou discutées ; arrérages \\ | |
| 75 | +État des charges dues & art.~1069 C.c.Q. & Passif qui suit la fraction ; à déduire ou à vérifier à la vente \\ | |
| 76 | +Police d'assurance du syndicat et évaluation de la valeur de reconstruction & art.~1073 C.c.Q. ; CCQ, r.~4.1, art.~3--4 & Montant assuré (reconstruction, évalué au moins tous les cinq ans par un évaluateur agréé), franchises ; règle transitoire du 0,5~\% \\ | |
| 77 | +Certificat de localisation de la partie privative & art.~1719 C.c.Q. ; A-23, r.~10, art.~10 et 14 & Désignation cadastrale, contenance cadastrale (distincte de la superficie habitable, \cref{ch:12}), servitudes \\ | |
| 78 | +Déclarations du vendeur (formulaire DVD de l'OACIQ) & Loi sur le courtage immobilier, art.~23--26 & État connu de la partie privative, travaux, sinistres, avis reçus du syndicat (formulaire propre à la copropriété divise, non consulté pour cet ouvrage) \\ | |
| 79 | +Pour une vente neuve : contrat préliminaire, note d'information, budget prévisionnel & art.~1785--1793 C.c.Q. ; r.~8.01, art.~11--13 & Incitatifs et inclusions du promoteur ; fonds de prévoyance prévisionnel (0,5~\% de la valeur de reconstruction ou étude) ; protection des acomptes \\ | |
| 80 | +Pour une conversion : rapport d'expert, circulaire d'information, autorisation du Tribunal & Loi sur le TAL, art.~54.4, 54.6 & État des composantes communes, réparations prévues sur cinq ans, budget prévisionnel par fraction, état des baux (\cref{sec:42:conversion}) \\ | |
| 81 | +\bottomrule | |
| 82 | +\end{tabularx} | |
| 83 | +\tabsource{synthèse de l'auteur d'après \textcite{ccq,reglCoproprieteDivise,reglAssuranceCopropriete,loiTAL,loiCourtage} et \texttt{sources/normes-A.csv}, textes lus le 31 octobre 2026.} | |
| 84 | +\end{table} | |
| 85 | + | |
| 86 | +\begin{quebec} | |
| 87 | +L'attestation de l'article~1068.1 n'existe que depuis le 14 août 2025, date d'entrée en vigueur du Règlement établissant diverses règles en matière de copropriété divise, et les copropriétés disposent d'un délai de trois ans et un jour à compter de cette date pour obtenir leur carnet d'entretien et leur première étude du fonds de prévoyance, le renflouement du fonds pouvant s'étaler sur au plus dix ans \parencite{reglCoproprieteDivise,quebecMesuresCoproprietes}. Jusqu'au 15 août 2028, l'évaluateur rencontrera donc trois situations : des syndicats qui disposent déjà d'une étude conforme, dont l'attestation donne directement la recommandation et le déficit ; des syndicats qui ont une étude ancienne, obtenue dans les deux années précédant le 14 août 2025 et valide cinq ans, ou dix ans pour les petits immeubles (art.~14 du règlement) ; et des syndicats qui n'ont encore ni carnet ni étude, pour lesquels l'attestation indiquera un solde sans recommandation. Dans ce dernier cas, l'évaluateur ne peut mesurer le déficit de capitalisation qu'en reconstituant lui-même un plan de réparations sommaire à partir de l'âge des composantes, comme au \cref{ch:07}, et il doit déclarer cette hypothèse. Les Normes de pratique professionnelle de l'Ordre des évaluateurs agréés du Québec consacrent par ailleurs leurs normes 15 et 16 aux études de fonds de prévoyance et leurs normes 13 et 14 à l'évaluation aux fins d'assurance \parencite{oeaqNormes} : l'évaluateur agréé est l'un des professionnels habilités à produire ces documents (art.~1 et 7 du règlement), et il est le seul à pouvoir fixer le montant d'assurance de reconstruction de l'article~1073 \parencite[art.~3]{reglAssuranceCopropriete}. | |
| 88 | +\end{quebec} | |
| 89 | + | |
| 90 | +\section{Évaluer une fraction par comparaison}\label{sec:42:comparaison} | |
| 91 | + | |
| 92 | +\subsection{Deux cercles de comparables}\label{sec:42:cercles} | |
| 93 | + | |
| 94 | +La méthode de comparaison s'applique aux fractions avec les règles générales des \cref{ch:16,ch:17}, mais la structure du bien impose une organisation particulière de la recherche. Écrivons le prix d'une fraction $i$ de l'immeuble $b$ sous la forme semi-logarithmique du \cref{ch:18}, en séparant explicitement ce qui tient à l'immeuble de ce qui tient à la fraction : | |
| 95 | +\begin{equation}\label{eq:42:modele} | |
| 96 | +\ln P_{ib} = \alpha_b + \mathbf{x}_{i}\T\boldsymbol\beta + \tau_{t(i)} + \varepsilon_{ib}, | |
| 97 | +\end{equation} | |
| 98 | +où $\alpha_b$ est un effet propre à l'immeuble qui résume sa localisation, son âge, sa qualité de construction, ses services, l'état de ses parties communes, le niveau de ses charges et la situation financière de son syndicat ; $\mathbf{x}_i$ rassemble les caractéristiques de la fraction (superficie, étage, orientation, stationnement, état intérieur) ; $\tau_t$ est l'effet de la date ; et $\varepsilon_{ib}$ le bruit de transaction. Cette écriture fait apparaître les deux cercles de comparables. Le premier cercle est celui des ventes conclues dans le \emph{même immeuble} : pour deux fractions $i$ et $j$ de l'immeuble $b$, la différence des logarithmes des prix ne dépend plus de $\alpha_b$, | |
| 99 | +\begin{equation}\label{eq:42:intra} | |
| 100 | +\ln P_{ib} - \ln P_{jb} = (\mathbf{x}_i - \mathbf{x}_j)\T\boldsymbol\beta + \tau_{t(i)} - \tau_{t(j)} + \varepsilon_{ib} - \varepsilon_{jb}, | |
| 101 | +\end{equation} | |
| 102 | +de sorte que la comparaison intra-immeuble élimine, par construction, toutes les différences de localisation, d'immeuble, de charges et de syndicat, et ne laisse à ajuster que la date et les caractéristiques de la fraction. C'est exactement le mécanisme des ventes appariées du \cref{ch:07} : la composante commune disparaît par différence et la variance de l'estimateur se réduit à celle des bruits de transaction, selon l'\cref{eq:7:var-paires}. Une vente récente d'une fraction semblable dans le même immeuble est donc le comparable le plus informatif qui soit, et la grille lui accorde un poids élevé même si elle est un peu plus ancienne qu'une vente externe. | |
| 103 | + | |
| 104 | +Le second cercle est celui des ventes conclues dans des \emph{immeubles concurrents} : les fractions que l'acheteur type du sujet aurait considérées comme des substituts, dans le même secteur et le même segment de prix. Ces ventes sont indispensables pour trois raisons. Elles seules permettent d'estimer l'effet du temps $\tau_t$ lorsque les ventes de l'immeuble sont rares ; elles seules informent sur la valeur de l'immeuble lui-même, c'est-à-dire sur $\alpha_b$, que la comparaison intra-immeuble ne mesure pas ; et elles seules protègent contre le risque qu'un immeuble entier soit « hors marché », par exemple parce que ses dernières ventes ont été conclues entre copropriétaires ou dans l'ignorance d'un problème que l'attestation révèle aujourd'hui. Pour ces comparables, l'écart $\alpha_b - \alpha_{b'}$ doit être ajusté, et c'est là que les ajustements propres à la copropriété interviennent : les différences de services et de charges, et surtout la différence de situation financière des deux syndicats. | |
| 105 | + | |
| 106 | +\begin{pratique} | |
| 107 | +La procédure recommandée réunit les deux cercles dans une même grille. On recense d'abord toutes les ventes de l'immeuble du sujet sur une fenêtre élargie, jusqu'à vingt-quatre ou trente-six mois, en les ramenant à la date d'évaluation par l'indice du marché des copropriétés du secteur ; on y ajoute ensuite trois à cinq ventes récentes d'immeubles concurrents, choisies pour leur ressemblance avec le sujet au sens de la distance d'attributs du \cref{ch:16}, en incluant si possible une fraction d'un immeuble dont la situation financière est manifestement différente de celle du sujet, afin que la grille rende visible l'ajustement de syndicat. Les ventes de l'immeuble sont vérifiées avec un soin particulier, parce que leur poids est élevé et parce que les transactions entre copropriétaires, les ventes à un locataire en place et les ventes conclues avant la découverte d'un vice des parties communes y sont plus fréquentes qu'ailleurs. | |
| 108 | +\end{pratique} | |
| 109 | + | |
| 110 | +\subsection{Les ajustements propres à la copropriété}\label{sec:42:ajustements} | |
| 111 | + | |
| 112 | +La grille d'une fraction reprend les cinq dimensions du \cref{ch:16} : droits transmis, financement, conditions de vente, conditions du marché, caractéristiques du bien. Les quatre premières s'appliquent sans modification, à ceci près que le prix d'une fraction neuve vendue par un promoteur incorpore souvent des incitatifs, des inclusions et des conditions de livraison qui doivent être retranchés avant comparaison, et que la vente d'une fraction louée à un investisseur transmet un droit grevé d'un bail que l'acquéreur ne peut résilier \parencite[art.~1937]{ccq}. La cinquième dimension, celle des caractéristiques, est où la copropriété se distingue, et quatre familles d'ajustements y apparaissent. | |
| 113 | + | |
| 114 | +\paragraph{Étage, orientation et vue.} Dans un immeuble sans ascenseur, le rez-de-chaussée se négocie en général avec une décote qui tient au bruit, à l'intimité et à la sécurité, et les étages supérieurs avec une prime qui tient à la lumière, à la vue et au calme, prime que l'effort de l'escalier tempère au troisième ; dans une tour avec ascenseur, la prime d'étage croît avec la hauteur et la vue. L'orientation agit par l'ensoleillement et par l'exposition au bruit de la rue. Ces effets sont des proportions du prix plutôt que des montants fixes, parce qu'ils portent sur l'ensemble de la partie privative ; on les écrit | |
| 115 | +\begin{equation}\label{eq:42:etage} | |
| 116 | +A^{\text{étage}}_j = P^{t}_j\,\bigl(c^{\text{ét}}_s - c^{\text{ét}}_j\bigr), \qquad A^{\text{orient}}_j = P^{t}_j\,\bigl(c^{\text{or}}_s - c^{\text{or}}_j\bigr), | |
| 117 | +\end{equation} | |
| 118 | +où $P^t_j$ est le prix du comparable ramené à la date d'évaluation et $c^{\text{ét}}$, $c^{\text{or}}$ les coefficients, en proportion du prix, de l'étage et de l'orientation, estimés par ventes appariées ou par un modèle hédonique du marché local, et comptés du point de vue du sujet. Ces coefficients de marché ne coïncident pas nécessairement avec ceux que la déclaration de copropriété a retenus pour la valeur relative : dans l'immeuble fictif du \cref{ch:07}, la déclaration valorise le troisième étage à 1,04 et le rez-de-chaussée à 0,96, alors que le marché fictif de ce chapitre le valorise à 1,02 et 0,96 ; la valeur relative est une convention de répartition, le coefficient de marché une mesure, et l'évaluateur ne confond pas les deux. | |
| 119 | + | |
| 120 | +\paragraph{Stationnement et rangement.} La case de stationnement intérieur et le rangement sont des parties communes à usage restreint dont la valeur est, elle, un montant à peu près fixe dans un secteur donné, parce qu'elle correspond au coût d'une solution de rechange (stationnement sur rue avec vignette, location d'une case) ; elle s'ajuste en dollars, et la vente séparée de cases dans les immeubles où la déclaration le permet en fournit la mesure la plus directe. Lorsque la case est une partie privative distincte, immatriculée séparément, elle peut être vendue ou louée indépendamment, ce qui en accroît la valeur ; lorsqu'elle est un usage restreint attaché à la fraction, elle ne peut l'être. | |
| 121 | + | |
| 122 | +\paragraph{Superficie.} La superficie de la partie privative s'ajuste au prix marginal du mètre carré, inférieur au prix moyen parce que l'élasticité du prix à la superficie est inférieure à l'unité (\cref{ch:16}) ; dans le marché fictif de ce chapitre, dont la valeur de référence est de \parmsq{3650} pour \msq{98} avec une élasticité de 0,60, le prix marginal vaut $0{,}60 \times 3\,650 = \parmsq{2190}$. L'évaluateur veille à ce que toutes les superficies soient mesurées selon la même convention : la contenance cadastrale du certificat de localisation, la superficie habitable des fiches de courtage et la superficie brute des plans du promoteur diffèrent parfois de dix pour cent (\cref{ch:12}). | |
| 123 | + | |
| 124 | +\paragraph{Frais de copropriété et situation financière du syndicat.} C'est la famille la plus délicate, et les deux sous-sections qui suivent lui sont consacrées. | |
| 125 | + | |
| 126 | +\subsection{La capitalisation des écarts de charges}\label{sec:42:charges} | |
| 127 | + | |
| 128 | +Deux fractions identiques dont l'une supporte des charges communes plus élevées ne valent pas la même chose, et le raisonnement qui fixe l'écart est celui du coût d'usage. Un acheteur qui compare les deux arbitre entre un prix plus élevé et des charges plus faibles ; à l'équilibre, l'écart de prix compense la valeur actuelle de l'écart de charges sur son horizon de détention, puis l'effet de cet écart sur le prix de revente. Si l'écart annuel $\Delta C$ est perpétuel et croît au taux $g$ des coûts, et si l'acheteur actualise au taux nominal $r$, la valeur actuelle de la charge supplémentaire est, par l'identité de Gordon du \cref{ch:30}, | |
| 129 | +\begin{equation}\label{eq:42:charges-gordon} | |
| 130 | +\mathrm{VA}(\Delta C) = \sum_{t=1}^{\infty} \frac{\Delta C\,(1+g)^{t-1}}{(1+r)^t} = \frac{\Delta C}{r - g}, | |
| 131 | +\end{equation} | |
| 132 | +ce qui, avec $r = 5$~\% et $g = 2{,}5$~\%, capitalise chaque dollar de charge annuelle à quarante dollars de prix. Cette borne théorique suppose que l'écart de charges est un pur coût, perçu et escompté intégralement par un acheteur rationnel ; elle est rarement atteinte, pour trois raisons. Une partie des charges plus élevées achète des services que l'acheteur valorise (ascenseur, concierge, piscine, sécurité) et qui sont déjà payés dans le prix par un autre canal, celui de la qualité de l'immeuble ; l'acheteur type a un horizon fini et escompte imparfaitement les charges lointaines ; et les charges futures sont incertaines, ce qui relève le taux effectif de capitalisation. L'évaluateur écrit donc l'ajustement avec un taux de capitalisation des charges $R_c$ propre au marché, | |
| 133 | +\begin{equation}\label{eq:42:charges} | |
| 134 | +A^{\text{ch}}_j = \frac{\bigl(c_j - c_s\bigr)\,S_s}{R_c}, | |
| 135 | +\end{equation} | |
| 136 | +où $c_j$ et $c_s$ sont les charges courantes annuelles par mètre carré du comparable et du sujet, $S_s$ la superficie du sujet, et où le signe est celui du point de vue du sujet : un comparable qui paie plus de charges est inférieur au sujet, et son prix est ajusté à la hausse. Trois précisions gouvernent l'application de cette formule. Premièrement, seules les \emph{charges courantes} entrent dans $c$ : les contributions au fonds de prévoyance et au fonds d'auto assurance en sont exclues, parce qu'elles ne sont pas un coût mais une épargne, et parce que leur insuffisance est traitée séparément par l'ajustement de syndicat ; un immeuble dont les frais sont élevés parce que son fonds est bien doté n'est pas pénalisé. Deuxièmement, les charges courantes sont normalisées : on écarte les postes exceptionnels d'une année et l'on compare des budgets de même périmètre, en vérifiant que le chauffage, l'eau chaude ou les assurances ne sont pas inclus dans l'un et exclus de l'autre. Troisièmement, $R_c$ n'est pas le taux d'actualisation de l'acheteur mais un paramètre de marché, à extraire par ventes appariées entre immeubles de charges différentes ou par le coefficient d'un modèle hédonique ; en l'absence de mesure, l'exemple de ce chapitre retient $R_c = 8$~\%, hypothèse pédagogique déclarée qui capitalise un dollar de charges à douze dollars et demi de prix, à mi-chemin entre la borne de Gordon et l'indifférence, et la \cref{sec:42:sensibilite} fait varier ce paramètre de 5 à 12~\%. Aucune mesure québécoise vérifiée de $R_c$ n'a été trouvée pour cet ouvrage ; la littérature nord-américaine sur l'évaluation des copropriétés souligne depuis longtemps que l'effet des frais sur les prix est l'un des points les moins bien documentés de la pratique \parencite{lentzWang1998}. | |
| 137 | + | |
| 138 | +\subsection{Le fonds de prévoyance et les cotisations spéciales}\label{sec:42:syndicat} | |
| 139 | + | |
| 140 | +Le \cref{ch:07} a défini le déficit de capitalisation $D_0$ d'un syndicat, l'\cref{eq:7:deficit}, comme l'écart entre le solde que le fonds devrait détenir pour que la cotisation actuelle suffise et le solde qu'il détient, et il a posé, dans l'\cref{eq:7:fraction}, que la part de ce déficit qui revient à la fraction $i$, soit $q_i D_0$, est retranchée de sa valeur sous l'hypothèse d'une capitalisation intégrale. La grille de comparaison transpose cette idée en un ajustement relatif : ce qui compte n'est pas le passif du sujet en soi, mais la différence entre le passif que l'acheteur du sujet devra assumer et celui que l'acheteur du comparable a assumé, puisque le prix du comparable l'incorpore déjà. En notant $d_j = q_j D_{0,b(j)}$ la part du comparable $j$ dans le passif de son syndicat et $d_s = q_s D_{0,b(s)}$ celle du sujet, l'ajustement s'écrit | |
| 141 | +\begin{equation}\label{eq:42:syndicat} | |
| 142 | +A^{\text{synd}}_j = \kappa\,\bigl(d_j - d_s\bigr), \qquad 0 \leq \kappa \leq 1, | |
| 143 | +\end{equation} | |
| 144 | +où $\kappa$ est le coefficient de capitalisation du passif par le marché : $\kappa = 1$ lorsque les acheteurs sont parfaitement informés et escomptent le passif dollar pour dollar, moins lorsqu'ils l'ignorent ou s'attendent à revendre avant les travaux. Le signe est à nouveau celui du point de vue du sujet : si le comparable porte un passif plus lourd que le sujet ($d_j > d_s$), son prix a été déprimé davantage et il faut le relever ; si le comparable est dans un immeuble sain alors que le sujet est sous-capitalisé, l'ajustement est négatif. Deux cas particuliers éclairent la formule. Dans le premier cercle, les comparables sont dans le même immeuble que le sujet et partagent son déficit, mais pas sa quote-part : $A^{\text{synd}}_j = \kappa (q_j - q_s) D_0$, petit mais non nul, et négatif lorsque le comparable a une quote-part inférieure à celle du sujet. Dans le second cercle, un comparable vendu dans un immeuble dont le fonds est conforme à son étude a $d_j = 0$, et l'ajustement vaut $-\kappa\, d_s$, c'est-à-dire exactement la correction de l'\cref{eq:7:fraction}. | |
| 145 | + | |
| 146 | +Le passif $D$ ne se limite pas au déficit latent du fonds. Il comprend aussi les \motcle{cotisations spéciales} votées en vertu de l'article~1072.1 mais non encore payées, les emprunts contractés par le syndicat pour des travaux et remboursés par les charges, les jugements et les réclamations en cours que l'attestation doit mentionner \parencite[art.~10]{reglCoproprieteDivise}, et les travaux prévus dans les dix prochaines années dont le financement n'est pas assuré par le fonds. Une cotisation spéciale votée est un passif certain, et la question pratique est de savoir qui la paie : l'acquéreur est tenu des charges communes dues au moment de l'acquisition \parencite[art.~1069]{ccq}, de sorte que la part exigible après la vente lui incombe sauf stipulation contraire de la promesse d'achat ; le prix négocié en tient compte, et l'évaluateur vérifie, pour chaque comparable, si une cotisation spéciale était connue et qui l'a supportée. Lorsqu'un comparable a été vendu avec une cotisation spéciale à la charge de l'acheteur, son prix est inférieur à ce qu'il aurait été sans elle, du montant de la part de l'acheteur, et la grille le relève d'autant par l'\cref{eq:42:syndicat}, qui traite indifféremment déficit latent et cotisation votée pourvu que $D$ soit mesuré de la même façon pour le sujet et pour chaque comparable. | |
| 147 | + | |
| 148 | +Enfin, la cotisation spéciale a une dimension de \emph{liquidité} que l'ajustement monétaire ne capture pas entièrement. Dans l'immeuble fictif, le déficit de \dollars{150908} réparti selon les quotes-parts représente, pour chaque fraction, entre 2,0 et 2,3 années de charges normalisées (\cref{fig:42:speciale}) : un appel de fonds de cette ampleur contraint des copropriétaires à emprunter ou à vendre, et il réduit le bassin d'acheteurs parce que certains prêteurs refusent de financer une fraction dont le syndicat a voté une cotisation importante non payée. Cet effet d'illiquidité, qui pousse $\kappa$ au-delà de l'unité dans les immeubles en difficulté, ne peut être mesuré que sur le marché local, par des paires de ventes dans des immeubles de situation financière différente. | |
| 149 | + | |
| 150 | +\begin{figure}[htbp] | |
| 151 | +\centering | |
| 152 | +\includegraphics[width=\linewidth]{ch42/ch42-fig-cotisation-speciale.pdf} | |
| 153 | +\caption{Pour chacune des huit fractions de l'immeuble fictif, les charges annuelles normalisées (charges courantes, cotisation de seuil au fonds de prévoyance, fonds d'auto assurance, stationnement à usage restreint) et la part d'une cotisation spéciale unique égale au déficit de capitalisation de \dollars{150908}, répartie selon la quote-part. La cotisation spéciale représente deux années de charges pour chaque fraction ; sa part est plus élevée pour les grandes fractions bien situées, dont la quote-part est plus forte.}\label{fig:42:speciale} | |
| 154 | +\figsource{\synth{script \texttt{analysis/python/figures/ch42\_cotisation\_speciale.py}, immeuble fictif du \cref{ch:07}, graine 20261031}.} | |
| 155 | +\end{figure} | |
| 156 | + | |
| 157 | +\section{Exemple intégral : la fraction F4 de l'immeuble fictif}\label{sec:42:exemple} | |
| 158 | + | |
| 159 | +\begin{synthetique} | |
| 160 | +L'immeuble fictif de huit fractions du \cref{ch:07} sert de sujet : mêmes superficies, étages, orientations, quotes-parts, budget, cotisation votée de \dollars{12000}, cotisation de seuil de \dollars{21891} et déficit de capitalisation de \dollars{150908}, tous obtenus par les fonctions \texttt{quotes\_parts}, \texttt{repartition\_charges}, \texttt{cotisation\_seuil} et \texttt{incidence\_valeur} de \texttt{analysis/python/cases/ch07\_servitudes\_copro.py}. Le marché fictif qui l'entoure est défini dans \texttt{analysis/python/cases/ch42\_copro.py} (graine 20261031) par des prix implicites déclarés : \parmsq{3650} pour une fraction de référence de \msq{98} au deuxième étage, orientée est ou ouest, sans stationnement, dans un immeuble bien capitalisé ; élasticité prix-superficie de 0,60 ; coefficients d'étage de $-4$~\% (rez-de-chaussée) et $+2$~\% (troisième) ; coefficients d'orientation de $-3$~\% (nord) et $+3$~\% (sud) ; case de stationnement intérieur \dollars{28000} ; croissance des prix de 2~\% par année ; taux de capitalisation des charges $R_c = 8$~\% ; coefficient de capitalisation du passif $\kappa = 1$. Les prix des cinq comparables sont engendrés par ce modèle, déflatés à leur date de vente, affectés d'un bruit de transaction d'écart-type logarithmique 0,02 et arrondis à \dollars{500} ; trois immeubles concurrents fictifs (B, C, D) diffèrent par leurs charges et par la situation de leur syndicat. Le niveau des prix a été choisi pour être de l'ordre de grandeur des médianes APCIQ du \cref{tab:42:marche}, sans décrire aucun marché réel ; les coefficients sont des hypothèses pédagogiques. Tous les chiffres de cette section et de la \cref{sec:42:sensibilite} sont imprimés par le script et vérifiés par \texttt{analysis/python/tests/test\_ch42.py}. | |
| 161 | +\end{synthetique} | |
| 162 | + | |
| 163 | +\begin{exemple}{Cinq comparables, deux cercles, un passif}{fraction} | |
| 164 | +Le sujet est la fraction F4 : \msq{98} de partie privative au deuxième étage, orientée au sud, avec une case de stationnement intérieur à usage restreint, quote-part de 14,08~\%, dans l'immeuble A de huit fractions sans ascenseur. La date d'évaluation est le 1\ier{} septembre 2026. L'attestation du syndicat révèle la situation du \cref{ch:07} : solde du fonds de \dollars{85000}, cotisation votée de \dollars{12000}, étude recommandant \dollars{21891}, aucune cotisation spéciale votée. Le déficit de capitalisation reconstitué est de \dollars{150908}, dont la part de F4 est $0{,}1408 \times 150\,908 = \dollars{21248}$ ; les charges courantes de l'immeuble, \dollars{40000} pour \msq{710}, représentent \parmsq{56.34} par année. | |
| 165 | + | |
| 166 | +\emph{Étape 1 : les comparables.} Le \cref{tab:42:comparables} décrit les cinq ventes retenues. Deux appartiennent au premier cercle : F2, même superficie et même orientation que le sujet mais au rez-de-chaussée, vendue \dollars{373000} six mois avant la date d'évaluation ; F7, \msq{85} au deuxième étage orientée à l'est, vendue \dollars{326500} dix mois plus tôt. Trois appartiennent au second cercle : C3, dans l'immeuble B de vingt-quatre fractions avec ascenseur, dont les charges courantes atteignent \parmsq{78} et dont le fonds est conforme à l'étude ; C4, au troisième étage de l'immeuble C de douze fractions, orientée à l'ouest, sans stationnement, dans un syndicat dont le déficit est modéré (\dollars{60000}, quote-part de 9~\%, soit \dollars{5400}) ; C5, au rez-de-chaussée de l'immeuble D de huit fractions, vendue \dollars{330500} alors qu'une cotisation spéciale de \dollars{240000} venait d'être votée et que l'acheteur en assumait la part de 13~\%, soit \dollars{31200}. | |
| 167 | + | |
| 168 | +\emph{Étape 2 : les ajustements.} L'ajustement de conditions du marché ramène chaque prix à la date d'évaluation au taux de 2~\% par année, soit $+\dollars{3712}$ pour F2 et $+\dollars{5433}$ pour F7. Les ajustements de propriété suivent, en dollars, dans l'ordre du \cref{tab:42:grille}. Pour F2, seule la différence d'étage subsiste : le sujet au deuxième étage est supérieur au comparable du rez-de-chaussée de quatre points, soit $+\dollars{15068}$ selon l'\cref{eq:42:etage} ; l'ajustement de syndicat est de $(0{,}1352 - 0{,}1408) \times 150\,908 = \dollars{-845}$, parce que l'acheteur de F2 a assumé une part du même déficit légèrement inférieure à celle que l'acheteur du sujet assumera. Pour F7, l'orientation est vaut trois points de moins que le sud ($+\dollars{9958}$), les treize mètres carrés manquants valent $13 \times 2\,190 = \dollars{28470}$ au prix marginal, et la différence de quote-part donne $\dollars{-3350}$. Pour C3, l'immeuble B paie \parmsq{21.66} de charges courantes de plus que l'immeuble A : l'\cref{eq:42:charges} donne $21{,}66 \times 98 / 0{,}08 = +\dollars{26536}$, à quoi s'oppose l'ajustement de syndicat $\kappa(0 - 21\,248) = \dollars{-21248}$, puisque le comparable est dans un immeuble sain et le sujet dans un immeuble sous-capitalisé ; ces deux ajustements, de sens contraires et de même ordre de grandeur, illustrent pourquoi il faut les séparer au lieu de comparer des frais bruts. Pour C4, il faut retrancher la prime du troisième étage ($\dollars{-7519}$), ajouter l'orientation sud ($+\dollars{11279}$), la case de stationnement ($+\dollars{28000}$) et la différence de charges ($\dollars{-5314}$, l'immeuble C payant moins que le sujet), retrancher deux mètres carrés et porter l'ajustement de syndicat à $5\,400 - 21\,248 = \dollars{-15848}$. Pour C5, la décote du rez-de-chaussée ($+\dollars{13396}$), six mètres carrés ($+\dollars{13140}$), de légères charges supérieures ($+\dollars{2036}$) et l'ajustement de syndicat $31\,200 - 21\,248 = +\dollars{9952}$ : l'acheteur de C5 a payé un passif plus lourd que celui que l'acheteur du sujet paiera, et son prix doit être relevé d'autant. | |
| 169 | + | |
| 170 | +\emph{Étape 3 : les prix ajustés et leur qualité.} Les prix ajustés s'échelonnent de \dollars{367010} (F7) à \dollars{390935} (F2), soit une étendue de 6,3~\% de la valeur pondérée. L'ajustement brut de F2 n'est que de 5,3~\% : c'est le comparable le plus proche, et la grille lui attribue un poids de 0,42 selon la pondération inverse de l'ajustement brut du \cref{ch:16}. Les autres poids vont de 0,11 (C4, dont l'ajustement brut atteint 20,0~\%) à 0,17 (C5). La \cref{fig:42:grille} décompose chaque ajustement. | |
| 171 | + | |
| 172 | +\emph{Étape 4 : la réconciliation.} La moyenne pondérée des prix ajustés est de \dollars{381052}, la moyenne simple de \dollars{377805} et la médiane de \dollars{375475}. La valeur du modèle de génération pour F4, qui tient lieu ici de « vraie » valeur, est de \dollars{375183} : la grille la retrouve à 1,6~\% près, et toutes les mesures centrales l'encadrent. L'évaluateur conclut à une valeur marchande de \dollars{380000}, arrondie au millier le plus proche de la moyenne pondérée, et il documente que cette valeur suppose la capitalisation intégrale du déficit du fonds ($\kappa = 1$) : si le syndicat régularisait sa situation en votant la cotisation de seuil et une cotisation spéciale égale au déficit, la fraction vaudrait environ \dollars{21000} de plus, mais son propriétaire aurait déboursé la même somme. | |
| 173 | + | |
| 174 | +\emph{Étape 5 : la vérification par l'unité de comparaison.} Rapportée à la superficie, la conclusion correspond à \parmsq{3878}, à comparer aux prix unitaires bruts des comparables, de \parmsq{3592} pour C5 à \parmsq{3841} pour F7 ; le sujet est au-dessus de tous les prix bruts parce qu'il cumule le deuxième étage, l'orientation sud et le stationnement, et l'écart avec F2, de même superficie, correspond à la décote du rez-de-chaussée. Cette lecture rapide ne remplace pas la grille, mais elle en est le contrôle de vraisemblance. | |
| 175 | +\end{exemple} | |
| 176 | + | |
| 177 | +\begin{table}[htbp] | |
| 178 | +\caption{Le sujet et les cinq comparables fictifs : caractéristiques, charges courantes, part du passif du syndicat et prix observé}\label{tab:42:comparables} | |
| 179 | +\centering\footnotesize\setlength{\tabcolsep}{3pt} | |
| 180 | +\begin{tabular}{llS[table-format=3.0]lccS[table-format=2.2]S[table-format=5.0]S[table-format=2.0]S[table-format=6.0]S[table-format=4.0]} | |
| 181 | +\toprule | |
| 182 | + & Immeuble & {Superf. (m\textsuperscript{2})} & Étage & Orient. & Stat. & {Charges (\$/m\textsuperscript{2}/an)} & {Passif (\$)} & {Mois} & {Prix (\$)} & {\$/m\textsuperscript{2}} \\ | |
| 183 | +\midrule | |
| 184 | +Sujet F4 & A (8 fractions) & 98 & 2\ieme{} & sud & oui & 56.34 & 21248 & 0 & {---} & {---} \\ | |
| 185 | +C1 = F2 & A (même immeuble) & 98 & RDC & sud & oui & 56.34 & 20403 & 6 & 373000 & 3806 \\ | |
| 186 | +C2 = F7 & A (même immeuble) & 85 & 2\ieme{} & est & oui & 56.34 & 17898 & 10 & 326500 & 3841 \\ | |
| 187 | +C3 & B (24 fractions, ascenseur) & 96 & 2\ieme{} & sud & oui & 78.00 & 0 & 3 & 364000 & 3792 \\ | |
| 188 | +C4 & C (12 fractions) & 100 & 3\ieme{} & ouest & non & 52.00 & 5400 & 4 & 373500 & 3735 \\ | |
| 189 | +C5 & D (8 fractions) & 92 & RDC & sud & oui & 58.00 & 31200 & 8 & 330500 & 3592 \\ | |
| 190 | +\bottomrule | |
| 191 | +\end{tabular} | |
| 192 | +\tabsource{\synth{\texttt{analysis/python/cases/ch42\_copro.py}} ; « passif » = part de la fraction dans le déficit de capitalisation ou dans la cotisation spéciale votée du syndicat ; « mois » = ancienneté de la vente à la date d'évaluation.} | |
| 193 | +\end{table} | |
| 194 | + | |
| 195 | +\begin{table}[htbp] | |
| 196 | +\caption{Grille d'ajustement de la fraction F4 (dollars ; signe du point de vue du sujet ; $g = 2$~\%, $R_c = 8$~\%, $\kappa = 1$)}\label{tab:42:grille} | |
| 197 | +\centering\footnotesize\setlength{\tabcolsep}{4pt} | |
| 198 | +\begin{tabular}{lS[table-format=6.0]S[table-format=6.0]S[table-format=6.0]S[table-format=6.0]S[table-format=6.0]} | |
| 199 | +\toprule | |
| 200 | +Élément & {C1 = F2} & {C2 = F7} & {C3} & {C4} & {C5} \\ | |
| 201 | +\midrule | |
| 202 | +Prix de vente & 373000 & 326500 & 364000 & 373500 & 330500 \\ | |
| 203 | +Conditions du marché (2~\%/an) & 3712 & 5433 & 1807 & 2474 & 4392 \\ | |
| 204 | +\addlinespace | |
| 205 | +Étage & 15068 & 0 & 0 & -7519 & 13396 \\ | |
| 206 | +Orientation & 0 & 9958 & 0 & 11279 & 0 \\ | |
| 207 | +Stationnement & 0 & 0 & 0 & 28000 & 0 \\ | |
| 208 | +Superficie (\parmsq{2190}) & 0 & 28470 & 4380 & -4380 & 13140 \\ | |
| 209 | +Charges courantes ($R_c = 8$~\%) & 0 & 0 & 26536 & -5314 & 2036 \\ | |
| 210 | +Situation du syndicat ($\kappa = 1$) & -845 & -3350 & -21248 & -15848 & 9952 \\ | |
| 211 | +\midrule | |
| 212 | +Prix ajusté & 390935 & 367010 & 375475 & 382191 & 373416 \\ | |
| 213 | +Ajustement brut (\%) & 5.3 & 14.5 & 14.8 & 20.0 & 13.0 \\ | |
| 214 | +Ajustement net (\%) & 4.8 & 12.4 & 3.2 & 2.3 & 13.0 \\ | |
| 215 | +Poids (inverse du brut) & 0.419 & 0.152 & 0.149 & 0.110 & 0.170 \\ | |
| 216 | +\bottomrule | |
| 217 | +\end{tabular} | |
| 218 | +\tabsource{\synth{\texttt{analysis/python/cases/ch42\_copro.py}} ; calculs par \texttt{traite.comparables} (\texttt{ComparableAjuste}, \texttt{poids\_inverse\_brut}, \texttt{reconciliation}) ; valeur pondérée \dollars{381052}, moyenne \dollars{377805}, médiane \dollars{375475}.} | |
| 219 | +\end{table} | |
| 220 | + | |
| 221 | +\begin{figure}[htbp] | |
| 222 | +\centering | |
| 223 | +\includegraphics[width=\linewidth]{ch42/ch42-fig-grille-fraction.pdf} | |
| 224 | +\caption{Décomposition des ajustements des cinq comparables fictifs de la fraction F4 : du prix observé (cercle) au prix ajusté (losange), les ajustements positifs s'empilent vers la droite et les ajustements négatifs, en teinte pâle, vers la gauche. Les deux ventes du même immeuble exigent peu d'ajustements ; les ventes d'immeubles concurrents font apparaître les ajustements de charges et de syndicat, souvent de sens contraires.}\label{fig:42:grille} | |
| 225 | +\figsource{\synth{script \texttt{analysis/python/figures/ch42\_grille.py}, graine 20261031}.} | |
| 226 | +\end{figure} | |
| 227 | + | |
| 228 | +\section{Analyse de sensibilité : capitalisation des charges et du passif}\label{sec:42:sensibilite} | |
| 229 | + | |
| 230 | +Les deux paramètres propres à la copropriété, $R_c$ et $\kappa$, sont aussi les moins bien mesurés. Le \cref{tab:42:sensibilite} recalcule la valeur pondérée de F4 pour $R_c$ de 5 à 12~\% et $\kappa$ de 0 à 1, les autres ajustements étant inchangés. La valeur varie de \dollars{379437} ($\kappa = 1$, $R_c = 12$~\%) à \dollars{386889} ($\kappa = 0$, $R_c = 5$~\%), soit une étendue de \dollars{7452}, moins de 2~\% de la conclusion. Cette robustesse n'est pas générale : elle tient à la composition de la grille. Les deux comparables du premier cercle, qui portent 57~\% du poids, ne dépendent ni de $R_c$ ni, à la différence de quote-part près, de $\kappa$ ; parmi les trois autres, C3 reçoit un ajustement de charges positif et un ajustement de syndicat négatif qui se compensent presque, et C4 et C5 reçoivent des ajustements de syndicat de signes opposés. La sensibilité d'une grille composée uniquement de ventes externes d'immeubles sains serait bien plus grande : le seul ajustement de syndicat du sujet, $-\kappa \times 21\,248$, ferait alors varier la conclusion de \dollars{21248} entre $\kappa = 0$ et $\kappa = 1$, soit 5,6~\%. La leçon est double. D'une part, inclure des ventes du même immeuble réduit la dépendance de la conclusion aux paramètres les plus incertains, ce qui est une raison supplémentaire de les rechercher. D'autre part, lorsque le sujet est dans un immeuble en difficulté et que les comparables ne le sont pas, la conclusion dépend de $\kappa$, et le rapport doit le dire : la valeur de \dollars{380000} est conditionnelle à la capitalisation intégrale du déficit, et une fourchette de \dollars{380000} à \dollars{400000} selon $\kappa$ est l'expression honnête de l'incertitude, que le \cref{ch:20} apprend à intégrer dans la réconciliation. | |
| 231 | + | |
| 232 | +\begin{table}[htbp] | |
| 233 | +\caption{Sensibilité de la valeur pondérée de F4 (\$) au taux de capitalisation des charges $R_c$ et au coefficient de capitalisation du passif $\kappa$}\label{tab:42:sensibilite} | |
| 234 | +\centering\small\setlength{\tabcolsep}{5pt} | |
| 235 | +\begin{tabular}{lS[table-format=6.0]S[table-format=6.0]S[table-format=6.0]S[table-format=6.0]S[table-format=6.0]} | |
| 236 | +\toprule | |
| 237 | + & \multicolumn{5}{c}{Taux de capitalisation des charges $R_c$} \\ | |
| 238 | +\cmidrule(lr){2-6} | |
| 239 | +$\kappa$ & {5~\%} & {6~\%} & {8~\%} & {10~\%} & {12~\%} \\ | |
| 240 | +\midrule | |
| 241 | +0,00 & 386889 & 386349 & 385415 & 384634 & 383969 \\ | |
| 242 | +0,50 & 384825 & 384120 & 382972 & 382075 & 381355 \\ | |
| 243 | +0,75 & 383910 & 383160 & 381966 & 381056 & 380340 \\ | |
| 244 | +1,00 & 383051 & 382271 & 381052 & 380142 & 379437 \\ | |
| 245 | +\bottomrule | |
| 246 | +\end{tabular} | |
| 247 | +\tabsource{\synth{\texttt{analysis/python/cases/ch42\_copro.py}} ; cas de base $R_c = 8$~\%, $\kappa = 1$ ; pondération inverse de l'ajustement brut recalculée à chaque combinaison.} | |
| 248 | +\end{table} | |
| 249 | + | |
| 250 | +\section{La copropriété indivise et le financement de l'acheteur}\label{sec:42:indivise} | |
| 251 | + | |
| 252 | +La copropriété indivise, dont le \cref{ch:06} et le \cref{ch:07} ont posé le régime \parencite[art.~1010, 1014, 1015, 1019]{ccq}, se distingue de la copropriété divise sur le marché par deux traits : la part indivise est moins liquide, et elle se finance plus difficilement. Le premier trait a été formalisé par la décote d'indivision de l'\cref{eq:7:indivision}, que l'évaluateur extrait du marché local par comparaison avec des fractions divises semblables ou avec d'autres parts indivises, et dont les déterminants sont lisibles dans la convention publiée : durée de l'indivision, droit de préemption des coïndivisaires, règles de décision, attribution d'un usage exclusif, et, pour les immeubles locatifs, l'interdiction faite à l'indivisaire de reprendre un logement sauf s'il n'a qu'un seul coïndivisaire qui est son conjoint \parencite[art.~1958]{ccq}. Le second trait mérite d'être chiffré, parce qu'il explique en grande partie le premier. | |
| 253 | + | |
| 254 | +Pour une fraction divise, l'acheteur-occupant a accès au prêt assuré : depuis le 15 décembre 2024, la mise de fonds minimale est de 5~\% sur la tranche du prix jusqu'à \dollars{500000} et de 10~\% sur la tranche comprise entre \dollars{500000} et le plafond assurable de \dollars{1500000}, l'amortissement pouvant atteindre trente ans pour les premiers acheteurs et les constructions neuves \parencite{fincan2024hypo}. Pour la fraction F4 à \dollars{380000}, la mise de fonds minimale est de \dollars{19000} et le prêt, avant la prime d'assurance qui s'y ajoute, de \dollars{361000} ; au taux effectif des prêts assurés à taux fixe de cinq ans et plus relevé par la Banque du Canada pour août 2026, 4,12~\%, un amortissement de vingt-cinq ans donne un paiement mensuel de \dollars{1922} selon la convention canadienne de composition semestrielle du \cref{ch:31}. En y ajoutant les charges normalisées de la fraction, \dollars{852} par mois, et les taxes, \dollars{275} par mois, le coût mensuel d'occupation atteint \dollars{3049} ; au taux admissible minimal du Bureau du surintendant des institutions financières, le plus élevé du taux contractuel plus deux points ou de 5,25~\%, soit 6,12~\%, le paiement de qualification est de \dollars{2336} \parencite{osfiMqr}. Ces chiffres fixent la contrainte budgétaire de l'acheteur type et donc la profondeur du marché. | |
| 255 | + | |
| 256 | +Pour une part indivise, il est d'usage que les prêteurs n'offrent pas de prêt assuré et exigent une mise de fonds nettement plus élevée, souvent de 20~\% ; cette pratique n'a pas été vérifiée dans une source normative pour cet ouvrage et elle varie selon les institutions, mais son effet, à supposer une mise de fonds de 20~\%, est immédiat : pour le même prix de \dollars{380000}, la mise de fonds passe de \dollars{19000} à \dollars{76000}, soit \dollars{57000} de plus, et le paiement mensuel tombe à \dollars{1619} sur un prêt de \dollars{304000}. Le bassin d'acheteurs capables de réunir \dollars{76000} est beaucoup plus étroit que celui des acheteurs capables d'en réunir \dollars{19000}, et c'est cette réduction du bassin, plus que les clauses de la convention, qui fonde la décote d'indivision dans les quartiers où l'acheteur marginal est un premier acheteur. La décote est donc d'autant plus forte que le prix est élevé par rapport aux revenus locaux et que le crédit est serré ; elle est faible, voire nulle, lorsque les acheteurs de parts indivises sont des ménages déjà propriétaires qui disposent d'une mise de fonds importante. L'évaluateur qui ne dispose pas de ventes de parts indivises comparables peut au moins raisonner ainsi, et déclarer sa décote comme une hypothèse motivée par la structure du financement plutôt que comme un chiffre d'usage. La conversion d'une indivision en copropriété divise, qui supprime la décote au prix des démarches de la \cref{sec:42:conversion}, est fréquente dans les plex des quartiers centraux de Montréal précisément parce que l'interdiction de conversion propre à l'agglomération ne s'applique pas à l'immeuble dont tous les logements sont occupés par des propriétaires indivis \parencite[art.~51, al.~3]{loiTAL}. | |
| 257 | + | |
| 258 | +\section{La copropriété locative : valeur marchande et valeur d'investissement}\label{sec:42:locative} | |
| 259 | + | |
| 260 | +\subsection{Un segment mesuré par la SCHL} | |
| 261 | + | |
| 262 | +Une fraction de copropriété détenue par un investisseur et louée est un bien à deux marchés : celui des acheteurs-occupants, qui fixe son prix de revente, et celui des locataires, qui fixe son revenu. L'Enquête sur les logements locatifs de la Société canadienne d'hypothèques et de logement mesure ce segment depuis plusieurs années, et ses résultats d'octobre 2025 pour les trois grandes régions métropolitaines du Québec sont rassemblés au \cref{tab:42:locative}. Trois constats s'en dégagent. Le parc de copropriétés offertes en location est considérable, plus de \num{51000} unités dans la RMR de Montréal, soit 21,9~\% de l'univers des copropriétés, et plus du quart à Gatineau. Les copropriétés louées sont moins souvent inoccupées que les appartements locatifs traditionnels, 2,1~\% contre 2,9~\% à Montréal, 0,6~\% contre 2,4~\% à Québec, 1,0~\% contre 3,8~\% à Gatineau, ce qui traduit leur localisation et leur qualité plutôt qu'une demande différente. Et leurs loyers sont nettement plus élevés que ceux du parc traditionnel, de 36~\% pour un deux chambres à Montréal (\dollars{1826} contre \dollars{1346}) et de 8~\% à Québec et à Gatineau, écarts que la SCHL signale comme statistiquement significatifs. | |
| 263 | + | |
| 264 | +\begin{table}[htbp] | |
| 265 | +\caption{Copropriétés offertes en location et appartements locatifs traditionnels : univers, inoccupation et loyers moyens, octobre 2025}\label{tab:42:locative} | |
| 266 | +\centering\footnotesize\setlength{\tabcolsep}{4pt} | |
| 267 | +\begin{tabular}{lS[table-format=6.0]S[table-format=5.0]S[table-format=2.1]S[table-format=1.1]S[table-format=1.1]S[table-format=4.0]S[table-format=4.0]S[table-format=4.0]} | |
| 268 | +\toprule | |
| 269 | + & \multicolumn{3}{c}{Univers des copropriétés} & \multicolumn{2}{c}{Inoccupation (\%)} & \multicolumn{3}{c}{Loyer moyen, 2 ch. (\$/mois)} \\ | |
| 270 | +\cmidrule(lr){2-4}\cmidrule(lr){5-6}\cmidrule(lr){7-9} | |
| 271 | +RMR & {Unités} & {Louées} & {Part (\%)} & {Copro.} & {ELL} & {Copro.} & {ELL} & {Copro., tous types} \\ | |
| 272 | +\midrule | |
| 273 | +Montréal & 233535 & 51119 & 21.9 & 2.1 & 2.9 & 1826 & 1346 & 1812 \\ | |
| 274 | +Québec & 36816 & 5709 & 15.5 & 0.6 & 2.4 & 1377 & 1277 & 1382 \\ | |
| 275 | +Gatineau (partie québécoise) & 10620 & 2911 & 27.4 & 1.0 & 3.8 & 1578 & 1460 & 1534 \\ | |
| 276 | +\bottomrule | |
| 277 | +\end{tabular} | |
| 278 | +\tabsource{SCHL, Enquête sur les logements locatifs, octobre 2025, tableaux 4.1.1, 4.1.2, 4.1.3 et 4.3.1 des fichiers de données par RMR \parencite{schlrmltables2025,schlLocatif2025}, lus le 31 octobre 2026 ; « ELL » = appartements d'initiative privée de trois logements et plus visés par l'enquête ; qualité des estimations a ou b sauf l'inoccupation des copropriétés de Montréal (b) ; les écarts de loyers sont signalés significatifs par la SCHL.} | |
| 279 | +\end{table} | |
| 280 | + | |
| 281 | +\subsection{Le revenu d'une fraction louée et ce qu'il révèle} | |
| 282 | + | |
| 283 | +Le revenu net d'exploitation d'une fraction louée se calcule comme celui de tout immeuble de revenu (\cref{ch:27,ch:28}), avec une particularité : les frais de copropriété remplacent la plupart des dépenses d'entretien de l'enveloppe, et la contribution normalisée au fonds de prévoyance joue le rôle de la réserve de remplacement que la convention de l'ouvrage inclut dans le revenu net : | |
| 284 | +\begin{equation}\label{eq:42:rne-copro} | |
| 285 | +\NOI = L\,(1 - v) - \bigl(T + I + F_{\text{cour}} + F_{\text{fonds}} + F_{\text{auto}} + \gamma\,L\,(1 - v)\bigr), | |
| 286 | +\end{equation} | |
| 287 | +où $L$ est le loyer annuel, $v$ le taux d'inoccupation et de mauvaises créances, $T$ les taxes foncières et scolaires, $I$ l'assurance du copropriétaire, $F$ les trois composantes des frais de copropriété et $\gamma$ la part de gestion. Le taux de capitalisation implicite est alors $R = \NOI / P$ pour un prix $P$ observé, et la valeur d'investissement, pour un investisseur qui exige un taux $R^*$, est $\NOI / R^*$ : | |
| 288 | +\begin{equation}\label{eq:42:taux-implicite} | |
| 289 | +R_{\text{impl}} = \frac{\NOI}{P}, \qquad V_{\text{inv}} = \frac{\NOI}{R^{*}}. | |
| 290 | +\end{equation} | |
| 291 | + | |
| 292 | +Appliquons ces formules à F4, louée \dollars{1700} par mois, loyer fictif de l'ordre de grandeur des copropriétés de deux chambres offertes en location dans les grandes RMR québécoises. Le revenu brut potentiel est de \dollars{20400} ; avec l'inoccupation de 2,1~\% des copropriétés louées de la RMR de Montréal, le revenu brut effectif est de \dollars{19972}. Les dépenses comprennent les taxes, \dollars{3300}, ordre de grandeur du compte moyen d'une copropriété montréalaise en 2026, l'assurance du copropriétaire, \dollars{600}, les frais de copropriété normalisés de F4, \dollars{10218}, dont \dollars{3082} de cotisation de seuil au fonds, et la gestion à 5~\% du revenu brut effectif, \dollars{999} ; elles totalisent \dollars{15117}, soit 75,7~\% du revenu brut effectif, et laissent un revenu net de \dollars{4855}. Rapporté à la valeur marchande de \dollars{381052} conclue plus haut, ce revenu représente un taux implicite de 1,27~\% ; hors cotisation au fonds, qui est une épargne, le revenu net est de \dollars{7937} et le taux de 2,08~\%. Le multiplicateur du revenu brut est de 18,7. Un investisseur qui exigerait le taux de 5,25~\% retenu pour les petits immeubles locatifs du marché fictif, à l'intérieur de la fourchette publiée par CBRE pour les immeubles multirésidentiels de faible hauteur de catégorie B à Québec au troisième trimestre de 2026 \parencite{cbrecaprates2026q3}, n'attribuerait à F4 qu'une valeur d'investissement de \dollars{92472}, le quart de sa valeur marchande. | |
| 293 | + | |
| 294 | +Ce résultat n'est pas une anomalie du marché fictif ; il décrit la structure réelle du segment. Le prix d'une fraction est fixé par les acheteurs-occupants, pour qui le coût d'usage se compare à un loyer et pour qui le logement est aussi un placement dans la hausse anticipée des prix ; l'investisseur qui achète au même prix n'obtient qu'un rendement courant très faible et parie sur la plus-value et sur la hausse des loyers, dans un régime où la fixation du loyer par le Tribunal administratif du logement ne fait aucune place à la valeur locative de marché (\cref{ch:26,ch:34}). Il en découle trois conséquences pour l'évaluateur. La première est que la méthode du revenu ne peut pas servir à estimer la valeur marchande d'une fraction de copropriété résidentielle : elle produit une valeur d'investissement, utile au bailleur pour décider, mais étrangère au prix que le marché formera. La deuxième est que la vente d'une fraction louée se compare à celle d'une fraction libre à la condition d'ajuster le droit transmis : l'acquéreur ne peut résilier le bail \parencite[art.~1937]{ccq} et ne peut reprendre le logement que dans les conditions des articles 1957 et suivants, de sorte qu'une fraction occupée par un locataire au loyer modeste se vend à des investisseurs, avec une décote que l'évaluateur mesure par paires de ventes et que la \cref{sec:42:conversion} utilise. La troisième est que la destination fixée par la déclaration de copropriété conditionne ce segment : une déclaration qui interdit la location, la limite à des baux d'une durée minimale ou interdit la location à court terme restreint le bassin d'acheteurs aux occupants, et l'évaluateur la lit avant de retenir une fraction louée comme comparable \parencite[art.~1053]{ccq}. | |
| 295 | + | |
| 296 | +\begin{quebec} | |
| 297 | +Le régime de la location à court terme des résidences relève, au Québec, d'une loi provinciale sur l'hébergement touristique et des règlements municipaux de zonage, qui n'ont pas été vérifiés pour cet ouvrage et dont le présent chapitre ne décrit pas le contenu ; l'évaluateur qui rencontre une fraction exploitée en hébergement touristique vérifie la déclaration de copropriété, le zonage et l'enregistrement provincial avant d'attribuer la moindre valeur à ce revenu, et il traite une fraction dont le revenu dépend d'une activité non autorisée comme une fraction résidentielle ordinaire. En revanche, deux règles vérifiées encadrent directement la copropriété locative : la fixation du loyer et le maintien dans les lieux s'appliquent au locataire d'une fraction comme à tout locataire \parencite[art.~1936--1937, 1941--1955]{ccq}, et le copropriétaire bailleur demeure tenu, envers le syndicat, de toutes les charges et de l'assurance responsabilité minimale de \dollars{1000000} ou \dollars{2000000} selon la taille de l'immeuble \parencite[art.~1]{reglAssuranceCopropriete}. | |
| 298 | +\end{quebec} | |
added
references/lib-ch20.bib
+53 −0
@@ -0,0 +1,53 @@ | ||
| 1 | +% Nouvelles références du chapitre 20 — vérifiées dans l'API Crossref (api.crossref.org/works/<DOI>) le 2026-10-31 ; à fusionner dans library.bib par l'orchestrateur. | |
| 2 | +% NON citées par clé dans ch20.tex (règle : citer uniquement des clés déjà présentes dans library.bib) : mentionnées en toutes lettres avec DOI en note de bas de page. | |
| 3 | + | |
| 4 | +@article{batesGranger1969, | |
| 5 | + author = {Bates, J. M. and Granger, C. W. J.}, | |
| 6 | + title = {The Combination of Forecasts}, | |
| 7 | + journaltitle = {Operational Research Quarterly}, | |
| 8 | + year = {1969}, | |
| 9 | + volume = {20}, | |
| 10 | + number = {4}, | |
| 11 | + pages = {451--468}, | |
| 12 | + doi = {10.1057/jors.1969.103}, | |
| 13 | + urldate = {2026-10-31}, | |
| 14 | + note = {Poids optimaux de deux prévisions corrélées (eq. 20.deux) ; Crossref indique le titre de revue actuel \emph{Journal of the Operational Research Society} ; vérifié Crossref 2026-10-31} | |
| 15 | +} | |
| 16 | + | |
| 17 | +@article{grangerRamanathan1984, | |
| 18 | + author = {Granger, Clive W. J. and Ramanathan, Ramu}, | |
| 19 | + title = {Improved Methods of Combining Forecasts}, | |
| 20 | + journaltitle = {Journal of Forecasting}, | |
| 21 | + year = {1984}, | |
| 22 | + volume = {3}, | |
| 23 | + number = {2}, | |
| 24 | + pages = {197--204}, | |
| 25 | + doi = {10.1002/for.3980030207}, | |
| 26 | + urldate = {2026-10-31}, | |
| 27 | + note = {Combinaison par régression, poids non contraints ; vérifié Crossref 2026-10-31} | |
| 28 | +} | |
| 29 | + | |
| 30 | +@article{efron1979, | |
| 31 | + author = {Efron, Bradley}, | |
| 32 | + title = {Bootstrap Methods: Another Look at the Jackknife}, | |
| 33 | + journaltitle = {The Annals of Statistics}, | |
| 34 | + year = {1979}, | |
| 35 | + volume = {7}, | |
| 36 | + number = {1}, | |
| 37 | + pages = {1--26}, | |
| 38 | + doi = {10.1214/aos/1176344552}, | |
| 39 | + urldate = {2026-10-31}, | |
| 40 | + note = {Article fondateur du bootstrap (sec. 20.bootstrap) ; vérifié Crossref 2026-10-31} | |
| 41 | +} | |
| 42 | + | |
| 43 | +@book{efronTibshirani1993, | |
| 44 | + author = {Efron, Bradley and Tibshirani, Robert J.}, | |
| 45 | + title = {An Introduction to the Bootstrap}, | |
| 46 | + publisher = {Chapman \& Hall}, | |
| 47 | + address = {New York}, | |
| 48 | + year = {1993}, | |
| 49 | + doi = {10.1007/978-1-4899-4541-9}, | |
| 50 | + isbn = {9780412042317}, | |
| 51 | + urldate = {2026-10-31}, | |
| 52 | + note = {Intervalles percentiles, choix de B ; vérifié Crossref 2026-10-31 (ISBN 9780412042317 / 9781489945419)} | |
| 53 | +} | |
modified
references/library.bib
+51 −0
@@ -5084,6 +5084,57 @@ | ||
| 5084 | 5084 | note = {Diagnostics de colinéarité (VIF, indices de conditionnement) et d'influence ; DOI de l'édition Wiley Series in Probability and Statistics (2005, réimpression) ; statut : partiellement vérifié (notice Wiley, non ouverte à la page)} |
| 5085 | 5085 | } |
| 5086 | 5086 | |
| 5087 | +@article{batesGranger1969, | |
| 5088 | + author = {Bates, J. M. and Granger, C. W. J.}, | |
| 5089 | + title = {The Combination of Forecasts}, | |
| 5090 | + journaltitle = {Operational Research Quarterly}, | |
| 5091 | + year = {1969}, | |
| 5092 | + volume = {20}, | |
| 5093 | + number = {4}, | |
| 5094 | + pages = {451--468}, | |
| 5095 | + doi = {10.1057/jors.1969.103}, | |
| 5096 | + urldate = {2026-10-31}, | |
| 5097 | + note = {Poids optimaux de deux prévisions corrélées (eq. 20.deux) ; Crossref indique le titre de revue actuel \emph{Journal of the Operational Research Society} ; vérifié Crossref 2026-10-31} | |
| 5098 | +} | |
| 5099 | + | |
| 5100 | +@article{grangerRamanathan1984, | |
| 5101 | + author = {Granger, Clive W. J. and Ramanathan, Ramu}, | |
| 5102 | + title = {Improved Methods of Combining Forecasts}, | |
| 5103 | + journaltitle = {Journal of Forecasting}, | |
| 5104 | + year = {1984}, | |
| 5105 | + volume = {3}, | |
| 5106 | + number = {2}, | |
| 5107 | + pages = {197--204}, | |
| 5108 | + doi = {10.1002/for.3980030207}, | |
| 5109 | + urldate = {2026-10-31}, | |
| 5110 | + note = {Combinaison par régression, poids non contraints ; vérifié Crossref 2026-10-31} | |
| 5111 | +} | |
| 5112 | + | |
| 5113 | +@article{efron1979, | |
| 5114 | + author = {Efron, Bradley}, | |
| 5115 | + title = {Bootstrap Methods: Another Look at the Jackknife}, | |
| 5116 | + journaltitle = {The Annals of Statistics}, | |
| 5117 | + year = {1979}, | |
| 5118 | + volume = {7}, | |
| 5119 | + number = {1}, | |
| 5120 | + pages = {1--26}, | |
| 5121 | + doi = {10.1214/aos/1176344552}, | |
| 5122 | + urldate = {2026-10-31}, | |
| 5123 | + note = {Article fondateur du bootstrap (sec. 20.bootstrap) ; vérifié Crossref 2026-10-31} | |
| 5124 | +} | |
| 5125 | + | |
| 5126 | +@book{efronTibshirani1993, | |
| 5127 | + author = {Efron, Bradley and Tibshirani, Robert J.}, | |
| 5128 | + title = {An Introduction to the Bootstrap}, | |
| 5129 | + publisher = {Chapman \& Hall}, | |
| 5130 | + address = {New York}, | |
| 5131 | + year = {1993}, | |
| 5132 | + doi = {10.1007/978-1-4899-4541-9}, | |
| 5133 | + isbn = {9780412042317}, | |
| 5134 | + urldate = {2026-10-31}, | |
| 5135 | + note = {Intervalles percentiles, choix de B ; vérifié Crossref 2026-10-31 (ISBN 9780412042317 / 9781489945419)} | |
| 5136 | +} | |
| 5137 | + | |
| 5087 | 5138 | @manual{arcRC4028, |
| 5088 | 5139 | author = {{Agence du revenu du Canada}}, |
| 5089 | 5140 | title = {RC4028 --- Remboursement de la TPS/TVH pour habitations neuves}, |
modified
solutions/ch20.tex
+29 −0
@@ -1 +1,30 @@ | ||
| 1 | 1 | % Solutions du chapitre 20 — Réconciliation et incertitude |
| 2 | +% Les chiffres sont vérifiés par analysis/python/tests/test_ch20.py (test_solutions_exercices) | |
| 3 | + | |
| 4 | +\begin{solution}{ex:20:1}{Deux indications indépendantes} | |
| 5 | +Les précisions sont $1/\sigma_1^2 = 1/30\,000^2 = 1{,}111 \times 10^{-9}$ et $1/\sigma_2^2 = 1/12\,000^2 = 6{,}944 \times 10^{-9}$ ; les poids inverses de la variance valent $w_1 = 1{,}111/(1{,}111 + 6{,}944) = 0{,}1379$ pour la comparaison et $w_2 = 0{,}8621$ pour le modèle. La valeur combinée est $0{,}1379 \times 582\,000 + 0{,}8621 \times 636\,000 = \dollars{628552}$ et son écart-type, par l'\cref{eq:20:variance} avec $\rho = 0$, $\sqrt{0{,}1379^2 \times 30\,000^2 + 0{,}8621^2 \times 12\,000^2} = \dollars{11142}$, ce qui est aussi $(1/\sigma_1^2 + 1/\sigma_2^2)^{-1/2}$. La moyenne simple vaut \dollars{609000} et son écart-type $\sqrt{0{,}25 \times 30\,000^2 + 0{,}25 \times 12\,000^2} = \dollars{16155}$. La moyenne simple est moins précise que la combinaison optimale (\dollars{11142}) et même que le modèle seul (\dollars{12000}), parce qu'elle donne la moitié du poids à l'indication dont la variance est six fois plus grande ; elle reste plus précise que la comparaison seule (\dollars{30000}), parce que toute moyenne de deux estimateurs indépendants réduit la variance de chacun pris isolément dès que les poids ne sont pas extrêmes. Le gain de la pondération optimale sur la moyenne simple est de 31 \% d'écart-type, et la conclusion se déplace de près de \dollars{20000}. | |
| 6 | +\end{solution} | |
| 7 | + | |
| 8 | +\begin{solution}{ex:20:2}{Trois intervalles} | |
| 9 | +L'écart-type d'estimation du logarithme est $\sqrt{0{,}00010} = 0{,}0100$ et la demi-largeur à 95 \% de l'intervalle de confiance $1{,}96 \times 0{,}0100 = 0{,}0196$, soit \pct{1.96} ; l'\cref{eq:20:ic} donne $\exp(13{,}3627 \mp 0{,}0196) \times 1{,}00335$, c'est-à-dire de \dollars{625586} à \dollars{650596}. L'écart-type de prédiction est $\sqrt{0{,}00010 + 0{,}0830^2} = \sqrt{0{,}00699} = 0{,}0836$, la demi-largeur $1{,}96 \times 0{,}0836 = 0{,}1639$, soit \pct{16.4}, et l'intervalle de prédiction (\cref{eq:18:intervalle}) $\exp(13{,}3627 \mp 0{,}1639)$, de \dollars{539740} à \dollars{749047} (le \cref{ch:18} obtient \dollars{539682} et \dollars{749087} avec les valeurs non arrondies). Le rapport des demi-largeurs en logarithme est $0{,}0836 / 0{,}0100 = 8{,}36$. L'intervalle de confiance porte sur $\E[P \mid \mathbf{x}_s]$, c'est-à-dire sur la valeur marchande telle qu'un seul modèle l'estime : c'est lui, et non l'intervalle de prédiction, qui a la nature d'une fourchette de valeur, mais il ne contient que l'incertitude d'estimation des coefficients d'une spécification donnée. L'intervalle de prédiction porte sur le prix d'une transaction future et doit être présenté comme une information sur le prix. Ni l'un ni l'autre n'est la fourchette conclue au \cref{sec:20:exemple}, \dollars{575000} à \dollars{640000}, parce que celle-ci intègre l'incertitude de spécification du modèle, l'écart entre les méthodes et l'incertitude des poids, qu'aucun intervalle tiré d'un seul modèle ne contient : la fourchette conclue est cinq fois plus large que l'intervalle de confiance et deux fois plus étroite que l'intervalle de prédiction. | |
| 10 | +\end{solution} | |
| 11 | + | |
| 12 | +\begin{solution}{ex:20:3}{Poids optimaux avec corrélation} | |
| 13 | +Avec $\sigma_1 = \dollars{30000}$ (comparaison) et $\sigma_2 = \dollars{12000}$ (modèle), l'\cref{eq:20:deux} donne $w_1^* = (\sigma_2^2 - \rho\sigma_1\sigma_2)/(\sigma_1^2 + \sigma_2^2 - 2\rho\sigma_1\sigma_2)$, où $\sigma_1\sigma_2 = 360 \times 10^6$. Pour $\rho = 0{,}3$ : $w_1^* = (144 - 108)/(1\,044 - 216) = 36/828 = 0{,}0435$, et l'écart-type minimal, par la seconde formule, $\sqrt{900 \times 144 \times (1 - 0{,}09) / 828} \times 10^3 = \dollars{11935}$. Pour $\rho = 0{,}5$ : $w_1^* = (144 - 180)/(1\,044 - 360) = -0{,}0526$ ; pour $\rho = 0{,}6$ : $w_1^* = (144 - 216)/(1\,044 - 432) = -0{,}1176$. Le poids devient négatif dès que $\rho > \sigma_2/\sigma_1 = 12\,000/30\,000 = 0{,}40$ : au-delà, l'erreur de la comparaison est si bien prédite par celle du modèle que la combinaison optimale l'utiliserait comme correction, en la soustrayant. Un poids négatif n'a pas de sens pour une indication de valeur ; l'évaluateur contraint les poids à $[0, 1]$, ce qui donne $w_1 = 0$ et un écart-type de \dollars{12000}, c'est-à-dire qu'il ne retient que le modèle et déclare que la comparaison, telle qu'elle a été construite, n'apporte aucune information indépendante. Pour la pratique, le résultat signifie qu'une grille ajustée avec les prix implicites d'une régression estimée sur les mêmes ventes n'est pas une seconde méthode, mais une seconde lecture de la même méthode, d'autant plus redondante que les comparables sont tenus pour certains ; sur le cas 1, la corrélation mesurée comparables fixes, $0{,}425$, dépasse d'ailleurs ce seuil, et seule la variance propre au tirage des candidats, qui ramène la corrélation totale à $0{,}088$, justifie de garder les deux indications. | |
| 14 | +\end{solution} | |
| 15 | + | |
| 16 | +\begin{solution}{ex:20:4}{Un bootstrap à la main} | |
| 17 | +La somme des vingt réplications est $11\,723$ milliers, d'où une moyenne de \dollars{586150} ; la somme des carrés des écarts à la moyenne vaut $4\,248{,}55 \times 10^6$ et l'écart-type bootstrap, avec $B - 1 = 19$ au dénominateur (\cref{eq:20:bootstrap}), $\sqrt{4\,248{,}55 \times 10^6 / 19} = \dollars{14954}$. Les quantiles par interpolation linéaire (position $q(B-1) + 1$ dans la liste triée) : le quantile à 5 \% est à la position $0{,}05 \times 19 + 1 = 1{,}95$, soit $561 + 0{,}95 \times (568 - 561) = 567{,}65$ milliers ; le quantile à 95 \% est à la position $19{,}05$, soit $611 + 0{,}05 \times (624 - 611) = 611{,}65$ milliers ; l'intervalle percentile à 90 \% va donc de \dollars{567650} à \dollars{611650}. L'intervalle à 80 \% s'obtient de même aux positions $2{,}9$ et $18{,}1$ : $568 + 0{,}9 \times 3 = 570{,}7$ et $603 + 0{,}1 \times 8 = 603{,}8$ milliers, soit de \dollars{570700} à \dollars{603800}. Vingt réplications suffisent pour l'écart-type parce que celui-ci est une moyenne de carrés sur toutes les réplications, dont l'erreur relative est de l'ordre de $1/\sqrt{2(B-1)} \approx 16$ \% ; elles sont insuffisantes pour les quantiles extrêmes parce que ceux-ci reposent sur une ou deux observations de queue : le quantile à 5 \% est ici déterminé par les deux plus petites valeurs seulement, et une seule réplication de plus ou de moins le déplace de plusieurs milliers de dollars. C'est pourquoi le chapitre utilise $B = 2\,000$ pour les intervalles percentiles. | |
| 18 | +\end{solution} | |
| 19 | + | |
| 20 | +\begin{solution}{ex:20:5}{L'indication par le coût : hypothèses d'usage et extraction} | |
| 21 | +(a) Terrain alloué : $V_L = 0{,}35 \times 575\,565 = \dollars{201448}$ ; dépréciation âge-vie : $D = 397\,567 \times 22/60 = \dollars{145775}$ ; indication : $201\,448 + 397\,567 - 145\,775 = \dollars{453240}$. (b) Avec 28 ans sur 75 : $D = 397\,567 \times 28/75 = \dollars{148425}$ et $V = 201\,448 + 397\,567 - 148\,425 = \dollars{450590}$. Les deux variantes restent inférieures de plus de \dollars{120000} aux indications par comparaison et par le coût du \cref{ch:24} : des hypothèses d'usage, même adoucies, ne rejoignent pas le marché, parce que la part du terrain y est sous-estimée (le \cref{ch:24} trouve 50,5 \% du prix des maisons récentes) et la dépréciation surestimée (22 \% extraite contre 37 à 47 \% par âge-vie). (c) Avec $V_L = \dollars{277000}$, l'\cref{eq:20:extraction} donne, à l'indication par comparaison, $D = 397\,567 - (575\,565 - 277\,000) = \dollars{99002}$, soit \pct{24.9} du coût neuf, et, à la prédiction hédonique, $D = 397\,567 - (637\,953 - 277\,000) = \dollars{36614}$, soit \pct{9.2}. Pour que le coût rejoigne le modèle avec la dépréciation totale du \cref{ch:24} (23,91 \%, soit \dollars{95066}), il faudrait un terrain de $637\,953 - (397\,567 - 95\,066) = \dollars{335452}$, soit \pct{21} de plus que la valeur extraite ; cette valeur n'est pas absurde, et le \cref{ch:25} doit trancher. (d) L'accord entre le coût (\dollars{579501}) et la comparaison (\dollars{575565}) n'est pas une confirmation indépendante parce que la dépréciation extraite du marché est, par construction, ce qui ramène le coût déprécié aux prix observés : si l'ensemble des ventes se vendait 10 \% plus cher, la dépréciation extraite baisserait et l'indication par le coût monterait d'autant, sans qu'aucune mesure physique du bâtiment ait changé ; le terrain, lui, est extrait de treize ventes du même marché, ramenées à la date par la tendance du même modèle, d'où la corrélation de $0{,}225$ avec la prédiction mesurée au \cref{tab:20:indications}. Un dossier réel exigerait de confirmer le terrain par des ventes de lots vacants ou par la méthode d'extraction sur des ventes récentes d'autres secteurs comparables (\cref{ch:25}), de vérifier le coût neuf par des soumissions locales ou des prix de maisons neuves décomposés (\cref{ch:21}), et de confronter la dépréciation extraite à l'état constaté du bâtiment par l'inspection (\cref{ch:11,ch:24}). | |
| 22 | +\end{solution} | |
| 23 | + | |
| 24 | +\begin{solution}{ex:20:6}{Pondérations alternatives et comparaison élargie} | |
| 25 | +(a) Avec les poids $0{,}30$, $0{,}55$ et $0{,}15$ : $0{,}30 \times 575\,565 + 0{,}55 \times 637\,953 + 0{,}15 \times 579\,501 = \dollars{610469}$, arrondi à \dollars{610000} ; avec $0{,}55$, $0{,}30$ et $0{,}15$ : \dollars{594872}, arrondi à \dollars{595000}. L'écart entre ces deux pondérations défendables est de \dollars{15600}, soit 2,6 \% de la conclusion, et toutes deux restent dans la fourchette conclue. (b) L'indication par comparaison élargie à seize candidats vaut \dollars{604689} ; avec les poids raisonnés, la conclusion devient $0{,}40 \times 604\,689 + 0{,}45 \times 637\,953 + 0{,}15 \times 579\,501 = \dollars{615880}$, arrondie à \dollars{615000}. L'effet, $+\dollars{11650}$, mesure ce que coûte à la conclusion le choix de cinq comparables qui se trouvent être des ventes bon marché : élargir l'échantillon rapproche la comparaison du modèle et déplace la conclusion vers lui, ce qui est l'arbitrage biais--variance du \cref{ch:16} appliqué à la réconciliation. (c) En relançant le bootstrap conjoint avec $k = 8$ comparables (fonction \texttt{bootstrap}, argument \texttt{k}), l'écart-type passe de \dollars{18385} à \dollars{18086} et l'indication à huit comparables, modèle fixe, vaut \dollars{581413}. La réduction est faible, bien que l'\cref{eq:16:variance} fasse attendre une variance divisée par $8/5$ pour des comparables de même variance et de même poids : les trois candidats ajoutés ont des ajustements bruts plus élevés, donc des poids plus faibles, de sorte que la somme des carrés des poids diminue peu, et le rééchantillonnage d'un bassin de seize ventes seulement fait réapparaître les mêmes candidats dans la plupart des réplications. \emph{Note} (150 mots). Ces variantes montrent que la fourchette de valeur à communiquer ne peut pas être l'intervalle bootstrap d'une seule configuration : la conclusion se déplace de \dollars{595000} à \dollars{615000} selon des choix de pondération et d'échantillon tous défendables, et cette plage, de 3,3 \% de la valeur, est du même ordre que l'écart-type bootstrap de l'indication par comparaison. La fourchette doit donc contenir au moins la plage des conclusions défendables, ce que fait l'encadrement par les indications crédibles (\dollars{575000} à \dollars{640000}), et le rapport doit dire quelles décisions, ici le nombre de comparables et le partage des poids entre la comparaison et le modèle, la gouvernent. L'évaluateur qui ne présente qu'une configuration et son intervalle bootstrap sous-estime l'incertitude de sa propre conclusion d'un facteur deux environ. | |
| 26 | +\end{solution} | |
| 27 | + | |
| 28 | +\begin{solution}{ex:20:7}{Rédiger la conclusion et l'incidence d'une hypothèse} | |
| 29 | +(a) Sous l'hypothèse extraordinaire $H$ (sous-sol exempt d'infiltration), la valeur conclue est $V_{\mid H} = \dollars{605000}$ ; si $H$ était fausse, $V_{\mid \neg H} = 605\,000 - 14\,000 = \dollars{591000}$. L'incidence (\cref{eq:10:incidence}) est $\Delta V_H = \dollars{14000}$, soit \pct{2.3} de la valeur conclue. Un lecteur qui attribue à $H$ une probabilité $p = 0{,}7$ retiendrait, par l'\cref{eq:10:esperance}, $\E[V] = 591\,000 + 0{,}7 \times 14\,000 = \dollars{600800}$. (b) Paragraphe (modèle, 190 mots) : « Trois indications ont été obtenues pour le sujet au 1\ier{} septembre 2026 : \dollars{575565} par la comparaison de cinq ventes vérifiées du secteur de Hull, ajustées par les prix implicites d'un modèle estimé sur 600 ventes ; \dollars{637953} par ce modèle ; \dollars{579501} par le coût, avec un terrain extrait de treize ventes récentes et une dépréciation extraite du marché. L'écart de 10 \% entre la comparaison et le modèle tient à ce que les cinq comparables se sont vendus en moyenne 8,5 \% sous leur prix prédit, écart que les onze autres ventes semblables du secteur ne partagent pas ; l'accord entre le coût et la comparaison tient en partie à ce que la dépréciation est extraite des mêmes prix. Nous avons pondéré la comparaison à 40 \%, le modèle à 45 \% et le coût à 15 \%, pour les raisons exposées à la section 7, et nous estimons la valeur marchande à \dollars{605000}, dans une fourchette de \dollars{575000} à \dollars{640000} ; le prix de vente probable se situe entre \dollars{540000} et \dollars{749000} dix-neuf fois sur vingt. Cette conclusion repose sur l'hypothèse extraordinaire que le sous-sol fini est exempt d'infiltration, non vérifiable derrière les finis ; si elle se révélait fausse, la valeur serait réduite d'environ \dollars{14000} (2,3 \%). » Le paragraphe expose le traitement de l'information et le raisonnement (règle 2.3, élément 8), décrit la réconciliation vers une conclusion unique sans moyenne (IVS 103, § 10.07) et rappelle l'hypothèse extraordinaire avec son incidence à l'énoncé de la conclusion (élément 7). (c) L'évaluateur ne doit pas remplacer $V_{\mid H}$ par $\E[V]$ parce que la valeur marchande est une estimation conditionnelle à un état du bien, non un pari sur cet état : c'est le lecteur, qui connaît ou apprécie $p$, qui forme l'espérance s'il le souhaite, et c'est pour le lui permettre que la norme exige l'incidence ; une valeur « espérée » mêlerait dans un seul chiffre une opinion sur le marché et une opinion sur l'état du sous-sol, et ne serait plus ni l'une ni l'autre. | |
| 30 | +\end{solution} | |
modified
solutions/ch36.tex
+29 −0
@@ -1 +1,30 @@ | ||
| 1 | 1 | % Solutions du chapitre 36 — Données de panel et identification causale |
| 2 | +% Les chiffres sont vérifiés par analysis/python/tests/test_ch36.py (test_solutions_exercices, test_solution_exercice_7). | |
| 3 | + | |
| 4 | +\begin{solution}{ex:36:1}{Doubles différences à la main} | |
| 5 | +(a) La variation des traités est $11{,}64 - 11{,}52 = 0{,}12$ et celle des témoins $11{,}47 - 11{,}40 = 0{,}07$ ; l'estimateur de l'\cref{eq:8:did} vaut $\hat\delta_{2\times 2} = 0{,}12 - 0{,}07 = 0{,}05$, soit $100\,[e^{0{,}05} - 1] = 5{,}13$ \% de loyer en plus. La multiplication par cent aurait donné $5{,}0$ \% ; l'écart est petit ici mais croît avec l'effet. (b) L'hypothèse est celle des tendances parallèles de l'\cref{eq:36:paralleles} : sans la gare, le logarithme des loyers des immeubles proches aurait augmenté de $0{,}07$, comme celui des immeubles éloignés. Un placebo consiste à appliquer le même calcul à deux périodes antérieures à l'annonce de la gare, par exemple les deux années précédant l'annonce ; l'estimateur doit alors être nul aux erreurs d'échantillonnage près. Une étude d'événement généralise le placebo à toutes les périodes antérieures et montre si les coefficients pré-traitement ont une pente. (c) L'anneau intermédiaire a été exclu parce que son exposition est ambiguë : il bénéficie sans doute en partie de la gare (il n'est pas un bon témoin) sans être à distance de marche (il n'est pas un bon traité). L'inclure dans les témoins atténuerait l'effet par débordement ; l'inclure dans les traités diluerait l'effet. On vérifie la pertinence du seuil de 800 m en le faisant varier et en estimant l'effet par anneaux successifs, comme le font \textcite{currie2015}. | |
| 6 | +\end{solution} | |
| 7 | + | |
| 8 | +\begin{solution}{ex:36:2}{Transformation within sur un petit panel} | |
| 9 | +(a) Les moyennes par immeuble sont $\bar y_1 = 11{,}15$, $\bar y_2 = 11{,}65$, $\bar y_3 = 10{,}85$ et $\bar x_1 = \bar x_2 = 0{,}5$, $\bar x_3 = 0$. Les données transformées sont $\ddot y = (-0{,}15 ;\ 0{,}15 ;\ -0{,}05 ;\ 0{,}05 ;\ -0{,}05 ;\ 0{,}05)$ et $\ddot x = (-0{,}5 ;\ 0{,}5 ;\ -0{,}5 ;\ 0{,}5 ;\ 0 ;\ 0)$. L'\cref{eq:36:within-est} donne $\hat\beta_W = \sum \ddot x\ddot y / \sum \ddot x^2 = (0{,}075 + 0{,}075 + 0{,}025 + 0{,}025)/(4 \times 0{,}25) = 0{,}20/1{,}00 = 0{,}20$ : la rénovation est associée à une hausse de $0{,}20$ du logarithme du revenu, soit 22 \%. Noter que le troisième immeuble, jamais rénové, ne contribue pas à l'estimation de $\beta$ (ses $\ddot x$ sont nuls) ; il contribuerait à l'estimation des effets de période si l'on en ajoutait. (b) La régression des moindres carrés ordinaires de $y$ sur $x$ sur les six observations donne une pente de $0{,}425$ : la moyenne de $y$ est $11{,}50$ pour $x = 1$ et $11{,}075$ pour $x = 0$. L'écart avec $0{,}20$ vient de ce que les immeubles rénovés (1 et 2) ont des revenus permanents plus élevés que l'immeuble 3 : l'effet fixe $\alpha_i$ est corrélé avec $x_{it}$, et la régression groupée attribue à la rénovation la différence de niveau entre immeubles, qui existait avant elle. C'est le biais de sélection de l'\cref{eq:36:selection}, que la transformation within élimine. (c) L'estimateur within reste sans biais si la décision de rénover ne dépend que de caractéristiques permanentes (absorbées par $\alpha_i$). Si les propriétaires ont rénové \emph{parce que} les revenus stagnaient, la décision répond à un choc passé $\varepsilon_{i,t-1}$ négatif, ce qui viole l'exogénéité stricte : la période post-rénovation est comparée à une période anormalement basse, et l'estimateur surestime l'effet par retour à la moyenne. Le remède est un groupe témoin dont les revenus stagnaient aussi mais qui n'a pas rénové, c'est-à-dire des doubles différences avec appariement sur la trajectoire pré-traitement. | |
| 10 | +\end{solution} | |
| 11 | + | |
| 12 | +\begin{solution}{ex:36:3}{Grappes et facteur de Moulton} | |
| 13 | +(a) Le facteur multiplicatif des erreurs-types est $\sqrt{1 + (m-1)\rho}$ : pour $m = 42$ et $\rho = 0{,}5$, $\sqrt{1 + 41 \times 0{,}5} = \sqrt{21{,}5} = 4{,}64$ ; pour $\rho = 0{,}9$, $\sqrt{37{,}9} = 6{,}16$ ; pour $m = 8$ et $\rho = 0{,}5$, $\sqrt{4{,}5} = 2{,}12$. La correction croît avec la longueur du panel : plus on observe longtemps le même immeuble, moins chaque trimestre supplémentaire apporte d'information indépendante. (b) Un rapport de $4{,}9$ implique $1 + 41\rho = 4{,}9^2 = 24{,}0$, d'où $\rho \approx 0{,}56$. Cette corrélation implicite est cohérente avec des séries construites comme des marches aléatoires autour d'une tendance, dont les écarts aux effets fixes sont fortement persistants ; elle n'est qu'indicative, parce que la formule suppose un régresseur constant dans la grappe et une corrélation uniforme, alors que $D_{it}$ varie dans le temps et que la corrélation décroît avec l'écart entre trimestres. (c) Le traitement varie entre municipalités et non entre immeubles d'une même municipalité : tout choc commun à une municipalité (son marché local, sa fiscalité, sa conjoncture) est corrélé avec le traitement de ses 200 immeubles, et les grappes doivent être les municipalités. Avec $G = 4$ grappes, l'\cref{eq:36:grappes} repose sur quatre vecteurs $\mathbf{X}_g\T\mathbf{e}_g$ pour estimer une matrice de variance : l'estimateur est très imprécis, biaisé vers le bas même avec la correction $G/(G-1)$, et les lois asymptotiques en $G \to \infty$ ne s'appliquent pas. Il faut recourir à des procédures de rééchantillonnage adaptées aux petits nombres de grappes, ou à l'agrégation au niveau des municipalités suivie d'une inférence exacte, et dire franchement que quatre unités traitées ou témoins ne permettent qu'une inférence fragile. | |
| 14 | +\end{solution} | |
| 15 | + | |
| 16 | +\begin{solution}{ex:36:4}{Lire une étude d'événement} | |
| 17 | +(a) Aucun coefficient pré-traitement n'est individuellement significatif au seuil de 5 \% : $-0{,}01$, $-0{,}02$ et $-0{,}03$ avec une erreur-type de $0{,}02$ donnent des statistiques $t$ de $0{,}5$, $1{,}0$ et $1{,}5$. Mais ils dessinent une pente nette : en lecture chronologique, ils passent de $-0{,}01$ en $k = -4$ à $-0{,}03$ en $k = -2$, puis à zéro en $k = -1$ par construction, ce qui signifie que les prix des immeubles bientôt cartographiés ont \emph{baissé} par rapport aux témoins de $k = -4$ à $k = -2$, puis sont remontés d'un coup au trimestre de référence. Une lecture plus plausible est que le trimestre $k = -1$ est un point haut accidentel, ou que la tendance différentielle est négative et que la référence la masque ; dans les deux cas, les tendances parallèles sont douteuses. (b) Si les traités étaient déjà sur une trajectoire relative descendante d'environ $0{,}01$ par année, les coefficients post-traitement de $-0{,}06$ à $-0{,}08$ surestiment la décote d'un montant qui croît avec $k$ : à $k = 3$, l'extrapolation de la pente pré-traitement expliquerait à elle seule environ $0{,}04$ des $0{,}08$. La \cref{sec:36:sensibilite} propose de détendancer les différences longues par la pente pré-traitement de chaque groupe, ou d'ajouter une tendance linéaire propre au groupe traité, en rapportant les deux résultats comme une fourchette ; elle rappelle aussi que la référence doit être choisie avant de voir les résultats et que l'on peut vérifier la robustesse en prenant pour référence la moyenne de plusieurs trimestres pré-traitement. (c) Si la cartographie a été annoncée deux ans avant sa publication, le marché a pu réagir dès l'annonce : les périodes $k = -8$ à $-1$ sont alors déjà « traitées » par l'information, et la pente pré-traitement observée pourrait \emph{être} l'effet d'anticipation plutôt qu'une violation des tendances parallèles. Il faut dater l'événement à l'annonce, ou au moins exclure les trimestres entre l'annonce et la publication de la fenêtre de référence, et interpréter l'effet mesuré à la publication comme l'effet \emph{supplémentaire} de la mise en vigueur formelle. | |
| 18 | +\end{solution} | |
| 19 | + | |
| 20 | +\begin{solution}{ex:36:5}{Poids de Goodman-Bacon} | |
| 21 | +(a) La cohorte précoce est traitée aux périodes 5 à 19, soit $15/20$ des périodes : $\bar D_k = 0{,}75$ ; la cohorte tardive aux périodes 15 à 19 : $\bar D_\ell = 0{,}25$. Le tableau $2 \times 20$ des $D_{it}$ a pour moyennes de ligne $0{,}75$ et $0{,}25$, pour moyennes de colonne $0$ (périodes 0 à 4), $0{,}5$ (périodes 5 à 14) et $1$ (périodes 15 à 19), et pour moyenne générale $0{,}5$. Les valeurs doublement centrées $D_{it} - \bar D_i - \bar D_t + \bar D$ valent, pour la cohorte précoce, $-0{,}25$ aux périodes 0 à 4, $+0{,}25$ aux périodes 5 à 14 et $-0{,}25$ aux périodes 15 à 19 ; pour la tardive, $+0{,}25$, $-0{,}25$ et $+0{,}25$. Toutes ont un carré de $0{,}0625$, donc $\hat V^D = 0{,}0625$. (b) Avec $n_k = n_\ell = 0{,}5$ et $n_{k\ell} = 0{,}5$, l'\cref{eq:36:bacon-poids} donne $s_{\ell k} = [(1)(0{,}75)]^2 \times 0{,}25 \times (0{,}25/0{,}75) \times (0{,}50/0{,}75) / 0{,}0625 = 0{,}5625 \times 0{,}25 \times 0{,}3333 \times 0{,}6667 / 0{,}0625 = 0{,}5$, et symétriquement $s_{k\ell} = [(1)(1 - 0{,}25)]^2 \times 0{,}25 \times (0{,}50/0{,}75) \times (0{,}25/0{,}75)/0{,}0625 = 0{,}5$. Les deux poids somment à un : sans unité jamais traitée, le TWFE est la moyenne à parts égales de la comparaison propre et de la comparaison interdite. (c) Les effets vrais sont $\delta(k) = 0{,}01\,(k + 1)$. La cohorte précoce, traitée quinze périodes ($k = 0$ à $14$), a un ATT de $0{,}01 \times 8 = 0{,}08$ ; la tardive, traitée cinq périodes, de $0{,}01 \times 3 = 0{,}03$ ; l'ATT global, pondéré par les observations traitées ($15 \times 0{,}08 + 5 \times 0{,}03)/20$, vaut $0{,}0675$. La comparaison propre $\hat\beta_{k\ell}$ mesure l'effet de la cohorte précoce sur la fenêtre $t < 15$, soit $k = 0$ à $9$ : $\E[\hat\beta_{k\ell}] = 0{,}01 \times 5{,}5 = 0{,}055$. Pour la comparaison interdite, l'effet moyen de la cohorte précoce passe de $0{,}055$ (périodes 5 à 14) à $0{,}01 \times 13 = 0{,}13$ (périodes 15 à 19, $k = 10$ à $14$), une hausse de $0{,}075$ que l'\cref{eq:36:interdite} retranche : $\E[\hat\beta_{\ell k}] = 0{,}03 - 0{,}075 = -0{,}045$. Le TWFE a donc pour espérance $0{,}5 \times 0{,}055 + 0{,}5 \times (-0{,}045) = 0{,}005$, contre un ATT global de $0{,}0675$ : il sous-estime l'effet de 93 \% alors que chaque effet individuel est positif, et il suffirait d'une pente un peu plus forte pour qu'il devienne négatif. | |
| 22 | +\end{solution} | |
| 23 | + | |
| 24 | +\begin{solution}{ex:36:6}{Callaway et Sant'Anna à la main} | |
| 25 | +(a) La base est la période $g - 1 = 2$. Pour $t = 3$, les différences longues des traités sont $10{,}12 - 10{,}05 = 0{,}07$ et $9{,}66 - 9{,}56 = 0{,}10$, de moyenne $0{,}085$ ; celles des témoins, $0{,}03$, $0{,}04$ et $0{,}05$, de moyenne $0{,}04$ ; donc $\widehat{\mathrm{ATT}}(3, 3) = 0{,}085 - 0{,}040 = 0{,}045$. Pour $t = 4$, les traités donnent $0{,}15$ et $0{,}16$ (moyenne $0{,}155$), les témoins $0{,}07$, $0{,}07$ et $0{,}08$ (moyenne $0{,}0733$) : $\widehat{\mathrm{ATT}}(3, 4) = 0{,}0817$. L'effet moyen de la cohorte, première agrégation de l'\cref{eq:36:cs-agreg}, vaut $(0{,}045 + 0{,}0817)/2 = 0{,}0633$, et l'effet croît d'une période à l'autre. (b) Le placebo $\widehat{\mathrm{ATT}}(3, 1)$ compare la période 1 à la base 2 : traités $-0{,}05$ et $-0{,}06$ (moyenne $-0{,}055$), témoins $-0{,}04$, $-0{,}05$ et $-0{,}03$ (moyenne $-0{,}04$) : $-0{,}015$. Il signifie que, de la période 1 à la période 2, les traités ont crû de $0{,}015$ de plus que les témoins ; sur une seule période et cinq unités, rien ne permet de distinguer une pré-tendance d'un bruit, mais le signe invite à la prudence : si cette avance relative se poursuivait, elle expliquerait un tiers de l'effet mesuré en $t = 3$. (c) L'estimateur $2 \times 2$ des périodes 2 et 3 est $(\bar y_{\text{tr},3} - \bar y_{\text{tr},2}) - (\bar y_{\text{té},3} - \bar y_{\text{té},2})$ ; comme la moyenne des différences est la différence des moyennes, c'est exactement $\widehat{\mathrm{ATT}}(3, 3)$ : l'estimateur de Callaway et Sant'Anna n'est rien d'autre qu'une collection de $2 \times 2$ propres, chacun ancré sur la dernière période non traitée. Avec une base universelle $g - 1$, tous les placebos sont mesurés par rapport à la même période, et un accident sur celle-ci se transmet à tous ; avec une base variable $t - 1$, le placebo de la période $t < g$ compare $t$ à $t - 1$ et mesure des \emph{variations} de l'écart plutôt que son \emph{niveau} relatif à la base. Les deux conventions donnent les mêmes effets post-traitement et des placebos différents ; la base variable est plus proche de l'esprit d'un test de pente, la base universelle de celui d'une étude d'événement classique. | |
| 26 | +\end{solution} | |
| 27 | + | |
| 28 | +\begin{solution}{ex:36:7}{Contrôle synthétique et bassin de donneurs} | |
| 29 | +La fonction \texttt{exercice\_7} de \texttt{analysis/python/cases/ch36\_panel.py} reproduit les calculs. (a) Avec les 35 donneurs des six RMR, le RMSPE pré-traitement est de $0{,}0031$, onze donneurs reçoivent plus de 1 \% du poids et l'effet moyen post-traitement est de $0{,}0363$ (\cref{tab:36:bilan}). Avec les neuf donneurs de Montréal seulement, l'ajustement est moins serré (RMSPE $0{,}0067$, sept donneurs à plus de 1 \%, P09 à $0{,}32$ et P27 à $0{,}20$) mais l'effet estimé, $0{,}0452$, est le plus proche de la vérité ($0{,}0484$). Avec les 26 donneurs hors Montréal, le RMSPE est de $0{,}0035$ et l'effet de $0{,}0375$. Le placebo qui déplace la mise en service à 2020T3 ajuste la cohorte sur dix-huit trimestres (RMSPE $0{,}0027$) et donne un écart moyen de $-0{,}0055$ sur les huit trimestres qui précèdent la vraie mise en service : rien de comparable à l'écart réel, ce qui conforte l'inférence. (b) Le bassin de Montréal donne l'écart le plus faible à la vérité parce qu'il impose la bonne tendance régionale : les donneurs des autres RMR peuvent reproduire la trajectoire pré-traitement par combinaison, mais rien ne garantit que la combinaison qui reproduit le passé de Montréal en reproduise l'avenir, surtout lorsque les tendances régionales diffèrent. Sur données réelles, la proximité à la vérité n'est pas observable, et le meilleur ajustement pré-traitement (ici le bassin complet) n'est pas non plus le critère : un bassin plus large ajuste toujours mieux le passé, par le seul jeu des degrés de liberté. Le critère est l'argument d'identification, le même marché et l'absence de débordement, et la sensibilité au bassin doit être rapportée comme une fourchette ($0{,}036$ à $0{,}045$ ici). (c) \emph{Méthode et limites (environ 200 mots).} L'effet de la mise en service sur le revenu net des immeubles desservis a été estimé par contrôle synthétique : la trajectoire moyenne du logarithme du revenu net des huit immeubles desservis a été reproduite, sur les vingt-six trimestres précédant la mise en service, par une combinaison à poids positifs d'immeubles non desservis du même portefeuille, et l'écart entre la trajectoire observée et cette combinaison après la mise en service est attribué à celle-ci. L'estimation repose sur trois hypothèses : les immeubles donneurs n'ont pas été affectés, directement ou par report de demande, par la mise en service ; la combinaison qui reproduit le passé aurait continué de reproduire le présent en l'absence de desserte ; et aucun autre événement propre aux immeubles desservis n'est survenu à la même date. L'ajustement pré-traitement est serré et le rapport des erreurs post et pré-traitement est le plus élevé des trente-six unités, ce qui correspond à une probabilité de $1/36$ d'observer un tel écart par hasard. L'estimation varie de $0{,}036$ à $0{,}045$ selon le bassin de donneurs retenu, et les estimateurs par régression du même dossier la situent entre $0{,}04$ et $0{,}05$ ; la plus-value retenue, de l'ordre de 4 \% du revenu net, est présentée comme une fourchette, non comme un point, et son passage à la valeur suppose un taux de capitalisation inchangé, hypothèse discutée séparément. | |
| 30 | +\end{solution} | |
modified
sources/registry.csv
+5 −0
@@ -366,6 +366,11 @@ SRC-18-002,article,"A Heteroskedasticity-Consistent Covariance Matrix Estimator | ||
| 366 | 366 | SRC-18-003,article,"A Simple Test for Heteroscedasticity and Random Coefficient Variation (Breusch, T. S. ; Pagan, A. R.)",Econometrica 47(5) 1287-1294,1979,https://doi.org/10.2307/1911963,2026-10-31,verifie,18,Vérifié via api.crossref.org ; clé breuschPagan1979 |
| 367 | 367 | SRC-18-004,article,"Detection of Influential Observation in Linear Regression (Cook, R. D.)",Technometrics 19(1) 15-18,1977,https://doi.org/10.1080/00401706.1977.10489493,2026-10-31,verifie,18,Vérifié via api.crossref.org ; clé cook1977 |
| 368 | 368 | SRC-18-005,livre,"Regression Diagnostics: Identifying Influential Data and Sources of Collinearity (Belsley, Kuh, Welsch)",John Wiley & Sons,1980 (DOI de la réimpression Wiley 2005),https://doi.org/10.1002/0471725153,2026-10-31,partiellement_verifie,18,Notice Wiley non ouverte à la page ; clé belsleyKuhWelsch1980 |
| 369 | +SRC-20-001,article,"The Combination of Forecasts (Bates, J. M. ; Granger, C. W. J.)",Operational Research Quarterly 20(4) 451-468,1969,https://doi.org/10.1057/jors.1969.103,2026-10-31,verifie,ch20,Vérifié via api.crossref.org ; clé batesGranger1969 (lib-ch20.bib) ; non cité par clé | |
| 370 | +SRC-20-002,article,"Improved Methods of Combining Forecasts (Granger, C. W. J. ; Ramanathan, R.)",Journal of Forecasting 3(2) 197-204,1984,https://doi.org/10.1002/for.3980030207,2026-10-31,verifie,ch20,Vérifié via api.crossref.org ; clé grangerRamanathan1984 ; non cité par clé | |
| 371 | +SRC-20-003,article,"Bootstrap Methods: Another Look at the Jackknife (Efron, B.)",The Annals of Statistics 7(1) 1-26,1979,https://doi.org/10.1214/aos/1176344552,2026-10-31,verifie,ch20,Vérifié via api.crossref.org ; clé efron1979 ; non cité par clé | |
| 372 | +SRC-20-004,livre,"An Introduction to the Bootstrap (Efron, B. ; Tibshirani, R. J.)",Chapman & Hall (Springer US),1993,https://doi.org/10.1007/978-1-4899-4541-9,2026-10-31,verifie,ch20,Vérifié via api.crossref.org (ISBN 9780412042317) ; clé efronTibshirani1993 ; non cité par clé | |
| 373 | +SRC-20-005,norme,"RICS Valuation – Global Standards (Red Book Global Standards), décembre 2024, effective 31 January 2025 — PDF complet",RICS,"Global, December 2024 ; effective from 31 January 2025",https://www.rics.org/content/dam/ricsglobal/documents/standards/Red-Book-Global-Standards-incorporating-IVS.pdf,2026-10-31,verifie,ch20,PDF téléchargé et converti (pdftotext) ; VPS 6 § 2.2 (o) et VPGA 10 §§ 1.1-1.4 relus ; complète SRC-C-023 (page web) ; clé rics2025 | |
| 369 | 374 | SRC-21-001,guide,RC4028 — Remboursement de la TPS/TVH pour habitations neuves,Agence du revenu du Canada,Rév. 10/2026,https://www.canada.ca/fr/agence-revenu/services/formulaires-publications/publications/rc4028/remboursement-tps-tvh-habitations-neuves.html,2026-10-31,partiellement_verifie,21,"Lu par agent web-fetch le 2026-10-31 : seuil 450 000 $ (maison construite par le propriétaire) verbatim ; remboursement premiers acheteurs (travaux dès le 20 mars 2025, 1 M$ / 1,5 M$) verbatim ; 36 % / 6 300 $ / 350 000 $ non vérifiés mot à mot. Pages Revenu Québec (TVQ) HTTP 403." |
| 370 | 375 | SRC-22-001,page_web,Salaire et taux — conventions collectives 2025-2029 (outil des taux de salaire),Commission de la construction du Québec,Page consultée le 2026-10-31,https://www.ccq.org/fr-CA/avantages-sociaux/salaire-taux,2026-10-31,verifie,22,"Lu par agent web-fetch : « L'employeur a l'obligation de payer les salariés selon les conventions collectives en vigueur » ; quatre secteurs (A génie civil et voirie, B industriel, C institutionnel et commercial, D résidentiel) ; outil des taux par date et secteur ; majoration annuelle au 26 avril 2026 (résidentiel léger 5,5 %, autres secteurs 5 % — association des pourcentages peu lisible dans l'extrait) ; priorité des textes des conventions sur l'outil. Taux horaires par métier non relevés. Clé bib existante : ccqSalaires (urldate à mettre à jour)." |
| 371 | 376 | SRC-22-002,page_web,CIQS — Canadian Institute of Quantity Surveyors (page d'accueil),Canadian Institute of Quantity Surveyors,Page consultée le 2026-10-31,https://ciqs.org/,2026-10-31,verifie,22,"Nom de l'organisme et titres décernés (Professional Quantity Surveyor, PQS ; Construction Estimator Certified, CEC) lus sur la page ; le nom français et les méthodes de mesurage publiées n'y figurent pas. Entrée bib proposée : ciqs (lib-ch22.bib), non citée." |
modified
verification/calculations.csv
+13 −0
@@ -547,3 +547,16 @@ CALC-35-10,35,"Ajustement de conditions du marché de G0404 (506 500 $, 2025T2 | ||
| 547 | 547 | CALC-35-11,35,"Sensibilité : révision BMN de 2025T2 (millésimes 2025T2/2025T4/2026T3) et de 2024T4 ; filtres 12/18 mois et oracle ; sous-échantillons 50 %/25 % (EAM, é.-t. finale) ; fenêtre hédonique 8 trimestres",analysis/python/cases/ch35_indices.py,analysis/python/tests/test_ch35.py::test_ajustement_et_sensibilite,"116,39 → 114,69 → 113,42 ; 114,45 → 109,99 ; hausses 20,68/17,98/19,41/18,39 % ; EAM 3,02/2,76/6,56/2,01 ; 4,26 & 4,77 ; 6,46 & 8,96 ; écart max 0,37 %",idem,verifie,2026-10-31 |
| 548 | 548 | CALC-35-12,35,"Solutions des exercices 35.1 à 35.8 (composition 3,79 % ; indices exp(τ) ; BMN à la main β = (0,0461 ; 0,1093) ; poids CS 1/0,902/0,807/0,737 ; SPAR 103,36/103,16 ; ajustements G0404 ; géométrique 1,1247 vs arithmétique 1,125 vs pondéré 1,1333)",analysis/python/cases/ch35_indices.py,analysis/python/tests/test_ch35.py::test_solutions_exercices,voir solutions/ch35.tex,idem,verifie,2026-10-31 |
| 549 | 549 | CALC-35-13,35,IPLN (18-10-0205-01) : variation sur douze mois en septembre 2026 par RMR et Canada ; sommets de la variation annuelle,analysis/python/figures/ch35_figures.py,—,"Montréal 1,2 ; Québec 1,9 ; Gatineau 0,3 ; Sherbrooke 1,2 ; Trois-Rivières 3,0 ; Canada −2,0 ; sommets 21,9 (2021-09), 14,3 (2021-05), 6,7 (2020-12), 12,2 (Canada 2021-08)",idem (calcul direct sur data/raw),verifie,2026-10-31 |
| 550 | +CALC-20-01,20,Indications héritées : comparaison pondérée (ch. 17) ; hédonique et intervalle de prédiction (ch. 18) ; coût neuf (ch. 21),analysis/python/cases/ch20_reconciliation.py,analysis/python/tests/test_ch20.py::test_indications_heritees,"575565 (é.-t. pondéré 33997 ; poids 0,179/0,327/0,202/0,159/0,133) ; 637953 ; IP [539682 ; 749087] ; C_N 397567,33",idem,verifie,2026-10-31 | |
| 551 | +CALC-20-02,20,Cascade hédonique vectorisée (grille_rapide) = traite.cas1.ajustements_hedoniques pour les 16 candidats ; identité P_aj = exp(x_s'β)·exp(e_j),analysis/python/cases/ch20_reconciliation.py,analysis/python/tests/test_ch20.py::test_grille_rapide_reproduit_la_bibliotheque,égalité à 1e-9 près ; valeur à 5 comparables = 575565,idem,verifie,2026-10-31 | |
| 552 | +CALC-20-03,20,"Résidus moyens exp(e)−1 : 5 retenus, 11 autres candidats, 16 candidats",analysis/python/cases/ch20_reconciliation.py,analysis/python/tests/test_ch20.py::test_residus_candidats,"−8,53 % ; −1,71 % ; −3,84 %",idem,verifie,2026-10-31 | |
| 553 | +CALC-20-04,20,"Bootstrap B = 2000 (graine 20261031) : candidats (A), ajustements (B), paramétrique (B'), conjoint (C), prédiction ; décomposition des variances ; corrélations",analysis/python/cases/ch20_reconciliation.py,analysis/python/tests/test_ch20.py::test_bootstrap,"é.-t. A 18160 (q5 552872, q95 611283) ; B 2672 ; B' 2751 ; C 18385 (q5 552095, q95 611779) ; prédiction 6531 ; Var(C) ≈ Var(A)+Var(B) à 5 % ; ρ(C,pred) 0,088 ; ρ(B,pred) 0,425 ; 99,7 % des réplications sous la prédiction",idem,verifie,2026-10-31 | |
| 554 | +CALC-20-05,20,Énumération exhaustive des C(16,5) = 4368 sous-ensembles de 5 candidats,analysis/python/cases/ch20_reconciliation.py,analysis/python/tests/test_ch20.py::test_exhaustif,"n 4368 ; moyenne 606317 ; é.-t. 20492 ; min 550231 ; max 661670 ; rang de la sélection 279 (6,4 %) ; 93,5 % sous la prédiction ; moyenne par nombre de retenus 625135/612161/600996/591335/582926/575565",idem,verifie | |
| 555 | +CALC-20-06,20,Élargissement de l'échantillon : indication par comparaison pour k = 5 … 16 candidats (modèle fixe),analysis/python/cases/ch20_reconciliation.py,analysis/python/tests/test_ch20.py::test_elargissement,"k=5 575565 ; k=10 592737 ; k=16 604689 (moyenne 611401 ; résidu moyen −3,84 %)",idem,verifie,2026-10-31 | |
| 556 | +CALC-20-07,20,"Indication par le coût reprise du ch. 24 (ch24_depreciation.py) : V_L 277000 (13 ventes récentes de Hull, médiane 276954), D extraite 22,00 % = 87461,05, D fonctionnelle 7605,31, D totale 95066,36 (23,91 %), valeur 579500,97 → 580000 ; lecture ventilée 445872,14 ; C_N + V_L 674567",analysis/python/cases/ch20_reconciliation.py,analysis/python/tests/test_ch20.py::test_cout,idem (identité V = V_L + C_N − D vérifiée),idem,verifie,2026-10-31 | |
| 557 | +CALC-20-08,20,Diagnostics d'extraction avec V_L = 277000 : D_marché à la prédiction hédonique et à la comparaison ; contre-exemple hypothèses d'usage (allocation 30 % × comparaison = 172669 ; âge-vie 28/60 = 185531 → 384705 ; extraction −67716),analysis/python/cases/ch20_reconciliation.py,analysis/python/tests/test_ch20.py::test_cout,"36614 (9,21 %) ; 99002 (24,90 %) ; 384705 ; −67716",idem,verifie,2026-10-31 | |
| 558 | +CALC-20-09,20,Bootstrap de l'indication par le coût (mêmes rééchantillons des 600 ventes que le modèle ; terrain extrait des ventes récentes de Hull ; profil quadratique) ; écart-type de spécification (7 variantes d'extraction du ch. 24),analysis/python/cases/ch20_reconciliation.py,analysis/python/tests/test_ch20.py::test_bootstrap_cout,"é.-t. 23575 (q5 552142, q95 628078) ; é.-t. V_L 29803 ; é.-t. taux 0,0419 ; ρ(coût,pred) 0,225 ; ρ(coût,comp) 0,018 ; spécification 2377 ; total en quadrature 23695",idem,verifie,2026-10-31 | |
| 559 | +CALC-20-10,20,"Poids de variance minimale (MCG) : optimalité vérifiée contre 200 perturbations ; formule à deux indications = MCG 2×2 ; poids négatif pour ρ = 0,9",analysis/python/cases/ch20_reconciliation.py,analysis/python/tests/test_ch20.py::test_poids_optimaux_et_mcg,"w_comp optimal à deux indications : 0,280 (ρ=0) ; 0,261 (0,088) ; 0,199 (0,3) ; 0,023 (0,6) ; −0,647 (0,9)",idem,verifie,2026-10-31 | |
| 560 | +CALC-20-11,20,"Six schémas de pondération ; conclusion raisonnée 0,40/0,45/0,15 ; bootstrap de la combinaison ; fourchettes",analysis/python/cases/ch20_reconciliation.py,analysis/python/tests/test_ch20.py::test_combinaison_chiffres,"égale 597673 (532741 avec le coût d'usage) ; inverse variance 0,240/0,616/0,144 → 614579 (é.-t. 9892) ; MCG 0,248/0,655/0,097 → 616823 ; raisonnée 604230 (é.-t. 10450) → 605000 ; bootstrap q5 592310, q95 622009 → 590000–625000 ; fourchette conclue 575000–640000 (10,7 %) ; écarts +5,1 % / −5,2 %",idem,verifie,2026-10-31 | |
| 561 | +CALC-20-12,20,"Espérance du prix du sujet selon la loi de génération (constante estimée sur 600 ventes, e.-t. 0,0034) et écarts des indications",analysis/python/cases/ch20_reconciliation.py,analysis/python/tests/test_ch20.py::test_verite_synthetique,"641159 [636930 ; 645415] ; hédonique −0,5 % ; comparaison −10,2 % ; coût −9,6 % ; conclusion −5,6 %",idem,verifie,2026-10-31 | |
| 562 | +CALC-20-13,20,"Solutions des exercices 1 à 7 (poids inverses de la variance, IC/IP, poids avec corrélation, bootstrap à la main, coût sous hypothèses d'usage et extraction, pondérations alternatives et k = 8/16, incidence d'une hypothèse)",analysis/python/cases/ch20_reconciliation.py,analysis/python/tests/test_ch20.py::test_solutions_exercices,"ex1 628552 (é.-t. 11142) ; ex2 IC [625586 ; 650596], IP [539740 ; 749047], rapport 8,36 ; ex3 w1 0,0435 (ρ=0,3), −0,0526 (0,5) ; ex4 586150, é.-t. 14954, [567650 ; 611650] ; ex5 453240, 450590, 99002, 36614, 335452 ; ex6 610469→610000, 594872→595000, 615880→615000, k=8 é.-t. 18086 ; ex7 Δ 14000, E 600800",idem,verifie,2026-10-31 | |
added
verification/chapters/ch20-calculations.csv
+14 −0
@@ -0,0 +1,14 @@ | ||
| 1 | +id,chapitre,description,script,test,resultat_attendu,resultat_obtenu,statut,date | |
| 2 | +CALC-20-01,20,Indications héritées : comparaison pondérée (ch. 17) ; hédonique et intervalle de prédiction (ch. 18) ; coût neuf (ch. 21),analysis/python/cases/ch20_reconciliation.py,analysis/python/tests/test_ch20.py::test_indications_heritees,"575565 (é.-t. pondéré 33997 ; poids 0,179/0,327/0,202/0,159/0,133) ; 637953 ; IP [539682 ; 749087] ; C_N 397567,33",idem,verifie,2026-10-31 | |
| 3 | +CALC-20-02,20,Cascade hédonique vectorisée (grille_rapide) = traite.cas1.ajustements_hedoniques pour les 16 candidats ; identité P_aj = exp(x_s'β)·exp(e_j),analysis/python/cases/ch20_reconciliation.py,analysis/python/tests/test_ch20.py::test_grille_rapide_reproduit_la_bibliotheque,"égalité à 1e-9 près ; valeur à 5 comparables = 575565","idem",verifie,2026-10-31 | |
| 4 | +CALC-20-03,20,"Résidus moyens exp(e)−1 : 5 retenus, 11 autres candidats, 16 candidats",analysis/python/cases/ch20_reconciliation.py,analysis/python/tests/test_ch20.py::test_residus_candidats,"−8,53 % ; −1,71 % ; −3,84 %",idem,verifie,2026-10-31 | |
| 5 | +CALC-20-04,20,"Bootstrap B = 2000 (graine 20261031) : candidats (A), ajustements (B), paramétrique (B'), conjoint (C), prédiction ; décomposition des variances ; corrélations",analysis/python/cases/ch20_reconciliation.py,analysis/python/tests/test_ch20.py::test_bootstrap,"é.-t. A 18160 (q5 552872, q95 611283) ; B 2672 ; B' 2751 ; C 18385 (q5 552095, q95 611779) ; prédiction 6531 ; Var(C) ≈ Var(A)+Var(B) à 5 % ; ρ(C,pred) 0,088 ; ρ(B,pred) 0,425 ; 99,7 % des réplications sous la prédiction",idem,verifie,2026-10-31 | |
| 6 | +CALC-20-05,20,Énumération exhaustive des C(16,5) = 4368 sous-ensembles de 5 candidats,analysis/python/cases/ch20_reconciliation.py,analysis/python/tests/test_ch20.py::test_exhaustif,"n 4368 ; moyenne 606317 ; é.-t. 20492 ; min 550231 ; max 661670 ; rang de la sélection 279 (6,4 %) ; 93,5 % sous la prédiction ; moyenne par nombre de retenus 625135/612161/600996/591335/582926/575565",idem,verifie,2026-10-31 | |
| 7 | +CALC-20-06,20,Élargissement de l'échantillon : indication par comparaison pour k = 5 … 16 candidats (modèle fixe),analysis/python/cases/ch20_reconciliation.py,analysis/python/tests/test_ch20.py::test_elargissement,"k=5 575565 ; k=10 592737 ; k=16 604689 (moyenne 611401 ; résidu moyen −3,84 %)",idem,verifie,2026-10-31 | |
| 8 | +CALC-20-07,20,"Indication par le coût reprise du ch. 24 (ch24_depreciation.py) : V_L 277000 (13 ventes récentes de Hull, médiane 276954), D extraite 22,00 % = 87461,05, D fonctionnelle 7605,31, D totale 95066,36 (23,91 %), valeur 579500,97 → 580000 ; lecture ventilée 445872,14 ; C_N + V_L 674567",analysis/python/cases/ch20_reconciliation.py,analysis/python/tests/test_ch20.py::test_cout,"idem (identité V = V_L + C_N − D vérifiée)",idem,verifie,2026-10-31 | |
| 9 | +CALC-20-08,20,"Diagnostics d'extraction avec V_L = 277000 : D_marché à la prédiction hédonique et à la comparaison ; contre-exemple hypothèses d'usage (allocation 30 % × comparaison = 172669 ; âge-vie 28/60 = 185531 → 384705 ; extraction −67716)",analysis/python/cases/ch20_reconciliation.py,analysis/python/tests/test_ch20.py::test_cout,"36614 (9,21 %) ; 99002 (24,90 %) ; 384705 ; −67716",idem,verifie,2026-10-31 | |
| 10 | +CALC-20-09,20,"Bootstrap de l'indication par le coût (mêmes rééchantillons des 600 ventes que le modèle ; terrain extrait des ventes récentes de Hull ; profil quadratique) ; écart-type de spécification (7 variantes d'extraction du ch. 24)",analysis/python/cases/ch20_reconciliation.py,analysis/python/tests/test_ch20.py::test_bootstrap_cout,"é.-t. 23575 (q5 552142, q95 628078) ; é.-t. V_L 29803 ; é.-t. taux 0,0419 ; ρ(coût,pred) 0,225 ; ρ(coût,comp) 0,018 ; spécification 2377 ; total en quadrature 23695",idem,verifie,2026-10-31 | |
| 11 | +CALC-20-10,20,"Poids de variance minimale (MCG) : optimalité vérifiée contre 200 perturbations ; formule à deux indications = MCG 2×2 ; poids négatif pour ρ = 0,9",analysis/python/cases/ch20_reconciliation.py,analysis/python/tests/test_ch20.py::test_poids_optimaux_et_mcg,"w_comp optimal à deux indications : 0,280 (ρ=0) ; 0,261 (0,088) ; 0,199 (0,3) ; 0,023 (0,6) ; −0,647 (0,9)",idem,verifie,2026-10-31 | |
| 12 | +CALC-20-11,20,"Six schémas de pondération ; conclusion raisonnée 0,40/0,45/0,15 ; bootstrap de la combinaison ; fourchettes",analysis/python/cases/ch20_reconciliation.py,analysis/python/tests/test_ch20.py::test_combinaison_chiffres,"égale 597673 (532741 avec le coût d'usage) ; inverse variance 0,240/0,616/0,144 → 614579 (é.-t. 9892) ; MCG 0,248/0,655/0,097 → 616823 ; raisonnée 604230 (é.-t. 10450) → 605000 ; bootstrap q5 592310, q95 622009 → 590000–625000 ; fourchette conclue 575000–640000 (10,7 %) ; écarts +5,1 % / −5,2 %",idem,verifie,2026-10-31 | |
| 13 | +CALC-20-12,20,"Espérance du prix du sujet selon la loi de génération (constante estimée sur 600 ventes, e.-t. 0,0034) et écarts des indications",analysis/python/cases/ch20_reconciliation.py,analysis/python/tests/test_ch20.py::test_verite_synthetique,"641159 [636930 ; 645415] ; hédonique −0,5 % ; comparaison −10,2 % ; coût −9,6 % ; conclusion −5,6 %",idem,verifie,2026-10-31 | |
| 14 | +CALC-20-13,20,"Solutions des exercices 1 à 7 (poids inverses de la variance, IC/IP, poids avec corrélation, bootstrap à la main, coût sous hypothèses d'usage et extraction, pondérations alternatives et k = 8/16, incidence d'une hypothèse)",analysis/python/cases/ch20_reconciliation.py,analysis/python/tests/test_ch20.py::test_solutions_exercices,"ex1 628552 (é.-t. 11142) ; ex2 IC [625586 ; 650596], IP [539740 ; 749047], rapport 8,36 ; ex3 w1 0,0435 (ρ=0,3), −0,0526 (0,5) ; ex4 586150, é.-t. 14954, [567650 ; 611650] ; ex5 453240, 450590, 99002, 36614, 335452 ; ex6 610469→610000, 594872→595000, 615880→615000, k=8 é.-t. 18086 ; ex7 Δ 14000, E 600800",idem,verifie,2026-10-31 | |
added
verification/chapters/ch20-claims.csv
+17 −0
@@ -0,0 +1,17 @@ | ||
| 1 | +id,chapitre,affirmation,source_id,verification,date,statut,notes | |
| 2 | +CLM-20-01,20,"IVS 2025, IVS 103 § 10.05-10.07 : plus d'une méthode « should be considered » ; réconciliation « into a single conclusion, without averaging, should be described by the valuer in the report » ; § A10.06 (f) de l'annexe (approche de marché) : « reconcile the indications of value »",SRC-C-014,"PDF officiel IVS-effective-31-January-2025.pdf téléchargé et lu (pdftotext) le 2026-10-31 ; paragraphes 10.05, 10.06, 10.07 et A10.06 relus",2026-10-31,verifie,"citations ≤ 25 mots" | |
| 3 | +CLM-20-02,20,"IVS 2025, IVS 106 § 30.06 : le rapport doit au minimum communiquer, entre autres, (n) « significant or special assumptions, and/or limiting conditions » et (p) « value and rationale for valuation »",SRC-C-014,"même PDF, § 30.06 (a)-(r) relu",2026-10-31,verifie, | |
| 4 | +CLM-20-03,20,"RICS Red Book Global Standards (déc. 2024, en vigueur 31 janv. 2025) : VPS 6 § 2.2 (o) commentaire obligatoire sur la « material valuation uncertainty » seulement si elle est « material » (hors des paramètres normalement attendus) ; VPGA 10 § 1.2 : « All valuations are estimates and therefore always subject to a degree of uncertainty »",SRC-20-005,"PDF Red-Book-Global-Standards-incorporating-IVS.pdf téléchargé et lu (pdftotext) le 2026-10-31 ; VPS 6 2.2 (o) et VPGA 10 §§ 1.1-1.4 relus",2026-10-31,verifie,"la clé rics2025 de library.bib pointe sur la page web ; le PDF est consigné dans ch20-registry.csv" | |
| 5 | +CLM-20-04,20,"NPP OEAQ : définition de la valeur marchande « prix sincère le plus probable » (Intro-11) ; règle 2.3 éléments 7 (hypothèses extraordinaires rappelées à chaque conclusion de valeur), 8 (traitement de l'information et raisonnement), 10 (justifier le rejet d'une méthode traditionnelle) ; aucune prescription sur la forme de la réconciliation, l'arrondi ou la fourchette",SRC-C-001,"notes-C.md §1.1 et §2.1 ; ch10.tex (sec:10:npp) ; absence de prescription = lecture de la table des matières et des règles 1.2/2.3 décrites dans notes-C",2026-10-31,verifie,"l'absence d'interdiction de la fourchette est une lecture a contrario, présentée comme telle" | |
| 6 | +CLM-20-05,20,"CUSPAP 2026, s. 3 : extraordinary assumption dont la fausseté « could materially alter the opinions or conclusions » ; hypothetical condition ; extraordinary limiting condition",SRC-C-018,"notes-C.md §2.5 ; repris du ch. 10",2026-10-31,verifie, | |
| 7 | +CLM-20-06,20,"Code de déontologie des évaluateurs agréés (C-26, r. 123) : art. 32 interdiction d'exclure sa responsabilité civile ; art. 41 rapport faisant état de la méthodologie et de l'étendue des recherches",SRC-C-003,"notes-C.md §2.2 (texte à jour au 1er juillet 2026)",2026-10-31,verifie, | |
| 8 | +CLM-20-07,20,"Règlement sur le rôle d'évaluation foncière (F-2.1, r. 13) art. 8-9 : méthode(s) la/les plus pertinente(s) parmi comparaison (3C), revenu (3D) et coût (3E), puis conciliation selon la partie 3F du MEFQ",SRC-B-002;SRC-B-006,"repris de CLM-21-005 (notes-B.md §9.7) ; contenu de la partie 3F non dépouillé",2026-10-31,verifie,"la partie 3F elle-même n'est pas reproduite ni dépouillée" | |
| 9 | +CLM-20-08,20,"Bates & Granger (1969) : combinaison optimale de deux prévisions selon variances et corrélation ; Granger & Ramanathan (1984) : poids non contraints par régression",SRC-20-001;SRC-20-002,"Crossref (titre, revue, volume, pages, DOI) ; contenu connu de la littérature, article non relu intégralement",2026-10-31,partiellement_verifie,"mentionnés en toutes lettres avec DOI (lib-ch20.bib), non cités par clé" | |
| 10 | +CLM-20-09,20,"Efron (1979) : article fondateur du bootstrap ; Efron & Tibshirani (1993) : exposé de référence, intervalles percentiles",SRC-20-003;SRC-20-004,"Crossref (titre, revue/éditeur, DOI, ISBN)",2026-10-31,verifie,"mentionnés en toutes lettres avec DOI, non cités par clé" | |
| 11 | +CLM-20-10,20,"Geltner (1993) modélise le lissage des valeurs d'expertise (ajustement partiel vers l'information nouvelle, ancrage sur la valeur précédente) ; Clayton, Geltner & Hamilton (2001) en trouvent la trace dans des évaluations individuelles",lib-D.bib:geltner1993;claytonGeltnerHamilton2001,"notes-D.md §ch. 20 (descriptions) ; articles non relus dans ce lot",2026-10-31,partiellement_verifie,"aucun coefficient ni chiffre de ces articles n'est cité" | |
| 12 | +CLM-20-11,20,"Northcraft & Neale (1987), Diaz (1990, 1997), Diaz & Hansz (2002) : ancrage et écarts au modèle normatif dans la sélection et la pondération des comparables",lib-D.bib:northcraftNeale1987;diaz1990;diaz1997;diazHansz2002,"notes-D.md §ch. 16 et §ch. 20 ; reprise des formulations des ch. 16-17",2026-10-31,verifie,"descriptions qualitatives seulement" | |
| 13 | +CLM-20-12,20,"Kummerow & Galfalvy (2002) : réconciliation comme arbitrage entre erreur de biais et erreur de variance ; Vandell (1991), Gau–Lai–Wang (1992), Green (1994) : pondération des comparables par la variance",lib-D.bib:kummerowGalfalvy2002;vandell1991;gauLaiWang1992;green1994,"notes-D.md §ch. 16 et §ch. 20",2026-10-31,verifie, | |
| 14 | +CLM-20-13,20,"Pratique professionnelle de la réconciliation : critères de quantité et qualité des données, pertinence de la méthode, cohérence (Appraisal of Real Estate, 16e éd., 2026 ; éd. canadienne)",SRC-D-003,"description générale des manuels ; pages non consultées dans ce lot",2026-10-31,partiellement_verifie,"présentée comme pratique courante, non comme norme" | |
| 15 | +CLM-20-14,20,"Hypothèses d'usage du contre-exemple : part du terrain 30 % de la valeur (allocation) et vie économique de 60 ans (âge-vie) pour une maison de banlieue à ossature de bois",—,"aucune source de marché ; ordres de grandeur pédagogiques déclarés comme tels dans le texte et remplacés par l'extraction du ch. 24",2026-10-31,non_verifie,"ne sert qu'à illustrer ce que donnent des hypothèses non extraites du marché" | |
| 16 | +CLM-20-15,20,"Indication par le coût du cas 1 : V_L 277 000 $ (13 ventes récentes de Hull), dépréciation extraite 22,00 % à 28 ans, désuétude fonctionnelle 7 605 $, total 23,91 %, valeur 579 501 $ (580 000 $) ; lecture ventilée 445 872 $",SRC-E-043,"reprise de analysis/python/cases/ch24_depreciation.py (test_ch20::test_cout) ; hypothèses du ch. 24 (CLM-24-012 à -015) non revérifiées ici",2026-10-31,verifie,"calcul vérifié ; les hypothèses sous-jacentes relèvent du ch. 24 et le terrain attend le ch. 25" | |
| 17 | +CLM-20-16,20,"Valeur de génération du sujet (641 159 $) : loi déclarée dans data/synthetic/README.md ; constante de normalisation estimée sur les 600 ventes (e.-t. 0,0034)",—,"recalcul dans ch20_reconciliation.verite ; données synthétiques",2026-10-31,verifie,"n'existe que pour le jeu synthétique" | |
added
verification/chapters/ch20-figures.csv
+5 −0
@@ -0,0 +1,5 @@ | ||
| 1 | +fichier,chapitre,script,donnees,type_donnees,legende_courte,statut | |
| 2 | +ch20/ch20-fig-residus-elargissement.pdf,20,analysis/python/figures/ch20_figures.py,data/synthetic/cas1_gatineau_ventes.csv,synthetique,"Résidus du modèle hédonique des 16 candidats (5 retenus : −8,5 % ; 11 autres : −1,7 %) et indication par comparaison selon le nombre k de comparables retenus (575 565 → 604 689 $) [données synthétiques]",verifie | |
| 3 | +ch20/ch20-fig-bootstrap-densite.pdf,20,analysis/python/figures/ch20_figures.py,data/synthetic/cas1_gatineau_ventes.csv,synthetique,"Densités bootstrap (B = 2 000) de l'indication par comparaison selon le rééchantillonnage (candidats, ajustements, les deux) et histogramme des 4 368 sous-ensembles de 5 candidats parmi 16 [données synthétiques]",verifie | |
| 4 | +ch20/ch20-fig-fourchettes-methodes.pdf,20,analysis/python/figures/ch20_figures.py,"data/synthetic/cas1_gatineau_ventes.csv ; cas1_gatineau_couts.csv",synthetique,"Indications du cas 1 et leurs intervalles (prix IP 95 %, coût ventilé et extrait, comparaison 5 et 16, hédonique, conclusion 605 000 $ et fourchette 575 000–640 000 $) avec l'espérance de la loi de génération [données synthétiques]",verifie | |
| 5 | +ch20/ch20-fig-sensibilite-poids.pdf,20,analysis/python/figures/ch20_figures.py,data/synthetic/cas1_gatineau_ventes.csv,synthetique,"Valeur combinée selon le poids de l'indication hédonique (poids du coût 0/15/30 %) et écart-type de la combinaison de deux indications selon la corrélation (poids optimal) [données synthétiques]",verifie | |
added
verification/chapters/ch20-registry.csv
+6 −0
@@ -0,0 +1,6 @@ | ||
| 1 | +id,type,titre,organisme,version_ou_date,url,date_consultation,statut,utilise_dans,notes | |
| 2 | +SRC-20-001,article,"The Combination of Forecasts (Bates, J. M. ; Granger, C. W. J.)",Operational Research Quarterly 20(4) 451-468,1969,https://doi.org/10.1057/jors.1969.103,2026-10-31,verifie,ch20,"Vérifié via api.crossref.org ; clé batesGranger1969 (lib-ch20.bib) ; non cité par clé" | |
| 3 | +SRC-20-002,article,"Improved Methods of Combining Forecasts (Granger, C. W. J. ; Ramanathan, R.)",Journal of Forecasting 3(2) 197-204,1984,https://doi.org/10.1002/for.3980030207,2026-10-31,verifie,ch20,"Vérifié via api.crossref.org ; clé grangerRamanathan1984 ; non cité par clé" | |
| 4 | +SRC-20-003,article,"Bootstrap Methods: Another Look at the Jackknife (Efron, B.)",The Annals of Statistics 7(1) 1-26,1979,https://doi.org/10.1214/aos/1176344552,2026-10-31,verifie,ch20,"Vérifié via api.crossref.org ; clé efron1979 ; non cité par clé" | |
| 5 | +SRC-20-004,livre,"An Introduction to the Bootstrap (Efron, B. ; Tibshirani, R. J.)",Chapman & Hall (Springer US),1993,https://doi.org/10.1007/978-1-4899-4541-9,2026-10-31,verifie,ch20,"Vérifié via api.crossref.org (ISBN 9780412042317) ; clé efronTibshirani1993 ; non cité par clé" | |
| 6 | +SRC-20-005,norme,"RICS Valuation – Global Standards (Red Book Global Standards), décembre 2024, effective 31 January 2025 — PDF complet",RICS,"Global, December 2024 ; effective from 31 January 2025",https://www.rics.org/content/dam/ricsglobal/documents/standards/Red-Book-Global-Standards-incorporating-IVS.pdf,2026-10-31,verifie,ch20,"PDF téléchargé et converti (pdftotext) ; VPS 6 § 2.2 (o) et VPGA 10 §§ 1.1-1.4 relus ; complète SRC-C-023 (page web) ; clé rics2025" | |
added
verification/chapters/ch20.md
+63 −0
@@ -0,0 +1,63 @@ | ||
| 1 | +# Chapitre 20 — Réconciliation et incertitude : bilan de rédaction (2026-10-31) | |
| 2 | + | |
| 3 | +## Compteurs | |
| 4 | +- Prose : ≈ 10 250 mots hors tableaux, figures et équations (12 830 mots `wc -w` sur `ch20.tex`) ; 38 pages compilées (chapitre + solutions + bibliographie du chapitre). | |
| 5 | +- Figures : 4 PDF Matplotlib (toutes synthétiques, cas 1, graine 20261031), produites par `analysis/python/figures/ch20_figures.py` : `ch20-fig-residus-elargissement`, `-bootstrap-densite`, `-fourchettes-methodes`, `-sensibilite-poids`. | |
| 6 | +- Tableaux : 7 (critères de pondération ; sources d'incertitude des ch. 16-18, 21, 24 ; indication par le coût reprise du ch. 24 ; seize candidats ; bootstrap et énumération ; indications et écarts-types ; six schémas de pondération). | |
| 7 | +- Équations numérotées : 11 (combinaison, variance avec corrélation, EQM, poids MCG, poids à deux indications, IC de la valeur, bootstrap et intervalle percentile, décomposition des variances, valeur par le coût, extraction de marché, règle d'arrondi) ; 2 définitions ; 1 proposition démontrée (poids de variance minimale avec corrélation, cas à deux indications, condition de poids négatif). | |
| 8 | +- Exercices : 7 (niveaux 1, 1, 2, 2, 2, 3, 3) ; solutions détaillées dans `solutions/ch20.tex`, chiffres testés (`test_solutions_exercices`). | |
| 9 | +- Encadrés : objectifs, synthetique, exemple intégral (7 étapes), pratique (paragraphe de conclusion type), quebec (NPP, Code de déontologie, Règlement sur le rôle / MEFQ 3F), erreurs (9), limites (7), synthese (6). | |
| 10 | +- Citations par clé : 20 clés distinctes, toutes présentes dans `references/library.bib` (vérifié par compilation : 0 citation non définie). Quatre références nouvelles (Bates & Granger 1969 ; Granger & Ramanathan 1984 ; Efron 1979 ; Efron & Tibshirani 1993) vérifiées Crossref, consignées dans `references/lib-ch20.bib`, mentionnées en toutes lettres avec DOI en note, **non citées par clé**. | |
| 11 | + | |
| 12 | +## Contrôles | |
| 13 | +- `tools/compile_chapter.sh 20` → `OK ch20 : 38 pages, 0 references/citations non definies, 0 overfull hbox`. | |
| 14 | +- `python3 -m pytest -q analysis/python/tests/test_ch20.py` → 12 passed (≈ 9 s ; le bootstrap du coût relance 2 000 fois l'extraction du ch. 24). | |
| 15 | +- `python3 tools/check_manuscript.py --no-log` : seules remarques pour ch20 = 4 figures non inscrites dans `verification/figures.csv` global (attendu : règle de collision, elles sont dans `ch20-figures.csv`). | |
| 16 | +- PDF relu : planche de contact des 38 pages + pages 10, 12, 13, 15, 17, 18, 19 à 55 dpi ; aucun tableau ni aucune figure ne déborde. | |
| 17 | +- Convention `\cref` sans article : article/préposition écrits partout (grep vérifié). | |
| 18 | +- Renvois fins résolus vers des chapitres compilés : `eq:16:variance`, `eq:16:poids`, `eq:16:kopt`, `sec:16:selection`, `def:comparable`, `prop:residus`, `eq:17:residus`, `tab:17:grille`, `eq:18:duan`, `eq:18:intervalle`, `tab:18:variantes`, `eq:21:identite`, `eq:21:cn-cas1`, `eq:10:incidence`, `eq:10:esperance`, `eq:10:fourchette`, `ch:11/24/25/39/40/45`. | |
| 19 | + | |
| 20 | +## Conclusion du cas 1 (à reprendre par le ch. 45) | |
| 21 | +| Indication | Valeur | Écart-type attribué | Source | | |
| 22 | +|---|---|---|---| | |
| 23 | +| Comparaison, 5 comparables (ch. 17) | 575 565 $ | 18 385 $ | bootstrap conjoint (candidats + ajustements) | | |
| 24 | +| Hédonique (ch. 18) | 637 953 $ | 11 462 $ | bootstrap 6 531 $ + spécification 9 420 $ | | |
| 25 | +| Coût (ch. 24) | 579 501 $ (580 000 $) | 23 695 $ | bootstrap 23 575 $ + variantes d'extraction 2 377 $ | | |
| 26 | + | |
| 27 | +Corrélations bootstrap : comparaison–hédonique 0,088 (0,425 à comparables fixes) ; coût–hédonique 0,225 ; coût–comparaison 0,018. | |
| 28 | +**Poids raisonnés : 0,40 (comparaison) / 0,45 (hédonique) / 0,15 (coût) → 604 230 $ ; valeur la plus probable 605 000 $ (arrondi au 5 000 $) ; fourchette de valeur conclue 575 000 – 640 000 $ (encadrement par les indications crédibles, arrondi vers l'extérieur) ; bande bootstrap à 90 % de la combinaison 590 000 – 625 000 $ (précision sous poids fixés, non communiquée comme fourchette) ; intervalle de prédiction du prix (ch. 18) 539 682 – 749 087 $, communiqué séparément.** Variantes : inverse de la variance 0,240/0,616/0,144 → 614 579 $ ; MCG avec corrélations 0,248/0,655/0,097 → 616 823 $ ; moyenne simple 597 673 $ (532 741 $ avec le coût du contre-exemple) ; 0,30/0,55/0,15 → 610 469 $ ; 0,55/0,30/0,15 → 594 872 $. | |
| 29 | +Espérance de la loi de génération : 641 159 $ (± 0,7 %) ; écarts : hédonique −0,5 %, comparaison −10,2 %, coût −9,6 %, conclusion −5,6 %. | |
| 30 | + | |
| 31 | +## Autres chiffres à reprendre | |
| 32 | +- Résidus des 16 candidats : 5 retenus −8,53 % (moyenne exp(e)−1), 11 autres −1,71 %, ensemble −3,84 % ; énumération des 4 368 sous-ensembles : moyenne 606 317 $, la sélection du ch. 16 au rang 279 (6,4 % les plus bas) ; élargissement k = 16 : 604 689 $ pondérée, 611 401 $ moyenne. | |
| 33 | +- Bootstrap : candidats é.-t. 18 160 $ (90 % : 552 872 – 611 283 $) ; ajustements 2 672 $ ; conjoint 18 385 $ ; prédiction 6 531 $ ; 99,7 % des réplications sous la prédiction hédonique. | |
| 34 | +- Diagnostics d'extraction avec V_L = 277 000 $ : D_marché = 99 002 $ (24,9 %) à la comparaison, 36 614 $ (9,2 %) au modèle ; terrain qu'il faudrait pour que le coût rejoigne le modèle avec D = 23,91 % : 335 452 $. | |
| 35 | +- Contre-exemple (hypothèses d'usage : allocation 30 % × comparaison = 172 669 $, âge-vie 28/60 = 185 531 $) : 384 705 $ ; extraction négative −67 716 $. | |
| 36 | + | |
| 37 | +## Cohérence inter-chapitres | |
| 38 | +- Aucune modification de `traite/*.py`, de `styles/`, de `main.tex` ni des fichiers d'autres chapitres. Le script **importe** `ch21_cout_neuf` (via `ch24_depreciation`) et `ch24_depreciation` (`main()` relancé silencieusement : V_L 277 000 $, D 95 066,36 $, V 579 500,97 $) ; les chiffres des ch. 16-18 proviennent de `traite.cas1`. L'indication par le coût a été intégrée telle que livrée par le ch. 24 (message de coordination du 2026-10-31), sans hypothèse propre ; les hypothèses d'usage ne servent que de contre-exemple. | |
| 39 | +- Le **ch. 25** doit confirmer V_L = 277 000 $ ; si la valeur change, le ch. 45 reconduit la réconciliation (`ch20_reconciliation.tout()` suit automatiquement `ch24_depreciation.main()` ; ±15 % de terrain déplacent l'indication par le coût de 537 951 à 621 051 $ et la conclusion raisonnée d'environ ±6 200 $ ; la fourchette conclue 575 000 – 640 000 $ ne bouge que si le coût sort de l'intervalle comparaison–hédonique). | |
| 40 | +- Le **ch. 45** reprend : 605 000 $, 575 000 – 640 000 $, poids 0,40/0,45/0,15 et le paragraphe de conclusion type (encadré « En pratique », sec. 20.8). | |
| 41 | +- Le **ch. 39** peut reprendre la matrice Σ (écarts-types et corrélations ci-dessus) comme point de départ d'une combinaison bayésienne ; le **ch. 40** la propagation de l'incertitude ; le **ch. 37** le lien bootstrap / validation croisée. | |
| 42 | +- Écart de notation signalé : le ch. 17 écrit « ≈ −8,5 % » et le ch. 18 « −8,7 % » (moyenne des résidus log −0,0907 ; moyenne de exp(e)−1 = −0,0853). Le ch. 20 utilise −8,5 % (moyenne de exp(e)−1, qui est la quantité de la prop. 17.2) ; à harmoniser au ch. 18. | |
| 43 | + | |
| 44 | +## Points non vérifiés ou partiellement vérifiés (voir ch20-claims.csv) | |
| 45 | +1. Critères de réconciliation des manuels (Appraisal of Real Estate 16e éd., éd. canadienne) : description générale, pages non consultées (CLM-20-13, partiellement_verifie). | |
| 46 | +2. Bates & Granger (1969), Granger & Ramanathan (1984) : métadonnées Crossref vérifiées, articles non relus intégralement (CLM-20-08). | |
| 47 | +3. Geltner (1993), Clayton–Geltner–Hamilton (2001) : descriptions des notes D, articles non relus ; aucun chiffre cité (CLM-20-10). | |
| 48 | +4. Hypothèses d'usage du contre-exemple (terrain 30 %, vie 60 ans) : non vérifiées sur le marché, déclarées comme pédagogiques (CLM-20-14). | |
| 49 | +5. Les hypothèses propres au ch. 24 (vies utiles, carence de salle d'eau, paramètres d'extraction, V_L sans vente de terrain vacant) ne sont pas revérifiées ici (CLM-20-15 renvoie à CLM-24-012 à -015). | |
| 50 | +6. Partie 3F du MEFQ (conciliation) : obligation réglementaire citée (art. 8-9 du Règlement sur le rôle), contenu non dépouillé (CLM-20-07). | |
| 51 | +7. Normes : IVS 2025 (IVS 103 §§ 10.05-10.07, A10.06 ; IVS 106 § 30.06) et Red Book 2025 (VPS 6 § 2.2 (o) ; VPGA 10 § 1.2) relus sur les PDF officiels le 2026-10-31 ; NPP, CUSPAP, Code de déontologie repris des notes C et du ch. 10. Aucune jurisprudence québécoise sur la fourchette de valeur recherchée. | |
| 52 | + | |
| 53 | +## Difficultés et choix | |
| 54 | +- La variance « conjointe » de l'indication par comparaison est dominée par le tirage des 16 candidats (98 % de la variance) ; l'incertitude des prix implicites est négligeable, comme au ch. 17. Le bootstrap d'un bassin de 16 ventes rend les quantiles extrêmes imprécis : l'énumération exhaustive des 4 368 sous-ensembles a été ajoutée pour situer la sélection. | |
| 55 | +- Le bootstrap du coût exclut, dans chaque réplication, les ventes des secteurs sans vente récente (terrain non extractible) de l'ajustement du profil ; sans cette règle, 15 % des réplications produisaient NaN. La moyenne bootstrap (588 089 $) est légèrement supérieure à l'indication (579 501 $) : médiane sur 13 ventes et profil quadratique sont biaisés en petit échantillon ; seuls l'écart-type et les corrélations sont utilisés. | |
| 56 | +- La fourchette communiquée est l'encadrement par les indications crédibles plutôt que la bande bootstrap sous poids fixés, parce que celle-ci manque la valeur de génération (641 159 $) de 16 000 $ : argument développé à la sec. 20.9. | |
| 57 | +- Gotcha tcolorbox : une virgule dans le titre optionnel de `quebec`/`pratique` casse pgfkeys (déjà signalé par le ch. 24) ; les titres du ch. 20 n'en contiennent pas. | |
| 58 | + | |
| 59 | +## Suggestions pour l'harmonisation | |
| 60 | +- Fusionner `lib-ch20.bib` dans `library.bib` et convertir les quatre mentions en toutes lettres (notes de bas de page des sec. 20.1 et 20.4) en `\textcite`/`\parencite`. | |
| 61 | +- Reporter dans l'annexe des conventions : « fourchette de valeur = encadrement par les indications crédibles, arrondi vers l'extérieur ; valeur la plus probable arrondie à un pas ≤ σ/2 ; l'intervalle de prédiction du prix est toujours communiqué séparément et sous son nom ». | |
| 62 | +- `grille_rapide` (cascade hédonique vectorisée) et `poids_gls` / `poids_optimaux_deux` sont candidats à `traite.comparables` si d'autres chapitres (39, 45, 46, 48) en ont besoin. | |
| 63 | +- Glossaire : `\motcle` posés pour réconciliation, indication de valeur, intervalle de confiance, intervalle de prédiction, fourchette de valeur, valeur la plus probable, bootstrap. | |
modified
verification/claims.csv
+16 −0
@@ -830,3 +830,19 @@ CLM-35-09,35,"Silverstein (2014) : révisions des indices de prix américains de | ||
| 830 | 830 | CLM-35-10,35,"Méthodologie publiée de l'indice S&P CoreLogic Case–Shiller (filtres numériques, moyenne mobile) : NON citée",—,Accès refusé (HTTP 403) le 2026-10-31 sur spglobal.com,2026-10-31,non_verifie,"Le chapitre s'en tient aux articles de Case et Shiller (1987, 1989) ; mentionné dans limites" |
| 831 | 831 | CLM-35-11,35,Shiller (2015) défend des marchés de dérivés et d'assurance adossés aux indices de prix des maisons comme instruments de partage du risque,SRC-D-016,notes-D.md (Irrational Exuberance,3e éd.,library.bib),2026-10-31 |
| 832 | 832 | CLM-35-12,35,"Hypothèses du jeu de reventes synthétique : p = 0,60 ; 15 % de reventes rapides (3-9 mois) à prime N(0,12 ; 0,04) ; δ² = 0,0004/mois ; σ = 0,085 ; tendance 4,43 %/an ; valeur au rôle = 0,93 × valeur au 2024-07-01, σ 0,07",—,Déclarées dans l'encadré synthetique et dans le script,2026-10-31,non_verifie,"Hypothèses pédagogiques, pas des estimations ; signalées comme telles" |
| 833 | +CLM-20-01,20,"IVS 2025, IVS 103 § 10.05-10.07 : plus d'une méthode « should be considered » ; réconciliation « into a single conclusion, without averaging, should be described by the valuer in the report » ; § A10.06 (f) de l'annexe (approche de marché) : « reconcile the indications of value »",SRC-C-014,"PDF officiel IVS-effective-31-January-2025.pdf téléchargé et lu (pdftotext) le 2026-10-31 ; paragraphes 10.05, 10.06, 10.07 et A10.06 relus",2026-10-31,verifie,citations ≤ 25 mots | |
| 834 | +CLM-20-02,20,"IVS 2025, IVS 106 § 30.06 : le rapport doit au minimum communiquer, entre autres, (n) « significant or special assumptions, and/or limiting conditions » et (p) « value and rationale for valuation »",SRC-C-014,"même PDF, § 30.06 (a)-(r) relu",2026-10-31,verifie, | |
| 835 | +CLM-20-03,20,"RICS Red Book Global Standards (déc. 2024, en vigueur 31 janv. 2025) : VPS 6 § 2.2 (o) commentaire obligatoire sur la « material valuation uncertainty » seulement si elle est « material » (hors des paramètres normalement attendus) ; VPGA 10 § 1.2 : « All valuations are estimates and therefore always subject to a degree of uncertainty »",SRC-20-005,PDF Red-Book-Global-Standards-incorporating-IVS.pdf téléchargé et lu (pdftotext) le 2026-10-31 ; VPS 6 2.2 (o) et VPGA 10 §§ 1.1-1.4 relus,2026-10-31,verifie,la clé rics2025 de library.bib pointe sur la page web ; le PDF est consigné dans ch20-registry.csv | |
| 836 | +CLM-20-04,20,"NPP OEAQ : définition de la valeur marchande « prix sincère le plus probable » (Intro-11) ; règle 2.3 éléments 7 (hypothèses extraordinaires rappelées à chaque conclusion de valeur), 8 (traitement de l'information et raisonnement), 10 (justifier le rejet d'une méthode traditionnelle) ; aucune prescription sur la forme de la réconciliation, l'arrondi ou la fourchette",SRC-C-001,notes-C.md §1.1 et §2.1 ; ch10.tex (sec:10:npp) ; absence de prescription = lecture de la table des matières et des règles 1.2/2.3 décrites dans notes-C,2026-10-31,verifie,"l'absence d'interdiction de la fourchette est une lecture a contrario, présentée comme telle" | |
| 837 | +CLM-20-05,20,"CUSPAP 2026, s. 3 : extraordinary assumption dont la fausseté « could materially alter the opinions or conclusions » ; hypothetical condition ; extraordinary limiting condition",SRC-C-018,notes-C.md §2.5 ; repris du ch. 10,2026-10-31,verifie, | |
| 838 | +CLM-20-06,20,"Code de déontologie des évaluateurs agréés (C-26, r. 123) : art. 32 interdiction d'exclure sa responsabilité civile ; art. 41 rapport faisant état de la méthodologie et de l'étendue des recherches",SRC-C-003,notes-C.md §2.2 (texte à jour au 1er juillet 2026),2026-10-31,verifie, | |
| 839 | +CLM-20-07,20,"Règlement sur le rôle d'évaluation foncière (F-2.1, r. 13) art. 8-9 : méthode(s) la/les plus pertinente(s) parmi comparaison (3C), revenu (3D) et coût (3E), puis conciliation selon la partie 3F du MEFQ",SRC-B-002;SRC-B-006,repris de CLM-21-005 (notes-B.md §9.7) ; contenu de la partie 3F non dépouillé,2026-10-31,verifie,la partie 3F elle-même n'est pas reproduite ni dépouillée | |
| 840 | +CLM-20-08,20,Bates & Granger (1969) : combinaison optimale de deux prévisions selon variances et corrélation ; Granger & Ramanathan (1984) : poids non contraints par régression,SRC-20-001;SRC-20-002,"Crossref (titre, revue, volume, pages, DOI) ; contenu connu de la littérature, article non relu intégralement",2026-10-31,partiellement_verifie,"mentionnés en toutes lettres avec DOI (lib-ch20.bib), non cités par clé" | |
| 841 | +CLM-20-09,20,"Efron (1979) : article fondateur du bootstrap ; Efron & Tibshirani (1993) : exposé de référence, intervalles percentiles",SRC-20-003;SRC-20-004,"Crossref (titre, revue/éditeur, DOI, ISBN)",2026-10-31,verifie,"mentionnés en toutes lettres avec DOI, non cités par clé" | |
| 842 | +CLM-20-10,20,"Geltner (1993) modélise le lissage des valeurs d'expertise (ajustement partiel vers l'information nouvelle, ancrage sur la valeur précédente) ; Clayton, Geltner & Hamilton (2001) en trouvent la trace dans des évaluations individuelles",lib-D.bib:geltner1993;claytonGeltnerHamilton2001,notes-D.md §ch. 20 (descriptions) ; articles non relus dans ce lot,2026-10-31,partiellement_verifie,aucun coefficient ni chiffre de ces articles n'est cité | |
| 843 | +CLM-20-11,20,"Northcraft & Neale (1987), Diaz (1990, 1997), Diaz & Hansz (2002) : ancrage et écarts au modèle normatif dans la sélection et la pondération des comparables",lib-D.bib:northcraftNeale1987;diaz1990;diaz1997;diazHansz2002,notes-D.md §ch. 16 et §ch. 20 ; reprise des formulations des ch. 16-17,2026-10-31,verifie,descriptions qualitatives seulement | |
| 844 | +CLM-20-12,20,"Kummerow & Galfalvy (2002) : réconciliation comme arbitrage entre erreur de biais et erreur de variance ; Vandell (1991), Gau–Lai–Wang (1992), Green (1994) : pondération des comparables par la variance",lib-D.bib:kummerowGalfalvy2002;vandell1991;gauLaiWang1992;green1994,notes-D.md §ch. 16 et §ch. 20,2026-10-31,verifie, | |
| 845 | +CLM-20-13,20,"Pratique professionnelle de la réconciliation : critères de quantité et qualité des données, pertinence de la méthode, cohérence (Appraisal of Real Estate, 16e éd., 2026 ; éd. canadienne)",SRC-D-003,description générale des manuels ; pages non consultées dans ce lot,2026-10-31,partiellement_verifie,"présentée comme pratique courante, non comme norme" | |
| 846 | +CLM-20-14,20,Hypothèses d'usage du contre-exemple : part du terrain 30 % de la valeur (allocation) et vie économique de 60 ans (âge-vie) pour une maison de banlieue à ossature de bois,—,aucune source de marché ; ordres de grandeur pédagogiques déclarés comme tels dans le texte et remplacés par l'extraction du ch. 24,2026-10-31,non_verifie,ne sert qu'à illustrer ce que donnent des hypothèses non extraites du marché | |
| 847 | +CLM-20-15,20,"Indication par le coût du cas 1 : V_L 277 000 $ (13 ventes récentes de Hull), dépréciation extraite 22,00 % à 28 ans, désuétude fonctionnelle 7 605 $, total 23,91 %, valeur 579 501 $ (580 000 $) ; lecture ventilée 445 872 $",SRC-E-043,reprise de analysis/python/cases/ch24_depreciation.py (test_ch20::test_cout) ; hypothèses du ch. 24 (CLM-24-012 à -015) non revérifiées ici,2026-10-31,verifie,calcul vérifié ; les hypothèses sous-jacentes relèvent du ch. 24 et le terrain attend le ch. 25 | |
| 848 | +CLM-20-16,20,"Valeur de génération du sujet (641 159 $) : loi déclarée dans data/synthetic/README.md ; constante de normalisation estimée sur les 600 ventes (e.-t. 0,0034)",—,recalcul dans ch20_reconciliation.verite ; données synthétiques,2026-10-31,verifie,n'existe que pour le jeu synthétique | |
modified
verification/figures.csv
+4 −0
@@ -177,3 +177,7 @@ ch35/ch35-fig-biais-mediane.pdf,35,analysis/python/figures/ch35_figures.py,data/ | ||
| 177 | 177 | ch35/ch35-fig-poids-case-shiller.pdf,35,analysis/python/figures/ch35_figures.py,reventes synthétiques générées par analysis/python/cases/ch35_indices.py (graine 20261031),synthetique,"Carrés des résidus BMN selon l'intervalle (reventes rapides rénovées distinguées), droites ajustées avec et sans elles, loi de génération, poids relatifs de Case–Shiller [données synthétiques]",verifie |
| 178 | 178 | ch35/ch35-fig-revision-filtre.pdf,35,analysis/python/figures/ch35_figures.py,reventes synthétiques générées par analysis/python/cases/ch35_indices.py (graine 20261031),synthetique,"Indice BMN selon le millésime de publication (2025T2, 2025T4, 2026T3) ; effet du filtre des reventes rapides (12 et 18 mois) et oracle sans reventes rénovées [données synthétiques]",verifie |
| 179 | 179 | ch35/ch35-fig-ipln-variation.pdf,35,analysis/python/figures/ch35_figures.py,data/raw/statcan_18100205/18100205.csv,reelle,"IPLN total (déc. 2016 = 100), variation sur douze mois, janv. 2011 – sept. 2026 : Montréal, Québec, Gatineau (partie QC), Sherbrooke, Trois-Rivières, Canada (18-10-0205-01, licence ouverte StatCan)",verifie |
| 180 | +ch20/ch20-fig-residus-elargissement.pdf,20,analysis/python/figures/ch20_figures.py,data/synthetic/cas1_gatineau_ventes.csv,synthetique,"Résidus du modèle hédonique des 16 candidats (5 retenus : −8,5 % ; 11 autres : −1,7 %) et indication par comparaison selon le nombre k de comparables retenus (575 565 → 604 689 $) [données synthétiques]",verifie | |
| 181 | +ch20/ch20-fig-bootstrap-densite.pdf,20,analysis/python/figures/ch20_figures.py,data/synthetic/cas1_gatineau_ventes.csv,synthetique,"Densités bootstrap (B = 2 000) de l'indication par comparaison selon le rééchantillonnage (candidats, ajustements, les deux) et histogramme des 4 368 sous-ensembles de 5 candidats parmi 16 [données synthétiques]",verifie | |
| 182 | +ch20/ch20-fig-fourchettes-methodes.pdf,20,analysis/python/figures/ch20_figures.py,data/synthetic/cas1_gatineau_ventes.csv ; cas1_gatineau_couts.csv,synthetique,"Indications du cas 1 et leurs intervalles (prix IP 95 %, coût ventilé et extrait, comparaison 5 et 16, hédonique, conclusion 605 000 $ et fourchette 575 000–640 000 $) avec l'espérance de la loi de génération [données synthétiques]",verifie | |
| 183 | +ch20/ch20-fig-sensibilite-poids.pdf,20,analysis/python/figures/ch20_figures.py,data/synthetic/cas1_gatineau_ventes.csv,synthetique,Valeur combinée selon le poids de l'indication hédonique (poids du coût 0/15/30 %) et écart-type de la combinaison de deux indications selon la corrélation (poids optimal) [données synthétiques],verifie | |
modified
verification/review_log.md
+1 −0
@@ -9,3 +9,4 @@ | ||
| 9 | 9 | - 2026-10-31 (ch. 15) : harmonisation à faire — ch. 4 « Sherbrooke 4,4 mois » vs 4,3 (ch. 3/15) ; rotation 15 % du cas 3 vs SCHL 8,5-9,4 % (déclaré). |
| 10 | 10 | - 2026-10-31 (ch. 32) : à trancher en harmonisation — taxes du dossier cas 2 (5 900 $) vs rôle × taux 2026 (5 438 $) ; prix cas 2 indexés ≈ +15 % vs médiane T3 2026 (normalisation au 2024-07-01). |
| 11 | 11 | - 2026-10-31 (ch. 28) : harmonisation — ch. 30 écrit « réserve 650 $/logement » alors que code/ch. 28/29/47 = 675,25 $/log. (8 103 $) ; nature des CAPEX 2027/2029/2032 décrite différemment ch. 30 vs ch. 47. |
| 12 | +- 2026-10-31 (ch. 35) : harmonisation — ch. 18 cite 28 774 $ (15,2 mois arrondis) vs 28 733 $ (ch. 17/35, exact) pour l'ajustement temporel de G0404. | |
| 12 | 13 | |