#!/usr/bin/env python3 """Figures du chapitre 15 — Études de marché et segmentation. Produit dans assets/figures/ch15/ : ch15-fig-mois-inventaire.pdf mois d'inventaire par RMR, T3 2026 (chiffres APCIQ cités ; calculs de l'auteur) — données réelles ch15-fig-dendrogramme.pdf dendrogramme de Ward des 600 ventes du cas 1 (attributs + coordonnées) — SYNTHÉTIQUE ch15-fig-silhouette.pdf silhouette moyenne selon k et jeu de variables ; profil de silhouette à k = 5 — SYNTHÉTIQUE ch15-fig-carte-segments.pdf carte fictive des segments k-moyennes (k = 2 et k = 5), petits multiples — SYNTHÉTIQUE ch15-fig-coefficients-secteurs.pdf coefficients hédoniques par secteur avec IC à 95 % (HC1) et coefficient commun — SYNTHÉTIQUE ch15-fig-loyers-rosemont.pdf structure des loyers et de l'univers locatif, zone SCHL 9 Rosemont vs RMR, oct. 2025 — données réelles Usage (racine) : PYTHONPATH=analysis/python python3 analysis/python/figures/ch15_figures.py""" from __future__ import annotations import sys, pathlib ROOT = pathlib.Path(__file__).resolve().parents[3] sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases")) import numpy as np, pandas as pd import matplotlib.pyplot as plt from matplotlib.ticker import FuncFormatter from scipy.cluster.hierarchy import dendrogram from traite.style import setup, palette, savefig, LARGEUR, fmt_milliers from traite import hedonique as h import ch15_segmentation as c15 def fig_mois_inventaire(r): ind = r["indicateurs"].copy() ind["court"] = ind["territoire"].str.replace("RMR de ", "", regex=False).str.replace("Province de Québec", "Province") ind = ind.sort_values("mois_inventaire", ascending=True) fig, ax = plt.subplots(figsize=(LARGEUR, 3.3)) 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) ax.barh(ind["court"], ind["mois_inventaire"], color=palette.brand, height=0.6) for y, (mi, dj) in enumerate(zip(ind["mois_inventaire"], ind["delai_unif_j"])): lab = f"{mi:.1f} mois" + (f" (délai unifam. {int(dj)} j)" if not np.isnan(dj) else "") ax.text(mi + 0.08, y, lab, va="center", ha="left", fontsize=8, color=palette.ink) ax.set_xlim(0, 8.6); ax.set_xlabel("Mois d'inventaire = inscriptions en vigueur / ventes mensuelles du trimestre") yt = len(ind) + 0.55 ax.text(2, yt, "favorable aux vendeurs (< 4 mois, usage)", ha="center", va="center", fontsize=7.5, color=palette.ink2) ax.text(5, yt, "équilibré (4 à 6)", ha="center", va="center", fontsize=7.5, color=palette.ink2) ax.text(7.2, yt, "favorable aux acheteurs (> 6)", ha="center", va="center", fontsize=7.5, color=palette.ink2) ax.set_ylim(-0.6, len(ind) + 1.0); ax.grid(axis="y", visible=False) savefig(fig, "ch15/ch15-fig-mois-inventaire.pdf") def fig_dendrogramme(r): L = r["classification"]["linkage"] fig, ax = plt.subplots(figsize=(LARGEUR, 3.4)) 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) # hauteurs de coupe pour k = 2 et k = 5 (distance de fusion des dernières agrégations) hauteurs = L[:, 2]; n = L.shape[0] + 1 for k, col, lab in [(2, palette.cat[2], "k = 2"), (5, palette.cat[1], "k = 5")]: y = 0.5 * (hauteurs[n - k - 1] + hauteurs[n - k]) 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) ax.set_ylabel("Distance de fusion (critère de Ward)"); ax.set_xlabel("Grappes terminales (effectif entre parenthèses) — 600 ventes synthétiques") ax.grid(axis="x", visible=False) savefig(fig, "ch15/ch15-fig-dendrogramme.pdf") def fig_silhouette(r): v = r["ventes"] fig, axes = plt.subplots(1, 2, figsize=(LARGEUR, 3.2), gridspec_kw={"width_ratios": [1.1, 1]}) ax = axes[0] for i, (jeu, lab) in enumerate([("attributs", "attributs seuls"), ("coordonnees", "coordonnées seules"), ("attributs+coordonnees", "attributs + coordonnées")]): Z = c15.caracteristiques_segmentation(v, jeu); t = c15.classifier(Z)["tableau"] ax.plot(t["k"], t["silhouette_kmeans"], marker="o", color=palette.cat[i], label=lab) 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") ax.set_title("(a) silhouette moyenne selon $k$", loc="left") ax = axes[1] Z = r["Z"].values; lab5 = r["labels_kmeans5"] from sklearn.metrics import silhouette_samples s = silhouette_samples(Z, lab5); y0 = 0 for g in range(5): sg = np.sort(s[lab5 == g])[::-1]; yy = np.arange(y0, y0 + len(sg)) ax.barh(yy, sg, height=1.0, color=palette.cat[g], lw=0) ax.text(-0.02, y0 + len(sg) / 2, f"{g + 1}", ha="right", va="center", fontsize=8); y0 += len(sg) + 8 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) 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") ax.grid(axis="y", visible=False) savefig(fig, "ch15/ch15-fig-silhouette.pdf") def fig_carte_segments(r): v = r["ventes"]; s = r["sujet"] 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())) lab2 = r["labels_kmeans"]; lab5 = r["labels_kmeans5"] 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))] fig, axes = plt.subplots(2, 3, figsize=(LARGEUR, 4.0), sharex=True, sharey=True) for ax, (titre, m) in zip(axes.ravel(), panneaux): ax.scatter(xy[~m, 0], xy[~m, 1], s=5, color=palette.grid, lw=0) ax.scatter(xy[m, 0], xy[m, 1], s=7, color=palette.brand, lw=0) ax.scatter(sxy[0, 0], sxy[0, 1], marker="*", s=70, color=palette.cat[2], edgecolor=palette.ink, lw=0.4, zorder=5) ax.set_title(f"{titre} ($n$ = {int(m.sum())})", loc="left", fontsize=8); ax.set_aspect("equal"); ax.grid(visible=False) fig.subplots_adjust(hspace=0.35, wspace=0.08) for ax in axes[1]: ax.set_xlabel("km (est)") for ax in axes[:, 0]: ax.set_ylabel("km (nord)") savefig(fig, "ch15/ch15-fig-carte-segments.pdf") def fig_coefficients_secteurs(r): ch = r["chow"]["secteur"]; ref = r["reference"]["coef"] vars_ = [("ln_S", "ln(superficie)"), ("qualite", "qualité (échelon)"), ("age", "âge (année)"), ("mois", "tendance (mois)")] ordre = ["Hull", "Gatineau", "Aylmer", "Buckingham", "Masson-Angers"] fig, axes = plt.subplots(1, 4, figsize=(LARGEUR, 2.9), sharey=True) for ax, (vv, titre) in zip(axes, vars_): for i, sct in enumerate(ordre): d = ch["par_segment"][sct]; b = d["coef"][vv]; se = d["se_hc1"][vv] ax.errorbar(b, i, xerr=1.96 * se, fmt="o", color=palette.brand, ecolor=palette.brand, capsize=2, ms=4, lw=1.1) ax.axvline(ref.loc[vv, "coef"], color=palette.cat[2], ls="--", lw=1.1) ax.set_title(titre, loc="left", fontsize=8.5); ax.grid(axis="y", visible=False) ax.ticklabel_format(axis="x", style="plain", useOffset=False) 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() 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) savefig(fig, "ch15/ch15-fig-coefficients-secteurs.pdf") def fig_loyers_rosemont(r): Z9 = c15.SCHL_ZONE9; types = ["studio", "1 ch.", "2 ch.", "3 ch. +", "tous"] rmr = {"studio": 1005, "1 ch.": 1131, "2 ch.": 1346, "3 ch. +": 1626, "tous": 1290} # SCHL tableau 1.1.2, Montréal (RMR), 2025 fig, axes = plt.subplots(1, 2, figsize=(LARGEUR, 3.2), gridspec_kw={"width_ratios": [1.25, 1]}) ax = axes[0]; x = np.arange(len(types)); w = 0.38 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") b2 = ax.bar(x + w / 2, [rmr[t] for t in types], w, color=palette.cat[1], label="RMR de Montréal") for bars in (b1, b2): 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) 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) ax.set_title("(a) loyer moyen par taille de logement", loc="left"); ax.grid(axis="x", visible=False) ax = axes[1]; t4 = types[:4]; u = [Z9["univers_2025"][t] for t in t4]; tot = Z9["univers_2025"]["tous"] bars = ax.bar(t4, u, color=palette.brand, width=0.6) 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) ax.set_ylim(0, 27000); ax.set_ylabel("Logements (univers ELL)"); ax.yaxis.set_major_formatter(FuncFormatter(fmt_milliers)) ax.set_title(f"(b) univers zone 9 : {fmt_milliers(tot)} log.", loc="left"); ax.grid(axis="x", visible=False) fig.subplots_adjust(wspace=0.35) savefig(fig, "ch15/ch15-fig-loyers-rosemont.pdf") def main(): setup(); r = c15.tout() fig_mois_inventaire(r); fig_dendrogramme(r); fig_silhouette(r); fig_carte_segments(r); fig_coefficients_secteurs(r); fig_loyers_rosemont(r) print("Figures du chapitre 15 écrites dans", ROOT / "assets/figures/ch15") if __name__ == "__main__": main()