| 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 |
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 |
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 |
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 |
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") |