#!/usr/bin/env python3 """Chapitre 17 — ajustements qualitatifs et quantitatifs des cinq comparables du cas 1 (Gatineau, données synthétiques). Reprend exactement la sélection du chapitre 16 (traite.cas1.selection) et la grille d'ajustements dérivée du modèle hédonique de référence (traite.cas1.ajustements_hedoniques). Imprime tous les chiffres cités dans le chapitre. Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch17_ajustements.py""" from __future__ import annotations import sys, pathlib, math import numpy as np, pandas as pd sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) from traite import cas1 from traite import comparables as cp ROOT = pathlib.Path(__file__).resolve().parents[3] GROUPES = ["conditions_marche", "localisation", "superficie", "terrain", "age", "qualite", "garage", "sous_sol", "piscine", "type"] LIBELLES = {"conditions_marche": "Conditions du marché", "localisation": "Localisation", "superficie": "Superficie habitable", "terrain": "Terrain", "age": "Âge", "qualite": "Qualité", "garage": "Garage", "sous_sol": "Sous-sol fini", "piscine": "Piscine", "type": "Type de bâtiment"} COLS_GROUPES = {"conditions_marche": ["mois"], "localisation": ["dist_centre"] + [f"sect_{x}" for x in cas1.SECTEURS[1:]], "superficie": ["ln_S"], "terrain": ["ln_T"], "age": ["age", "age2_100"], "qualite": ["qualite"], "garage": ["garage"], "sous_sol": ["sous_sol"], "piscine": ["piscine"], "type": ["etages"]} TAUX_GENERATION = 1.0906 ** 0.5 - 1 # 9,06 % sur deux ans (rôle 2027-2029 de Gatineau) → 4,43 %/an composé (loi du jeu) # ---------------------------------------------------------------- 1. base : modèle, comparables, grille hédonique def base() -> dict: t = cas1.tout() m = t["modele"]; comps = t["selection"]["comparables"]; s = t["sujet"] adj = t["ajustements"].copy() res = pd.Series(m["residus"], index=t["ventes"].index).loc[comps.index].values adj["residu_log"] = res adj["residu_pct"] = np.exp(res) - 1 g = m["croissance_mensuelle"] return {**t, "comps": comps, "adj": adj, "g_mensuel": g, "g_annuel": (1 + g) ** 12 - 1, "point_sans_duan": float(t["intervalle"]["point"]), "facteur_duan": float(np.mean(np.exp(m["residus"])))} # ---------------------------------------------------------------- 2. cascade d'un comparable et variantes de forme def dlogs(b: dict, comp_index) -> dict: """Δln par groupe : β_k (x_s − x_c) pour chaque groupe d'ajustement.""" m = b["modele"]; beta = pd.Series(m["beta"], index=m["X"].columns) Xs = cas1.matrice(cas1.sujet_df(b["sujet"])).iloc[0] Xc = cas1.matrice(b["comps"]).loc[comp_index] return {g: float(sum(beta[c] * (Xs[c] - Xc[c]) for c in cols)) for g, cols in COLS_GROUPES.items()} def cascade(b: dict, id_vente: str, ordre: list[str] | None = None) -> pd.DataFrame: """Cascade multiplicative : à chaque étape A_k = base × (exp(Δln_k) − 1), base ← base + A_k.""" ordre = ordre or GROUPES idx = b["comps"].index[b["comps"]["id_vente"] == id_vente][0] d = dlogs(b, idx); base_ = float(b["comps"].loc[idx, "prix_vente"]) rows = [{"etape": "Prix de vente", "dlog": 0.0, "pct": 0.0, "ajustement": 0.0, "base_apres": base_}] for g in ordre: a = base_ * (math.exp(d[g]) - 1); base_ += a rows.append({"etape": LIBELLES[g], "dlog": d[g], "pct": math.exp(d[g]) - 1, "ajustement": a, "base_apres": base_}) 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_}) return pd.DataFrame(rows) def formes(b: dict) -> pd.DataFrame: """Trois façons d'agréger les mêmes effets : additif sur le prix brut, multiplicatif (cascade), mixte (temps en %, reste en $).""" out = [] for idx, c in b["comps"].iterrows(): d = dlogs(b, idx); P = float(c["prix_vente"]) effets = {g: math.exp(v) - 1 for g, v in d.items()} additif = cp.prix_ajuste_additif(P, {g: P * e for g, e in effets.items()}) multiplicatif = cp.prix_ajuste_multiplicatif(P, list(effets.values())) base_t = P * (1 + effets["conditions_marche"]) 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"] out.append({"id_vente": c["id_vente"], "prix_vente": P, "additif": additif, "multiplicatif": multiplicatif, "mixte": mixte, "ecart_additif_pct": additif / multiplicatif - 1, "ecart_mixte_pct": mixte / multiplicatif - 1, "somme_dlog": sum(d.values())}) return pd.DataFrame(out) # ---------------------------------------------------------------- 3. ajustement de temps def temps(b: dict, id_vente: str = "G0404") -> dict: c = b["comps"][b["comps"]["id_vente"] == id_vente].iloc[0] P = float(c["prix_vente"]); mois = (pd.Timestamp(b["sujet"]["date_evaluation"]) - c["date_vente"]).days / 30.4375 g = b["g_mensuel"] return {"id_vente": id_vente, "prix": P, "mois": mois, "coefficient": P * (math.exp(math.log(1 + g) * mois) - 1), "taux_compose_estime": cp.ajustement_temps_taux(P, b["g_annuel"], mois), "taux_compose_generation": cp.ajustement_temps_taux(P, TAUX_GENERATION, mois), "lineaire_simple": P * g * mois, "indices": cp.ajustement_temps(P, 100.0, 100.0 * (1 + b["g_annuel"]) ** (mois / 12)), "g_mensuel": g, "g_annuel": b["g_annuel"], "taux_generation": TAUX_GENERATION} # ---------------------------------------------------------------- 4. ventes appariées CRITERES = {"superficie_pct": 0.05, "age_ans": 5, "mois_max": 12} def paires(b: dict, attribut: str, superficie_pct: float = 0.05, age_ans: float = 5, mois_max: float = 12, memes: tuple = ("secteur", "type", "qualite_1a5", "piscine")) -> pd.DataFrame: """Paires (avec, sans) construites dans les 600 ventes : même secteur, même type, même qualité, même piscine, autre attribut binaire identique, écart d'exactement une unité sur l'attribut étudié, superficie ±superficie_pct, âge ±age_ans, dates à moins de mois_max mois. La vente « sans » est ramenée à la date de la vente « avec » par le taux mensuel du modèle.""" v = b["ventes"]; g = b["g_mensuel"] autres = [c for c in ["garage_places", "sous_sol_fini"] if c != attribut] rows = [] for _, r in v.iterrows(): m = v[v[attribut] == r[attribut] - 1] for c in list(memes) + autres: m = m[m[c] == r[c]] m = m[(abs(m["superficie_habitable_m2"] / r["superficie_habitable_m2"] - 1) <= superficie_pct) & (abs(m["age"] - r["age"]) <= age_ans) & (abs((m["date_vente"] - r["date_vente"]).dt.days) <= mois_max * 30.4375)] for _, q in m.iterrows(): dm = (r["date_vente"] - q["date_vente"]).days / 30.4375 q_t = q["prix_vente"] * (1 + g) ** dm 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, "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, "superficie_avec": r["superficie_habitable_m2"], "superficie_sans": q["superficie_habitable_m2"], "secteur": r["secteur"]}) return pd.DataFrame(rows) def resume_paires(p: pd.DataFrame) -> dict: r = cp.ventes_appariees(list(p["prix_avec"]), list(p["prix_sans_temps"])) r["erreur_type"] = r["ecart_type"] / math.sqrt(r["n"]) if r["n"] > 1 else float("nan") r["pct_moyen"] = float(p["pct"].mean()); r["pct_median"] = float(p["pct"].median()) return r def variantes_paires(b: dict, attribut: str) -> pd.DataFrame: out = [] 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)]: p = paires(b, attribut, sp, aa, mm) if len(p) > 1: r = resume_paires(p) 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"]}) else: 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}) return pd.DataFrame(out) # ---------------------------------------------------------------- 5. Halvorsen–Palmquist et Kennedy def hp_kennedy(b: dict) -> pd.DataFrame: coef = b["modele"]["coef"] rows = [] for k in ["garage", "sous_sol", "piscine", "etages", "sect_Aylmer", "sect_Gatineau", "sect_Buckingham", "sect_Masson-Angers"]: be, se = float(coef.loc[k, "coef"]), float(coef.loc[k, "se_hc1"]) rows.append({"variable": k, "beta": be, "se": se, "naif_pct": be, "hp_pct": cp.ajustement_depuis_coefficient_log(be), "kennedy_pct": cp.ajustement_depuis_coefficient_log_sans_biais(be, se ** 2)}) return pd.DataFrame(rows) # ---------------------------------------------------------------- 6. grille, brut/net, pondération, réconciliation def comparables_ajustes(b: dict, adj: pd.DataFrame | None = None) -> list[cp.ComparableAjuste]: adj = b["adj"] if adj is None else adj return [cp.ComparableAjuste(r["id_vente"], float(r["prix_vente"]), {g: float(r[g]) for g in GROUPES}) for _, r in adj.iterrows()] def reconcilier(b: dict, adj: pd.DataFrame | None = None) -> dict: ca = comparables_ajustes(b, adj) w_brut = cp.poids_inverse_brut(ca) return {"comparables": ca, "grille": cp.grille(ca), "poids_inverse_brut": w_brut, "egal": cp.reconciliation(ca), "inverse_brut": cp.reconciliation(ca, w_brut)} def analyse_qualitative(b: dict) -> pd.DataFrame: """Analyse relative : pour chaque élément, le comparable est-il inférieur (ajustement > 0), égal ou supérieur (< 0) au sujet ? Le net > 0 signifie « comparable globalement inférieur » : la valeur du sujet devrait dépasser son prix.""" rows = [] for _, r in b["adj"].iterrows(): row = {"id_vente": r["id_vente"], "prix_vente": r["prix_vente"]} for g in GROUPES: a = r[g]; row[g] = "inf." if a > 1 else ("sup." if a < -1 else "égal") row["net"] = r["prix_ajuste"] - r["prix_vente"] row["position"] = "inférieur" if row["net"] > 0 else "supérieur" rows.append(row) q = pd.DataFrame(rows) return q def encadrement(q: pd.DataFrame) -> dict: inf = q[q["position"] == "inférieur"]["prix_vente"]; sup = q[q["position"] == "supérieur"]["prix_vente"] return {"borne_basse": float(inf.max()) if len(inf) else float("nan"), "borne_haute": float(sup.min()) if len(sup) else float("nan"), "n_inferieurs": int(len(inf)), "n_superieurs": int(len(sup))} # ---------------------------------------------------------------- 7. sensibilité def grille_avec_beta(b: dict, beta_mod: pd.Series) -> pd.DataFrame: m = dict(b["modele"]); m["beta"] = beta_mod.values return cas1.ajustements_hedoniques(m, b["comps"], b["sujet"]) def sensibilite(b: dict) -> pd.DataFrame: """Valeur pondérée (poids inverse du brut) lorsque les coefficients d'un groupe varient de ±1,96 erreur-type, lorsque le taux de croissance change, ou lorsqu'un ajustement est omis.""" m = b["modele"]; beta = pd.Series(m["beta"], index=m["X"].columns); se = m["coef"]["se_hc1"] ref = reconcilier(b)["inverse_brut"]["valeur_ponderee"] rows = [{"scenario": "Référence (grille hédonique)", "bas": ref, "haut": ref}] for g in ["conditions_marche", "superficie", "terrain", "age", "qualite", "garage", "sous_sol"]: vals = [] for sgn in (-1, 1): bm = beta.copy() for c in COLS_GROUPES[g]: bm[c] = beta[c] + sgn * 1.96 * se[c] vals.append(reconcilier(b, grille_avec_beta(b, bm))["inverse_brut"]["valeur_ponderee"]) rows.append({"scenario": f"{LIBELLES[g]} : coefficient ±1,96 é.-t.", "bas": min(vals), "haut": max(vals)}) # taux de croissance imposé (3 % et 6 % par an) vals = [] for taux in (0.03, 0.06): bm = beta.copy(); bm["mois"] = math.log(1 + taux) / 12 vals.append(reconcilier(b, grille_avec_beta(b, bm))["inverse_brut"]["valeur_ponderee"]) rows.append({"scenario": "Conditions du marché : taux de 3 % à 6 %/an", "bas": min(vals), "haut": max(vals)}) # omission d'un ajustement for g in ["qualite", "age", "garage", "sous_sol", "conditions_marche"]: bm = beta.copy() for c in COLS_GROUPES[g]: bm[c] = 0.0 v = reconcilier(b, grille_avec_beta(b, bm))["inverse_brut"]["valeur_ponderee"] rows.append({"scenario": f"Omission de l'ajustement « {LIBELLES[g].lower()} »", "bas": min(v, ref), "haut": max(v, ref), "valeur": v}) # pondération rec = reconcilier(b) rows.append({"scenario": "Pondération : égale vs inverse du brut", "bas": min(rec["egal"]["valeur_ponderee"], ref), "haut": max(rec["egal"]["valeur_ponderee"], ref)}) df = pd.DataFrame(rows); df["etendue"] = df["haut"] - df["bas"] return df # ---------------------------------------------------------------- 8. tout def tout() -> dict: b = base() r = {"base": b, "cascade_G0404": cascade(b, "G0404"), "cascade_G0404_ordre_inverse": cascade(b, "G0404", list(reversed(GROUPES))), "formes": formes(b), "temps": temps(b), "hp_kennedy": hp_kennedy(b)} r["paires_garage"] = paires(b, "garage_places", **{"superficie_pct": CRITERES["superficie_pct"], "age_ans": CRITERES["age_ans"], "mois_max": CRITERES["mois_max"]}) r["paires_sous_sol"] = paires(b, "sous_sol_fini", **{"superficie_pct": CRITERES["superficie_pct"], "age_ans": CRITERES["age_ans"], "mois_max": CRITERES["mois_max"]}) r["resume_garage"] = resume_paires(r["paires_garage"]); r["resume_sous_sol"] = resume_paires(r["paires_sous_sol"]) r["variantes_garage"] = variantes_paires(b, "garage_places"); r["variantes_sous_sol"] = variantes_paires(b, "sous_sol_fini") r["reconciliation"] = reconcilier(b) r["qualitatif"] = analyse_qualitative(b); r["encadrement"] = encadrement(r["qualitatif"]) r["sensibilite"] = sensibilite(b) # identité : prix ajusté_j = prédiction ponctuelle (sans Duan) × exp(résidu_j) r["identite_residus"] = pd.DataFrame({"id_vente": b["adj"]["id_vente"], "prix_ajuste": b["adj"]["prix_ajuste"], "point_x_exp_res": b["point_sans_duan"] * np.exp(b["adj"]["residu_log"])}) # prix implicites hédoniques du garage et du sous-sol, au prix médian des paires et au niveau du sujet coef = b["modele"]["coef"] for k, attr in (("garage", "garage"), ("sous_sol", "sous_sol")): eff = math.exp(coef.loc[k, "coef"]) - 1 pm = float(r[f"paires_{attr}"]["prix_sans_temps"].median()) 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)} return r def main(): pd.set_option("display.width", 220); pd.set_option("display.max_columns", 30) r = tout(); b = r["base"]; m = b["modele"] 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"])) print(m["coef"].round(5).to_string()) 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)) print("== Grille hédonique (cascade) :\n", b["adj"].round(1).to_string(index=False)) 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())) 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"])) 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)) print("== Identité prix ajusté = point × exp(résidu) :\n", r["identite_residus"].round(1).to_string(index=False)) print("== Cascade G0404 :\n", r["cascade_G0404"].round(4).to_string(index=False)) print("== Cascade G0404, ordre inverse (même total, répartition différente) :\n", r["cascade_G0404_ordre_inverse"].round(1).to_string(index=False)) print("== Formes additive / multiplicative / mixte :\n", r["formes"].round(4).to_string(index=False)) 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"])) for attr in ("garage", "sous_sol"): rs = r[f"resume_{attr}"]; im = r[f"implicite_{attr}"] 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"])) print(r[f"variantes_{attr}"].round(2).to_string(index=False)) print("== Halvorsen–Palmquist / Kennedy :\n", r["hp_kennedy"].round(5).to_string(index=False)) rec = r["reconciliation"] print("== Poids inverse du brut :", np.round(rec["poids_inverse_brut"], 3)) print("== Réconciliation égale :", {k: (round(v, 1) if isinstance(v, float) else v) for k, v in rec["egal"].items() if k != "poids"}) 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"}) print("== Analyse qualitative :\n", r["qualitatif"].to_string(index=False)); print("== Encadrement :", r["encadrement"]) print("== Sensibilité :\n", r["sensibilite"].round(0).to_string(index=False)) return r if __name__ == "__main__": main()