#!/usr/bin/env python3 """Chapitre 33 — figures (4) : (1) SCHL, île de Montréal, loyers et inoccupation par taille d'immeuble et par année de construction (DONNÉES RÉELLES, Enquête sur les logements locatifs d'octobre 2025) ; (2) cas 4 : valeur par logement et taux global par RMR ; (3) taux global selon la taille : comparables du cas 3 et immeubles du cas 4 ; (4) sensibilité de la valeur par logement de l'immeuble P45 (36 logements) à la RMR et à l'état (régression du cas 4). Idempotent ; écrit dans assets/figures/ch33/ch33-fig-.pdf. Usage (racine) : PYTHONPATH=analysis/python python3 analysis/python/figures/ch33_figures.py""" from __future__ import annotations import sys, pathlib import numpy as np, pandas as pd import matplotlib.pyplot as plt from matplotlib.ticker import FuncFormatter sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) from traite.style import setup, palette, savefig, LARGEUR, fmt_milliers sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "cases")) import ch33_six_logements as ch ROOT = pathlib.Path(__file__).resolve().parents[3] pct1 = FuncFormatter(lambda x, p: f"{x:.1f}".replace(".", ",") + " %") pct2 = FuncFormatter(lambda x, p: f"{x*100:.1f}".replace(".", ",") + " %") kdol = FuncFormatter(lambda x, p: f"{x/1000:.0f} k$") dol = FuncFormatter(lambda x, p: fmt_milliers(x) + " $") def fig_schl(R): t = R["schl"]["taille"]; a = R["schl"]["annee"] t = t[t["categorie"] != "Tous les immeubles"].copy(); a = a[a["categorie"] != "Tous les immeubles"].copy() t["etiquette"] = t["categorie"].str.replace(" unités", "").str.replace(" et +", " et plus") a["etiquette"] = a["categorie"].map({"Avant 1940": "< 1940", "1940 à 1959": "1940-59", "1960 à 1974": "1960-74", "1975 à 1989": "1975-89", "1990 à 2004": "1990-2004", "2005 à 2014": "2005-14", "2015 ou après": "2015 +"}) fig, axes = plt.subplots(2, 2, figsize=(LARGEUR, 5.4), gridspec_kw={"hspace": 0.75, "wspace": 0.35}) for (ax_l, ax_i), df, titre in ((axes[0], t, "taille d'immeuble"), (axes[1], a, "année de construction")): x = np.arange(len(df)); w = 0.38 ax_l.bar(x - w / 2, df["loyer_tous"], w, color=palette.cat[0], label="tous les logements") ax_l.bar(x + w / 2, df["loyer_2ch"], w, color=palette.cat[1], label="2 chambres") for xi, v in zip(x - w / 2, df["loyer_tous"]): ax_l.text(xi, v + 25, fmt_milliers(v), ha="center", va="bottom", fontsize=6.5, color=palette.ink) ax_l.set_xticks(x); ax_l.set_xticklabels(df["etiquette"], fontsize=7, rotation=30, ha="right") ax_l.yaxis.set_major_formatter(dol); ax_l.set_ylim(0, 2450); ax_l.set_title(f"Loyer moyen selon la {titre}", loc="left", fontsize=8.5) ax_i.bar(x, df["inoccupation_tous"], 0.6, color=palette.cat[2]) for xi, v in zip(x, df["inoccupation_tous"]): if not np.isnan(v): ax_i.text(xi, v + 0.08, f"{v:.1f}".replace(".", ","), ha="center", va="bottom", fontsize=7, color=palette.ink) else: ax_i.text(xi, 0.15, "n. d.", ha="center", va="bottom", fontsize=7, color=palette.ink2) ax_i.set_xticks(x); ax_i.set_xticklabels(df["etiquette"], fontsize=7, rotation=30, ha="right") ax_i.yaxis.set_major_formatter(pct1); ax_i.set_ylim(0, 6.6); ax_i.set_title(f"Inoccupation selon la {titre}", loc="left", fontsize=8.5) axes[0][0].legend(loc="upper left", fontsize=6.5, ncol=1) fig.text(0.99, 0.005, "Source : SCHL, Enquête sur les logements locatifs, octobre 2025, île de Montréal (zones 1-18) ; « n. d. » : donnée supprimée par la SCHL (fiabilité insuffisante)", ha="right", va="bottom", fontsize=6.5, color=palette.ink2) return savefig(fig, "ch33/ch33-fig-schl-typologie.pdf") def fig_rmr(R): g = R["sommaire"]["par_rmr"]; pf = R["sommaire"]["portefeuille"] rmrs = list(g.index); x = np.arange(len(rmrs)) fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(LARGEUR, 3.3), gridspec_kw={"wspace": 0.38}) ax1.bar(x, g["valeur_par_logement"], 0.6, color=palette.cat[0], alpha=0.85, label="valeur agrégée / logements (RMR)") rng = np.random.default_rng(20261031) for i, r in enumerate(rmrs): s = pf[pf["rmr"] == r] ax1.scatter(i + rng.uniform(-0.18, 0.18, len(s)), s["valeur_par_logement"], s=np.sqrt(s["nb_logements"]) * 4, color=palette.ink, alpha=0.55, zorder=3, label="immeuble (aire proportionnelle au nombre de logements)" if i == 0 else None) ax1.text(i, 9000, fmt_milliers(round(g.loc[r, "valeur_par_logement"], -3)), ha="center", va="bottom", fontsize=6.8, color="white", rotation=90) ax1.set_xticks(x); ax1.set_xticklabels(rmrs, rotation=25, ha="right", fontsize=7.5); ax1.yaxis.set_major_formatter(kdol); ax1.set_ylim(0, 320_000) ax1.set_title("Valeur 2026 par logement", loc="left") ax2.errorbar(x, g["taux_median"], yerr=[g["taux_median"] - g["taux_min"], g["taux_max"] - g["taux_median"]], fmt="o", color=palette.cat[1], ecolor=palette.ink2, capsize=3, label="médiane (étendue min–max)") ax2.scatter(x, g["taux_pondere"], marker="_", s=260, color=palette.cat[0], linewidths=2.2, label="taux pondéré (RNE / valeur)", zorder=4) ax2.axhline(R["effet_taille"]["cas3_mediane"], color=palette.ink, linestyle=":", linewidth=1, label="cas 3 : médiane des 40 ventes (Montréal)") for i, r in enumerate(rmrs): ax2.text(i + 0.12, g.loc[r, "taux_pondere"], f"{g.loc[r, 'taux_pondere']*100:.2f}".replace(".", ","), fontsize=7, va="center") ax2.set_xticks(x); ax2.set_xticklabels(rmrs, rotation=25, ha="right", fontsize=7.5); ax2.yaxis.set_major_formatter(pct2); ax2.set_ylim(0.035, 0.075) ax2.set_title("Taux global par RMR", loc="left") h1, l1 = ax1.get_legend_handles_labels(); h2, l2 = ax2.get_legend_handles_labels() fig.legend(h1 + h2, l1 + l2, loc="lower center", bbox_to_anchor=(0.5, -0.16), ncol=2, fontsize=7) fig.text(0.99, -0.22, "DONNÉES SYNTHÉTIQUES — cas 4 (48 immeubles, 6 RMR)", ha="right", va="bottom", fontsize=7, color=palette.ink2) return savefig(fig, "ch33/ch33-fig-rmr.pdf") def fig_taux_taille(R): et = R["effet_taille"]; c3 = et["cas3_comparables"]; pf = R["sommaire"]["portefeuille"]; p = R["p45"]; reg = p["regression"] fig, ax = plt.subplots(figsize=(LARGEUR, 3.6)) m = pf["rmr"] == "Montréal" ax.scatter(c3["nb_logements"], c3["taux_global"], s=22, color=palette.cat[0], marker="o", alpha=0.8, label=f"cas 3 : {len(c3)} ventes, Montréal (2024-09 à 2026-08)", zorder=3) ax.scatter(pf.loc[m, "nb_logements"], pf.loc[m, "taux_global"], s=30, color=palette.cat[1], marker="s", alpha=0.85, label=f"cas 4 : Montréal ({int(m.sum())} immeubles)", zorder=3) ax.scatter(pf.loc[~m, "nb_logements"], pf.loc[~m, "taux_global"], s=30, color=palette.cat[2], marker="^", alpha=0.85, label=f"cas 4 : cinq autres RMR ({int((~m).sum())} immeubles)", zorder=3) xs = np.linspace(6, 30, 50) ax.plot(xs, et["cas3_constante"] + (et["cas3_pente_pb_par_logement"] / 1e4) * (xs - 12), color=palette.cat[0], linewidth=1.2, label="cas 3 : régression linéaire (état 3)") xs4 = np.linspace(6, 72, 80) ax.plot(xs4, [ch.taux_predit(reg, "Montréal", 3, k, 50) for k in xs4], color=palette.cat[1], linewidth=1.2, linestyle="--", label="cas 4 : régression semi-log, Montréal (état 3, 50 ans)") ax.plot(xs4, [ch.taux_predit(reg, "Gatineau", 3, k, 50) for k in xs4], color=palette.cat[2], linewidth=1.2, linestyle="--", label="cas 4 : idem, Gatineau") ax.scatter([12], [et["cas3_mediane"]], marker="*", s=140, color=palette.ink, zorder=5) ax.annotate("sujet du cas 3 (12 log., médiane 4,415 %)", (12, et["cas3_mediane"]), xytext=(14, 0.0385), fontsize=7, arrowprops=dict(arrowstyle="-", color=palette.ink2, lw=0.7)) ax.scatter([p["n"]], [p["taux"]["fichier"]], marker="D", s=60, facecolors="none", edgecolors=palette.ink, linewidths=1.3, zorder=5) ax.annotate(f"{p['id']} : {p['n']} log., Gatineau, état {p['etat']}", (p["n"], p["taux"]["fichier"]), xytext=(40, 0.0455), fontsize=7, arrowprops=dict(arrowstyle="-", color=palette.ink2, lw=0.7)) ax.set_xscale("log"); ax.set_xticks([6, 8, 12, 16, 24, 36, 48, 72]); ax.get_xaxis().set_major_formatter(FuncFormatter(lambda x, p: f"{x:.0f}")) ax.xaxis.set_minor_formatter(FuncFormatter(lambda x, p: "")) ax.yaxis.set_major_formatter(pct2); ax.set_xlabel("Nombre de logements (échelle logarithmique)"); ax.set_ylabel("Taux global (RNE / prix ou valeur)") ax.set_title("Taux global selon la taille de l'immeuble : effet de taille modeste, effet de marché dominant", loc="left") ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.2), ncol=2, fontsize=7) ax.text(0.99, -0.46, "DONNÉES SYNTHÉTIQUES (cas 3 et cas 4)", transform=ax.transAxes, ha="right", va="bottom", fontsize=7, color=palette.ink2) return savefig(fig, "ch33/ch33-fig-taux-taille.pdf") def fig_sensibilite(R): s = R["sensibilite"]; p = R["p45"] G = s["grille_valeur_par_logement"]; T = s["grille_taux"] fig, ax = plt.subplots(figsize=(LARGEUR, 3.4)) im = ax.imshow(G.values / 1000, cmap=palette.cmap_seq(), aspect="auto", origin="lower") ax.set_xticks(range(G.shape[1])); ax.set_xticklabels(G.columns, fontsize=7.5, rotation=20, ha="right") ax.set_yticks(range(G.shape[0])); ax.set_yticklabels(G.index, fontsize=7.5) seuil = np.percentile(G.values / 1000, 60) for i in range(G.shape[0]): for j in range(G.shape[1]): v = G.values[i, j] / 1000; coul = "white" if v > seuil else palette.ink ax.text(j, i, f"{v:.0f} k$\n{T.values[i, j]*100:.2f} %".replace(".", ","), ha="center", va="center", fontsize=6.8, color=coul) j0 = list(G.columns).index(p["rmr"]); i0 = p["etat"] - 1 ax.add_patch(plt.Rectangle((j0 - 0.5, i0 - 0.5), 1, 1, fill=False, edgecolor=palette.cat[1], linewidth=1.8)) ax.grid(False); ax.set_xlabel("Région métropolitaine (RMR)"); ax.set_ylabel("État de l'immeuble (1 = mauvais, 5 = excellent)") ax.set_title(f"P45 (36 logements, 72 ans) : valeur par logement et taux prédits selon la RMR et l'état ; cadre = sujet", loc="left") cb = fig.colorbar(im, ax=ax, pad=0.02); cb.set_label("valeur par logement (k$)") ax.text(0.99, -0.30, "DONNÉES SYNTHÉTIQUES — régression semi-log du cas 4 (48 immeubles), RNE de P45 constant", transform=ax.transAxes, ha="right", va="bottom", fontsize=7, color=palette.ink2) return savefig(fig, "ch33/ch33-fig-p45-sensibilite.pdf") def main(): setup(); R = ch.calculer() for f in (fig_schl, fig_rmr, fig_taux_taille, fig_sensibilite): print("OK", f(R).relative_to(ROOT)) if __name__ == "__main__": main()