SPB Git forge

spb/wp1_uqo

Public
3commits 1branches 1releases
2.6 MBsize
maindefault branch
1 h agolast push
TeX 52% Python 47.9%
24.1 KB · 569 lines python
Raw Blame History
1"""2Régression quantile spatiale — Hétérogénéité de la capitalisation3des aménités dans le marché résidentiel québécois45Méthodes :6  1. Régression quantile standard (Koenker & Bassett 1978)7  2. Régression quantile avec lag spatial instrumenté (Kim & Muller 2004)8  3. Tests d'autocorrélation spatiale (Moran's I)9  4. Tests d'égalité inter-quantile (Wald)1011Quantiles : τ ∈ {0.10, 0.25, 0.50, 0.75, 0.90}12"""1314import json15import re16import warnings17import numpy as np18import pandas as pd19import statsmodels.api as sm20from statsmodels.regression.quantile_regression import QuantReg21from scipy.spatial import cKDTree22from scipy import sparse, stats23from collections import OrderedDict2425warnings.filterwarnings("ignore")2627# ═══════════════════════════════════════════════════════════════28# 1. CHARGEMENT ET PRÉPARATION (identique à hedonic_final.py)29# ═══════════════════════════════════════════════════════════════3031with open("house_listings_1767845793472.json", "r") as f:32    raw = json.load(f)333435def pnum(text, default=np.nan):36    if not text:37        return default38    nums = re.findall(r"-?[\d.]+", str(text).replace(",", ""))39    return float(nums[0]) if nums else default404142def land_sqft(text):43    if not text:44        return np.nan45    val = pnum(text)46    if np.isnan(val):47        return np.nan48    t = text.lower()49    if "m2" in t: return val * 10.763950    if "acre" in t: return val * 4356051    if "hectare" in t: return val * 10763952    if "arpent" in t: return val * 3680053    return val545556def floor_sqft(measurements):57    if not measurements:58        return np.nan59    for m in measurements:60        area = m.get("Area", "")61        val = pnum(area)62        if not np.isnan(val) and val > 0:63            return val * 10.7639 if "m2" in area.lower() else val64    return np.nan656667def city(addr_text):68    if not addr_text:69        return "Inconnu"70    parts = addr_text.split("|")71    if len(parts) >= 2:72        c = parts[1].strip().split(",")[0].strip()73        m = re.match(r"^([^(]+)", c)74        return m.group(1).strip() if m else c75    return "Inconnu"767778def region(c):79    c = c.lower()80    mtl = ["montréal", "montreal", "laval", "longueuil", "brossard", "terrebonne",81           "blainville", "repentigny", "boucherville", "saint-jérôme", "mirabel",82           "boisbriand", "sainte-thérèse", "châteauguay", "saint-eustache",83           "deux-montagnes", "mascouche", "varennes", "vaudreuil", "dorval",84           "saint-hubert", "saint-lambert", "candiac", "carignan", "saint-bruno",85           "saint-constant", "saint-basile", "sainte-julie", "beloeil",86           "mont-saint-hilaire", "saint-jean-sur-richelieu", "chambly", "la prairie",87           "kirkland", "pointe-claire", "dollard", "côte-saint-luc", "westmount",88           "hampstead", "mont-royal", "outremont", "verdun", "lasalle", "lachine",89           "pierrefonds", "roxboro", "anjou", "saint-léonard", "rivière-des-prairies"]90    qc = ["québec", "quebec", "lévis", "levis", "beauport", "charlesbourg",91          "sainte-foy", "saint-augustin", "l'ancienne-lorette", "cap-rouge"]92    gat = ["gatineau", "hull", "aylmer"]93    for kw in mtl:94        if kw in c: return "Montreal"95    for kw in qc:96        if kw in c: return "Quebec"97    for kw in gat:98        if kw in c: return "Gatineau"99    return "Regions"100101102records = []103for item in raw:104    p = item.get("Property", {})105    b = item.get("Building", {})106    l = item.get("Land", {})107    a = p.get("Address", {})108    am = p.get("AmmenitiesNearBy", "")109    pk = p.get("ParkingType", "")110    records.append({111        "price": pnum(p.get("PriceUnformattedValue")),112        "bedrooms": pnum(b.get("Bedrooms")),113        "bathrooms": pnum(b.get("BathroomTotal")),114        "half_baths": pnum(b.get("HalfBathTotal", "0")),115        "stories": pnum(b.get("StoriesTotal")),116        "floor_sqft": floor_sqft(b.get("FloorAreaMeasurements", [])),117        "land_sqft": land_sqft(l.get("SizeTotal")),118        "parking_spaces": pnum(p.get("ParkingSpaceTotal", "0")),119        "lat": pnum(a.get("Latitude")),120        "lon": pnum(a.get("Longitude")),121        "city": city(a.get("AddressText", "")),122        "near_highway": int("Highway" in am),123        "near_hospital": int("Hospital" in am),124        "near_park": int("Park" in am),125        "near_school": int("School" in am),126        "near_transit": int("Transit" in am),127        "near_golf": int("Golf" in am),128        "has_garage": int("Garage" in pk),129    })130131df = pd.DataFrame(records)132df = df[df["price"] > 0].copy()133df["total_baths"] = df["bathrooms"] + 0.5 * df["half_baths"]134df["region"] = df["city"].apply(region)135136df2 = df.dropna(subset=["bedrooms", "total_baths", "floor_sqft", "lat", "lon"]).copy()137df2 = df2[df2["floor_sqft"] > 100].copy()138df2["stories"] = df2["stories"].fillna(df2["stories"].median())139df2["parking_spaces"] = df2["parking_spaces"].fillna(0)140df2["land_sqft"] = df2["land_sqft"].clip(upper=df2["land_sqft"].quantile(0.99))141df2.loc[df2["land_sqft"] <= 0, "land_sqft"] = np.nan142df2 = df2.dropna(subset=["land_sqft"]).copy()143144df2["ln_price"] = np.log(df2["price"])145df2["ln_floor"] = np.log(df2["floor_sqft"])146df2["ln_land"] = np.log(df2["land_sqft"])147148MTL_LAT, MTL_LON = 45.5017, -73.5673149df2["dist_mtl"] = np.sqrt(150    ((df2["lat"] - MTL_LAT) * 111) ** 2 +151    ((df2["lon"] - MTL_LON) * 111 * np.cos(np.radians(MTL_LAT))) ** 2152)153df2["ln_dist"] = np.log(df2["dist_mtl"].clip(lower=0.5))154155for col in ["ln_price", "ln_floor", "ln_land"]:156    q01, q99 = df2[col].quantile([0.005, 0.995])157    df2 = df2[(df2[col] >= q01) & (df2[col] <= q99)]158159center_cols = ["bedrooms", "total_baths", "stories", "parking_spaces",160               "ln_floor", "ln_land", "ln_dist"]161means = {}162for col in center_cols:163    means[col] = df2[col].mean()164    df2[f"{col}_c"] = df2[col] - means[col]165166reg_dum = pd.get_dummies(df2["region"], prefix="reg", drop_first=True)167df2 = pd.concat([df2.reset_index(drop=True), reg_dum.reset_index(drop=True)], axis=1)168169# ═══════════════════════════════════════════════════════════════170# 2. MATRICE DE POIDS SPATIAUX (KNN)171# ═══════════════════════════════════════════════════════════════172173print("=" * 90)174print("RÉGRESSION QUANTILE SPATIALE — CAPITALISATION DES AMÉNITÉS")175print("Hétérogénéité à travers la distribution des prix")176print("=" * 90)177178K = 10179coords = df2[["lat", "lon"]].values180tree = cKDTree(coords)181distances, indices = tree.query(coords, k=K + 1)182183n = len(df2)184rows, cols, vals = [], [], []185for i in range(n):186    neighbors = indices[i, 1:]187    dists = distances[i, 1:]188    inv_d = 1.0 / np.maximum(dists, 1e-10)189    w_sum = inv_d.sum()190    for j, nb in enumerate(neighbors):191        rows.append(i)192        cols.append(nb)193        vals.append(inv_d[j] / w_sum)194195W = sparse.csr_matrix((vals, (rows, cols)), shape=(n, n))196197print(f"\n  Matrice de poids : KNN (K={K}), pondération inverse de la distance")198print(f"  N = {n:,d}")199200# ═══════════════════════════════════════════════════════════════201# 3. TEST DE MORAN — AUTOCORRÉLATION SPATIALE202# ═══════════════════════════════════════════════════════════════203204print("\n" + "=" * 90)205print("TEST D'AUTOCORRÉLATION SPATIALE (MORAN'S I)")206print("=" * 90)207208features = [209    "bedrooms_c", "total_baths_c", "stories_c",210    "ln_floor_c", "parking_spaces_c", "has_garage",211    "ln_land_c", "ln_dist_c",212    "near_highway", "near_hospital", "near_park",213    "near_school", "near_transit", "near_golf",214]215reg_cols = [c for c in df2.columns if c.startswith("reg_")]216features += reg_cols217218X = sm.add_constant(df2[features].astype(float))219y = df2["ln_price"].values220221222def moran_i(resid, W_sparse):223    n = len(resid)224    z = resid - resid.mean()225    Wz = W_sparse.dot(z)226    S0 = W_sparse.sum()227    I = (n / S0) * (z @ Wz) / (z @ z)228    EI = -1.0 / (n - 1)229    S1 = 0.5 * ((W_sparse + W_sparse.T).multiply(W_sparse + W_sparse.T)).sum()230    S2 = np.array(((W_sparse + W_sparse.T).sum(axis=1)).flatten())[0]231    S2 = (S2 ** 2).sum()232    k = (1.0 / n) * ((z ** 4).sum()) / ((1.0 / n) * (z ** 2).sum()) ** 2233    var_I = (n * ((n**2 - 3*n + 3)*S1 - n*S2 + 3*S0**2)234             - k * (n*(n-1)*S1 - 2*n*S2 + 6*S0**2)) / ((n-1)*(n-2)*(n-3)*S0**2) - EI**2235    Z = (I - EI) / np.sqrt(max(var_I, 1e-20))236    p = 2 * (1 - stats.norm.cdf(abs(Z)))237    return I, Z, p238239240ols = sm.OLS(y, X).fit()241I_ols, Z_ols, p_ols = moran_i(ols.resid, W)242243print(f"\n  Résidus MCO :")244print(f"    Moran's I  = {I_ols:.4f}")245print(f"    Z-score    = {Z_ols:.2f}")246print(f"    p-value    = {p_ols:.2e}")247print(f"    → {'Autocorrélation spatiale SIGNIFICATIVE' if p_ols < 0.05 else 'Pas d autocorrélation significative'}")248249# ═══════════════════════════════════════════════════════════════250# 4. RÉGRESSIONS QUANTILES STANDARD251# ═══════════════════════════════════════════════════════════════252253print("\n" + "=" * 90)254print("RÉGRESSIONS QUANTILES — τ ∈ {0.10, 0.25, 0.50, 0.75, 0.90}")255print("=" * 90)256257taus = [0.10, 0.25, 0.50, 0.75, 0.90]258qr_results = OrderedDict()259260for tau in taus:261    mod = QuantReg(y, X)262    res = mod.fit(q=tau, max_iter=5000)263    qr_results[tau] = res264265amenity_vars = ["near_highway", "near_hospital", "near_park",266                "near_school", "near_transit", "near_golf", "has_garage"]267268print(f"\n  --- Coefficients des aménités par quantile ---")269print(f"\n  {'Variable':<20s}", end="")270for tau in taus:271    print(f"  {'τ='+str(tau):>12s}", end="")272print(f"  {'MCO':>12s}")273print("  " + "-" * (20 + 13 * (len(taus) + 1)))274275for var in amenity_vars:276    print(f"  {var:<20s}", end="")277    for tau in taus:278        coef = qr_results[tau].params[var]279        pval = qr_results[tau].pvalues[var]280        sig = "***" if pval < 0.001 else "**" if pval < 0.01 else "*" if pval < 0.05 else ""281        print(f"  {coef:>8.4f}{sig:<4s}", end="")282    coef_ols = ols.params[var]283    pval_ols = ols.pvalues[var]284    sig_ols = "***" if pval_ols < 0.001 else "**" if pval_ols < 0.01 else "*" if pval_ols < 0.05 else ""285    print(f"  {coef_ols:>8.4f}{sig_ols:<4s}")286287# ═══════════════════════════════════════════════════════════════288# 5. TABLEAU COMPLET — TOUS LES COEFFICIENTS PAR QUANTILE289# ═══════════════════════════════════════════════════════════════290291print("\n" + "=" * 90)292print("TABLEAU COMPLET — COEFFICIENTS ET ERREURS STANDARDS")293print("=" * 90)294295all_vars = ["const"] + features296297for tau in taus:298    res = qr_results[tau]299    n_sig = sum(1 for v in features if res.pvalues[v] < 0.05)300    print(f"\n  ┌── τ = {tau:.2f} {'─' * 70}")301    print(f"  │  Pseudo-R² = {res.prsquared:.4f}  |  Variables sig. = {n_sig}/{len(features)}")302    print(f"  │  {'Variable':<25s} {'β':>9s} {'Err.std':>9s} {'t':>8s} {'p':>8s} {'Sig':>4s}")303    print(f"  │  {'-' * 65}")304    for var in all_vars:305        coef = res.params[var]306        se = res.bse[var]307        t = res.tvalues[var]308        p = res.pvalues[var]309        sig = "***" if p < 0.001 else "**" if p < 0.01 else "*" if p < 0.05 else ""310        print(f"  │  {var:<25s} {coef:>9.4f} {se:>9.4f} {t:>8.2f} {p:>8.4f} {sig:>4s}")311    print(f"  └{'─' * 78}")312313# ═══════════════════════════════════════════════════════════════314# 6. RÉGRESSION QUANTILE AVEC LAG SPATIAL (2SQR)315# ═══════════════════════════════════════════════════════════════316317print("\n" + "=" * 90)318print("RÉGRESSION QUANTILE SPATIALE — LAG SPATIAL INSTRUMENTÉ (2SQR)")319print("  Instruments : WX (lag spatial des exogènes)")320print("  Méthode : Kim & Muller (2004)")321print("=" * 90)322323Wy = W.dot(y)324325X_exog = df2[features].astype(float).values326WX = W.dot(X_exog)327328Z_iv = np.column_stack([np.ones(n), X_exog, WX])329first_stage = sm.OLS(Wy, Z_iv).fit()330Wy_hat = first_stage.fittedvalues331print(f"\n  Première étape (Wy sur Z) : R² = {first_stage.rsquared:.4f}")332333X_spatial = np.column_stack([np.ones(n), X_exog, Wy_hat])334spatial_feature_names = ["const"] + features + ["W·ln_price"]335336sqr_results = OrderedDict()337for tau in taus:338    mod = QuantReg(y, X_spatial)339    res = mod.fit(q=tau, max_iter=5000)340    sqr_results[tau] = res341342print(f"\n  --- Coefficients spatiaux des aménités par quantile ---")343print(f"\n  {'Variable':<20s}", end="")344for tau in taus:345    print(f"  {'τ='+str(tau):>12s}", end="")346print()347print("  " + "-" * (20 + 13 * len(taus)))348349display_vars = amenity_vars + ["W·ln_price"]350for var in display_vars:351    idx = spatial_feature_names.index(var)352    print(f"  {var:<20s}", end="")353    for tau in taus:354        coef = sqr_results[tau].params[idx]355        se = sqr_results[tau].bse[idx]356        t_stat = coef / se if se > 0 else 0357        pval = 2 * (1 - stats.norm.cdf(abs(t_stat)))358        sig = "***" if pval < 0.001 else "**" if pval < 0.01 else "*" if pval < 0.05 else ""359        print(f"  {coef:>8.4f}{sig:<4s}", end="")360    print()361362# Lag spatial — tableau complet363print(f"\n  --- Tableau complet du modèle spatial (τ = 0.50) ---")364res50 = sqr_results[0.50]365print(f"  Pseudo-R² = {res50.prsquared:.4f}")366print(f"\n  {'Variable':<25s} {'β':>9s} {'Err.std':>9s} {'t':>8s} {'p':>8s} {'Sig':>4s}")367print("  " + "-" * 65)368for i, var in enumerate(spatial_feature_names):369    coef = res50.params[i]370    se = res50.bse[i]371    t_stat = coef / se if se > 0 else 0372    pval = 2 * (1 - stats.norm.cdf(abs(t_stat)))373    sig = "***" if pval < 0.001 else "**" if pval < 0.01 else "*" if pval < 0.05 else ""374    print(f"  {var:<25s} {coef:>9.4f} {se:>9.4f} {t_stat:>8.2f} {pval:>8.4f} {sig:>4s}")375376# ═══════════════════════════════════════════════════════════════377# 7. TESTS D'ÉGALITÉ INTER-QUANTILE (WALD)378# ═══════════════════════════════════════════════════════════════379380print("\n" + "=" * 90)381print("TESTS D'ÉGALITÉ INTER-QUANTILE (WALD)")382print("  H₀ : β(τ_low) = β(τ_high) pour chaque aménité")383print("=" * 90)384385test_pairs = [(0.10, 0.90), (0.25, 0.75), (0.10, 0.50), (0.50, 0.90)]386387print(f"\n  {'Variable':<20s}", end="")388for t1, t2 in test_pairs:389    print(f"  {f'τ{t1}=τ{t2}':>12s}", end="")390print()391print("  " + "-" * (20 + 13 * len(test_pairs)))392393for var in amenity_vars:394    print(f"  {var:<20s}", end="")395    for t1, t2 in test_pairs:396        b1 = qr_results[t1].params[var]397        b2 = qr_results[t2].params[var]398        se1 = qr_results[t1].bse[var]399        se2 = qr_results[t2].bse[var]400        diff = b1 - b2401        se_diff = np.sqrt(se1**2 + se2**2)402        wald = (diff / se_diff) ** 2 if se_diff > 0 else 0403        p_wald = 1 - stats.chi2.cdf(wald, 1)404        sig = "***" if p_wald < 0.001 else "**" if p_wald < 0.01 else "*" if p_wald < 0.05 else "n.s."405        print(f"  {wald:>7.2f} {sig:<4s}", end="")406    print()407408# Test omnibus (Koenker-Bassett)409print(f"\n  --- Test omnibus : tous les coefficients égaux à travers les quantiles ---")410print(f"  (F de Koenker-Bassett, τ = 0.10 vs 0.90)")411412omnibus_vars = features413diff_vec = np.array([qr_results[0.10].params[v] - qr_results[0.90].params[v] for v in omnibus_vars])414se_vec = np.array([np.sqrt(qr_results[0.10].bse[v]**2 + qr_results[0.90].bse[v]**2) for v in omnibus_vars])415wald_vec = (diff_vec / np.maximum(se_vec, 1e-10)) ** 2416omnibus_stat = wald_vec.sum()417omnibus_p = 1 - stats.chi2.cdf(omnibus_stat, len(omnibus_vars))418print(f"  χ²({len(omnibus_vars)}) = {omnibus_stat:.2f},  p = {omnibus_p:.2e}")419print(f"  → {'Hétérogénéité significative à travers les quantiles' if omnibus_p < 0.05 else 'Pas d hétérogénéité significative'}")420421# ═══════════════════════════════════════════════════════════════422# 8. MORAN'S I SUR RÉSIDUS PAR QUANTILE423# ═══════════════════════════════════════════════════════════════424425print("\n" + "=" * 90)426print("AUTOCORRÉLATION SPATIALE DES RÉSIDUS PAR QUANTILE")427print("=" * 90)428429print(f"\n  {'Modèle':<30s} {'Moran I':>10s} {'Z':>8s} {'p':>10s}  Diagnostic")430print("  " + "-" * 75)431432I_val, Z_val, p_val = moran_i(ols.resid, W)433diag = "SPATIAL" if p_val < 0.05 else "OK"434print(f"  {'MCO':<30s} {I_val:>10.4f} {Z_val:>8.2f} {p_val:>10.2e}  {diag}")435436for tau in taus:437    resid_qr = y - qr_results[tau].predict(X)438    I_val, Z_val, p_val = moran_i(resid_qr, W)439    diag = "SPATIAL" if p_val < 0.05 else "OK"440    print(f"  {'QR τ=' + str(tau):<30s} {I_val:>10.4f} {Z_val:>8.2f} {p_val:>10.2e}  {diag}")441442for tau in taus:443    resid_sqr = y - sqr_results[tau].predict(X_spatial)444    I_val, Z_val, p_val = moran_i(resid_sqr, W)445    diag = "SPATIAL" if p_val < 0.05 else "OK"446    print(f"  {'SQR τ=' + str(tau):<30s} {I_val:>10.4f} {Z_val:>8.2f} {p_val:>10.2e}  {diag}")447448# ═══════════════════════════════════════════════════════════════449# 9. PRIX IMPLICITES PAR QUANTILE450# ═══════════════════════════════════════════════════════════════451452print("\n" + "=" * 90)453print("PRIX IMPLICITES DES AMÉNITÉS PAR QUANTILE (en $)")454print("  WTP = prix_quantile × (exp(β) - 1)")455print("=" * 90)456457price_quantiles = {tau: np.exp(np.quantile(y, tau)) for tau in taus}458459print(f"\n  Prix de référence par quantile :")460for tau in taus:461    print(f"    τ = {tau:.2f} → {price_quantiles[tau]:>12,.0f} $")462463print(f"\n  {'Aménité':<20s}", end="")464for tau in taus:465    print(f"  {'τ='+str(tau):>12s}", end="")466print()467print("  " + "-" * (20 + 13 * len(taus)))468469for var in amenity_vars:470    label = var.replace("near_", "").replace("has_", "").capitalize()471    print(f"  {label:<20s}", end="")472    for tau in taus:473        coef = qr_results[tau].params[var]474        wtp = price_quantiles[tau] * (np.exp(coef) - 1)475        pval = qr_results[tau].pvalues[var]476        sig = "*" if pval < 0.05 else ""477        print(f"  {wtp:>+10,.0f}{sig:<2s}", end="")478    print()479480# ═══════════════════════════════════════════════════════════════481# 10. EFFETS DIRECTS / INDIRECTS (MODÈLE SPATIAL, τ = 0.50)482# ═══════════════════════════════════════════════════════════════483484print("\n" + "=" * 90)485print("EFFETS DIRECTS, INDIRECTS ET TOTAUX (SQR, τ = 0.50)")486print("  Effet direct  = β / (1 - ρ)")487print("  Effet indirect = ρ·β / (1 - ρ)")488print("=" * 90)489490rho_idx = spatial_feature_names.index("W·ln_price")491rho = sqr_results[0.50].params[rho_idx]492493print(f"\n  ρ (lag spatial) = {rho:.4f}")494print(f"  Multiplicateur  = 1/(1-ρ) = {1/(1-rho):.4f}")495496median_price = df2["price"].median()497print(f"  Prix médian     = {median_price:,.0f} $")498499print(f"\n  {'Aménité':<20s} {'β':>8s} {'Direct':>12s} {'Indirect':>12s} {'Total':>12s} {'Total ($)':>12s}")500print("  " + "-" * 80)501502for var in amenity_vars:503    idx = spatial_feature_names.index(var)504    beta = sqr_results[0.50].params[idx]505    direct = beta / (1 - rho)506    indirect = rho * beta / (1 - rho)507    total = beta / (1 - rho)508    total_dollar = median_price * (np.exp(total) - 1)509    print(f"  {var:<20s} {beta:>8.4f} {direct:>12.4f} {indirect:>12.4f} {total:>12.4f} {total_dollar:>+12,.0f} $")510511# ═══════════════════════════════════════════════════════════════512# 11. RÉSUMÉ EXÉCUTIF513# ═══════════════════════════════════════════════════════════════514515print("\n" + "=" * 90)516print("RÉSUMÉ EXÉCUTIF — RÉGRESSION QUANTILE SPATIALE")517print("=" * 90)518519n_hetero = 0520for var in amenity_vars:521    b10 = qr_results[0.10].params[var]522    b90 = qr_results[0.90].params[var]523    se10 = qr_results[0.10].bse[var]524    se90 = qr_results[0.90].bse[var]525    se_diff = np.sqrt(se10**2 + se90**2)526    if se_diff > 0 and abs((b10 - b90) / se_diff) > 1.96:527        n_hetero += 1528529print(f"""530  ┌─────────────────────────────────────────────────────────────────┐531  │  DONNÉES                                                        │532  │  N = {n:>6,d} maisons unifamiliales, Québec (Centris)           │533  │  Prix médian = {median_price:>10,.0f} $                                   │534  │                                                                 │535  │  AUTOCORRÉLATION SPATIALE                                       │536  │  Moran's I (résidus MCO) = {I_ols:.4f} (p < 0.001)              │537  │  → Justifie le modèle spatial                                   │538  │                                                                 │539  │  RÉSULTAT PRINCIPAL                                             │540  │  {n_hetero}/{len(amenity_vars)} aménités montrent une hétérogénéité significative     │541  │  entre τ=0.10 et τ=0.90 (test de Wald, p < 0.05)              │542  │                                                                 │543  │  IMPLICATION                                                    │544  │  Les prix implicites moyens (MCO) masquent des différences      │545  │  importantes entre segments de prix. La capitalisation des      │546  │  aménités n'est pas uniforme à travers la distribution.         │547  │                                                                 │548  │  CONTRIBUTION                                                   │549  │  Première application de la régression quantile spatiale        │550  │  au marché résidentiel québécois avec données Centris.          │551  └─────────────────────────────────────────────────────────────────┘552""")553554# Détail de l'hétérogénéité555print("  Détail par aménité (τ=0.10 vs τ=0.90) :")556print(f"  {'Aménité':<20s} {'β(0.10)':>10s} {'β(0.90)':>10s} {'Diff':>10s} {'Wald':>8s} {'Sig':>5s}")557print("  " + "-" * 68)558for var in amenity_vars:559    b10 = qr_results[0.10].params[var]560    b90 = qr_results[0.90].params[var]561    se10 = qr_results[0.10].bse[var]562    se90 = qr_results[0.90].bse[var]563    diff = b10 - b90564    se_diff = np.sqrt(se10**2 + se90**2)565    wald = (diff / se_diff) ** 2 if se_diff > 0 else 0566    p_wald = 1 - stats.chi2.cdf(wald, 1)567    sig = "***" if p_wald < 0.001 else "**" if p_wald < 0.01 else "*" if p_wald < 0.05 else "n.s."568    print(f"  {var:<20s} {b10:>10.4f} {b90:>10.4f} {diff:>+10.4f} {wald:>8.2f} {sig:>5s}")569