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# ---------------------------------------------------------------- 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