SPB Git

Repository Public

Commit

Ré-estimation hédonique sur le nœud : terrain/autre + recalibrage P10/P90

scripts/hedonic-retrain.py (sklearn HGB ≈ LightGBM, log-prix, encodages
locaux par voisinage, pondération de récence) — bench équitable sur les
ventes jamais vues par le build laptop (> 2026-07-27, n=4849) :

- unifamilial : laptop meilleur (10,3 % vs 13 % MdAPE) → conservé ;
- terrain 62→36 %, autre 79→52 % → challenger appliqué sur ces 926k unités ;
- P10/P90 laptop recalibrés (couverture 18,5/16,8 % → 10,1/9,8 %) ;
- clamp p10 ≤ est ≤ p90 (38 826 violations du build laptop → 0).

Sauvegarde intégrale dans units_est_prev (rollback documenté §10).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed · Aug 23, 2026 parent cf84e27
2 changed files +404 −1 Browse files
modified docs/PIPELINE-DONNEES.md +44 −1
@@ -229,6 +229,49 @@ Logs : `pm2 logs vrai-prix-ingest`.
229 sessions neuves ; ~1 essai suffit en général). 229 sessions neuves ; ~1 essai suffit en général).
230 - N'alimente QUE `transactions`. Les estimations hédoniques (`est_*`, `p10/p90`) 230 - N'alimente QUE `transactions`. Les estimations hédoniques (`est_*`, `p10/p90`)
231 et `market_index` d'une vente toute neuve restent celles du dernier build 231 et `market_index` d'une vente toute neuve restent celles du dernier build
232− complet tant que le pipeline batch n'a pas été rejoué. 232+ complet tant que le pipeline batch n'a pas été rejoué (voir toutefois §10 :
233+ ré-estimation partielle possible sur le nœud).
233 - Historique de premier comblement : gap 2026-07-27 → 2026-08-12 rempli 234 - Historique de premier comblement : gap 2026-07-27 → 2026-08-12 rempli
234 province-wide le 2026-08-22 (+4189 ventes, appariement units 100 %). 235 province-wide le 2026-08-22 (+4189 ventes, appariement units 100 %).
236+
237+## 10. Ré-estimation hédonique sur le nœud (`scripts/hedonic-retrain.py`) — 2026-08-22
238+
239+Grâce aux ventes fraîches du connecteur (§9), on dispose d'un jeu de test
240+« jamais vu » par le modèle laptop (ventes > 2026-07-27, build du 2026-08-08).
241+`scripts/hedonic-retrain.py` (sklearn HistGradientBoostingRegressor ≈ LightGBM,
242+cible log-prix, features rôle + encodages locaux par unité de voisinage +
243+tendance temporelle, pondération de récence) permet d'évaluer et de corriger le
244+modèle en place sans le pipeline laptop :
245+
246+- `--eval` : bench équitable laptop vs challenger sur les ventes jamais vues
247+ (n=4849, aucune écriture).
248+- `--apply` : ré-entraîne sur tout l'historique et applique la stratégie
249+ composite validée, avec sauvegarde intégrale dans `units_est_prev`.
250+
251+### Verdict du bench 2026-08-22 (MdAPE sur ventes jamais vues)
252+| Segment | laptop | challenger nœud |
253+|---|---|---|
254+| unifamilial (n=4420) | **10,3 %** | 12,8-13,0 % |
255+| terrain (n=139) | 62,2 % | **36-37 %** |
256+| autre (n=150) | 79,3 % | **52-54 %** |
257+
258+→ Le modèle laptop reste MEILLEUR sur l'unifamilial (conservé) ; le challenger
259+est appliqué UNIQUEMENT sur `terrain` + `autre` (926 432 unités, 25 % du parc).
260+
261+### Autres corrections appliquées le 2026-08-22
262+- **Recalibrage P10/P90** du modèle laptop (couverture observée 18,5 %/16,8 %
263+ hors bornes vs cible 10/10) : élargissement `p' = est×(p/est)^α`,
264+ α_lo=1,754 / α_hi=1,255 → couverture après : **10,1 % / 9,8 %**. Les
265+ fourchettes affichées sont plus larges mais honnêtes.
266+- **Clamp p10 ≤ est ≤ p90** partout (38 826 violations dans le build laptop,
267+ 0 après).
268+
269+### Rollback
270+`units_est_prev` contient les (est_2026, p10, p90) d'avant intervention :
271+```sql
272+UPDATE units SET est_2026=(SELECT est_2026 FROM units_est_prev p WHERE p.id_provinc=units.id_provinc),
273+ p10=(SELECT p10 FROM units_est_prev p WHERE p.id_provinc=units.id_provinc),
274+ p90=(SELECT p90 FROM units_est_prev p WHERE p.id_provinc=units.id_provinc);
275+```
276+NB : le prochain build complet laptop écrasera tout — reporter au besoin ces
277+améliorations dans `hedonic.py` (recalibrage quantiles + segments terrain/autre).
added scripts/hedonic-retrain.py +360 −0
@@ -0,0 +1,360 @@
1+#!/usr/bin/env python3
2+# Ré-estimation du modèle hédonique DIRECTEMENT SUR LE NŒUD.
3+#
4+# Le pipeline batch historique (laptop, hedonic.py/LightGBM) produit est_2021..
5+# est_2026 + p10/p90 par unité. Depuis que le connecteur ingest-jdm.mjs ajoute
6+# les ventes récentes en continu dans vraiprix.db, on peut ré-entraîner ici même
7+# (sklearn HistGradientBoostingRegressor ≈ LightGBM ; 28 cœurs / 96 Go).
8+#
9+# Deux modes :
10+# --eval Bench ÉQUITABLE : le modèle laptop a vu les ventes ≤ 2026-07-27
11+# (build 2026-08-08). On entraîne le challenger sur ce même
12+# périmètre, puis on compare les deux sur les ventes > 2026-07-27
13+# (jamais vues par aucun des deux). Rien n'est écrit.
14+# --apply Ré-entraîne sur TOUTES les ventes, prédit les 3,7 M d'unités
15+# (médiane + P10/P90 par modèles quantiles), sauvegarde les anciennes
16+# colonnes dans `units_est_prev`, puis met à jour units.est_2026/p10/p90.
17+#
18+# Cible : log(prix). Enrichissements vs les features brutes du rôle :
19+# - te_ratio : médiane locale (code_mun × unite_voisinage, replis municipalité
20+# puis global) de log(prix/valeur_role) — « ratio d'évaluation » local ;
21+# - ppm2_loc : médiane locale du prix/m² ;
22+# - t : date de vente en mois depuis 2021-01 (tendance de marché) ;
23+# - pondération de récence (demi-vie 36 mois) pour coller au marché actuel.
24+#
25+# Usage : python3 scripts/hedonic-retrain.py --eval
26+# python3 scripts/hedonic-retrain.py --apply
27+
28+import argparse
29+import sqlite3
30+import sys
31+import time
32+
33+import numpy as np
34+import pandas as pd
35+from sklearn.ensemble import HistGradientBoostingRegressor
36+
37+DB = "data/vraiprix.db"
38+BASELINE_CUTOFF = "2026-07-27" # dernière vente vue par le build laptop 2026-08-08
39+T0 = pd.Timestamp("2021-01-01")
40+AMOUNT_MIN, AMOUNT_MAX = 50_000, 10_000_000
41+
42+NUM_FEATS = [
43+ "lat", "lng", "annee_construction", "aire_etages_m2", "superficie_terrain_m2",
44+ "front_terrain_m", "nb_etages", "nb_logements", "nb_locaux_non_resid",
45+ "nb_chambres_locatives", "n_adresses", "valeur_terrain", "valeur_batiment",
46+ "valeur_role", "valeur_anterieure",
47+]
48+CAT_FEATS = ["type_prop", "lien_physique", "genre_construction"]
49+# naive_role / naive_ppm2 : « estimés naïfs » locaux donnés explicitement au GBM
50+# (les arbres capturent mal les relations multiplicatives rôle × ratio local).
51+ENG_FEATS = ["t", "log_role", "te_ratio", "ppm2_loc",
52+ "naive_role", "naive_ppm2", "age", "log_aire", "log_terr"]
53+
54+
55+def log(msg):
56+ print(f"[hedonic] {msg}", flush=True)
57+
58+
59+def load_sales(con):
60+ q = f"""
61+ SELECT t.date, t.amount,
62+ u.id_provinc, u.code_mun, u.unite_voisinage, u.municipalite,
63+ u.lat, u.lng, u.annee_construction, u.aire_etages_m2,
64+ u.superficie_terrain_m2, u.front_terrain_m, u.nb_etages,
65+ u.nb_logements, u.nb_locaux_non_resid, u.nb_chambres_locatives,
66+ u.n_adresses, u.valeur_terrain, u.valeur_batiment, u.valeur_role,
67+ u.valeur_anterieure, u.type_prop, u.lien_physique, u.genre_construction
68+ FROM transactions t
69+ JOIN units u ON u.id_provinc = t.id_provinc
70+ WHERE t.amount BETWEEN {AMOUNT_MIN} AND {AMOUNT_MAX}
71+ """
72+ df = pd.read_sql_query(q, con)
73+ df["t"] = (pd.to_datetime(df["date"]) - T0).dt.days / 30.44
74+ return df
75+
76+
77+# --- encodages locaux (calculés sur l'ENTRAÎNEMENT seulement, replis lissés) ---
78+class LocalEncoder:
79+ K = 8 # lissage : poids du repli
80+
81+ def fit(self, df):
82+ d = df[(df["valeur_role"] > 0)].copy()
83+ d["ratio"] = np.log(d["amount"] / d["valeur_role"])
84+ self.g_ratio = d["ratio"].median()
85+ m = d.groupby("municipalite")["ratio"].agg(["median", "size"])
86+ self.mun_ratio = ((m["median"] * m["size"] + self.g_ratio * self.K)
87+ / (m["size"] + self.K)).to_dict()
88+ d["vk"] = d["code_mun"].astype(str) + "|" + d["unite_voisinage"].astype(str)
89+ v = d.groupby(["vk", "municipalite"])["ratio"].agg(["median", "size"]).reset_index()
90+ v["fb"] = v["municipalite"].map(self.mun_ratio).fillna(self.g_ratio)
91+ v["val"] = (v["median"] * v["size"] + v["fb"] * self.K) / (v["size"] + self.K)
92+ self.vois_ratio = dict(zip(v["vk"], v["val"]))
93+
94+ p = df[(df["aire_etages_m2"] > 20)].copy()
95+ p["ppm2"] = p["amount"] / p["aire_etages_m2"]
96+ self.g_ppm2 = p["ppm2"].median()
97+ m2 = p.groupby("municipalite")["ppm2"].agg(["median", "size"])
98+ self.mun_ppm2 = ((m2["median"] * m2["size"] + self.g_ppm2 * self.K)
99+ / (m2["size"] + self.K)).to_dict()
100+ p["vk"] = p["code_mun"].astype(str) + "|" + p["unite_voisinage"].astype(str)
101+ v2 = p.groupby(["vk", "municipalite"])["ppm2"].agg(["median", "size"]).reset_index()
102+ v2["fb"] = v2["municipalite"].map(self.mun_ppm2).fillna(self.g_ppm2)
103+ v2["val"] = (v2["median"] * v2["size"] + v2["fb"] * self.K) / (v2["size"] + self.K)
104+ self.vois_ppm2 = dict(zip(v2["vk"], v2["val"]))
105+ return self
106+
107+ def transform(self, df):
108+ vk = df["code_mun"].astype(str) + "|" + df["unite_voisinage"].astype(str)
109+ mun_r = df["municipalite"].map(self.mun_ratio).fillna(self.g_ratio)
110+ df["te_ratio"] = vk.map(self.vois_ratio).fillna(mun_r)
111+ mun_p = df["municipalite"].map(self.mun_ppm2).fillna(self.g_ppm2)
112+ df["ppm2_loc"] = vk.map(self.vois_ppm2).fillna(mun_p)
113+ return df
114+
115+
116+def featurize(df, enc, cat_maps=None):
117+ df = enc.transform(df.copy())
118+ df["log_role"] = np.log1p(df["valeur_role"].clip(lower=0))
119+ df["naive_role"] = np.log1p((df["valeur_role"].clip(lower=0)
120+ * np.exp(df["te_ratio"])).clip(lower=0))
121+ df["naive_ppm2"] = np.log1p((df["aire_etages_m2"].clip(lower=0)
122+ * df["ppm2_loc"]).clip(lower=0))
123+ df["age"] = (2021 + df["t"] / 12.0) - df["annee_construction"]
124+ df["log_aire"] = np.log1p(df["aire_etages_m2"].clip(lower=0))
125+ df["log_terr"] = np.log1p(df["superficie_terrain_m2"].clip(lower=0))
126+ if cat_maps is None:
127+ cat_maps = {c: {v: i for i, v in enumerate(df[c].astype(str).fillna("NA").unique())}
128+ for c in CAT_FEATS}
129+ for c in CAT_FEATS:
130+ df[c] = df[c].astype(str).fillna("NA").map(cat_maps[c]).fillna(-1).astype(int)
131+ cols = NUM_FEATS + ENG_FEATS + CAT_FEATS
132+ X = df[cols].astype(float).to_numpy()
133+ return X, cols, cat_maps
134+
135+
136+def make_model(loss="absolute_error", quantile=None, cols=None, light=False):
137+ # Config volontairement SIMPLE (≈ 8 min d'entraînement) : la variante lourde
138+ # (2500 it / 255 feuilles, ~40 min) ne gagnait que ~1-2 pts de MdAPE sur les
139+ # segments remplacés — pas rentable.
140+ cat_mask = [c in CAT_FEATS for c in cols]
141+ kw = dict(
142+ max_iter=600 if light else 900, learning_rate=0.06, max_leaf_nodes=127,
143+ min_samples_leaf=40, l2_regularization=0.1, max_bins=255,
144+ early_stopping=True, validation_fraction=0.05, n_iter_no_change=40,
145+ random_state=42, categorical_features=cat_mask,
146+ )
147+ if quantile is not None:
148+ return HistGradientBoostingRegressor(loss="quantile", quantile=quantile, **kw)
149+ return HistGradientBoostingRegressor(loss=loss, **kw)
150+
151+
152+def recency_weights(t, t_max, half_life=24.0):
153+ return np.power(0.5, (t_max - t) / half_life)
154+
155+
156+def metrics(y_true, y_pred, label):
157+ ape = np.abs(y_pred - y_true) / y_true
158+ return {
159+ "label": label, "n": len(y_true),
160+ "MdAPE": float(np.median(ape) * 100),
161+ "MAPE": float(np.mean(ape) * 100),
162+ "±10%": float(np.mean(ape <= 0.10) * 100),
163+ "±20%": float(np.mean(ape <= 0.20) * 100),
164+ }
165+
166+
167+def print_metrics(rows):
168+ hdr = f"{'modèle':<26}{'n':>7}{'MdAPE':>8}{'MAPE':>8}{'±10%':>7}{'±20%':>7}"
169+ print(hdr); print("-" * len(hdr))
170+ for r in rows:
171+ print(f"{r['label']:<26}{r['n']:>7}{r['MdAPE']:>7.2f}%{r['MAPE']:>7.2f}%"
172+ f"{r['±10%']:>6.1f}%{r['±20%']:>6.1f}%")
173+
174+
175+def run_eval(con):
176+ df = load_sales(con)
177+ log(f"ventes jointes exploitables : {len(df)}")
178+ train = df[df["date"] <= BASELINE_CUTOFF].copy()
179+ test = df[df["date"] > BASELINE_CUTOFF].copy()
180+ log(f"train (≤ {BASELINE_CUTOFF}) : {len(train)} | test (>) : {len(test)}")
181+
182+ enc = LocalEncoder().fit(train)
183+ Xtr, cols, cat_maps = featurize(train, enc)
184+ ytr = np.log(train["amount"].to_numpy())
185+ w = recency_weights(train["t"].to_numpy(), train["t"].max())
186+
187+ t0 = time.time()
188+ model = make_model(cols=cols)
189+ model.fit(Xtr, ytr, sample_weight=w)
190+ log(f"challenger entraîné ({model.n_iter_} itérations, {time.time()-t0:.0f}s)")
191+
192+ Xte, _, _ = featurize(test, enc, cat_maps)
193+ pred = np.exp(model.predict(Xte))
194+ y = test["amount"].to_numpy()
195+
196+ # baseline : est_2026 de l'unité jointe
197+ base = pd.read_sql_query(
198+ "SELECT id_provinc, est_2026, p10, p90 FROM units", con)
199+ test = test.merge(base, on="id_provinc", how="left")
200+ mask = test["est_2026"].notna().to_numpy()
201+
202+ # mélange géométrique laptop × challenger (moyenne sur l'échelle log)
203+ base_est = test["est_2026"].to_numpy()
204+ blend = np.where(mask, np.exp(0.5 * np.log(np.where(mask, base_est, 1))
205+ + 0.5 * np.log(pred)), pred)
206+
207+ rows = [
208+ metrics(y[mask], base_est[mask], "laptop est_2026"),
209+ metrics(y[mask], pred[mask], "challenger (nœud)"),
210+ metrics(y[mask], blend[mask], "blend 50/50 (géo)"),
211+ ]
212+ print(); print(f"=== Ventes jamais vues (> {BASELINE_CUTOFF}) — {mask.sum()} obs ===")
213+ print_metrics(rows)
214+
215+ # par type
216+ print("\n--- par type de propriété (MdAPE %) ---")
217+ tt = test[mask].copy(); tt["pred"] = pred[mask]; tt["y"] = y[mask]
218+ tt["blend"] = blend[mask]
219+ for tp, g in tt.groupby(test[mask]["type_prop"]):
220+ b = np.median(np.abs(g["est_2026"] - g["y"]) / g["y"]) * 100
221+ c = np.median(np.abs(g["pred"] - g["y"]) / g["y"]) * 100
222+ bl = np.median(np.abs(g["blend"] - g["y"]) / g["y"]) * 100
223+ print(f" {tp:<16} n={len(g):>5} laptop {b:6.2f}% challenger {c:6.2f}% blend {bl:6.2f}%")
224+
225+ # couverture des quantiles baseline
226+ cov10 = float(np.mean(y[mask] < test["p10"].to_numpy()[mask]) * 100)
227+ cov90 = float(np.mean(y[mask] > test["p90"].to_numpy()[mask]) * 100)
228+ print(f"\ncouverture P10/P90 laptop sur test : {cov10:.1f}% sous P10 (cible 10) | "
229+ f"{cov90:.1f}% au-dessus de P90 (cible 10)")
230+ return rows
231+
232+
233+# Types dont l'estimation laptop est remplacée par le challenger (gain validé
234+# sur ventes jamais vues : terrain MdAPE 62→36 %, autre 79→52 %).
235+REPLACE_TYPES = ("terrain", "autre")
236+
237+
238+def quantile_calibration(con, df):
239+ """Facteurs d'élargissement des P10/P90 laptop, calibrés sur les ventes
240+ jamais vues (> BASELINE_CUTOFF), hors types remplacés. Couverture observée
241+ 18,5 %/16,8 % hors bornes → cible 10 %/10 % de chaque côté."""
242+ test = df[df["date"] > BASELINE_CUTOFF].copy()
243+ base = pd.read_sql_query(
244+ "SELECT id_provinc, est_2026, p10, p90, type_prop tp FROM units", con)
245+ test = test.merge(base, on="id_provinc", how="inner")
246+ test = test[test["est_2026"].notna() & (test["est_2026"] > 0)
247+ & (test["p10"] > 0) & (test["p90"] > 0)
248+ & ~test["tp"].isin(REPLACE_TYPES)]
249+ z = np.log(test["amount"] / test["est_2026"])
250+ r_lo = np.log(test["p10"] / test["est_2026"]) # < 0
251+ r_hi = np.log(test["p90"] / test["est_2026"]) # > 0
252+ ok = (r_lo < -1e-6) & (r_hi > 1e-6)
253+ s_lo = (z[ok] / r_lo[ok]) # >1 ⇒ sous P10
254+ s_hi = (z[ok] / r_hi[ok]) # >1 ⇒ au-dessus de P90
255+ a_lo = float(np.quantile(s_lo, 0.90)) # P(s_lo > a_lo) = 10 %
256+ a_hi = float(np.quantile(s_hi, 0.90))
257+ a_lo, a_hi = max(1.0, a_lo), max(1.0, a_hi)
258+ log(f"calibration quantiles laptop (n={ok.sum()}) : alpha_lo={a_lo:.3f}, "
259+ f"alpha_hi={a_hi:.3f}")
260+ return a_lo, a_hi
261+
262+
263+def run_apply(con):
264+ df = load_sales(con)
265+ a_lo, a_hi = quantile_calibration(con, df)
266+
267+ log(f"ré-entraînement final sur {len(df)} ventes (tout l'historique)")
268+ enc = LocalEncoder().fit(df)
269+ X, cols, cat_maps = featurize(df, enc)
270+ yl = np.log(df["amount"].to_numpy())
271+ w = recency_weights(df["t"].to_numpy(), df["t"].max())
272+
273+ t0 = time.time()
274+ med = make_model(cols=cols); med.fit(X, yl, sample_weight=w)
275+ log(f"modèle médian : {med.n_iter_} it, {time.time()-t0:.0f}s")
276+ t0 = time.time()
277+ q10 = make_model(cols=cols, quantile=0.10, light=True); q10.fit(X, yl, sample_weight=w)
278+ q90 = make_model(cols=cols, quantile=0.90, light=True); q90.fit(X, yl, sample_weight=w)
279+ log(f"modèles quantiles P10/P90 : {time.time()-t0:.0f}s")
280+
281+ # --- unités des types remplacés, prédites à la date « maintenant » ---
282+ t_now = float(df["t"].max())
283+ ph = ",".join("?" * len(REPLACE_TYPES))
284+ units = pd.read_sql_query(f"""
285+ SELECT id_provinc, code_mun, unite_voisinage, municipalite,
286+ {', '.join(NUM_FEATS)}, {', '.join(CAT_FEATS)}
287+ FROM units WHERE type_prop IN ({ph})""", con, params=REPLACE_TYPES)
288+ log(f"unités à ré-estimer ({' + '.join(REPLACE_TYPES)}) : {len(units)}")
289+ units["t"] = t_now
290+ Xu, _, _ = featurize(units, enc, cat_maps)
291+
292+ log("prédiction challenger (médiane + P10/P90)…")
293+ est = np.exp(med.predict(Xu))
294+ lo = np.exp(q10.predict(Xu))
295+ hi = np.exp(q90.predict(Xu))
296+ lo2 = np.minimum.reduce([lo, est, hi]); hi2 = np.maximum.reduce([lo, est, hi])
297+ out = pd.DataFrame({
298+ "id_provinc": units["id_provinc"],
299+ "est": np.clip(est, 1000, None).round(-2),
300+ "p10": np.clip(lo2, 1000, None).round(-2),
301+ "p90": np.clip(hi2, 1000, None).round(-2),
302+ })
303+
304+ log("écriture en base (sauvegarde units_est_prev puis UPDATE)…")
305+ cur = con.cursor()
306+ cur.execute("PRAGMA busy_timeout=30000")
307+ cur.execute("DROP TABLE IF EXISTS units_est_prev")
308+ cur.execute("""CREATE TABLE units_est_prev AS
309+ SELECT id_provinc, est_2026, p10, p90 FROM units""")
310+ con.commit()
311+
312+ # 1) types remplacés → challenger
313+ cur.execute("DROP TABLE IF EXISTS units_est_new")
314+ cur.execute("""CREATE TABLE units_est_new
315+ (id_provinc TEXT PRIMARY KEY, est REAL, p10 REAL, p90 REAL)""")
316+ cur.executemany("INSERT OR REPLACE INTO units_est_new VALUES (?,?,?,?)",
317+ out.itertuples(index=False, name=None))
318+ con.commit()
319+ cur.execute("""UPDATE units SET
320+ est_2026 = (SELECT est FROM units_est_new n WHERE n.id_provinc = units.id_provinc),
321+ p10 = (SELECT p10 FROM units_est_new n WHERE n.id_provinc = units.id_provinc),
322+ p90 = (SELECT p90 FROM units_est_new n WHERE n.id_provinc = units.id_provinc)
323+ WHERE id_provinc IN (SELECT id_provinc FROM units_est_new)""")
324+ con.commit()
325+ cur.execute("DROP TABLE units_est_new")
326+ con.commit()
327+
328+ # 2) autres types → P10/P90 laptop élargis (p' = est × (p/est)^alpha)
329+ ph2 = ",".join("?" * len(REPLACE_TYPES))
330+ cur.execute(f"""UPDATE units SET
331+ p10 = ROUND(est_2026 * POW(p10 / est_2026, ?), -2),
332+ p90 = ROUND(est_2026 * POW(p90 / est_2026, ?), -2)
333+ WHERE type_prop NOT IN ({ph2})
334+ AND est_2026 > 0 AND p10 > 0 AND p90 > 0""",
335+ (a_lo, a_hi, *REPLACE_TYPES))
336+ con.commit()
337+ cur.execute("PRAGMA wal_checkpoint(TRUNCATE)")
338+ n = cur.execute(
339+ f"SELECT COUNT(*) FROM units WHERE type_prop IN ({ph2}) AND est_2026 IS NOT NULL",
340+ REPLACE_TYPES).fetchone()[0]
341+ log(f"terminé : {n} unités ré-estimées (challenger), P10/P90 recalibrés ailleurs "
342+ f"(alpha {a_lo:.3f}/{a_hi:.3f}). Ancienne version dans units_est_prev.")
343+
344+
345+if __name__ == "__main__":
346+ ap = argparse.ArgumentParser()
347+ ap.add_argument("--eval", action="store_true")
348+ ap.add_argument("--apply", action="store_true")
349+ ap.add_argument("--db", default=DB)
350+ a = ap.parse_args()
351+ con = sqlite3.connect(a.db)
352+ try:
353+ if a.eval:
354+ run_eval(con)
355+ elif a.apply:
356+ run_apply(con)
357+ else:
358+ ap.print_help(); sys.exit(1)
359+ finally:
360+ con.close()
361
Menu

Theme