SPB Git forge

spb/wp16_uqo

Public
11commits 1branches 1releases
3.3 MBsize
maindefault branch
25 min agolast push
TeX 52.9% Python 45.7% Shell 1.4%

Modèles : ré-estimation trimestrielle, effets mensuels, GBM L1, correction médiane M10

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 7 days ago (Oct 10, 2026) parent d96d5d8

2 changed files +22 −11

modified pipeline/03_models.py +21 −10
@@ -2,7 +2,7 @@
2 2 """03 — Modèles d'ajustement et modèles de référence, ré-estimés chaque trimestre avec les seules ventes
3 3 antérieures au trimestre (as-of 2024-01, 2024-04, …, 2026-07) — comme le ferait un AVM ré-entraîné régulièrement :
4 4 • grille hédonique globale (coefficients) et régionale rétrécie → ajustements B / C
5 − • LightGBM sur le prix ajusté dans le temps (avec et sans rôle) → ajustements C-GBM, M11
5 + • LightGBM (objectif L1 = médiane) sur le prix ajusté dans le temps (avec et sans rôle) → ajustements C-GBM, M11
6 6 • hédonique globale directe avec effets de municipalité → M10
7 7 Sorties : models_<période>.json, gbm_pred_<période>.npy (prédiction f pour TOUTES les ventes)."""
8 8 import pickle
@@ -45,13 +45,19 @@ for m_asof in ASOF:
45 45 # mesurent des différences entre biens voisins et non entre marchés
46 46 def within(frame, cols, by):
47 47 return frame[cols] - frame.groupby(by)[cols].transform("mean")
48 − Fw = within(pd.concat([F, tr[["code_mun"]]], axis=1), FEATS, "code_mun")
48 + # effets mensuels résiduels (base = dernier mois connu m_asof-1) : l'indice régional ne retire pas toute la
49 + # tendance commune ; sans eux, les modèles globaux sous-estiment les ventes récentes
50 + months_tr = sorted(tr["month"].unique()); MCOLS = [f"m{m}" for m in months_tr if m != m_asof - 1]
51 + Fm = pd.concat([F] + [pd.Series((tr["month"] == m).astype(float), index=tr.index, name=f"m{m}") for m in months_tr if m != m_asof - 1], axis=1)
52 + Fw = within(pd.concat([Fm, tr[["code_mun"]]], axis=1), FEATS + MCOLS, "code_mun")
49 53 yw = y - pd.Series(y, index=tr.index).groupby(tr["code_mun"]).transform("mean").to_numpy()
50 − bw, *_ = np.linalg.lstsq(Fw.to_numpy(), yw, rcond=None)
54 + bw_all, *_ = np.linalg.lstsq(Fw.to_numpy(), yw, rcond=None)
55 + bw = bw_all[:len(FEATS)]; bm = bw_all[len(FEATS):]
51 56 beta_global = dict(zip(FEATS, bw.tolist()))
52 − xb = F.to_numpy() @ bw
57 + xb = F.to_numpy() @ bw + Fm[MCOLS].to_numpy() @ bm
53 58 resid = y - xb
54 − r2w = 1 - np.var(yw - Fw.to_numpy() @ bw) / np.var(yw)
59 + r2w = 1 - np.var(yw - Fw.to_numpy() @ bw_all) / np.var(yw)
60 + Fw = Fw[FEATS]
55 61 log(f" hédonique within-municipalité : R² within = {r2w:.3f} ; β = { {k: round(v, 3) for k, v in beta_global.items()} }")
56 62 # --- grille régionale (within-municipalité) rétrécie vers la globale (n/(n+300))
57 63 beta_region = {}
@@ -70,6 +76,9 @@ for m_asof in ASOF:
70 76 mun_eff = tr.groupby("code_mun")["res_m"].agg(["sum", "count"])
71 77 mun_eff = (mun_eff["sum"] / (mun_eff["count"] + 20)).to_dict()
72 78 tr["res_u"] = tr["res_m"] - tr["code_mun"].map(mun_eff).fillna(0)
79 + # correction médiane : la MCO vise la moyenne du log ; la distribution est asymétrique (queue basse), on recentre sur la médiane
80 + uv_tmp = tr.groupby(["code_mun", "unite_voisinage"])["res_u"].transform(lambda v: v.sum() / (len(v) + 10))
81 + median_shift = float(np.median(tr["res_u"] - uv_tmp))
73 82 uv = tr.groupby(["code_mun", "unite_voisinage"])["res_u"].agg(["sum", "count"])
74 83 uv_eff = {f"{a}|{b}": v for (a, b), v in (uv["sum"] / (uv["count"] + 10)).items()}
75 84 # --- LightGBM (prix ajusté dans le temps) avec rôle et sans rôle
@@ -78,9 +87,10 @@ for m_asof in ASOF:
78 87 G["lat"] = d["lat"]; G["lng"] = d["lng"]; G["x_km"] = d["x_km"]; G["y_km"] = d["y_km"]
79 88 G["rid"] = d["rid"]; G["btype"] = d["btype"]
80 89 G["ln_role"] = np.log(d["role_vente"]); G["part_terrain"] = d["part_terrain"]; G["role_vintage"] = d["role_vintage"]
90 + G["month"] = np.minimum(d["month"], m_asof - 1).astype(float) # tendance résiduelle ; hors échantillon = dernier mois connu
81 91 return G
82 92 G = gbm_frame(tr)
83 − params = dict(objective="regression", learning_rate=0.05, num_leaves=127, min_data_in_leaf=40, feature_fraction=0.8,
93 + params = dict(objective="regression_l1", learning_rate=0.05, num_leaves=127, min_data_in_leaf=40, feature_fraction=0.8,
84 94 bagging_fraction=0.8, bagging_freq=1, lambda_l2=1.0, verbose=-1, num_threads=20)
85 95 models = {}
86 96 for name, cols in [("gbm", [c for c in G.columns if c not in ("ln_role", "part_terrain", "role_vintage")]),
@@ -94,14 +104,15 @@ for m_asof in ASOF:
94 104 best = bst.best_iteration
95 105 bst = lgb.train(params, ds, num_boost_round=best)
96 106 models[name] = bst
97 − pred_all = bst.predict(gbm_frame(df)[cols])
107 + Gall = gbm_frame(df); Gall["month"] = float(m_asof - 1) # f évalué au niveau du dernier mois connu pour toutes les ventes
108 + pred_all = bst.predict(Gall[cols])
98 109 np.save(OUT / f"{name}_pred_{period}.npy", pred_all.astype(np.float32))
99 − r2 = 1 - np.var(y - bst.predict(G[cols])) / np.var(y)
100 − log(f" {name}: {best} arbres, R² (in-sample) = {r2:.3f}")
110 + rr = y - bst.predict(G[cols]); r2 = 1 - np.var(rr) / np.var(y)
111 + log(f" {name}: {best} arbres, R² (in-sample) = {r2:.3f}, résidu médian = {np.median(rr) * 100:.2f} %")
101 112 bst.save_model(str(OUT / f"{name}_{period}.txt"))
102 113 save_json({"period": period, "m_asof": m_asof, "start": start, "n_train": len(tr), "feats": FEATS,
103 114 "beta_global": beta_global, "intercept_global": 0.0, "region_fe": reg_eff,
104 115 "beta_region": beta_region, "mrc_eff": mrc_eff, "mun_eff": mun_eff, "uv_eff": uv_eff,
105 − "r2_within": float(r2w), "sd_resid_u": float(tr["res_u"].std())},
116 + "r2_within": float(r2w), "sd_resid_u": float(tr["res_u"].std()), "median_shift": median_shift},
106 117 f"models_{period}.json")
107 118 log("modèles terminés")
modified pipeline/05_analyze.py +1 −1
@@ -126,7 +126,7 @@ for a in load_json("asof.json")["asof"]:
126 126 shift = level_shift(mod["m_asof"])
127 127 fe = (T["region"].map(mod["region_fe"]).fillna(0) + T["mrc"].map(mod["mrc_eff"]).fillna(0) + T["code_mun"].map(mod["mun_eff"]).fillna(0)
128 128 + (T["code_mun"].astype(str) + "|" + T["unite_voisinage"].astype(str)).map(mod["uv_eff"]).fillna(0)).to_numpy()
129 − M10[mask] = (XF @ beta + fe + shift)[mask]
129 + M10[mask] = (XF @ beta + fe + shift + mod["median_shift"])[mask]
130 130 M11[mask] = (np.load(OUT / f"gbm_role_pred_{period}.npy")[T["idx"].to_numpy()] + shift)[mask]
131 131 M11b[mask] = (np.load(OUT / f"gbm_pred_{period}.npy")[T["idx"].to_numpy()] + shift)[mask]
132 132
133 133