SPB Git forge

spb/wp1_uqo

Public
3commits 1branches 1releases
2.6 MBsize
maindefault branch
1 h agolast push
TeX 52% Python 47.9%
20.1 KB · 469 lines python
Raw Blame History
1"""2Modèle hédonique des prix immobiliers — Québec (v2)3Spécification améliorée avec correction de la sélection de surface,4variables quadratiques, et interactions spatiales.5"""67import json8import re9import warnings10import numpy as np11import pandas as pd12import statsmodels.api as sm13from statsmodels.stats.outliers_influence import variance_inflation_factor1415warnings.filterwarnings("ignore")1617# ─────────────────────────────────────────────────────────────────────18# 1. CHARGEMENT ET EXTRACTION19# ─────────────────────────────────────────────────────────────────────20print("=" * 90)21print("MODÈLE HÉDONIQUE v2 — PRIX DES MAISONS UNIFAMILIALES AU QUÉBEC")22print("=" * 90)2324with open("house_listings_1767845793472.json", "r") as f:25    raw = json.load(f)262728def parse_numeric(text, default=np.nan):29    if not text:30        return default31    nums = re.findall(r"-?[\d.]+", str(text).replace(",", ""))32    if nums:33        try:34            return float(nums[0])35        except ValueError:36            return default37    return default383940def parse_land_sqft(text):41    if not text:42        return np.nan43    val = parse_numeric(text)44    if np.isnan(val):45        return np.nan46    t = text.lower()47    if "m2" in t:48        return val * 10.763949    elif "acre" in t:50        return val * 4356051    elif "hectare" in t:52        return val * 10763953    elif "arpent" in t:54        return val * 3680055    return val565758def parse_floor_sqft(measurements):59    if not measurements:60        return np.nan61    for m in measurements:62        area = m.get("Area", "")63        val = parse_numeric(area)64        if not np.isnan(val) and val > 0:65            if "m2" in area.lower():66                return val * 10.763967            return val68    return np.nan697071def extract_city(address_text):72    if not address_text:73        return "Inconnu"74    parts = address_text.split("|")75    if len(parts) >= 2:76        city_part = parts[1].strip()77        city = city_part.split(",")[0].strip()78        match = re.match(r"^([^(]+)", city)79        if match:80            return match.group(1).strip()81        return city82    return "Inconnu"838485def classify_region(city):86    c = city.lower()87    mtl = ["montréal", "montreal", "laval", "longueuil", "brossard",88           "terrebonne", "blainville", "repentigny", "boucherville",89           "saint-jérôme", "mirabel", "boisbriand", "sainte-thérèse",90           "châteauguay", "saint-eustache", "deux-montagnes",91           "mascouche", "varennes", "vaudreuil", "dorval",92           "saint-hubert", "saint-lambert", "candiac", "carignan",93           "saint-bruno", "saint-constant", "saint-basile",94           "sainte-julie", "beloeil", "mont-saint-hilaire",95           "saint-jean-sur-richelieu", "chambly", "la prairie",96           "kirkland", "pointe-claire", "dollard", "côte-saint-luc",97           "westmount", "hampstead", "mont-royal", "outremont",98           "verdun", "lasalle", "lachine", "pierrefonds", "roxboro",99           "anjou", "saint-léonard", "rivière-des-prairies"]100    qc = ["québec", "quebec", "lévis", "levis", "beauport",101          "charlesbourg", "sainte-foy", "saint-augustin",102          "l'ancienne-lorette", "shannon", "stoneham",103          "saint-nicolas", "saint-romuald", "cap-rouge"]104    sherb = ["sherbrooke", "magog", "fleurimont"]105    trois = ["trois-rivières", "trois-rivieres", "shawinigan", "cap-de-la-madeleine"]106    gatineau = ["gatineau", "hull", "aylmer"]107    for kw in mtl:108        if kw in c:109            return "Montreal"110    for kw in qc:111        if kw in c:112            return "Quebec"113    for kw in sherb:114        if kw in c:115            return "Sherbrooke"116    for kw in trois:117        if kw in c:118            return "TroisRivieres"119    for kw in gatineau:120        if kw in c:121            return "Gatineau"122    return "Regions"123124125records = []126for item in raw:127    prop = item.get("Property", {})128    bld = item.get("Building", {})129    land = item.get("Land", {})130    addr = prop.get("Address", {})131    address_text = addr.get("AddressText", "")132    amenities = prop.get("AmmenitiesNearBy", "")133    parking_type = prop.get("ParkingType", "")134135    records.append({136        "price": parse_numeric(prop.get("PriceUnformattedValue")),137        "bedrooms": parse_numeric(bld.get("Bedrooms")),138        "bathrooms": parse_numeric(bld.get("BathroomTotal")),139        "half_baths": parse_numeric(bld.get("HalfBathTotal", "0")),140        "stories": parse_numeric(bld.get("StoriesTotal")),141        "floor_sqft": parse_floor_sqft(bld.get("FloorAreaMeasurements", [])),142        "land_sqft": parse_land_sqft(land.get("SizeTotal")),143        "parking_spaces": parse_numeric(prop.get("ParkingSpaceTotal", "0")),144        "latitude": parse_numeric(addr.get("Latitude")),145        "longitude": parse_numeric(addr.get("Longitude")),146        "city": extract_city(address_text),147        "near_highway": 1 if "Highway" in amenities else 0,148        "near_hospital": 1 if "Hospital" in amenities else 0,149        "near_park": 1 if "Park" in amenities else 0,150        "near_school": 1 if "School" in amenities else 0,151        "near_transit": 1 if "Transit" in amenities else 0,152        "near_university": 1 if "University" in amenities or "CEGEP" in amenities else 0,153        "near_golf": 1 if "Golf" in amenities else 0,154        "has_garage": 1 if "Garage" in parking_type else 0,155    })156157df = pd.DataFrame(records)158df = df[df["price"] > 0].copy()159print(f"\n  {len(df)} annonces chargées")160161# ─────────────────────────────────────────────────────────────────────162# 2. FEATURE ENGINEERING163# ─────────────────────────────────────────────────────────────────────164print("\n[2] Feature engineering...")165166df["ln_price"] = np.log(df["price"])167df["total_baths"] = df["bathrooms"] + 0.5 * df["half_baths"]168df["region"] = df["city"].apply(classify_region)169170# ── MODÈLE A : Sans surface habitable (N ≈ 18 000) ──────────────────171# ── MODÈLE B : Avec surface habitable (N ≈ 6 500, plus précis) ──────172173# Préparer le sous-échantillon complet174df_full = df.dropna(subset=["bedrooms", "total_baths", "latitude", "longitude"]).copy()175df_full["stories"] = df_full["stories"].fillna(df_full["stories"].median())176df_full["parking_spaces"] = df_full["parking_spaces"].fillna(0)177178# Traitement du terrain : clipper les valeurs aberrantes et log179df_full["land_sqft"] = df_full["land_sqft"].clip(upper=df_full["land_sqft"].quantile(0.99))180df_full.loc[df_full["land_sqft"] <= 0, "land_sqft"] = np.nan181df_full["ln_land"] = np.log(df_full["land_sqft"])182183# Distance au centre-ville de Montréal (45.5017, -73.5673)184MTL_LAT, MTL_LON = 45.5017, -73.5673185df_full["dist_montreal_km"] = np.sqrt(186    ((df_full["latitude"] - MTL_LAT) * 111) ** 2 +187    ((df_full["longitude"] - MTL_LON) * 111 * np.cos(np.radians(MTL_LAT))) ** 2188)189df_full["ln_dist_mtl"] = np.log(df_full["dist_montreal_km"].clip(lower=0.5))190191# Variables quadratiques192df_full["bedrooms_sq"] = df_full["bedrooms"] ** 2193df_full["baths_sq"] = df_full["total_baths"] ** 2194195# Interaction : chambres × salles de bain196df_full["bed_x_bath"] = df_full["bedrooms"] * df_full["total_baths"]197198# Nombre d'aménités proches (score d'urbanité)199amenity_cols = ["near_highway", "near_hospital", "near_park", "near_school",200                "near_transit", "near_university", "near_golf"]201df_full["amenity_score"] = df_full[amenity_cols].sum(axis=1)202203# Outliers204p01 = df_full["ln_price"].quantile(0.005)205p99 = df_full["ln_price"].quantile(0.995)206df_full = df_full[(df_full["ln_price"] >= p01) & (df_full["ln_price"] <= p99)].copy()207208# Sous-échantillon avec surface habitable209df_floor = df_full[df_full["floor_sqft"].notna() & (df_full["floor_sqft"] > 100)].copy()210df_floor["ln_floor"] = np.log(df_floor["floor_sqft"])211df_floor["floor_per_bed"] = df_floor["floor_sqft"] / df_floor["bedrooms"].clip(lower=1)212df_floor["ln_floor_per_bed"] = np.log(df_floor["floor_per_bed"])213214print(f"  Modèle A (sans surface) : N = {len(df_full)}")215print(f"  Modèle B (avec surface) : N = {len(df_floor)}")216217# ─────────────────────────────────────────────────────────────────────218# 3. STATISTIQUES DESCRIPTIVES219# ─────────────────────────────────────────────────────────────────────220print("\n" + "=" * 90)221print("[3] STATISTIQUES DESCRIPTIVES")222print("=" * 90)223224print(f"\n  --- Distribution des prix ---")225for label, sub in [("Tous", df_full), ("Avec surface", df_floor)]:226    print(f"\n  [{label}] N = {len(sub)}")227    print(f"    Moyenne  : {sub['price'].mean():>12,.0f} $")228    print(f"    Médiane  : {sub['price'].median():>12,.0f} $")229    print(f"    Éc.-type : {sub['price'].std():>12,.0f} $")230    print(f"    IQR      : [{sub['price'].quantile(.25):,.0f} — {sub['price'].quantile(.75):,.0f}]")231232print(f"\n  --- Régions ---")233for reg in sorted(df_full["region"].unique()):234    n = (df_full["region"] == reg).sum()235    med = df_full.loc[df_full["region"] == reg, "price"].median()236    print(f"    {reg:<15s}: {n:>5d}  (médian: {med:>10,.0f} $)")237238# ─────────────────────────────────────────────────────────────────────239# 4. MODÈLE A — SANS SURFACE HABITABLE240# ─────────────────────────────────────────────────────────────────────241print("\n" + "=" * 90)242print("[4] MODÈLE A — SANS SURFACE HABITABLE")243print("=" * 90)244245region_dummies_A = pd.get_dummies(df_full["region"], prefix="reg", drop_first=True)246df_A = pd.concat([df_full.reset_index(drop=True), region_dummies_A.reset_index(drop=True)], axis=1)247248features_A = [249    "bedrooms", "bedrooms_sq",250    "total_baths", "baths_sq",251    "bed_x_bath",252    "stories",253    "parking_spaces", "has_garage",254    "ln_land",255    "ln_dist_mtl",256    "near_highway", "near_hospital", "near_park",257    "near_school", "near_transit", "near_golf",258]259reg_cols_A = [c for c in df_A.columns if c.startswith("reg_")]260features_A += reg_cols_A261262df_A_clean = df_A.dropna(subset=features_A + ["ln_price"]).copy()263X_A = sm.add_constant(df_A_clean[features_A].astype(float))264y_A = df_A_clean["ln_price"]265266model_A = sm.OLS(y_A, X_A).fit(cov_type="HC3")267print(f"\n{model_A.summary()}")268269# ─────────────────────────────────────────────────────────────────────270# 5. MODÈLE B — AVEC SURFACE HABITABLE271# ─────────────────────────────────────────────────────────────────────272print("\n" + "=" * 90)273print("[5] MODÈLE B — AVEC SURFACE HABITABLE (échantillon réduit)")274print("=" * 90)275276region_dummies_B = pd.get_dummies(df_floor["region"], prefix="reg", drop_first=True)277df_B = pd.concat([df_floor.reset_index(drop=True), region_dummies_B.reset_index(drop=True)], axis=1)278279features_B = [280    "bedrooms",281    "total_baths",282    "stories",283    "ln_floor",284    "parking_spaces", "has_garage",285    "ln_land",286    "ln_dist_mtl",287    "near_highway", "near_hospital", "near_park",288    "near_school", "near_transit", "near_golf",289]290reg_cols_B = [c for c in df_B.columns if c.startswith("reg_")]291features_B += reg_cols_B292293df_B_clean = df_B.dropna(subset=features_B + ["ln_price"]).copy()294X_B = sm.add_constant(df_B_clean[features_B].astype(float))295y_B = df_B_clean["ln_price"]296297model_B = sm.OLS(y_B, X_B).fit(cov_type="HC3")298print(f"\n{model_B.summary()}")299300# ─────────────────────────────────────────────────────────────────────301# 6. COMPARAISON ET INTERPRÉTATION302# ─────────────────────────────────────────────────────────────────────303print("\n" + "=" * 90)304print("[6] COMPARAISON DES MODÈLES")305print("=" * 90)306307for label, mod, X, y in [("A (sans surface)", model_A, X_A, y_A),308                          ("B (avec surface)", model_B, X_B, y_B)]:309    pred_ln = mod.fittedvalues310    actual = np.exp(y)311    predicted = np.exp(pred_ln)312    abs_err = np.abs(actual - predicted)313    pct_err = abs_err / actual * 100314315    print(f"\n  Modèle {label}")316    print(f"    N            : {int(mod.nobs):,d}")317    print(f"    R²           : {mod.rsquared:.4f}")318    print(f"    R² ajusté    : {mod.rsquared_adj:.4f}")319    print(f"    AIC          : {mod.aic:,.0f}")320    print(f"    BIC          : {mod.bic:,.0f}")321    print(f"    RMSE ($)     : {np.sqrt(np.mean((actual - predicted)**2)):>12,.0f}")322    print(f"    MAE ($)      : {abs_err.mean():>12,.0f}")323    print(f"    MAPE         : {pct_err.mean():.1f}%")324    print(f"    Médiane APE  : {pct_err.median():.1f}%")325326# ─────────────────────────────────────────────────────────────────────327# 7. INTERPRÉTATION DÉTAILLÉE DU MEILLEUR MODÈLE328# ─────────────────────────────────────────────────────────────────────329print("\n" + "=" * 90)330print("[7] INTERPRÉTATION DES PRIX IMPLICITES")331print("=" * 90)332333best = model_B334best_features = features_B335median_price = df_floor["price"].median()336median_vals = df_B_clean[features_B].median()337338print(f"\n  Modèle retenu : B (avec surface habitable)")339print(f"  Prix médian   : {median_price:,.0f} $")340print(f"\n  {'Variable':<25s} {'β':>8s} {'p':>8s}  {'Sig':>3s}  {'Interprétation'}")341print("  " + "-" * 85)342343for var in best_features:344    coef = best.params[var]345    pval = best.pvalues[var]346    sig = "***" if pval < 0.001 else "**" if pval < 0.01 else "*" if pval < 0.05 else ""347348    if var.startswith("ln_"):349        # Élasticité350        interp = f"Élasticité = {coef:.3f}  (+1% → {coef:.3f}% prix, soit ~{median_price*coef/100:+,.0f} $)"351    elif var.startswith("reg_"):352        pct = (np.exp(coef) - 1) * 100353        impl = median_price * (np.exp(coef) - 1)354        interp = f"Prime vs Régions : {pct:+.1f}% ({impl:+,.0f} $)"355    else:356        pct = (np.exp(coef) - 1) * 100357        impl = median_price * (np.exp(coef) - 1)358        interp = f"+1 unité → {pct:+.1f}% ({impl:+,.0f} $)"359360    print(f"  {var:<25s} {coef:>8.4f} {pval:>8.4f}  {sig:>3s}  {interp}")361362# ─────────────────────────────────────────────────────────────────────363# 8. VIF (Variance Inflation Factors) pour le modèle B364# ─────────────────────────────────────────────────────────────────────365print("\n" + "=" * 90)366print("[8] FACTEURS D'INFLATION DE LA VARIANCE (VIF) — Modèle B")367print("=" * 90)368369X_vif = df_B_clean[features_B].astype(float)370vif_data = []371for i, col in enumerate(X_vif.columns):372    vif_val = variance_inflation_factor(X_vif.values, i)373    vif_data.append((col, vif_val))374375print(f"\n  {'Variable':<25s} {'VIF':>8s}  Note")376print("  " + "-" * 55)377for col, vif_val in sorted(vif_data, key=lambda x: -x[1]):378    note = ""379    if vif_val > 10:380        note = "⚠ ÉLEVÉ"381    elif vif_val > 5:382        note = "modéré"383    print(f"  {col:<25s} {vif_val:>8.2f}  {note}")384385# ─────────────────────────────────────────────────────────────────────386# 9. DIAGNOSTICS RÉSIDUS MODÈLE B387# ─────────────────────────────────────────────────────────────────────388print("\n" + "=" * 90)389print("[9] DIAGNOSTICS DES RÉSIDUS — Modèle B")390print("=" * 90)391392resid = model_B.resid393fitted = model_B.fittedvalues394395jb_stat, jb_pval, skew, kurt = sm.stats.jarque_bera(resid)396bp_lm, bp_pval, _, _ = sm.stats.diagnostic.het_breuschpagan(resid, X_B)397398print(f"\n  Jarque-Bera       : {jb_stat:,.1f} (p = {jb_pval:.2e})")399print(f"  Asymétrie         : {skew:.4f}")400print(f"  Kurtosis          : {kurt:.4f}")401print(f"  Breusch-Pagan LM  : {bp_lm:,.1f} (p = {bp_pval:.2e})")402print(f"  Condition number  : {model_B.condition_number:.0f}")403404if bp_pval < 0.05:405    print("  → Hétéroscédasticité présente — erreurs HC3 robustes utilisées ✓")406if jb_pval < 0.05:407    print("  → Non-normalité des résidus — inférence asymptotique valide (N grand) ✓")408if model_B.condition_number < 30:409    print("  → Pas de multicollinéarité problématique ✓")410411# Distribution des résidus par quantile412print(f"\n  Résidus standardisés (distribution) :")413std_resid = resid / resid.std()414for q in [1, 5, 25, 50, 75, 95, 99]:415    val = np.percentile(std_resid, q)416    print(f"    P{q:<2d} : {val:>+6.3f}")417418# ─────────────────────────────────────────────────────────────────────419# RÉSUMÉ EXÉCUTIF420# ─────────────────────────────────────────────────────────────────────421print("\n" + "=" * 90)422print("RÉSUMÉ EXÉCUTIF")423print("=" * 90)424425pred_B = np.exp(model_B.fittedvalues)426actual_B = np.exp(y_B)427mape_B = (np.abs(actual_B - pred_B) / actual_B * 100).mean()428medape_B = (np.abs(actual_B - pred_B) / actual_B * 100).median()429430print(f"""431  DONNÉES432  -------433  Source      : Centris (annonces actives, Québec)434  Échantillon : {len(raw):,d} maisons unifamiliales435  Couverture  : Province de Québec436437  MODÈLE RETENU (B — avec surface habitable)438  -------------------------------------------439  Spécification : ln(Prix) = β₀ + Σ βₖ·Xₖ + ε440  Estimateur    : MCO avec erreurs robustes HC3441  N             : {int(model_B.nobs):,d}442  R² ajusté     : {model_B.rsquared_adj:.4f}  ({model_B.rsquared_adj*100:.1f}% de la variance expliquée)443  MAPE          : {mape_B:.1f}%444  Médiane APE   : {medape_B:.1f}%445446  PRINCIPAUX RÉSULTATS (prix implicites au prix médian de {median_price:,.0f} $)447  ─────────────────────────────────────────────────────────────────────448""")449450key_vars = [v for v in best_features if best.pvalues[v] < 0.05 and not v.startswith("reg_")]451for var in key_vars:452    coef = best.params[var]453    if var.startswith("ln_"):454        desc = f"+1% → {median_price * coef / 100:+,.0f} $"455    else:456        impl = median_price * (np.exp(coef) - 1)457        desc = f"+1 unité → {impl:+,.0f} $"458    print(f"    {var:<22s}: {desc}")459460reg_vars = [v for v in best_features if v.startswith("reg_") and best.pvalues[v] < 0.05]461if reg_vars:462    print(f"\n  PRIMES RÉGIONALES (vs. Régions périphériques)")463    for var in reg_vars:464        coef = best.params[var]465        impl = median_price * (np.exp(coef) - 1)466        print(f"    {var.replace('reg_', ''):<22s}: {impl:+,.0f} $ ({(np.exp(coef)-1)*100:+.1f}%)")467468print()469