spb/traite-evaluation-qc
Public
1#!/usr/bin/env python32"""Figures du chapitre 15 — Études de marché et segmentation. Produit dans assets/figures/ch15/ :3 ch15-fig-mois-inventaire.pdf mois d'inventaire par RMR, T3 2026 (chiffres APCIQ cités ; calculs de l'auteur) — données réelles4 ch15-fig-dendrogramme.pdf dendrogramme de Ward des 600 ventes du cas 1 (attributs + coordonnées) — SYNTHÉTIQUE5 ch15-fig-silhouette.pdf silhouette moyenne selon k et jeu de variables ; profil de silhouette à k = 5 — SYNTHÉTIQUE6 ch15-fig-carte-segments.pdf carte fictive des segments k-moyennes (k = 2 et k = 5), petits multiples — SYNTHÉTIQUE7 ch15-fig-coefficients-secteurs.pdf coefficients hédoniques par secteur avec IC à 95 % (HC1) et coefficient commun — SYNTHÉTIQUE8 ch15-fig-loyers-rosemont.pdf structure des loyers et de l'univers locatif, zone SCHL 9 Rosemont vs RMR, oct. 2025 — données réelles9Usage (racine) : PYTHONPATH=analysis/python python3 analysis/python/figures/ch15_figures.py"""10from __future__ import annotations11import sys, pathlib12ROOT = pathlib.Path(__file__).resolve().parents[3]13sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases"))14import numpy as np, pandas as pd15import matplotlib.pyplot as plt16from matplotlib.ticker import FuncFormatter17from scipy.cluster.hierarchy import dendrogram18from traite.style import setup, palette, savefig, LARGEUR, fmt_milliers19from traite import hedonique as h20import ch15_segmentation as c152122def fig_mois_inventaire(r):23 ind = r["indicateurs"].copy()24 ind["court"] = ind["territoire"].str.replace("RMR de ", "", regex=False).str.replace("Province de Québec", "Province")25 ind = ind.sort_values("mois_inventaire", ascending=True)26 fig, ax = plt.subplots(figsize=(LARGEUR, 3.3))27 ax.axvspan(0, 4, color=palette.cat[1], alpha=0.08, lw=0); ax.axvspan(4, 6, color=palette.grid, alpha=0.35, lw=0); ax.axvspan(6, 8, color=palette.cat[2], alpha=0.08, lw=0)28 ax.barh(ind["court"], ind["mois_inventaire"], color=palette.brand, height=0.6)29 for y, (mi, dj) in enumerate(zip(ind["mois_inventaire"], ind["delai_unif_j"])):30 lab = f"{mi:.1f} mois" + (f" (délai unifam. {int(dj)} j)" if not np.isnan(dj) else "")31 ax.text(mi + 0.08, y, lab, va="center", ha="left", fontsize=8, color=palette.ink)32 ax.set_xlim(0, 8.6); ax.set_xlabel("Mois d'inventaire = inscriptions en vigueur / ventes mensuelles du trimestre")33 yt = len(ind) + 0.5534 ax.text(2, yt, "favorable aux vendeurs (< 4 mois, usage)", ha="center", va="center", fontsize=7.5, color=palette.ink2)35 ax.text(5, yt, "équilibré (4 à 6)", ha="center", va="center", fontsize=7.5, color=palette.ink2)36 ax.text(7.2, yt, "favorable aux acheteurs (> 6)", ha="center", va="center", fontsize=7.5, color=palette.ink2)37 ax.set_ylim(-0.6, len(ind) + 1.0); ax.grid(axis="y", visible=False)38 savefig(fig, "ch15/ch15-fig-mois-inventaire.pdf")3940def fig_dendrogramme(r):41 L = r["classification"]["linkage"]42 fig, ax = plt.subplots(figsize=(LARGEUR, 3.4))43 d = dendrogram(L, truncate_mode="lastp", p=30, ax=ax, color_threshold=0, above_threshold_color=palette.brand, leaf_rotation=90, leaf_font_size=7, no_labels=False)44 # hauteurs de coupe pour k = 2 et k = 5 (distance de fusion des dernières agrégations)45 hauteurs = L[:, 2]; n = L.shape[0] + 146 for k, col, lab in [(2, palette.cat[2], "k = 2"), (5, palette.cat[1], "k = 5")]:47 y = 0.5 * (hauteurs[n - k - 1] + hauteurs[n - k])48 ax.axhline(y, color=col, ls="--", lw=1.1); ax.text(ax.get_xlim()[1] * 0.995, y, lab, ha="right", va="bottom", fontsize=8, color=col)49 ax.set_ylabel("Distance de fusion (critère de Ward)"); ax.set_xlabel("Grappes terminales (effectif entre parenthèses) — 600 ventes synthétiques")50 ax.grid(axis="x", visible=False)51 savefig(fig, "ch15/ch15-fig-dendrogramme.pdf")5253def fig_silhouette(r):54 v = r["ventes"]55 fig, axes = plt.subplots(1, 2, figsize=(LARGEUR, 3.2), gridspec_kw={"width_ratios": [1.1, 1]})56 ax = axes[0]57 for i, (jeu, lab) in enumerate([("attributs", "attributs seuls"), ("coordonnees", "coordonnées seules"), ("attributs+coordonnees", "attributs + coordonnées")]):58 Z = c15.caracteristiques_segmentation(v, jeu); t = c15.classifier(Z)["tableau"]59 ax.plot(t["k"], t["silhouette_kmeans"], marker="o", color=palette.cat[i], label=lab)60 ax.set_xlabel("Nombre de segments $k$ (k-moyennes)"); ax.set_ylabel("Silhouette moyenne $\\bar s$"); ax.set_ylim(0, 0.8); ax.legend(loc="lower left")61 ax.set_title("(a) silhouette moyenne selon $k$", loc="left")62 ax = axes[1]63 Z = r["Z"].values; lab5 = r["labels_kmeans5"]64 from sklearn.metrics import silhouette_samples65 s = silhouette_samples(Z, lab5); y0 = 066 for g in range(5):67 sg = np.sort(s[lab5 == g])[::-1]; yy = np.arange(y0, y0 + len(sg))68 ax.barh(yy, sg, height=1.0, color=palette.cat[g], lw=0)69 ax.text(-0.02, y0 + len(sg) / 2, f"{g + 1}", ha="right", va="center", fontsize=8); y0 += len(sg) + 870 ax.axvline(s.mean(), color=palette.ink2, ls="--", lw=1); ax.text(s.mean() + 0.01, y0, f"$\\bar s$ = {s.mean():.2f}", fontsize=8, va="top", color=palette.ink2)71 ax.set_xlim(-0.25, 0.75); ax.set_yticks([]); ax.set_xlabel("Silhouette $s(i)$ de chaque vente"); ax.set_title("(b) profil à $k$ = 5 (attributs + coordonnées)", loc="left")72 ax.grid(axis="y", visible=False)73 savefig(fig, "ch15/ch15-fig-silhouette.pdf")7475def fig_carte_segments(r):76 v = r["ventes"]; s = r["sujet"]77 xy = h.coordonnees_km(v["lat"].values, v["lon"].values); sxy = h.coordonnees_km(np.array([s["lat"]]), np.array([s["lon"]]), lat0=float(v["lat"].mean()), lon0=float(v["lon"].mean()))78 lab2 = r["labels_kmeans"]; lab5 = r["labels_kmeans5"]79 panneaux = [("(a) $k$ = 2, « Est »", lab2 == (np.bincount(lab2).argmin()))] + [(f"({c}) $k$ = 5, seg. {g + 1}", lab5 == g) for c, g in zip("bcdef", range(5))]80 fig, axes = plt.subplots(2, 3, figsize=(LARGEUR, 4.0), sharex=True, sharey=True)81 for ax, (titre, m) in zip(axes.ravel(), panneaux):82 ax.scatter(xy[~m, 0], xy[~m, 1], s=5, color=palette.grid, lw=0)83 ax.scatter(xy[m, 0], xy[m, 1], s=7, color=palette.brand, lw=0)84 ax.scatter(sxy[0, 0], sxy[0, 1], marker="*", s=70, color=palette.cat[2], edgecolor=palette.ink, lw=0.4, zorder=5)85 ax.set_title(f"{titre} ($n$ = {int(m.sum())})", loc="left", fontsize=8); ax.set_aspect("equal"); ax.grid(visible=False)86 fig.subplots_adjust(hspace=0.35, wspace=0.08)87 for ax in axes[1]: ax.set_xlabel("km (est)")88 for ax in axes[:, 0]: ax.set_ylabel("km (nord)")89 savefig(fig, "ch15/ch15-fig-carte-segments.pdf")9091def fig_coefficients_secteurs(r):92 ch = r["chow"]["secteur"]; ref = r["reference"]["coef"]93 vars_ = [("ln_S", "ln(superficie)"), ("qualite", "qualité (échelon)"), ("age", "âge (année)"), ("mois", "tendance (mois)")]94 ordre = ["Hull", "Gatineau", "Aylmer", "Buckingham", "Masson-Angers"]95 fig, axes = plt.subplots(1, 4, figsize=(LARGEUR, 2.9), sharey=True)96 for ax, (vv, titre) in zip(axes, vars_):97 for i, sct in enumerate(ordre):98 d = ch["par_segment"][sct]; b = d["coef"][vv]; se = d["se_hc1"][vv]99 ax.errorbar(b, i, xerr=1.96 * se, fmt="o", color=palette.brand, ecolor=palette.brand, capsize=2, ms=4, lw=1.1)100 ax.axvline(ref.loc[vv, "coef"], color=palette.cat[2], ls="--", lw=1.1)101 ax.set_title(titre, loc="left", fontsize=8.5); ax.grid(axis="y", visible=False)102 ax.ticklabel_format(axis="x", style="plain", useOffset=False)103 axes[0].set_yticks(range(len(ordre))); axes[0].set_yticklabels([f"{s} ($n$ = {ch['par_segment'][s]['n']})" for s in ordre]); axes[0].invert_yaxis()104 fig.text(0.5, -0.04, "Coefficient estimé par secteur (point) avec intervalle à 95 % (HC1) ; tireté : coefficient commun du modèle de référence", ha="center", fontsize=8, color=palette.ink2)105 savefig(fig, "ch15/ch15-fig-coefficients-secteurs.pdf")106107def fig_loyers_rosemont(r):108 Z9 = c15.SCHL_ZONE9; types = ["studio", "1 ch.", "2 ch.", "3 ch. +", "tous"]109 rmr = {"studio": 1005, "1 ch.": 1131, "2 ch.": 1346, "3 ch. +": 1626, "tous": 1290} # SCHL tableau 1.1.2, Montréal (RMR), 2025110 fig, axes = plt.subplots(1, 2, figsize=(LARGEUR, 3.2), gridspec_kw={"width_ratios": [1.25, 1]})111 ax = axes[0]; x = np.arange(len(types)); w = 0.38112 b1 = ax.bar(x - w / 2, [Z9["loyer_2025"][t] for t in types], w, color=palette.brand, label="zone 9 Rosemont/La Petite-Patrie")113 b2 = ax.bar(x + w / 2, [rmr[t] for t in types], w, color=palette.cat[1], label="RMR de Montréal")114 for bars in (b1, b2):115 for b in bars: ax.text(b.get_x() + b.get_width() / 2, b.get_height() + 25, fmt_milliers(b.get_height()), ha="center", va="bottom", fontsize=6.5, rotation=90, color=palette.ink)116 ax.set_xticks(x); ax.set_xticklabels(types); ax.set_ylabel("Loyer moyen, octobre 2025 ($/mois)"); ax.set_ylim(0, 2300); ax.legend(loc="upper left", fontsize=7.5)117 ax.set_title("(a) loyer moyen par taille de logement", loc="left"); ax.grid(axis="x", visible=False)118 ax = axes[1]; t4 = types[:4]; u = [Z9["univers_2025"][t] for t in t4]; tot = Z9["univers_2025"]["tous"]119 bars = ax.bar(t4, u, color=palette.brand, width=0.6)120 for b, val in zip(bars, u): ax.text(b.get_x() + b.get_width() / 2, val + 300, f"{fmt_milliers(val)}\n({100 * val / tot:.0f} %)", ha="center", va="bottom", fontsize=7.5)121 ax.set_ylim(0, 27000); ax.set_ylabel("Logements (univers ELL)"); ax.yaxis.set_major_formatter(FuncFormatter(fmt_milliers))122 ax.set_title(f"(b) univers zone 9 : {fmt_milliers(tot)} log.", loc="left"); ax.grid(axis="x", visible=False)123 fig.subplots_adjust(wspace=0.35)124 savefig(fig, "ch15/ch15-fig-loyers-rosemont.pdf")125126def main():127 setup(); r = c15.tout()128 fig_mois_inventaire(r); fig_dendrogramme(r); fig_silhouette(r); fig_carte_segments(r); fig_coefficients_secteurs(r); fig_loyers_rosemont(r)129 print("Figures du chapitre 15 écrites dans", ROOT / "assets/figures/ch15")130131if __name__ == "__main__":132 main()133