spb/traite-evaluation-qc
Public
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# ---------------------------------------------------------------- 4. ventes appariées83CRITERES = {"superficie_pct": 0.05, "age_ans": 5, "mois_max": 12}8485def paires(b: dict, attribut: str, superficie_pct: float = 0.05, age_ans: float = 5, mois_max: float = 12,86 memes: tuple = ("secteur", "type", "qualite_1a5", "piscine")) -> pd.DataFrame:87 """Paires (avec, sans) construites dans les 600 ventes : même secteur, même type, même qualité, même piscine, autre attribut88 binaire identique, écart d'exactement une unité sur l'attribut étudié, superficie ±superficie_pct, âge ±age_ans, dates à89 moins de mois_max mois. La vente « sans » est ramenée à la date de la vente « avec » par le taux mensuel du modèle."""90 v = b["ventes"]; g = b["g_mensuel"]91 autres = [c for c in ["garage_places", "sous_sol_fini"] if c != attribut]92 rows = []93 for _, r in v.iterrows():94 m = v[v[attribut] == r[attribut] - 1]95 for c in list(memes) + autres:96 m = m[m[c] == r[c]]97 m = m[(abs(m["superficie_habitable_m2"] / r["superficie_habitable_m2"] - 1) <= superficie_pct) & (abs(m["age"] - r["age"]) <= age_ans)98 & (abs((m["date_vente"] - r["date_vente"]).dt.days) <= mois_max * 30.4375)]99 for _, q in m.iterrows():100 dm = (r["date_vente"] - q["date_vente"]).days / 30.4375101 q_t = q["prix_vente"] * (1 + g) ** dm102 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,103 "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,104 "superficie_avec": r["superficie_habitable_m2"], "superficie_sans": q["superficie_habitable_m2"], "secteur": r["secteur"]})105 return pd.DataFrame(rows)106107def resume_paires(p: pd.DataFrame) -> dict:108 r = cp.ventes_appariees(list(p["prix_avec"]), list(p["prix_sans_temps"]))109 r["erreur_type"] = r["ecart_type"] / math.sqrt(r["n"]) if r["n"] > 1 else float("nan")110 r["pct_moyen"] = float(p["pct"].mean()); r["pct_median"] = float(p["pct"].median())111 return r112113def variantes_paires(b: dict, attribut: str) -> pd.DataFrame:114 out = []115 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)]:116 p = paires(b, attribut, sp, aa, mm)117 if len(p) > 1:118 r = resume_paires(p)119 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"]})120 else:121 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})122 return pd.DataFrame(out)123124# ---------------------------------------------------------------- 5. Halvorsen–Palmquist et Kennedy125def hp_kennedy(b: dict) -> pd.DataFrame:126 coef = b["modele"]["coef"]127 rows = []128 for k in ["garage", "sous_sol", "piscine", "etages", "sect_Aylmer", "sect_Gatineau", "sect_Buckingham", "sect_Masson-Angers"]:129 be, se = float(coef.loc[k, "coef"]), float(coef.loc[k, "se_hc1"])130 rows.append({"variable": k, "beta": be, "se": se, "naif_pct": be, "hp_pct": cp.ajustement_depuis_coefficient_log(be),131 "kennedy_pct": cp.ajustement_depuis_coefficient_log_sans_biais(be, se ** 2)})132 return pd.DataFrame(rows)133134# ---------------------------------------------------------------- 6. grille, brut/net, pondération, réconciliation135def comparables_ajustes(b: dict, adj: pd.DataFrame | None = None) -> list[cp.ComparableAjuste]:136 adj = b["adj"] if adj is None else adj137 return [cp.ComparableAjuste(r["id_vente"], float(r["prix_vente"]), {g: float(r[g]) for g in GROUPES}) for _, r in adj.iterrows()]138139def reconcilier(b: dict, adj: pd.DataFrame | None = None) -> dict:140 ca = comparables_ajustes(b, adj)141 w_brut = cp.poids_inverse_brut(ca)142 return {"comparables": ca, "grille": cp.grille(ca), "poids_inverse_brut": w_brut,143 "egal": cp.reconciliation(ca), "inverse_brut": cp.reconciliation(ca, w_brut)}144145def analyse_qualitative(b: dict) -> pd.DataFrame:146 """Analyse relative : pour chaque élément, le comparable est-il inférieur (ajustement > 0), égal ou supérieur (< 0) au sujet ?147 Le net > 0 signifie « comparable globalement inférieur » : la valeur du sujet devrait dépasser son prix."""148 rows = []149 for _, r in b["adj"].iterrows():150 row = {"id_vente": r["id_vente"], "prix_vente": r["prix_vente"]}151 for g in GROUPES:152 a = r[g]; row[g] = "inf." if a > 1 else ("sup." if a < -1 else "égal")153 row["net"] = r["prix_ajuste"] - r["prix_vente"]154 row["position"] = "inférieur" if row["net"] > 0 else "supérieur"155 rows.append(row)156 q = pd.DataFrame(rows)157 return q158159def encadrement(q: pd.DataFrame) -> dict:160 inf = q[q["position"] == "inférieur"]["prix_vente"]; sup = q[q["position"] == "supérieur"]["prix_vente"]161 return {"borne_basse": float(inf.max()) if len(inf) else float("nan"), "borne_haute": float(sup.min()) if len(sup) else float("nan"),162 "n_inferieurs": int(len(inf)), "n_superieurs": int(len(sup))}163164# ---------------------------------------------------------------- 7. sensibilité165def grille_avec_beta(b: dict, beta_mod: pd.Series) -> pd.DataFrame:166 m = dict(b["modele"]); m["beta"] = beta_mod.values167 return cas1.ajustements_hedoniques(m, b["comps"], b["sujet"])168169def sensibilite(b: dict) -> pd.DataFrame:170 """Valeur pondérée (poids inverse du brut) lorsque les coefficients d'un groupe varient de ±1,96 erreur-type, lorsque le taux171 de croissance change, ou lorsqu'un ajustement est omis."""172 m = b["modele"]; beta = pd.Series(m["beta"], index=m["X"].columns); se = m["coef"]["se_hc1"]173 ref = reconcilier(b)["inverse_brut"]["valeur_ponderee"]174 rows = [{"scenario": "Référence (grille hédonique)", "bas": ref, "haut": ref}]175 for g in ["conditions_marche", "superficie", "terrain", "age", "qualite", "garage", "sous_sol"]:176 vals = []177 for sgn in (-1, 1):178 bm = beta.copy()179 for c in COLS_GROUPES[g]: bm[c] = beta[c] + sgn * 1.96 * se[c]180 vals.append(reconcilier(b, grille_avec_beta(b, bm))["inverse_brut"]["valeur_ponderee"])181 rows.append({"scenario": f"{LIBELLES[g]} : coefficient ±1,96 é.-t.", "bas": min(vals), "haut": max(vals)})182 # taux de croissance imposé (3 % et 6 % par an)183 vals = []184 for taux in (0.03, 0.06):185 bm = beta.copy(); bm["mois"] = math.log(1 + taux) / 12186 vals.append(reconcilier(b, grille_avec_beta(b, bm))["inverse_brut"]["valeur_ponderee"])187 rows.append({"scenario": "Conditions du marché : taux de 3 % à 6 %/an", "bas": min(vals), "haut": max(vals)})188 # omission d'un ajustement189 for g in ["qualite", "age", "garage", "sous_sol", "conditions_marche"]:190 bm = beta.copy()191 for c in COLS_GROUPES[g]: bm[c] = 0.0192 v = reconcilier(b, grille_avec_beta(b, bm))["inverse_brut"]["valeur_ponderee"]193 rows.append({"scenario": f"Omission de l'ajustement « {LIBELLES[g].lower()} »", "bas": min(v, ref), "haut": max(v, ref), "valeur": v})194 # pondération195 rec = reconcilier(b)196 rows.append({"scenario": "Pondération : égale vs inverse du brut", "bas": min(rec["egal"]["valeur_ponderee"], ref), "haut": max(rec["egal"]["valeur_ponderee"], ref)})197 df = pd.DataFrame(rows); df["etendue"] = df["haut"] - df["bas"]198 return df199200# ---------------------------------------------------------------- 8. tout201def tout() -> dict:202 b = base()203 r = {"base": b, "cascade_G0404": cascade(b, "G0404"), "cascade_G0404_ordre_inverse": cascade(b, "G0404", list(reversed(GROUPES))),204 "formes": formes(b), "temps": temps(b), "hp_kennedy": hp_kennedy(b)}205 r["paires_garage"] = paires(b, "garage_places", **{"superficie_pct": CRITERES["superficie_pct"], "age_ans": CRITERES["age_ans"], "mois_max": CRITERES["mois_max"]})206 r["paires_sous_sol"] = paires(b, "sous_sol_fini", **{"superficie_pct": CRITERES["superficie_pct"], "age_ans": CRITERES["age_ans"], "mois_max": CRITERES["mois_max"]})207 r["resume_garage"] = resume_paires(r["paires_garage"]); r["resume_sous_sol"] = resume_paires(r["paires_sous_sol"])208 r["variantes_garage"] = variantes_paires(b, "garage_places"); r["variantes_sous_sol"] = variantes_paires(b, "sous_sol_fini")209 r["reconciliation"] = reconcilier(b)210 r["qualitatif"] = analyse_qualitative(b); r["encadrement"] = encadrement(r["qualitatif"])211 r["sensibilite"] = sensibilite(b)212 # identité : prix ajusté_j = prédiction ponctuelle (sans Duan) × exp(résidu_j)213 r["identite_residus"] = pd.DataFrame({"id_vente": b["adj"]["id_vente"], "prix_ajuste": b["adj"]["prix_ajuste"],214 "point_x_exp_res": b["point_sans_duan"] * np.exp(b["adj"]["residu_log"])})215 # prix implicites hédoniques du garage et du sous-sol, au prix médian des paires et au niveau du sujet216 coef = b["modele"]["coef"]217 for k, attr in (("garage", "garage"), ("sous_sol", "sous_sol")):218 eff = math.exp(coef.loc[k, "coef"]) - 1219 pm = float(r[f"paires_{attr}"]["prix_sans_temps"].median())220 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)}221 return r222223def main():224 pd.set_option("display.width", 220); pd.set_option("display.max_columns", 30)225 r = tout(); b = r["base"]; m = b["modele"]226 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"]))227 print(m["coef"].round(5).to_string())228 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))229 print("== Grille hédonique (cascade) :\n", b["adj"].round(1).to_string(index=False))230 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()))231 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"]))232 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))233 print("== Identité prix ajusté = point × exp(résidu) :\n", r["identite_residus"].round(1).to_string(index=False))234 print("== Cascade G0404 :\n", r["cascade_G0404"].round(4).to_string(index=False))235 print("== Cascade G0404, ordre inverse (même total, répartition différente) :\n", r["cascade_G0404_ordre_inverse"].round(1).to_string(index=False))236 print("== Formes additive / multiplicative / mixte :\n", r["formes"].round(4).to_string(index=False))237 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"]))238 for attr in ("garage", "sous_sol"):239 rs = r[f"resume_{attr}"]; im = r[f"implicite_{attr}"]240 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"]))241 print(r[f"variantes_{attr}"].round(2).to_string(index=False))242 print("== Halvorsen–Palmquist / Kennedy :\n", r["hp_kennedy"].round(5).to_string(index=False))243 rec = r["reconciliation"]244 print("== Poids inverse du brut :", np.round(rec["poids_inverse_brut"], 3))245 print("== Réconciliation égale :", {k: (round(v, 1) if isinstance(v, float) else v) for k, v in rec["egal"].items() if k != "poids"})246 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"})247 print("== Analyse qualitative :\n", r["qualitatif"].to_string(index=False)); print("== Encadrement :", r["encadrement"])248 print("== Sensibilité :\n", r["sensibilite"].round(0).to_string(index=False))249 return r250251if __name__ == "__main__":252 main()253