SPB Git
20.2 KB · 272 lines python
1#!/usr/bin/env python32"""Chapitre 17 — ajustements qualitatifs et quantitatifs des cinq comparables du cas 1 (Gatineau, données synthétiques).3Reprend exactement la sélection du chapitre 16 (traite.cas1.selection) et la grille d'ajustements dérivée du modèle4hédonique de référence (traite.cas1.ajustements_hedoniques). Imprime tous les chiffres cités dans le chapitre.5Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch17_ajustements.py"""6from __future__ import annotations7import sys, pathlib, math8import numpy as np, pandas as pd9sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))10from traite import cas111from traite import comparables as cp1213ROOT = pathlib.Path(__file__).resolve().parents[3]14GROUPES = ["conditions_marche", "localisation", "superficie", "terrain", "age", "qualite", "garage", "sous_sol", "piscine", "type"]15LIBELLES = {"conditions_marche": "Conditions du marché", "localisation": "Localisation", "superficie": "Superficie habitable",16            "terrain": "Terrain", "age": "Âge", "qualite": "Qualité", "garage": "Garage", "sous_sol": "Sous-sol fini",17            "piscine": "Piscine", "type": "Type de bâtiment"}18COLS_GROUPES = {"conditions_marche": ["mois"], "localisation": ["dist_centre"] + [f"sect_{x}" for x in cas1.SECTEURS[1:]],19                "superficie": ["ln_S"], "terrain": ["ln_T"], "age": ["age", "age2_100"], "qualite": ["qualite"],20                "garage": ["garage"], "sous_sol": ["sous_sol"], "piscine": ["piscine"], "type": ["etages"]}21TAUX_GENERATION = 1.0906 ** 0.5 - 1   # 9,06 % sur deux ans (rôle 2027-2029 de Gatineau) → 4,43 %/an composé (loi du jeu)2223# ---------------------------------------------------------------- 1. base : modèle, comparables, grille hédonique24def base() -> dict:25    t = cas1.tout()26    m = t["modele"]; comps = t["selection"]["comparables"]; s = t["sujet"]27    adj = t["ajustements"].copy()28    res = pd.Series(m["residus"], index=t["ventes"].index).loc[comps.index].values29    adj["residu_log"] = res30    adj["residu_pct"] = np.exp(res) - 131    g = m["croissance_mensuelle"]32    return {**t, "comps": comps, "adj": adj, "g_mensuel": g, "g_annuel": (1 + g) ** 12 - 1,33            "point_sans_duan": float(t["intervalle"]["point"]), "facteur_duan": float(np.mean(np.exp(m["residus"])))}3435# ---------------------------------------------------------------- 2. cascade d'un comparable et variantes de forme36def dlogs(b: dict, comp_index) -> dict:37    """Δln par groupe : β_k (x_s − x_c) pour chaque groupe d'ajustement."""38    m = b["modele"]; beta = pd.Series(m["beta"], index=m["X"].columns)39    Xs = cas1.matrice(cas1.sujet_df(b["sujet"])).iloc[0]40    Xc = cas1.matrice(b["comps"]).loc[comp_index]41    return {g: float(sum(beta[c] * (Xs[c] - Xc[c]) for c in cols)) for g, cols in COLS_GROUPES.items()}4243def cascade(b: dict, id_vente: str, ordre: list[str] | None = None) -> pd.DataFrame:44    """Cascade multiplicative : à chaque étape A_k = base × (exp(Δln_k) − 1), base ← base + A_k."""45    ordre = ordre or GROUPES46    idx = b["comps"].index[b["comps"]["id_vente"] == id_vente][0]47    d = dlogs(b, idx); base_ = float(b["comps"].loc[idx, "prix_vente"])48    rows = [{"etape": "Prix de vente", "dlog": 0.0, "pct": 0.0, "ajustement": 0.0, "base_apres": base_}]49    for g in ordre:50        a = base_ * (math.exp(d[g]) - 1); base_ += a51        rows.append({"etape": LIBELLES[g], "dlog": d[g], "pct": math.exp(d[g]) - 1, "ajustement": a, "base_apres": base_})52    rows.append({"etape": "Prix ajusté", "dlog": sum(d.values()), "pct": math.exp(sum(d.values())) - 1, "ajustement": base_ - rows[0]["base_apres"], "base_apres": base_})53    return pd.DataFrame(rows)5455def formes(b: dict) -> pd.DataFrame:56    """Trois façons d'agréger les mêmes effets : additif sur le prix brut, multiplicatif (cascade), mixte (temps en %, reste en $)."""57    out = []58    for idx, c in b["comps"].iterrows():59        d = dlogs(b, idx); P = float(c["prix_vente"])60        effets = {g: math.exp(v) - 1 for g, v in d.items()}61        additif = cp.prix_ajuste_additif(P, {g: P * e for g, e in effets.items()})62        multiplicatif = cp.prix_ajuste_multiplicatif(P, list(effets.values()))63        base_t = P * (1 + effets["conditions_marche"])64        mixte = cp.prix_ajuste_mixte(P, [effets["conditions_marche"]], {g: base_t * e for g, e in effets.items() if g != "conditions_marche"})["prix_ajuste"]65        out.append({"id_vente": c["id_vente"], "prix_vente": P, "additif": additif, "multiplicatif": multiplicatif, "mixte": mixte,66                    "ecart_additif_pct": additif / multiplicatif - 1, "ecart_mixte_pct": mixte / multiplicatif - 1, "somme_dlog": sum(d.values())})67    return pd.DataFrame(out)6869# ---------------------------------------------------------------- 3. ajustement de temps70def temps(b: dict, id_vente: str = "G0404") -> dict:71    c = b["comps"][b["comps"]["id_vente"] == id_vente].iloc[0]72    P = float(c["prix_vente"]); mois = (pd.Timestamp(b["sujet"]["date_evaluation"]) - c["date_vente"]).days / 30.437573    g = b["g_mensuel"]74    return {"id_vente": id_vente, "prix": P, "mois": mois,75            "coefficient": P * (math.exp(math.log(1 + g) * mois) - 1),76            "taux_compose_estime": cp.ajustement_temps_taux(P, b["g_annuel"], mois),77            "taux_compose_generation": cp.ajustement_temps_taux(P, TAUX_GENERATION, mois),78            "lineaire_simple": P * g * mois,79            "indices": cp.ajustement_temps(P, 100.0, 100.0 * (1 + b["g_annuel"]) ** (mois / 12)),80            "g_mensuel": g, "g_annuel": b["g_annuel"], "taux_generation": TAUX_GENERATION}8182# ---------------------------------------------------------------- 3b. ajustement de financement (balance de prix de vente)83def financement(prix: float = 620000.0, montant: float = 150000.0, taux_contrat: float = 0.02, taux_marche: float = 0.05, mois: int = 60, amortissement_mois: int | None = None) -> dict:84    """Avantage de financement = montant prêté − valeur actualisée (au taux du marché) des paiements promis ; taux nominaux à composition85    semestrielle (convention canadienne). Intérêts seulement + capital au terme si amortissement_mois est None, sinon mensualités amorties86    et solde dû au terme."""87    from traite import finance as f88    im, ic = f.taux_mensuel_canadien(taux_marche), f.taux_mensuel_canadien(taux_contrat)89    if amortissement_mois is None:90        paiement = montant * ic; solde = montant91    else:92        paiement = montant * f.facteur_recouvrement(ic, amortissement_mois); solde = paiement * f.facteur_va_annuite(ic, amortissement_mois - mois)93    va = paiement * f.facteur_va_annuite(im, mois) + solde * f.facteur_va(im, mois)94    avantage = montant - va95    return {"paiement_mensuel": paiement, "solde_terme": solde, "valeur_actualisee": va, "avantage": avantage, "ajustement": -avantage,96            "ajustement_pct": -avantage / prix, "prix_comptant": prix - avantage, "i_m_contrat": ic, "i_m_marche": im}9798# ---------------------------------------------------------------- 4. ventes appariées99CRITERES = {"superficie_pct": 0.05, "age_ans": 5, "mois_max": 12}100101def paires(b: dict, attribut: str, superficie_pct: float = 0.05, age_ans: float = 5, mois_max: float = 12,102           memes: tuple = ("secteur", "type", "qualite_1a5", "piscine")) -> pd.DataFrame:103    """Paires (avec, sans) construites dans les 600 ventes : même secteur, même type, même qualité, même piscine, autre attribut104    binaire identique, écart d'exactement une unité sur l'attribut étudié, superficie ±superficie_pct, âge ±age_ans, dates à105    moins de mois_max mois. La vente « sans » est ramenée à la date de la vente « avec » par le taux mensuel du modèle."""106    v = b["ventes"]; g = b["g_mensuel"]107    autres = [c for c in ["garage_places", "sous_sol_fini"] if c != attribut]108    rows = []109    for _, r in v.iterrows():110        m = v[v[attribut] == r[attribut] - 1]111        for c in list(memes) + autres:112            m = m[m[c] == r[c]]113        m = m[(abs(m["superficie_habitable_m2"] / r["superficie_habitable_m2"] - 1) <= superficie_pct) & (abs(m["age"] - r["age"]) <= age_ans)114              & (abs((m["date_vente"] - r["date_vente"]).dt.days) <= mois_max * 30.4375)]115        for _, q in m.iterrows():116            dm = (r["date_vente"] - q["date_vente"]).days / 30.4375117            q_t = q["prix_vente"] * (1 + g) ** dm118            rows.append({"avec": r["id_vente"], "sans": q["id_vente"], "prix_avec": r["prix_vente"], "prix_sans": q["prix_vente"], "prix_sans_temps": q_t,119                         "delta_mois": dm, "diff_brute": r["prix_vente"] - q["prix_vente"], "diff_temps": r["prix_vente"] - q_t, "pct": r["prix_vente"] / q_t - 1,120                         "superficie_avec": r["superficie_habitable_m2"], "superficie_sans": q["superficie_habitable_m2"], "secteur": r["secteur"]})121    return pd.DataFrame(rows)122123def resume_paires(p: pd.DataFrame) -> dict:124    r = cp.ventes_appariees(list(p["prix_avec"]), list(p["prix_sans_temps"]))125    r["erreur_type"] = r["ecart_type"] / math.sqrt(r["n"]) if r["n"] > 1 else float("nan")126    r["pct_moyen"] = float(p["pct"].mean()); r["pct_median"] = float(p["pct"].median())127    return r128129def variantes_paires(b: dict, attribut: str) -> pd.DataFrame:130    out = []131    for sp, aa, mm in [(0.05, 5, 6), (0.05, 5, 12), (0.05, 5, 18), (0.075, 5, 12), (0.05, 7, 12), (0.075, 7, 18)]:132        p = paires(b, attribut, sp, aa, mm)133        if len(p) > 1:134            r = resume_paires(p)135            out.append({"superficie_pct": sp, "age_ans": aa, "mois_max": mm, "n": r["n"], "moyenne": r["moyenne"], "mediane": r["mediane"], "ecart_type": r["ecart_type"], "erreur_type": r["erreur_type"], "pct_moyen": r["pct_moyen"]})136        else:137            out.append({"superficie_pct": sp, "age_ans": aa, "mois_max": mm, "n": len(p), "moyenne": np.nan, "mediane": np.nan, "ecart_type": np.nan, "erreur_type": np.nan, "pct_moyen": np.nan})138    return pd.DataFrame(out)139140# ---------------------------------------------------------------- 5. Halvorsen–Palmquist et Kennedy141def hp_kennedy(b: dict) -> pd.DataFrame:142    coef = b["modele"]["coef"]143    rows = []144    for k in ["garage", "sous_sol", "piscine", "etages", "sect_Aylmer", "sect_Gatineau", "sect_Buckingham", "sect_Masson-Angers"]:145        be, se = float(coef.loc[k, "coef"]), float(coef.loc[k, "se_hc1"])146        rows.append({"variable": k, "beta": be, "se": se, "naif_pct": be, "hp_pct": cp.ajustement_depuis_coefficient_log(be),147                     "kennedy_pct": cp.ajustement_depuis_coefficient_log_sans_biais(be, se ** 2)})148    return pd.DataFrame(rows)149150# ---------------------------------------------------------------- 6. grille, brut/net, pondération, réconciliation151def comparables_ajustes(b: dict, adj: pd.DataFrame | None = None) -> list[cp.ComparableAjuste]:152    adj = b["adj"] if adj is None else adj153    return [cp.ComparableAjuste(r["id_vente"], float(r["prix_vente"]), {g: float(r[g]) for g in GROUPES}) for _, r in adj.iterrows()]154155def reconcilier(b: dict, adj: pd.DataFrame | None = None) -> dict:156    ca = comparables_ajustes(b, adj)157    w_brut = cp.poids_inverse_brut(ca)158    return {"comparables": ca, "grille": cp.grille(ca), "poids_inverse_brut": w_brut,159            "egal": cp.reconciliation(ca), "inverse_brut": cp.reconciliation(ca, w_brut)}160161def analyse_qualitative(b: dict) -> pd.DataFrame:162    """Analyse relative : pour chaque élément, le comparable est-il inférieur (ajustement > 0), égal ou supérieur (< 0) au sujet ?163    Le net > 0 signifie « comparable globalement inférieur » : la valeur du sujet devrait dépasser son prix."""164    rows = []165    for _, r in b["adj"].iterrows():166        row = {"id_vente": r["id_vente"], "prix_vente": r["prix_vente"]}167        for g in GROUPES:168            a = r[g]; row[g] = "inf." if a > 1 else ("sup." if a < -1 else "égal")169        row["net"] = r["prix_ajuste"] - r["prix_vente"]170        row["position"] = "inférieur" if row["net"] > 0 else "supérieur"171        rows.append(row)172    q = pd.DataFrame(rows)173    return q174175def encadrement(q: pd.DataFrame) -> dict:176    inf = q[q["position"] == "inférieur"]["prix_vente"]; sup = q[q["position"] == "supérieur"]["prix_vente"]177    return {"borne_basse": float(inf.max()) if len(inf) else float("nan"), "borne_haute": float(sup.min()) if len(sup) else float("nan"),178            "n_inferieurs": int(len(inf)), "n_superieurs": int(len(sup))}179180# ---------------------------------------------------------------- 7. sensibilité181def grille_avec_beta(b: dict, beta_mod: pd.Series) -> pd.DataFrame:182    m = dict(b["modele"]); m["beta"] = beta_mod.values183    return cas1.ajustements_hedoniques(m, b["comps"], b["sujet"])184185def sensibilite(b: dict) -> pd.DataFrame:186    """Valeur pondérée (poids inverse du brut) lorsque les coefficients d'un groupe varient de ±1,96 erreur-type, lorsque le taux187    de croissance change, ou lorsqu'un ajustement est omis."""188    m = b["modele"]; beta = pd.Series(m["beta"], index=m["X"].columns); se = m["coef"]["se_hc1"]189    ref = reconcilier(b)["inverse_brut"]["valeur_ponderee"]190    rows = [{"scenario": "Référence (grille hédonique)", "bas": ref, "haut": ref}]191    for g in ["conditions_marche", "superficie", "terrain", "age", "qualite", "garage", "sous_sol"]:192        vals = []193        for sgn in (-1, 1):194            bm = beta.copy()195            for c in COLS_GROUPES[g]: bm[c] = beta[c] + sgn * 1.96 * se[c]196            vals.append(reconcilier(b, grille_avec_beta(b, bm))["inverse_brut"]["valeur_ponderee"])197        rows.append({"scenario": f"{LIBELLES[g]} : coefficient ±1,96 é.-t.", "bas": min(vals), "haut": max(vals)})198    # taux de croissance imposé (3 % et 6 % par an)199    vals = []200    for taux in (0.03, 0.06):201        bm = beta.copy(); bm["mois"] = math.log(1 + taux) / 12202        vals.append(reconcilier(b, grille_avec_beta(b, bm))["inverse_brut"]["valeur_ponderee"])203    rows.append({"scenario": "Conditions du marché : taux de 3 % à 6 %/an", "bas": min(vals), "haut": max(vals)})204    # omission d'un ajustement205    for g in ["qualite", "age", "garage", "sous_sol", "conditions_marche"]:206        bm = beta.copy()207        for c in COLS_GROUPES[g]: bm[c] = 0.0208        v = reconcilier(b, grille_avec_beta(b, bm))["inverse_brut"]["valeur_ponderee"]209        rows.append({"scenario": f"Omission de l'ajustement « {LIBELLES[g].lower()} »", "bas": min(v, ref), "haut": max(v, ref), "valeur": v})210    # pondération211    rec = reconcilier(b)212    rows.append({"scenario": "Pondération : égale vs inverse du brut", "bas": min(rec["egal"]["valeur_ponderee"], ref), "haut": max(rec["egal"]["valeur_ponderee"], ref)})213    df = pd.DataFrame(rows); df["etendue"] = df["haut"] - df["bas"]214    return df215216# ---------------------------------------------------------------- 8. tout217def tout() -> dict:218    b = base()219    r = {"base": b, "cascade_G0404": cascade(b, "G0404"), "cascade_G0404_ordre_inverse": cascade(b, "G0404", list(reversed(GROUPES))),220         "formes": formes(b), "temps": temps(b), "hp_kennedy": hp_kennedy(b),221         "financement": financement(), "financement_amorti": financement(amortissement_mois=300)}222    r["paires_garage"] = paires(b, "garage_places", **{"superficie_pct": CRITERES["superficie_pct"], "age_ans": CRITERES["age_ans"], "mois_max": CRITERES["mois_max"]})223    r["paires_sous_sol"] = paires(b, "sous_sol_fini", **{"superficie_pct": CRITERES["superficie_pct"], "age_ans": CRITERES["age_ans"], "mois_max": CRITERES["mois_max"]})224    r["resume_garage"] = resume_paires(r["paires_garage"]); r["resume_sous_sol"] = resume_paires(r["paires_sous_sol"])225    r["variantes_garage"] = variantes_paires(b, "garage_places"); r["variantes_sous_sol"] = variantes_paires(b, "sous_sol_fini")226    r["reconciliation"] = reconcilier(b)227    r["qualitatif"] = analyse_qualitative(b); r["encadrement"] = encadrement(r["qualitatif"])228    r["sensibilite"] = sensibilite(b)229    # identité : prix ajusté_j = prédiction ponctuelle (sans Duan) × exp(résidu_j)230    r["identite_residus"] = pd.DataFrame({"id_vente": b["adj"]["id_vente"], "prix_ajuste": b["adj"]["prix_ajuste"],231                                          "point_x_exp_res": b["point_sans_duan"] * np.exp(b["adj"]["residu_log"])})232    # prix implicites hédoniques du garage et du sous-sol, au prix médian des paires et au niveau du sujet233    coef = b["modele"]["coef"]234    for k, attr in (("garage", "garage"), ("sous_sol", "sous_sol")):235        eff = math.exp(coef.loc[k, "coef"]) - 1236        pm = float(r[f"paires_{attr}"]["prix_sans_temps"].median())237        r[f"implicite_{attr}"] = {"effet_pct": eff, "au_prix_median_paires": eff * pm, "prix_median_paires": pm, "au_sujet": eff * b["point_sans_duan"] / (1 + eff)}238    return r239240def main():241    pd.set_option("display.width", 220); pd.set_option("display.max_columns", 30)242    r = tout(); b = r["base"]; m = b["modele"]243    print("== Modèle hédonique de référence : n = %d, k = %d, R² = %.3f, R² ajusté = %.3f, sigma = %.4f" % (m["n"], m["k"], m["r2"], m["r2_ajuste"], m["sigma"]))244    print(m["coef"].round(5).to_string())245    print("== Croissance mensuelle %.4f %% ; taux annuel équivalent %.3f %% ; taux de génération %.3f %%" % (100 * b["g_mensuel"], 100 * b["g_annuel"], 100 * TAUX_GENERATION))246    print("== Grille hédonique (cascade) :\n", b["adj"].round(1).to_string(index=False))247    print("== Moyenne des prix ajustés %.0f ; médiane %.0f ; écart-type %.0f (prix bruts : moyenne %.0f, écart-type %.0f)" % (b["adj"].prix_ajuste.mean(), b["adj"].prix_ajuste.median(), b["adj"].prix_ajuste.std(), b["adj"].prix_vente.mean(), b["adj"].prix_vente.std()))248    print("== Prédiction hédonique (Duan) %.0f ; point sans Duan %.0f ; facteur de Duan %.4f ; IC 95 %% [%.0f ; %.0f]" % (b["prediction_hedonique"], b["point_sans_duan"], b["facteur_duan"], b["intervalle"]["bas"], b["intervalle"]["haut"]))249    print("== Résidus des cinq comparables (log) :", np.round(b["adj"].residu_log.values, 4), "moyenne log %.4f ; moyenne exp(e)−1 %.4f" % (b["adj"].residu_log.mean(), np.mean(np.exp(b["adj"].residu_log)) - 1))250    print("== Identité prix ajusté = point × exp(résidu) :\n", r["identite_residus"].round(1).to_string(index=False))251    print("== Cascade G0404 :\n", r["cascade_G0404"].round(4).to_string(index=False))252    print("== Cascade G0404, ordre inverse (même total, répartition différente) :\n", r["cascade_G0404_ordre_inverse"].round(1).to_string(index=False))253    print("== Formes additive / multiplicative / mixte :\n", r["formes"].round(4).to_string(index=False))254    t = r["temps"]; print("== Ajustement de temps G0404 (%.1f mois) : coefficient %.0f ; taux composé estimé %.0f ; taux de génération 4,43 %% %.0f ; linéaire %.0f ; indices %.0f" % (t["mois"], t["coefficient"], t["taux_compose_estime"], t["taux_compose_generation"], t["lineaire_simple"], t["indices"]))255    fi = r["financement"]; fa = r["financement_amorti"]256    print("== Financement (balance 150 000 $ à 2 %% c. marché 5 %%, 5 ans, intérêts seulement) : intérêt mensuel %.2f ; VA %.0f ; avantage %.0f ; ajustement %.2f %% ; prix comptant %.0f ; variante amortie 25 ans : avantage %.0f" % (fi["paiement_mensuel"], fi["valeur_actualisee"], fi["avantage"], 100 * fi["ajustement_pct"], fi["prix_comptant"], fa["avantage"]))257    for attr in ("garage", "sous_sol"):258        rs = r[f"resume_{attr}"]; im = r[f"implicite_{attr}"]259        print("== Paires %s : n = %d, moyenne %.0f, médiane %.0f, écart-type %.0f, erreur-type %.0f, pct moyen %.2f %% ; prix implicite hédonique %.2f %% soit %.0f au prix médian des paires (%.0f) et %.0f au niveau du sujet" % (attr, rs["n"], rs["moyenne"], rs["mediane"], rs["ecart_type"], rs["erreur_type"], 100 * rs["pct_moyen"], 100 * im["effet_pct"], im["au_prix_median_paires"], im["prix_median_paires"], im["au_sujet"]))260        print(r[f"variantes_{attr}"].round(2).to_string(index=False))261    print("== Halvorsen–Palmquist / Kennedy :\n", r["hp_kennedy"].round(5).to_string(index=False))262    rec = r["reconciliation"]263    print("== Poids inverse du brut :", np.round(rec["poids_inverse_brut"], 3))264    print("== Réconciliation égale :", {k: (round(v, 1) if isinstance(v, float) else v) for k, v in rec["egal"].items() if k != "poids"})265    print("== Réconciliation inverse du brut :", {k: (round(v, 1) if isinstance(v, float) else v) for k, v in rec["inverse_brut"].items() if k != "poids"})266    print("== Analyse qualitative :\n", r["qualitatif"].to_string(index=False)); print("== Encadrement :", r["encadrement"])267    print("== Sensibilité :\n", r["sensibilite"].round(0).to_string(index=False))268    return r269270if __name__ == "__main__":271    main()272
Files · 031fae1bd368de12dc60ff542f1be9a8fd73e699
Menu

Theme