#!/usr/bin/env python3 """Chapitre 16 — sélection des comparables pour le sujet du cas 1 (Gatineau, données synthétiques). Imprime tous les chiffres cités dans le chapitre et produit les figures du chapitre 16. Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch16_selection.py [--figures]""" from __future__ import annotations import sys, json, pathlib, datetime import numpy as np, pandas as pd sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) from traite import comparables as cp ROOT = pathlib.Path(__file__).resolve().parents[3] D = ROOT / "data/synthetic" ECHELLES = {"superficie_habitable_m2": 25.0, "superficie_terrain_m2": 150.0, "age": 10.0, "qualite_1a5": 1.0, "garage_places": 1.0, "sous_sol_fini": 1.0, "dist_km": 1.5, "mois": 6.0} POIDS = {"superficie_habitable_m2": 2.0, "superficie_terrain_m2": 0.7, "age": 1.0, "qualite_1a5": 1.0, "garage_places": 0.5, "sous_sol_fini": 0.3, "dist_km": 1.5, "mois": 1.0} def charger(): v = pd.read_csv(D / "cas1_gatineau_ventes.csv", parse_dates=["date_vente"]) s = json.loads((D / "cas1_gatineau_sujet.json").read_text()) return v, s def preparer(v: pd.DataFrame, s: dict) -> pd.DataFrame: """Étape 1 : filtres qualitatifs (marché, délai, type) ; étape 2 : distance d'attributs.""" date_eval = pd.Timestamp(s["date_evaluation"]) v = v.copy() v["mois"] = (date_eval - v["date_vente"]).dt.days / 30.4375 v["dist_km"] = np.sqrt(((v["lat"] - s["lat"]) * 111) ** 2 + ((v["lon"] - s["lon"]) * 78) ** 2) v["prix_m2"] = v["prix_vente"] / v["superficie_habitable_m2"] suj = dict(s); suj["mois"] = 0.0; suj["dist_km"] = 0.0 v["distance"] = [cp.distance_attributs(suj, r, ECHELLES, POIDS) for r in v.to_dict("records")] return v def selectionner(v: pd.DataFrame, s: dict, k: int = 5, mois_max: float = 18, dist_max: float = 3.0, meme_type: bool = True) -> dict: f = v[(v["mois"] <= mois_max) & (v["mois"] >= 0) & (v["dist_km"] <= dist_max)] etape = {"candidats_total": len(v), "apres_delai": int((v["mois"].between(0, mois_max)).sum()), "apres_rayon": len(f)} if meme_type: f = f[f["type"] == s["type"]]; etape["apres_type"] = len(f) f = f[(f["superficie_habitable_m2"].between(0.75 * s["superficie_habitable_m2"], 1.25 * s["superficie_habitable_m2"]))] etape["apres_superficie"] = len(f) f = f.sort_values("distance") top = f.head(k) return {"etapes": etape, "candidats": f, "comparables": top} def main(figures: bool = False): v, s = charger(); v = preparer(v, s) sel = selectionner(v, s) top = sel["comparables"] print("== Sujet :", {k: s[k] for k in ["secteur", "type", "superficie_habitable_m2", "superficie_terrain_m2", "annee_construction", "qualite_1a5", "garage_places", "sous_sol_fini"]}) print("== Entonnoir :", sel["etapes"]) cols = ["id_vente", "date_vente", "secteur", "superficie_habitable_m2", "superficie_terrain_m2", "annee_construction", "qualite_1a5", "garage_places", "sous_sol_fini", "dist_km", "mois", "prix_vente", "prix_m2", "distance"] pd.set_option("display.width", 200) print("== Comparables retenus :\n", top[cols].round(2).to_string(index=False)) # unités de comparaison cand = sel["candidats"] print(f"== $/m² : médiane candidats {cand['prix_m2'].median():.0f}, min {cand['prix_m2'].min():.0f}, max {cand['prix_m2'].max():.0f} ; comparables retenus médiane {top['prix_m2'].median():.0f}") ind_m2 = top["prix_m2"].median() * s["superficie_habitable_m2"] print(f"== Indication brute par $/m² médian × {s['superficie_habitable_m2']} m² = {ind_m2:,.0f} $") print(f"== Fourchette des prix bruts des comparables : {top['prix_vente'].min():,.0f} – {top['prix_vente'].max():,.0f} ; moyenne {top['prix_vente'].mean():,.0f} ; médiane {top['prix_vente'].median():,.0f}") # sensibilité : k et rayon sens = [] for k in [3, 4, 5, 6, 8, 10]: for r in [1.5, 3.0, 5.0]: for m in [12, 18, 24]: t = selectionner(v, s, k, m, r)["comparables"] if len(t) >= 3: sens.append({"k": k, "rayon_km": r, "mois_max": m, "n": len(t), "mediane_prix": t["prix_vente"].median(), "mediane_prix_m2": t["prix_m2"].median(), "indication_m2": t["prix_m2"].median() * s["superficie_habitable_m2"]}) sens = pd.DataFrame(sens) print("== Sensibilité (indication par $/m²) : min %.0f, max %.0f, écart-type %.0f, médiane %.0f" % (sens.indication_m2.min(), sens.indication_m2.max(), sens.indication_m2.std(), sens.indication_m2.median())) print(sens.groupby("k").indication_m2.agg(["min", "median", "max"]).round(0)) # rang des 5 retenus selon une autre pondération (robustesse) if figures: faire_figures(v, s, sel, sens) return {"sujet": s, "ventes": v, "selection": sel, "sensibilite": sens} def faire_figures(v, s, sel, sens): import matplotlib.pyplot as plt from traite.style import setup, palette, savefig, LARGEUR, DEMI, fmt_dollars, fmt_milliers from matplotlib.ticker import FuncFormatter setup() top = sel["comparables"]; cand = sel["candidats"] # Fig 1 : nuage prix × superficie, candidats vs retenus vs sujet fig, ax = plt.subplots(figsize=(LARGEUR, 3.6)) ax.scatter(v["superficie_habitable_m2"], v["prix_vente"], s=8, color=palette.grid, label="Toutes les ventes (n = %d)" % len(v), zorder=1) ax.scatter(cand["superficie_habitable_m2"], cand["prix_vente"], s=14, color=palette.cat[0], alpha=0.8, label="Candidats après filtres (n = %d)" % len(cand), zorder=2) ax.scatter(top["superficie_habitable_m2"], top["prix_vente"], s=46, facecolor="none", edgecolor=palette.cat[1], linewidth=1.6, label="Comparables retenus (n = %d)" % len(top), zorder=3) ax.axvline(s["superficie_habitable_m2"], color=palette.ink2, ls="--", lw=0.9) ax.annotate("Sujet : %d m²" % s["superficie_habitable_m2"], (s["superficie_habitable_m2"], ax.get_ylim()[1] * 0.97), xytext=(6, -2), textcoords="offset points", fontsize=8, color=palette.ink2, va="top") ax.set_xlabel("Superficie habitable (m²)"); ax.set_ylabel("Prix de vente") ax.yaxis.set_major_formatter(FuncFormatter(fmt_dollars)); ax.legend(loc="lower right", fontsize=7.5) savefig(fig, "ch16/ch16-fig-nuage-selection.pdf") # Fig 2 : carte schématique des ventes (coordonnées synthétiques) avec rayon de recherche fig, ax = plt.subplots(figsize=(LARGEUR, 4.0)) for i, sec in enumerate(["Hull", "Gatineau", "Aylmer", "Buckingham", "Masson-Angers"]): w = v[v["secteur"] == sec] ax.scatter(w["lon"], w["lat"], s=7, color=palette.cat[i], alpha=0.55, label=sec) ax.scatter(top["lon"], top["lat"], s=50, facecolor="none", edgecolor=palette.ink, linewidth=1.4, label="Comparables retenus", zorder=4) ax.scatter([s["lon"]], [s["lat"]], marker="*", s=140, color=palette.ink, zorder=5, label="Sujet") th = np.linspace(0, 2 * np.pi, 200) ax.plot(s["lon"] + 3.0 / 78 * np.cos(th), s["lat"] + 3.0 / 111 * np.sin(th), color=palette.ink2, ls="--", lw=0.9) ax.text(s["lon"] + 3.0 / 78, s["lat"] + 0.002, "rayon 3 km", fontsize=7.5, color=palette.ink2) ax.set_xlabel("Longitude (coordonnées synthétiques)"); ax.set_ylabel("Latitude"); ax.set_aspect(111 / 78) ax.legend(fontsize=7, ncol=2, loc="upper left"); ax.grid(True, lw=0.4) savefig(fig, "ch16/ch16-fig-carte-comparables.pdf") # Fig 3 : distribution du $/m² : toutes ventes du secteur vs candidats vs retenus fig, ax = plt.subplots(figsize=(LARGEUR, 3.0)) groupes = [v.loc[v["secteur"] == s["secteur"], "prix_m2"], cand["prix_m2"], top["prix_m2"]] labels = ["Ventes du secteur %s\n(n = %d)" % (s["secteur"], len(groupes[0])), "Candidats filtrés\n(n = %d)" % len(cand), "Comparables retenus\n(n = %d)" % len(top)] bp = ax.boxplot(groupes, vert=False, widths=0.55, patch_artist=True, medianprops=dict(color=palette.ink, lw=1.4), flierprops=dict(marker="o", ms=3, alpha=0.5)) for patch, c in zip(bp["boxes"], palette.cat[:3]): patch.set(facecolor=c, alpha=0.35, edgecolor=c) ax.set_yticks([1, 2, 3]); ax.set_yticklabels(labels, fontsize=7.5); ax.set_xlabel("Prix par mètre carré habitable ($/m²)") ax.xaxis.set_major_formatter(FuncFormatter(fmt_milliers)) for i, g in enumerate(groupes, 1): ax.annotate("méd. %s" % fmt_milliers(g.median()), (g.median(), i + 0.33), fontsize=7.5, ha="center", color=palette.ink2) savefig(fig, "ch16/ch16-fig-boites-prix-m2.pdf") # Fig 4 : sensibilité de l'indication par $/m² au nombre de comparables et au rayon fig, ax = plt.subplots(figsize=(LARGEUR, 3.2)) for j, r in enumerate([1.5, 3.0, 5.0]): w = sens[(sens["rayon_km"] == r) & (sens["mois_max"] == 18)] ax.plot(w["k"], w["indication_m2"], marker="o", color=palette.cat[j], label="rayon %.1f km" % r) ax.annotate("%.1f km" % r, (w["k"].iloc[-1], w["indication_m2"].iloc[-1]), xytext=(5, 0), textcoords="offset points", fontsize=7.5, color=palette.cat[j], va="center") ax.set_xlabel("Nombre de comparables retenus (k)"); ax.set_ylabel("Indication brute ($/m² médian × superficie)") ax.yaxis.set_major_formatter(FuncFormatter(fmt_dollars)); ax.legend(fontsize=7.5, loc="lower right") savefig(fig, "ch16/ch16-fig-sensibilite-selection.pdf") print("figures écrites dans assets/figures/ch16/") if __name__ == "__main__": main(figures="--figures" in sys.argv)