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