SPB Git
25.3 KB · 340 lines python
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
Files · 9dc3847990198c3379e1a64231cde8ea5f1d747e
Menu

Theme