spb/traite-evaluation-qc
Public
1#!/usr/bin/env python32"""Chapitre 15 — Études de marché et segmentation. Calculs de l'exemple intégral et des tableaux du chapitre.34Trois blocs, tous reproductibles (graine 20261031) :5 A. indicateurs de marché (mois d'inventaire, taux d'absorption, délais) à partir des chiffres APCIQ du T3 2026 cités6 avec attribution (data/synthetic/calibration.json, sources/notes-E.md) — chiffres réels, calculs de l'auteur ;7 B. segmentation des 600 ventes SYNTHÉTIQUES du cas 1 (Gatineau) : a priori par secteur, puis statistique (k-moyennes,8 classification hiérarchique de Ward, silhouette) ; test de Chow (classique et Wald robuste HC1) de stabilité des9 coefficients hédoniques entre segments, via traite.hedonique.mco ; validation croisée pooled vs segmenté ;10 C. étude de marché locatif pour le cas 3 (12 logements, Rosemont) : loyers, inoccupation, univers, rotation et offre11 neuve de la zone SCHL 9 (Enquête sur les logements locatifs, octobre 2025) confrontés à l'état locatif synthétique12 (traite.cas3.revenus) — on ne recalcule aucun RNE ici.13Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch15_segmentation.py"""14from __future__ import annotations15import sys, math, pathlib16import numpy as np, pandas as pd17sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))18from traite import cas1, cas319from traite import hedonique as h20from scipy import stats21from scipy.cluster.hierarchy import linkage, fcluster22from sklearn.cluster import KMeans23from sklearn.metrics import silhouette_score, silhouette_samples, adjusted_rand_score24from sklearn.model_selection import KFold2526ROOT = pathlib.Path(__file__).resolve().parents[3]27GRAINE = 202610312829# =============================================================================== A. indicateurs de marché (APCIQ T3 2026)30# Source : APCIQ par le système Centris, communiqué du 14 octobre 2026 et baromètres FSMI T3 2026 (consultés le 2026-10-31).31# Ventes résidentielles du trimestre, inscriptions en vigueur ; délais de vente médians (jours) unifamiliale / copropriété / plex.32APCIQ_T3_2026 = {33 "Province de Québec": {"ventes": 20935, "inscriptions": 42408, "delais": (45, 58, 48)},34 "RMR de Montréal": {"ventes": 9265, "inscriptions": 20350, "delais": (40, 61, 51)},35 "RMR de Québec": {"ventes": 2161, "inscriptions": 2204, "delais": (29, 34, 30)},36 "RMR de Gatineau": {"ventes": 1044, "inscriptions": 2256, "delais": (34, 36, 38)},37 "RMR de Sherbrooke": {"ventes": 569, "inscriptions": 825, "delais": None},38 "RMR de Trois-Rivières": {"ventes": 372, "inscriptions": 447, "delais": None},39 "RMR de Saguenay": {"ventes": 371, "inscriptions": 498, "delais": None},40}41# Baromètre Gatineau T3 2026 : ventes résidentielles par secteur Centris et médiane unifamiliale ($).42GATINEAU_SECTEURS_T3_2026 = {43 "Gatineau": {"ventes": 346, "mediane_unif": 464900}, "Hull": {"ventes": 174, "mediane_unif": 475000},44 "Périphérie": {"ventes": 215, "mediane_unif": 530000}, "Buckingham/Masson-Angers": {"ventes": 108, "mediane_unif": 395000},45 "Aylmer": {"ventes": 201, "mediane_unif": 530000},46}47JOURS_PAR_MOIS = 365.25 / 124849def mois_inventaire(inscriptions: float, ventes_trimestre: float) -> float:50 """MI = inscriptions en vigueur / (ventes du trimestre / 3)."""51 return inscriptions / (ventes_trimestre / 3.0)5253def taux_absorption(inscriptions: float, ventes_trimestre: float) -> float:54 """Part du stock écoulée par mois = ventes mensuelles / inscriptions = 1 / MI."""55 return (ventes_trimestre / 3.0) / inscriptions5657def indicateurs_apciq() -> pd.DataFrame:58 rows = []59 for terr, d in APCIQ_T3_2026.items():60 mi = mois_inventaire(d["inscriptions"], d["ventes"])61 r = {"territoire": terr, "ventes_trim": d["ventes"], "inscriptions": d["inscriptions"], "ventes_mois": d["ventes"] / 3.0,62 "mois_inventaire": mi, "taux_absorption_mensuel": taux_absorption(d["inscriptions"], d["ventes"])}63 if d["delais"]:64 r.update({"delai_unif_j": d["delais"][0], "delai_copro_j": d["delais"][1], "delai_plex_j": d["delais"][2],65 "delai_unif_mois": d["delais"][0] / JOURS_PAR_MOIS, "ratio_stock_flux": mi / (d["delais"][0] / JOURS_PAR_MOIS)})66 rows.append(r)67 return pd.DataFrame(rows)6869def parts_secteurs_gatineau(v: pd.DataFrame) -> pd.DataFrame:70 """Parts des ventes par secteur : marché réel (APCIQ T3 2026) vs jeu synthétique du cas 1 (Buckingham + Masson-Angers regroupés ;71 la périphérie n'existe pas dans le jeu synthétique)."""72 reel = pd.Series({k: d["ventes"] for k, d in GATINEAU_SECTEURS_T3_2026.items()})73 syn = v["secteur"].replace({"Buckingham": "Buckingham/Masson-Angers", "Masson-Angers": "Buckingham/Masson-Angers"}).value_counts()74 out = pd.DataFrame({"ventes_apciq_t3_2026": reel, "part_apciq": reel / reel.sum(), "ventes_synthetiques": syn.reindex(reel.index).fillna(0).astype(int)})75 out["part_synthetique"] = out["ventes_synthetiques"] / out["ventes_synthetiques"].sum()76 return out7778# =============================================================================== B. segmentation (cas 1, synthétique)79def matrice_base(df: pd.DataFrame) -> pd.DataFrame:80 """Régresseurs du modèle de référence SANS les indicatrices de secteur (les segments les remplacent)."""81 X = cas1.matrice(df)82 return X[[c for c in X.columns if not c.startswith("sect_")]]8384def _ssr(X: np.ndarray, y: np.ndarray) -> float:85 r = h.mco(X, y); return float(r["residus"] @ r["residus"])8687def chow(v: pd.DataFrame, groupes: pd.Series, nom: str = "groupe") -> dict:88 """Test de Chow (1960) de l'égalité des coefficients entre G segments.89 Version « pentes » : le modèle contraint garde une constante propre à chaque segment (indicatrices) et des pentes communes ;90 le modèle libre estime une équation par segment. F = [(SSR_c − SSR_l)/q] / [SSR_l/(n − G k0)], q = (G − 1)(k0 − 1).91 Version « classique » : modèle contraint entièrement commun (constante incluse), q = (G − 1) k0.92 Version robuste : Wald HC1 sur les interactions (régresseurs × indicatrices) du modèle empilé (χ² à q degrés de liberté)."""93 Xb = matrice_base(v); y = np.log(v["prix_vente"].astype(float)).values94 g = pd.Series(groupes, index=v.index).astype(str); niveaux = sorted(g.unique()); G = len(niveaux)95 # un régresseur constant à l'intérieur d'un segment (ex. « à étages » quand on segmente par type) n'est pas identifiable96 # dans l'équation de ce segment : il est retiré du jeu de pentes testées (sa contribution passe dans la constante du segment)97 constants = [c for c in Xb.columns if c != "const" and any(Xb.loc[(g == l).values, c].std(ddof=0) < 1e-12 for l in niveaux)]98 Xb = Xb.drop(columns=constants); n, k0 = Xb.shape99 ref = niveaux[0]100 D = pd.get_dummies(g, prefix="g").astype(float); D = D[[f"g_{l}" for l in niveaux[1:]]]101 # contraint (constantes par segment) et classique (tout commun)102 ssr_c = _ssr(np.column_stack([Xb.values, D.values]), y)103 ssr_classique = _ssr(Xb.values, y)104 # libre : une régression par segment105 par_segment = {}; ssr_l = 0.0106 for l in niveaux:107 m = (g == l).values; r = h.mco(Xb.values[m], y[m]); ssr_l += float(r["residus"] @ r["residus"])108 par_segment[l] = {"n": int(m.sum()), "coef": pd.Series(r["beta"], index=Xb.columns), "se_hc1": pd.Series(r["se_hc1"], index=Xb.columns),109 "sigma": float(np.sqrt(r["sigma2"])), "r2": float(r["r2"])}110 q = (G - 1) * (k0 - 1); df2 = n - G * k0111 F = ((ssr_c - ssr_l) / q) / (ssr_l / df2); p = float(stats.f.sf(F, q, df2))112 q0 = (G - 1) * k0; F0 = ((ssr_classique - ssr_l) / q0) / (ssr_l / df2); p0 = float(stats.f.sf(F0, q0, df2))113 # Wald robuste sur le modèle empilé : X_b, puis X_b(sans const) × D_g pour g ≠ ref, puis D_g (constantes)114 pentes = [c for c in Xb.columns if c != "const"]115 blocs = [Xb.values]; noms = list(Xb.columns)116 for l in niveaux[1:]:117 d = D[f"g_{l}"].values[:, None]118 blocs.append(Xb[pentes].values * d); noms += [f"{c}×{l}" for c in pentes]119 blocs.append(d); noms.append(f"const×{l}")120 Xs = np.column_stack(blocs); r = h.mco(Xs, y)121 idx = [i for i, nme in enumerate(noms) if "×" in nme and not nme.startswith("const×")]122 theta = r["beta"][idx]; V = r["V_hc1"][np.ix_(idx, idx)]123 W = float(theta @ np.linalg.solve(V, theta)); pW = float(stats.chi2.sf(W, len(idx)))124 return {"nom": nom, "niveaux": niveaux, "G": G, "n": n, "k0": k0, "ssr_contraint": ssr_c, "ssr_classique": ssr_classique, "ssr_libre": ssr_l,125 "q": q, "df2": df2, "F": float(F), "p": p, "F_classique": float(F0), "q_classique": q0, "p_classique": p0,126 "wald_hc1": W, "p_wald": pW, "q_wald": len(idx), "par_segment": par_segment,127 "sigma_contraint": math.sqrt(ssr_c / (n - k0 - G + 1)), "sigma_libre": math.sqrt(ssr_l / df2), "regresseurs_retires": constants}128129def tableau_coefficients(ch: dict, variables=("ln_S", "ln_T", "age", "qualite", "garage", "mois")) -> pd.DataFrame:130 rows = []131 for l, d in ch["par_segment"].items():132 row = {"segment": l, "n": d["n"], "sigma": d["sigma"], "r2": d["r2"]}133 for vv in variables:134 row[vv] = d["coef"][vv]; row[f"se_{vv}"] = d["se_hc1"][vv]135 rows.append(row)136 return pd.DataFrame(rows)137138def caracteristiques_segmentation(v: pd.DataFrame, jeu: str = "attributs+coordonnees") -> pd.DataFrame:139 """Variables de classification, centrées-réduites. jeu ∈ {attributs, coordonnees, attributs+coordonnees}."""140 xy = h.coordonnees_km(v["lat"].values, v["lon"].values)141 F = pd.DataFrame(index=v.index)142 if "attributs" in jeu:143 F["ln_S"] = np.log(v["superficie_habitable_m2"]); F["ln_T"] = np.log(v["superficie_terrain_m2"])144 F["age"] = v["age"].astype(float); F["qualite"] = v["qualite_1a5"].astype(float); F["etages"] = (v["type"] == "à étages").astype(float)145 if "coordonnees" in jeu:146 F["x_km"] = xy[:, 0]; F["y_km"] = xy[:, 1]; F["dist_centre"] = v["dist_centre_km"].astype(float)147 Z = (F - F.mean()) / F.std(ddof=0)148 return Z149150def silhouette_manuelle(Z: np.ndarray, labels: np.ndarray) -> np.ndarray:151 """s(i) = (b_i − a_i)/max(a_i, b_i) — implémentation directe (vérification de sklearn dans les tests)."""152 d = np.sqrt(((Z[:, None, :] - Z[None, :, :]) ** 2).sum(-1)); s = np.zeros(len(Z))153 for i in range(len(Z)):154 own = labels == labels[i]; own[i] = False155 if own.sum() == 0: s[i] = 0.0; continue156 a = d[i, own].mean()157 b = min(d[i, labels == l].mean() for l in np.unique(labels) if l != labels[i])158 s[i] = (b - a) / max(a, b)159 return s160161def classifier(Z: pd.DataFrame, k_max: int = 8) -> dict:162 """k-moyennes (20 initialisations, graine fixée) et Ward pour k = 2..k_max ; silhouette moyenne pour chaque k."""163 Zv = Z.values; L = linkage(Zv, method="ward")164 rows = []; labels_km = {}; labels_w = {}; inertie = {}165 for k in range(2, k_max + 1):166 km = KMeans(n_clusters=k, n_init=20, random_state=GRAINE).fit(Zv)167 lw = fcluster(L, t=k, criterion="maxclust")168 labels_km[k] = km.labels_; labels_w[k] = lw; inertie[k] = float(km.inertia_)169 rows.append({"k": k, "silhouette_kmeans": float(silhouette_score(Zv, km.labels_)), "silhouette_ward": float(silhouette_score(Zv, lw)),170 "inertie_kmeans": float(km.inertia_), "ari_kmeans_ward": float(adjusted_rand_score(km.labels_, lw))})171 tab = pd.DataFrame(rows)172 k_opt = int(tab.loc[tab["silhouette_kmeans"].idxmax(), "k"])173 return {"linkage": L, "tableau": tab, "labels_kmeans": labels_km, "labels_ward": labels_w, "k_opt": k_opt, "inertie": inertie}174175def profil_segments(v: pd.DataFrame, labels: np.ndarray) -> pd.DataFrame:176 d = v.assign(segment=labels)177 g = d.groupby("segment")178 out = pd.DataFrame({"n": g.size(), "superficie_med": g["superficie_habitable_m2"].median(), "age_med": g["age"].median(),179 "qualite_moy": g["qualite_1a5"].mean(), "dist_centre_med": g["dist_centre_km"].median(),180 "prix_med": g["prix_vente"].median(), "prix_m2_med": (d["prix_vente"] / d["superficie_habitable_m2"]).groupby(d["segment"]).median(),181 "secteur_dominant": g["secteur"].agg(lambda s: s.value_counts().index[0]),182 "part_secteur_dominant": g["secteur"].agg(lambda s: s.value_counts().iloc[0] / len(s))})183 return out184185def validation_croisee(v: pd.DataFrame, segments: dict, n_plis: int = 10) -> pd.DataFrame:186 """RMSE (log) et erreur absolue médiane (%) hors échantillon, 10 plis : modèle de référence (pentes communes + indicatrices de187 secteur), pentes communes + indicatrices de segment, et une équation par segment (variance accrue sur les petits segments)."""188 y = np.log(v["prix_vente"].astype(float)).values; Xb = matrice_base(v)189 kf = KFold(n_splits=n_plis, shuffle=True, random_state=GRAINE)190 def ajuster_predire(Xtr, ytr, Xte):191 with np.errstate(invalid="ignore"): # petits segments : variance nulle d'un régresseur → erreurs-types non définies (non utilisées ici)192 b = h.mco(Xtr, ytr)["beta"]193 return Xte @ b194 resultats = []195 specs = {"Équation unique sans segment": ("aucun", v["secteur"].astype(str))}196 for nom, lab in segments.items():197 specs[f"Pentes communes + indicatrices ({nom})"] = ("dummies", pd.Series(lab, index=v.index).astype(str))198 specs[f"Une équation par segment ({nom})"] = ("separe", pd.Series(lab, index=v.index).astype(str))199 for nom, (mode, lab) in specs.items():200 err = np.zeros(len(y))201 D = pd.get_dummies(lab).astype(float).values202 for tr, te in kf.split(Xb):203 if mode == "aucun":204 err[te] = y[te] - ajuster_predire(Xb.values[tr], y[tr], Xb.values[te])205 elif mode == "dummies":206 Xd = np.column_stack([Xb.values, D[:, 1:]])207 err[te] = y[te] - ajuster_predire(Xd[tr], y[tr], Xd[te])208 else:209 for l in np.unique(lab):210 mtr = tr[(lab.values[tr] == l)]; mte = te[(lab.values[te] == l)]211 if len(mte) == 0: continue212 if len(mtr) <= Xb.shape[1] + 2: # segment trop petit : repli sur le modèle commun213 err[mte] = y[mte] - ajuster_predire(Xb.values[tr], y[tr], Xb.values[mte])214 else:215 err[mte] = y[mte] - ajuster_predire(Xb.values[mtr], y[mtr], Xb.values[mte])216 resultats.append({"specification": nom, "rmse_log": float(np.sqrt(np.mean(err ** 2))), "mape_mediane_pct": float(100 * np.median(np.abs(np.exp(err) - 1))),217 "erreur_abs_moy_pct": float(100 * np.mean(np.abs(np.exp(err) - 1)))})218 return pd.DataFrame(resultats)219220# =============================================================================== C. marché locatif, cas 3 (SCHL zone 9 Rosemont)221# Source : SCHL, Enquête sur les logements locatifs, octobre 2025 (Rapport sur le marché locatif publié le 11 décembre 2025),222# tableaux de données RMR de Montréal : 1.1.1 (inoccupation), 1.1.2 (loyer moyen), 1.1.3 (univers), 1.1.5 (variation même échantillon),223# 1.1.6 (rotation), 1.1.9 (loyer des logements inoccupés vs occupés), 1.3.1-1.3.4 (taille d'immeuble), 3.1.7 (parc neuf).224# Lettres : indicateur de qualité SCHL (a excellent, b très bon, c bon, d passable) ; None = donnée supprimée (**).225SCHL_ZONE9 = {226 "zone": "Zone 9 — Rosemont/La Petite-Patrie",227 "loyer_2025": {"studio": 1010, "1 ch.": 1055, "2 ch.": 1348, "3 ch. +": 1750, "tous": 1237},228 "qualite_loyer_2025": {"studio": "c", "1 ch.": "d", "2 ch.": "c", "3 ch. +": "c", "tous": "c"},229 "loyer_2024": {"studio": 819, "1 ch.": 1093, "2 ch.": None, "3 ch. +": 1782, "tous": 1237},230 "univers_2025": {"studio": 5553, "1 ch.": 12405, "2 ch.": 22520, "3 ch. +": 4076, "tous": 44554},231 "inocc_2025": {"studio": None, "1 ch.": 0.5, "2 ch.": None, "3 ch. +": None, "tous": None},232 "inocc_2024": {"studio": 2.0, "1 ch.": 0.4, "2 ch.": None, "3 ch. +": 0.0, "tous": 0.7},233 "variation_2025": {"studio": None, "1 ch.": 8.8, "2 ch.": 6.3, "3 ch. +": 4.2, "tous": 7.5},234 "inocc_taille_2025": {"3 à 5": None, "6 à 19": None, "20 à 49": 4.1, "50 à 99": 0.0, "100 +": 2.8},235 "loyer_inoccupe_vs_occupe_2025": {"studio": (905, 1012), "1 ch.": (1295, 1054), "2 ch.": (None, 1285), "3 ch. +": (None, 1750), "tous": (None, 1213)},236 "neuf_2022_2025": {"univers": 627, "loyer_tous": 1920, "loyer_2ch": 2226, "univers_2ch": 306},237}238SCHL_ILE_6_19 = {"inocc_2025": 2.2, "inocc_2024": 1.7, "loyer_2ch_2025": 1177, "loyer_tous_2025": 1162, "rotation_2025": 8.5, "rotation_2024": 9.4}239SCHL_RMR = {"inocc_2025": 2.9, "inocc_2024": 2.1, "loyer_2ch_2025": 1346, "loyer_tous_2025": 1290, "rotation_2025": 9.4, "rotation_2024": 11.1,240 "loyer_inoccupe_tous": 1628, "loyer_occupe_tous": 1281, "loyer_inoccupe_2ch": 1817, "loyer_occupe_2ch": 1333, "univers_2025": 667266}241242def etude_locative_cas3() -> dict:243 c = cas3.charger(); etat = c["etat"]; hyp = c["hyp"]; rev = cas3.revenus(etat, hyp)244 corr = {"3½": "1 ch.", "4½": "2 ch.", "5½": "3 ch. +"}245 g = etat.groupby("pieces").agg(n=("logement", "size"), contrat=("loyer_mensuel_contrat", "mean"), marche=("loyer_mensuel_marche", "mean"),246 superficie=("superficie_m2", "mean"))247 g["type_schl"] = [corr[p] for p in g.index]248 g["loyer_schl_zone9_2025"] = [SCHL_ZONE9["loyer_2025"][t] for t in g["type_schl"]]249 g["ratio_marche_schl"] = g["marche"] / g["loyer_schl_zone9_2025"]250 g["ecart_contrat_marche_pct"] = 100 * (1 - g["contrat"] / g["marche"]) # écart en % du loyer de marché (convention du ch. 47)251 u = SCHL_ZONE9["univers_2025"]252 parts = {k: u[k] / u["tous"] for k in ["studio", "1 ch.", "2 ch.", "3 ch. +"]}253 ecart_1ch = SCHL_ZONE9["loyer_inoccupe_vs_occupe_2025"]["1 ch."]254 ecart_rmr = (SCHL_RMR["loyer_inoccupe_tous"] / SCHL_RMR["loyer_occupe_tous"] - 1)255 rot = {"hypothese_cas3": hyp["rotation_annuelle"], "schl_rmr_2025": SCHL_RMR["rotation_2025"] / 100, "schl_ile_6_19_2025": SCHL_ILE_6_19["rotation_2025"] / 100}256 rattrapage = {k: r * rev["ecart_marche"] for k, r in rot.items()} # part de l'écart récupérable la première année257 horizon = {k: 1.0 / r for k, r in rot.items()} # années pour renouveler tous les baux au rythme r258 return {"par_type": g, "revenus": rev, "ecart_global_pct": 100 * (1 - rev["rbp_contrat"] / rev["rbp_marche"]), "parts_univers": parts,259 "ecart_inoccupe_occupe_1ch_zone9_pct": 100 * (ecart_1ch[0] / ecart_1ch[1] - 1), "ecart_inoccupe_occupe_rmr_pct": 100 * ecart_rmr,260 "rotation": rot, "rattrapage_an1": rattrapage, "horizon_renouvellement": horizon,261 "neuf_prime_pct": 100 * (SCHL_ZONE9["neuf_2022_2025"]["loyer_tous"] / SCHL_ZONE9["loyer_2025"]["tous"] - 1),262 "neuf_part_univers_pct": 100 * SCHL_ZONE9["neuf_2022_2025"]["univers"] / u["tous"],263 "inocc_hyp_cas3_pct": 100 * hyp["taux_inoccupation_pertes"]}264265# =============================================================================== tout266def tout() -> dict:267 d = cas1.charger(); v = d["ventes"]268 ind = indicateurs_apciq(); parts = parts_secteurs_gatineau(v)269 ref = cas1.hedonique(v)270 # segmentation a priori : secteurs ; autres partitions a priori (type, période)271 ch_secteur = chow(v, v["secteur"], "secteur")272 est = v["secteur"].replace({"Buckingham": "Est", "Masson-Angers": "Est"})273 ch_secteur4 = chow(v, est, "secteur (4 groupes, Est regroupé)")274 ch_type = chow(v, v["type"], "type de bâtiment")275 periode = np.where(v["date_vente"] >= pd.Timestamp("2025-01-01"), "2025-2026", "2023-2024")276 ch_periode = chow(v, pd.Series(periode, index=v.index), "période")277 # segmentation statistique278 Z = caracteristiques_segmentation(v, "attributs+coordonnees"); cl = classifier(Z)279 k = cl["k_opt"]; lab_km = cl["labels_kmeans"][k]; lab_w = cl["labels_ward"][k]280 sil_i = silhouette_samples(Z.values, lab_km)281 prof = profil_segments(v, lab_km + 1)282 croise = pd.crosstab(pd.Series(lab_km + 1, index=v.index, name="segment"), v["secteur"])283 ari_secteur = float(adjusted_rand_score(v["secteur"], lab_km)); ari_ward = float(adjusted_rand_score(lab_km, lab_w))284 jeux = {}285 for jeu in ["attributs", "coordonnees", "attributs+coordonnees"]:286 Zj = caracteristiques_segmentation(v, jeu); cj = classifier(Zj); kj = cj["k_opt"]287 jeux[jeu] = {"k_opt": kj, "silhouette": float(cj["tableau"].loc[cj["tableau"]["k"] == kj, "silhouette_kmeans"].iloc[0]),288 "ari_secteur": float(adjusted_rand_score(v["secteur"], cj["labels_kmeans"][kj])),289 "silhouette_k5": float(cj["tableau"].loc[cj["tableau"]["k"] == 5, "silhouette_kmeans"].iloc[0]),290 "ari_secteur_k5": float(adjusted_rand_score(v["secteur"], cj["labels_kmeans"][5]))}291 ch_kmeans = chow(v, pd.Series(lab_km + 1, index=v.index), f"k-moyennes (k = {k})")292 ch_ward = chow(v, pd.Series(lab_w, index=v.index), f"Ward (k = {k})")293 lab_km5 = cl["labels_kmeans"][5]294 ch_kmeans5 = chow(v, pd.Series(lab_km5 + 1, index=v.index), "k-moyennes (k = 5)")295 prof5 = profil_segments(v, lab_km5 + 1); croise5 = pd.crosstab(pd.Series(lab_km5 + 1, index=v.index, name="segment"), v["secteur"])296 ari_secteur5 = float(adjusted_rand_score(v["secteur"], lab_km5))297 cv = validation_croisee(v, {"secteur": v["secteur"].values, f"k-moyennes, k = {k}": lab_km + 1, "k-moyennes, k = 5": lab_km5 + 1})298 loc = etude_locative_cas3()299 return {"ventes": v, "sujet": d["sujet"], "indicateurs": ind, "parts_gatineau": parts, "reference": ref,300 "chow": {"secteur": ch_secteur, "secteur4": ch_secteur4, "type": ch_type, "periode": ch_periode, "kmeans": ch_kmeans, "ward": ch_ward, "kmeans5": ch_kmeans5},301 "labels_kmeans5": lab_km5, "profil5": prof5, "croise5": croise5, "ari_secteur5": ari_secteur5,302 "Z": Z, "classification": cl, "k_opt": k, "labels_kmeans": lab_km, "labels_ward": lab_w, "silhouette_i": sil_i, "profil": prof,303 "croise": croise, "ari_secteur": ari_secteur, "ari_kmeans_ward": ari_ward, "jeux": jeux, "cv": cv, "locatif": loc}304305def main():306 pd.set_option("display.width", 220); pd.set_option("display.max_columns", 30)307 r = tout()308 print("=== A. Indicateurs APCIQ T3 2026 (chiffres cités ; calculs de l'auteur)")309 print(r["indicateurs"].round(3).to_string(index=False))310 print("\nParts des ventes par secteur, Gatineau (APCIQ vs synthétique) :\n", r["parts_gatineau"].round(3).to_string())311 print("\n=== B. Modèle de référence (traite.cas1.hedonique) : R² = %.3f, σ = %.4f" % (r["reference"]["r2"], r["reference"]["sigma"]))312 for key, ch in r["chow"].items():313 print(f"\n--- Chow [{ch['nom']}] : G = {ch['G']}, k0 = {ch['k0']}, n = {ch['n']}")314 print(f" SSR contraint (constantes par segment) = {ch['ssr_contraint']:.4f} ; SSR classique = {ch['ssr_classique']:.4f} ; SSR libre = {ch['ssr_libre']:.4f}")315 print(f" F pentes = {ch['F']:.3f} (q = {ch['q']}, df2 = {ch['df2']}), p = {ch['p']:.4f} ; F classique = {ch['F_classique']:.3f} (q = {ch['q_classique']}), p = {ch['p_classique']:.4g}")316 print(f" Wald HC1 = {ch['wald_hc1']:.2f} (q = {ch['q_wald']}), p = {ch['p_wald']:.4f} ; σ contraint {ch['sigma_contraint']:.4f} vs libre {ch['sigma_libre']:.4f} ; retirés : {ch['regresseurs_retires']}")317 if key in ("secteur", "kmeans"):318 print(tableau_coefficients(ch).round(4).to_string(index=False))319 print("\n--- Classification (attributs + coordonnées) :\n", r["classification"]["tableau"].round(3).to_string(index=False))320 print(f" k optimal (silhouette k-moyennes) = {r['k_opt']} ; ARI(k-moyennes, secteurs) = {r['ari_secteur']:.3f} ; ARI(k-moyennes, Ward) = {r['ari_kmeans_ward']:.3f}")321 print(" silhouette moyenne par segment :", pd.Series(r["silhouette_i"]).groupby(r["labels_kmeans"] + 1).mean().round(3).to_dict())322 print("\n--- Profil des segments k-moyennes :\n", r["profil"].round(2).to_string())323 print("\n--- Segments × secteurs :\n", r["croise"].to_string())324 print(f"\n--- k = 5 : ARI(k-moyennes, secteurs) = {r['ari_secteur5']:.3f}\n", r["profil5"].round(2).to_string(), "\n", r["croise5"].to_string())325 print("\n--- Jeux de variables :\n", pd.DataFrame(r["jeux"]).T.round(3).to_string())326 print("\n--- Validation croisée (10 plis) :\n", r["cv"].round(4).to_string(index=False))327 L = r["locatif"]328 print("\n=== C. Étude de marché locatif, cas 3 (SCHL zone 9, oct. 2025 ; état locatif synthétique)")329 print(L["par_type"].round(3).to_string())330 print(" RBP contrat %.0f $, RBP marché %.0f $, écart %.0f $ (%.1f %%)" % (L["revenus"]["rbp_contrat"], L["revenus"]["rbp_marche"], L["revenus"]["ecart_marche"], L["ecart_global_pct"]))331 print(" parts de l'univers zone 9 :", {k: round(x, 3) for k, x in L["parts_univers"].items()})332 print(" écart loyer inoccupé/occupé : 1 ch. zone 9 %.1f %% ; tous RMR %.1f %%" % (L["ecart_inoccupe_occupe_1ch_zone9_pct"], L["ecart_inoccupe_occupe_rmr_pct"]))333 print(" rotation :", {k: round(x, 3) for k, x in L["rotation"].items()}, "; rattrapage an 1 :", {k: round(x) for k, x in L["rattrapage_an1"].items()},334 "; horizon (ans) :", {k: round(x, 1) for k, x in L["horizon_renouvellement"].items()})335 print(" parc neuf 2022-2025 zone 9 : prime de loyer %.1f %%, part de l'univers %.2f %%" % (L["neuf_prime_pct"], L["neuf_part_univers_pct"]))336 return r337338if __name__ == "__main__":339 main()340