Thibaut Forest
Claude Fable 5
HEAD: T3 warmup gate - gated hazard order + alpha 1.4 boost - CV 1418.3
e4745d2 Download batteryswap_example/models_cv.py from thibautforest/BatterySwapAI2026-Example: direct link, hf CLI and curl.
- Browser
- Download file 94.3 kB
-
https://huggingface.co/thibautforest/BatterySwapAI2026-Example/resolve/main/batteryswap_example/models_cv.py
- Command line
-
hf download hf://thibautforest/BatterySwapAI2026-Example/batteryswap_example/models_cv.py
-
curl -L -o models_cv.py https://huggingface.co/thibautforest/BatterySwapAI2026-Example/resolve/main/batteryswap_example/models_cv.py
94.3 kB
| """ | |
| Model comparison under walk-forward CV. | |
| Protocol | |
| -------- | |
| - Training pairs at origin T: all (device, origin') rows whose 42-day window closed | |
| before T (origin' <= T - 42d). Window closed => label fully observed, and there is | |
| a built-in embargo: no training window overlaps the scored window. | |
| - Walk-forward: the model is refit at every scored origin on exactly the pairs that | |
| would have been observable then. | |
| - DEV block s_8..s_23: hyperparameters (p_star, day quantile, planner) are tuned here. | |
| - TEST block s_24..s_41: scored once with dev-chosen settings. | |
| Models predict, per alive device: | |
| p42 - probability of EOL within the 42-day window | |
| day_q - quantile of days-to-EOL conditional on dying in the window | |
| Planners turn (p42, day_q) into a plan: | |
| onepd - worst-first, one per day (control, shape of the shipped planner) | |
| sched - swap each included battery on its predicted day | |
| batch - sched + move each building's swaps to the group's earliest day and | |
| cap building-groups per day (travel is expensive, early days are cheap) | |
| """ | |
| from pathlib import Path | |
| import os | |
| import sys | |
| import warnings | |
| import numpy as np | |
| import pandas as pd | |
| # Benign and expected: tau_projected (and occasionally other physics columns) | |
| # is all-NaN at the earliest origins, before the master curve exists; the | |
| # imputer skips the column, which has been the accepted behavior since the | |
| # feature was added (phase 6). One fit per origin per fold prints it, so an | |
| # all48oof run emits it ~290 times - silence THIS message only, so real | |
| # warnings stay visible. | |
| warnings.filterwarnings( | |
| 'ignore', message='Skipping features without any observed values') | |
| from sklearn.impute import SimpleImputer | |
| from sklearn.preprocessing import StandardScaler | |
| from sklearn.pipeline import make_pipeline | |
| from sklearn.linear_model import LogisticRegression | |
| from sklearn.ensemble import HistGradientBoostingClassifier, GradientBoostingRegressor | |
| from sklearn.metrics import roc_auc_score | |
| from batteryswap_public.utils import load_dataset, iterate_scenarios | |
| from batteryswap_public.evaluate import evaluate_plan, check_plan_valid | |
| from batteryswap_example.features import load as load_features, WINDOW_DAYS | |
| FEATURES = ['v_last', 'v_drop', 'slope_30', 'slope_90', 'v_min_30', | |
| 'staleness', 'age_days', | |
| 'slope_lastk', 'drop_recent', 'below_frac', 'accel', 'n_30', | |
| 'tau_projected'] | |
| # NOT in FEATURES, deliberately: 'vrel', 'vmin7_rank'. features.py computes | |
| # both (and v_min_7), and the training table carries them, so enabling them is | |
| # a one-line change here. Held back from the pooling submission so that | |
| # submission isolates ONE change and stays attributable - phase 28 stacked four | |
| # and needed two further phases to untangle the damage. Evidence as of | |
| # 2026-08-21 is also unsettled: vrel survived a leave-one-out ablation on the | |
| # blind harness, vmin7_rank was ablated OUT (-12.8), and a static-fit gate | |
| # showed the whole feature-set swap swinging +55 to -109 depending on fit pool. | |
| # vstd_30/rng_30 (plunge-precursor volatility, AUC 0.83/0.81 on the silent | |
| # cohort) rejected as GLOBAL features: dev +17, test -0.7 - the signal is | |
| # diluted across the healthy majority. Used instead by the SilentFix override. | |
| # Rejected by CV despite AUC 0.91 on voltage-silent pairs: cohort_rel_age / | |
| # cohort_overdue (install-batch calibrated clock, computed in features.py). | |
| # Coverage is too low at dev-era origins (14% exact / 54% monthly), so the | |
| # no-information majority degrades the fit in every encoding tried | |
| # (dev 2074 -> 2279 raw, -> 2433 overdue-neutral). Revisit only if far more | |
| # batch deaths become observable. | |
| # tau_projected = C^-1(v_corr_last) - staleness: targets devices that go dark and | |
| # keep discharging (the model's main blind spot - 25 of 60 test-block misses). | |
| # Other physics features (tau_hat, fit_err, v_corr_last, thresh_dist) remain | |
| # computed in features.py but excluded: walk-forward CV showed no cost improvement | |
| # over the empirical voltage features, which already saturate the same signal. | |
| DEV_ORIGINS = range(8, 24) | |
| TEST_ORIGINS = range(24, 42) | |
| DAY_QUANTILES = (0.05, 0.20, 0.50, 0.80) | |
| # --------------------------------------------------------------------------- models | |
| class DayQuantiles: | |
| """Quantiles of days-to-EOL, fit on training pairs that died in-window.""" | |
| def __init__(self, n_estimators=100, max_depth=2, quantiles=DAY_QUANTILES): | |
| self.models = {} | |
| self.fallback = {} | |
| self.n_estimators = n_estimators | |
| self.max_depth = max_depth | |
| self.quantiles = quantiles | |
| def fit(self, X, days): | |
| for q in self.quantiles: | |
| self.fallback[q] = float(np.quantile(days, q)) | |
| if len(days) >= 40: | |
| m = make_pipeline( | |
| SimpleImputer(strategy='median'), | |
| GradientBoostingRegressor(loss='quantile', alpha=q, | |
| n_estimators=self.n_estimators, | |
| max_depth=self.max_depth, | |
| random_state=0)) | |
| m.fit(X, days) | |
| self.models[q] = m | |
| return self | |
| def predict(self, X): | |
| out = {} | |
| for q in self.quantiles: | |
| if q in self.models: | |
| out[f'day_q{q}'] = np.clip(self.models[q].predict(X), 0, WINDOW_DAYS) | |
| else: | |
| out[f'day_q{q}'] = np.full(len(X), self.fallback[q]) | |
| return pd.DataFrame(out, index=X.index) | |
| Q6 = (0.05, 0.10, 0.20, 0.35, 0.50, 0.80) | |
| class SklearnRUL: | |
| """p42 from a sklearn classifier + DayQuantiles for timing.""" | |
| def __init__(self, kind): | |
| self.opts = {} | |
| if ':' in kind: | |
| kind, spec = kind.split(':', 1) | |
| self.opts = {k: float(v) for k, v in | |
| (kv.split('=') for kv in spec.split(','))} | |
| self.kind = kind | |
| def fit(self, train): | |
| X, y = train[FEATURES], train['dies42'] | |
| if self.kind == 'logistic': | |
| self.clf = make_pipeline(SimpleImputer(strategy='median'), | |
| StandardScaler(), | |
| LogisticRegression(max_iter=2000, C=1.0)) | |
| elif self.kind == 'gbm': | |
| self.clf = HistGradientBoostingClassifier( | |
| max_iter=200, max_depth=3, learning_rate=0.1, | |
| min_samples_leaf=20, random_state=0) | |
| elif self.kind == 'logcal': | |
| # calibrated probabilities: makes sum(p42) a true expected count and | |
| # expected-cost planning meaningful | |
| from sklearn.calibration import CalibratedClassifierCV | |
| base = make_pipeline(SimpleImputer(strategy='median'), | |
| StandardScaler(), | |
| LogisticRegression(max_iter=2000, C=1.0)) | |
| self.clf = CalibratedClassifierCV(base, method='sigmoid', cv=5) | |
| elif self.kind == 'logbag': | |
| # bagged logistic: averages away fit variance near the inclusion | |
| # cutoff, where a p42 flip decides catch-vs-emergency | |
| from sklearn.ensemble import BaggingClassifier | |
| base = make_pipeline(SimpleImputer(strategy='median'), | |
| StandardScaler(), | |
| LogisticRegression(max_iter=2000, | |
| C=self.opts.get('C', 1.0))) | |
| self.clf = BaggingClassifier( | |
| base, n_estimators=int(self.opts.get('bags', 15)), random_state=0) | |
| elif self.kind == 'rf': | |
| from sklearn.ensemble import RandomForestClassifier | |
| cw = 'balanced_subsample' if self.opts.get('bal') else None | |
| self.clf = make_pipeline( | |
| SimpleImputer(strategy='median'), | |
| RandomForestClassifier( | |
| n_estimators=int(self.opts.get('trees', 500)), | |
| min_samples_leaf=int(self.opts.get('leaf', 5)), | |
| max_features='sqrt', class_weight=cw, | |
| random_state=0, n_jobs=-1)) | |
| elif self.kind == 'xtrees': | |
| from sklearn.ensemble import ExtraTreesClassifier | |
| self.clf = make_pipeline( | |
| SimpleImputer(strategy='median'), | |
| ExtraTreesClassifier(n_estimators=500, min_samples_leaf=5, | |
| max_features='sqrt', random_state=0, | |
| n_jobs=-1)) | |
| elif self.kind == 'mlp': | |
| from sklearn.neural_network import MLPClassifier | |
| self.clf = make_pipeline( | |
| SimpleImputer(strategy='median'), | |
| StandardScaler(), | |
| MLPClassifier(hidden_layer_sizes=(32, 16), alpha=1e-2, | |
| max_iter=1000, early_stopping=True, | |
| random_state=0)) | |
| self.clf.fit(X, y) | |
| dying = train[train['dies42'] == 1] | |
| self.days = DayQuantiles( | |
| n_estimators=int(self.opts.get('dtrees', 100)), | |
| max_depth=int(self.opts.get('ddepth', 2)), | |
| quantiles=Q6 if self.opts.get('q6') else DAY_QUANTILES, | |
| ).fit(dying[FEATURES], dying['days_to_eol']) | |
| return self | |
| def predict(self, rows): | |
| X = rows[FEATURES] | |
| out = self.days.predict(X) | |
| out['p42'] = self.clf.predict_proba(X)[:, 1] | |
| if GHOST_ZERO and 'days_below_245' in rows.columns: | |
| # a device below the 2.45 death threshold for >42d with no EOL on | |
| # file will never be scored as a death (26 such ghosts in train) - | |
| # swapping it is a pure false-positive cost. Require it to STILL | |
| # be in the death zone: days_below_245 is sticky from the first | |
| # crossing, and a dip-then-recovery must stay eligible. | |
| ghost = ((rows['days_below_245'] > WINDOW_DAYS) | |
| & (rows['v_corr_last'] < 2.55)).values | |
| out.loc[ghost, 'p42'] = 0.0 | |
| return out | |
| GHOST_ZERO = False # phase-15 candidate: exclude label-process ghosts from plans | |
| QUARANTINE = False # phase-15 candidate: drop looks-dead-labeled-alive training rows | |
| class LogbagLAH: | |
| """ | |
| Two-regime scorer for the label process (phase 17). The metric charges | |
| RECORDED deaths, and EOL recording lags the physical 2.45-crossing by a | |
| median ~34d (a third of recorded deaths lag >42d; ghosts never get one). | |
| For devices already in the death zone the uncertainty is not "will it | |
| die" but "will the label arrive in this window" - a pure function of | |
| time-since-crossing. So: | |
| not crossed -> champion logbag p42 (its real domain) | |
| crossed and still low -> p42 = A(a) = P(EOL recorded in (a, a+42] | none | |
| by a), a Kaplan-Meier on observed crossing->EOL lags (uncensored: | |
| recorded deaths; censored: crossed devices with no EOL yet). | |
| Unlike the phase-15 binary zero rule this keeps mid-lag devices included | |
| (A ~ 0.3-0.6) and lets true ghosts fade gradually as their censored mass | |
| flattens the curve. domain_split=True additionally trains the classifier | |
| only on not-crossed rows, since it is never queried in the death zone. | |
| """ | |
| BUCKET = 14.0 # KM lag buckets (days); 82 events cannot support finer | |
| def __init__(self, domain_split=False): | |
| self.domain_split = domain_split | |
| def fit(self, train): | |
| crossed = train['days_below_245'].notna() & (train['days_below_245'] >= 0) | |
| self.who = SklearnRUL('logbag').fit(train[~crossed] if self.domain_split | |
| else train) | |
| # one lag observation per device: crossing -> EOL (or censored at the | |
| # last origin it appears). days_below_245 is leak-free per row. | |
| sub = train[crossed] | |
| per_dev = sub.sort_values('origin').groupby('device_id').tail(1) | |
| cross_date = per_dev['origin'] - pd.to_timedelta(per_dev['days_below_245'], unit='D') | |
| has_eol = per_dev['eol'].notna() | |
| lag = np.where(has_eol, | |
| (per_dev['eol'] - cross_date).dt.days, | |
| per_dev['days_below_245']) | |
| lag = np.clip(lag, 0, None) | |
| event = has_eol.values | |
| # discrete KM over lag buckets | |
| k = (lag // self.BUCKET).astype(int) | |
| kmax = int(k.max()) + 1 if len(k) else 1 | |
| surv, s = [], 1.0 | |
| for b in range(kmax): | |
| at_risk = (k >= b).sum() | |
| died = ((k == b) & event).sum() | |
| s *= 1.0 - (died / at_risk if at_risk > 0 else 0.0) | |
| surv.append(s) | |
| self.surv = np.array(surv) | |
| return self | |
| def _arrival(self, a): | |
| """P(EOL recorded within the next 42d | crossed a days ago, none yet).""" | |
| b0 = np.clip((a // self.BUCKET).astype(int), 0, len(self.surv) - 1) | |
| b1 = np.clip(((a + WINDOW_DAYS) // self.BUCKET).astype(int), 0, len(self.surv) - 1) | |
| s0 = self.surv[b0] | |
| s1 = self.surv[b1] | |
| return np.where(s0 > 1e-9, 1.0 - s1 / s0, 0.0) | |
| def predict(self, rows): | |
| out = self.who.predict(rows) | |
| crossed = (rows['days_below_245'].notna() | |
| & (rows['days_below_245'] >= 0) | |
| & (rows['v_corr_last'] < 2.50)).values | |
| if crossed.any(): | |
| a = rows.loc[crossed, 'days_below_245'].values | |
| out.loc[crossed, 'p42'] = self._arrival(a) | |
| return out | |
| SILENT_V = 2.55 | |
| SILENT_FEATURES = ['rng_30', 'vstd_30', 'tau_projected', 'cohort_rel_age_m', | |
| 'age_days', 'v_corr_last'] | |
| class SilentFix: | |
| """ | |
| Base logbag plus a dedicated blind-zone model. Batteries whose voltage still | |
| looks healthy (v_corr_last > SILENT_V) get a rank boost from a second | |
| logistic trained ONLY on healthy-voltage pairs, using the plunge-precursor | |
| and cohort signals that carry AUC there while the level is blind. | |
| Scale-free: emits rank_score for ORDERING only; p42 (and hence the topsum | |
| count) is untouched. | |
| """ | |
| def __init__(self, w=0.3): | |
| self.w = w | |
| def fit(self, train): | |
| self.base = SklearnRUL('logbag').fit(train) | |
| sil = train[train['v_corr_last'] > SILENT_V] | |
| self.silent = None | |
| if sil['dies42'].sum() >= 20: | |
| self.silent = make_pipeline(SimpleImputer(strategy='median'), | |
| StandardScaler(), | |
| LogisticRegression(max_iter=2000, C=1.0)) | |
| self.silent.fit(sil[SILENT_FEATURES], sil['dies42']) | |
| return self | |
| def predict(self, rows): | |
| out = self.base.predict(rows) | |
| out['rank_score'] = out['p42'] | |
| if self.silent is not None: | |
| mask = rows['v_corr_last'] > SILENT_V | |
| if mask.any(): | |
| p_sil = self.silent.predict_proba(rows.loc[mask, SILENT_FEATURES])[:, 1] | |
| out.loc[mask, 'rank_score'] = out.loc[mask, 'p42'] + self.w * p_sil | |
| return out | |
| class RankBlendRF: | |
| """logbag's probability scale with (logbag+RF) mean-rank ordering: keeps | |
| sum(p42) exactly (topsum count unchanged) while letting the forest's ranking | |
| reorder WHO is included - RF ranks better at some origins but its raw | |
| probabilities are too compressed for count-tracking.""" | |
| def fit(self, train): | |
| self.a = SklearnRUL('logbag').fit(train) | |
| self.b = SklearnRUL('rf').fit(train) | |
| return self | |
| def predict(self, rows): | |
| out = self.a.predict(rows) | |
| mean_rank = (out['p42'].rank() + self.b.predict(rows)['p42'].rank()) / 2 | |
| vals = np.sort(out['p42'].values) # same multiset of values | |
| out['p42'] = pd.Series(vals, index=mean_rank.sort_values().index).reindex(out.index) | |
| return out | |
| class RankVoltage: | |
| """Control: score by v_last only; p42 is a rank in [0,1], day from DayQuantiles.""" | |
| def fit(self, train): | |
| dying = train[train['dies42'] == 1] | |
| self.days = DayQuantiles().fit(dying[FEATURES], dying['days_to_eol']) | |
| return self | |
| def predict(self, rows): | |
| out = self.days.predict(rows[FEATURES]) | |
| v = rows['v_last'].fillna(np.inf) | |
| out['p42'] = 1.0 - v.rank(pct=True).values # lowest voltage -> highest score | |
| return out | |
| class WeibullAFT: | |
| """lifelines Weibull AFT on censored pairs; survival curve gives p42 and timing.""" | |
| def fit(self, train_censored): | |
| from lifelines import WeibullAFTFitter | |
| df = train_censored[FEATURES + ['duration', 'event']].copy() | |
| self.imputer = SimpleImputer(strategy='median').fit(df[FEATURES]) | |
| df[FEATURES] = self.imputer.transform(df[FEATURES]) | |
| self.aft = WeibullAFTFitter(penalizer=0.01) | |
| with warnings.catch_warnings(): | |
| warnings.simplefilter('ignore') | |
| self.aft.fit(df, duration_col='duration', event_col='event') | |
| return self | |
| def predict(self, rows): | |
| X = rows[FEATURES].copy() | |
| X[FEATURES] = self.imputer.transform(X[FEATURES]) | |
| times = np.arange(1, WINDOW_DAYS + 1) | |
| surv = self.aft.predict_survival_function(X, times=times) # times x devices | |
| cdf = 1.0 - surv.T # devices x times | |
| p42 = cdf.iloc[:, -1] | |
| out = pd.DataFrame({'p42': p42.values}, index=rows.index) | |
| # per-day death mass and expected residual life given surviving the window, | |
| # for the expected-cost planner | |
| pmf = np.diff(np.column_stack([np.zeros(len(cdf)), cdf.values]), axis=1) | |
| for t in times: | |
| out[f'pd_{t}'] = pmf[:, t - 1] | |
| far = np.arange(WINDOW_DAYS, 400) | |
| surv_far = self.aft.predict_survival_function(X, times=far).T.values | |
| s42 = np.maximum(surv_far[:, 0], 1e-12) | |
| out['e_far'] = WINDOW_DAYS + np.trapezoid(surv_far, far, axis=1) / s42 | |
| # quantiles of death time conditional on dying within the window | |
| cond = cdf.div(p42.replace(0, np.nan), axis=0).clip(upper=1.0) | |
| for q in DAY_QUANTILES: | |
| day = (cond.values >= q).argmax(axis=1) + 1.0 | |
| day[~(cond.values[:, -1] >= q)] = WINDOW_DAYS | |
| out[f'day_q{q}'] = np.where(p42.values > 0, day, WINDOW_DAYS) | |
| return out | |
| class DiscreteHazard: | |
| """ | |
| Discrete-time hazard logistic on person-period (pair, week-bucket) rows. | |
| Trains on ALL prior pairs, not just closed windows: a pair whose window is | |
| still open at the scored origin contributes its observed buckets (event if | |
| the death is already visible, censored otherwise). Recovers the freshest | |
| training data - the six origins immediately before the scored one - which | |
| training_pairs() discards entirely. | |
| p42 = 1 - prod_k (1 - h_k); conditional day quantiles from the bucket CDF. | |
| """ | |
| BUCKET = 7.0 | |
| K = 6 # buckets cover days (7k, 7(k+1)], k=0..5 | |
| def __init__(self, extra=None): | |
| self.feats = FEATURES + (extra or []) | |
| def fit(self, train_censored): | |
| dur = train_censored['duration'].clip(upper=WINDOW_DAYS).values | |
| event = ((train_censored['event'] == 1) & | |
| (train_censored['duration'] <= WINDOW_DAYS)).values | |
| X = train_censored[self.feats].values | |
| rows, ys, ks = [], [], [] | |
| for k in range(self.K): | |
| lo, hi = self.BUCKET * k, self.BUCKET * (k + 1) | |
| died_here = event & (dur > lo) & (dur <= hi) | |
| # censored-mid-bucket exposure is dropped: only fully observed | |
| # buckets contribute negatives | |
| in_bucket = (dur >= hi) | died_here | |
| rows.append(X[in_bucket]) | |
| ys.append(died_here[in_bucket]) | |
| ks.append(np.full(in_bucket.sum(), k)) | |
| Xpp = np.vstack(rows) | |
| kpp = np.concatenate(ks) | |
| Xpp = np.column_stack([Xpp] + [(kpp == k).astype(float) | |
| for k in range(1, self.K)]) | |
| self.clf = make_pipeline(SimpleImputer(strategy='median'), | |
| StandardScaler(), | |
| LogisticRegression(max_iter=2000, C=1.0)) | |
| self.clf.fit(Xpp, np.concatenate(ys)) | |
| dead = train_censored[(train_censored['event'] == 1) & | |
| (train_censored['duration'] <= WINDOW_DAYS)] | |
| self.days = DayQuantiles().fit(dead[FEATURES], dead['duration']) | |
| return self | |
| def predict(self, rows): | |
| X = rows[self.feats].values | |
| n = len(rows) | |
| haz = np.empty((n, self.K)) | |
| for k in range(self.K): | |
| dummies = [(np.full(n, k) == j).astype(float) for j in range(1, self.K)] | |
| haz[:, k] = self.clf.predict_proba(np.column_stack([X] + dummies))[:, 1] | |
| surv = np.cumprod(1.0 - haz, axis=1) | |
| p42 = 1.0 - surv[:, -1] | |
| out = self.days.predict(rows[FEATURES]) | |
| out['p42'] = p42 | |
| # override day quantiles with the hazard model's own conditional CDF | |
| cdf = (1.0 - surv) / np.maximum(p42[:, None], 1e-12) | |
| ends = self.BUCKET * np.arange(1, self.K + 1) | |
| for q in DAY_QUANTILES: | |
| day = np.array([np.interp(q, np.concatenate([[0.0], cdf[i]]), | |
| np.concatenate([[0.0], ends])) | |
| for i in range(n)]) | |
| out[f'day_q{q}'] = np.clip(day, 0, WINDOW_DAYS) | |
| return out | |
| def determined_labels(train_censored): | |
| """Pairs whose dies42 label is already fully determined at the cut: | |
| survived the whole window (duration >= 42) or died inside it. Recovers | |
| open-window positives that training_pairs() throws away.""" | |
| neg = train_censored['duration'] >= WINDOW_DAYS | |
| pos = (train_censored['event'] == 1) & (train_censored['duration'] <= WINDOW_DAYS) | |
| sub = train_censored[neg | pos].copy() | |
| sub['dies42'] = ((sub['event'] == 1) & | |
| (sub['duration'] <= WINDOW_DAYS)).astype(int) | |
| sub['days_to_eol'] = sub['duration'] | |
| return sub | |
| class LogBagOpen: | |
| """logbag trained on the determined-label set (closed windows + open-window | |
| deaths observed before the origin).""" | |
| def fit(self, train_censored): | |
| from sklearn.ensemble import BaggingClassifier | |
| train = determined_labels(train_censored) | |
| X, y = train[FEATURES], train['dies42'] | |
| base = make_pipeline(SimpleImputer(strategy='median'), | |
| StandardScaler(), | |
| LogisticRegression(max_iter=2000, C=1.0)) | |
| self.clf = BaggingClassifier(base, n_estimators=15, random_state=0) | |
| self.clf.fit(X, y) | |
| dying = train[train['dies42'] == 1] | |
| self.days = DayQuantiles().fit(dying[FEATURES], dying['days_to_eol']) | |
| return self | |
| def predict(self, rows): | |
| X = rows[FEATURES] | |
| out = self.days.predict(X) | |
| out['p42'] = self.clf.predict_proba(X)[:, 1] | |
| return out | |
| class BlendDH: | |
| """Mean of LogBagOpen and DiscreteHazard p42: same features, but different | |
| training sets and likelihoods, so their errors decorrelate.""" | |
| def fit(self, train_censored): | |
| self.a = LogBagOpen().fit(train_censored) | |
| self.b = DiscreteHazard().fit(train_censored) | |
| return self | |
| def predict(self, rows): | |
| out = self.a.predict(rows) | |
| out['p42'] = 0.5 * (out['p42'] + self.b.predict(rows)['p42']) | |
| return out | |
| class BlendDHAux: | |
| """Mean of logbag_aux (closed windows) and DiscreteHazard-with-aux | |
| (censored open windows): decorrelated training sets, shared feature set.""" | |
| def fit(self, train_censored): | |
| # recover the scored origin T: censored durations end exactly at T | |
| tc = train_censored | |
| T = (tc['origin'] + pd.to_timedelta(tc['duration'], unit='D')).max() | |
| closed = tc[tc['origin'] <= T - pd.Timedelta(days=WINDOW_DAYS)] | |
| self.a = LogBagFeats(['rng_90', 'vstd_30', 'rng_ratio', 'gap_max_60']).fit(closed) | |
| self.b = DiscreteHazard(['rng_90', 'vstd_30', 'rng_ratio', 'gap_max_60']).fit(train_censored) | |
| return self | |
| def predict(self, rows): | |
| out = self.a.predict(rows) | |
| out['p42'] = 0.5 * (out['p42'] + self.b.predict(rows)['p42']) | |
| return out | |
| class CostWeightBag: | |
| """ | |
| Bagged logistic with DECISION-STAKE sample weights (phase 17): a missed | |
| positive costs ~emergency (~200); a false inclusion costs its era's | |
| false-swap price (0.5/day against the end_time+30 imputation, so early | |
| origins' negatives matter ~10x more than near-data-end ones). Weights | |
| normalized to mean 1 so the L2 scale (C=1) stays comparable to logbag. | |
| """ | |
| MISS_W = 200.0 | |
| def __init__(self, n_estimators=15): | |
| self.n_estimators = n_estimators | |
| def fit(self, train): | |
| X, y = train[FEATURES], train['dies42'] | |
| data_end = train['origin'].max() | |
| fp_price = (0.5 * ((data_end - train['origin']).dt.days + 30 - 21) | |
| ).clip(lower=1.0).values | |
| w = np.where(y.values == 1, self.MISS_W, fp_price) | |
| w = w / w.mean() | |
| rng = np.random.RandomState(0) | |
| self.models = [] | |
| n = len(X) | |
| for _ in range(self.n_estimators): | |
| idx = rng.randint(0, n, n) | |
| m = make_pipeline(SimpleImputer(strategy='median'), | |
| StandardScaler(), | |
| LogisticRegression(max_iter=2000, C=1.0)) | |
| m.fit(X.iloc[idx], y.iloc[idx], | |
| logisticregression__sample_weight=w[idx]) | |
| self.models.append(m) | |
| dying = train[train['dies42'] == 1] | |
| self.days = DayQuantiles().fit(dying[FEATURES], dying['days_to_eol']) | |
| return self | |
| def predict(self, rows): | |
| X = rows[FEATURES] | |
| out = self.days.predict(X) | |
| out['p42'] = np.mean([m.predict_proba(X)[:, 1] for m in self.models], axis=0) | |
| return out | |
| class WeightedBagLogistic: | |
| """Bagged logistic with recency-weighted samples: pairs from recent origins | |
| count more, on the theory that fleet composition and degradation mix drift.""" | |
| def __init__(self, half_life=180.0, n_estimators=15): | |
| self.half_life = half_life | |
| self.n_estimators = n_estimators | |
| def fit(self, train): | |
| X, y = train[FEATURES], train['dies42'] | |
| age = (train['origin'].max() - train['origin']).dt.days.values | |
| w = 0.5 ** (age / self.half_life) | |
| rng = np.random.RandomState(0) | |
| self.models = [] | |
| n = len(X) | |
| for _ in range(self.n_estimators): | |
| idx = rng.randint(0, n, n) | |
| m = make_pipeline(SimpleImputer(strategy='median'), | |
| StandardScaler(), | |
| LogisticRegression(max_iter=2000, C=1.0)) | |
| m.fit(X.iloc[idx], y.iloc[idx], | |
| logisticregression__sample_weight=w[idx]) | |
| self.models.append(m) | |
| dying = train[train['dies42'] == 1] | |
| self.days = DayQuantiles().fit(dying[FEATURES], dying['days_to_eol']) | |
| return self | |
| def predict(self, rows): | |
| X = rows[FEATURES] | |
| out = self.days.predict(X) | |
| out['p42'] = np.mean([m.predict_proba(X)[:, 1] for m in self.models], axis=0) | |
| return out | |
| class BlendRUL: | |
| """Mean of logbag and gbm probabilities: linear and tree models miss | |
| different deaths; averaging keeps sum(p42) meaningful for topsum.""" | |
| def fit(self, train): | |
| self.a = SklearnRUL('logbag').fit(train) | |
| try: | |
| self.b = SklearnRUL('gbm').fit(train) | |
| except ValueError: # hist-GBM needs more rows than the earliest origins have | |
| self.b = None | |
| return self | |
| def predict(self, rows): | |
| out = self.a.predict(rows) | |
| if self.b is not None: | |
| out['p42'] = 0.5 * (out['p42'] + self.b.predict(rows)['p42']) | |
| return out | |
| class LogBagC: | |
| """logbag with a non-default inverse regularization C.""" | |
| def __init__(self, C): | |
| self.C = C | |
| def fit(self, train): | |
| from sklearn.ensemble import BaggingClassifier | |
| X, y = train[FEATURES], train['dies42'] | |
| base = make_pipeline(SimpleImputer(strategy='median'), | |
| StandardScaler(), | |
| LogisticRegression(max_iter=2000, C=self.C)) | |
| self.clf = BaggingClassifier(base, n_estimators=15, random_state=0) | |
| self.clf.fit(X, y) | |
| dying = train[train['dies42'] == 1] | |
| self.days = DayQuantiles().fit(dying[FEATURES], dying['days_to_eol']) | |
| return self | |
| def predict(self, rows): | |
| X = rows[FEATURES] | |
| out = self.days.predict(X) | |
| out['p42'] = self.clf.predict_proba(X)[:, 1] | |
| return out | |
| class LogBagFeats: | |
| """logbag with extra feature columns (aux usage-dynamics table).""" | |
| def __init__(self, extra, n_estimators=15): | |
| self.feats = FEATURES + extra | |
| self.n_estimators = n_estimators | |
| def fit(self, train): | |
| from sklearn.ensemble import BaggingClassifier | |
| X, y = train[self.feats], train['dies42'] | |
| base = make_pipeline(SimpleImputer(strategy='median'), | |
| StandardScaler(), | |
| LogisticRegression(max_iter=2000, C=1.0)) | |
| self.clf = BaggingClassifier(base, n_estimators=self.n_estimators, random_state=0) | |
| self.clf.fit(X, y) | |
| dying = train[train['dies42'] == 1] | |
| self.days = DayQuantiles().fit(dying[FEATURES], dying['days_to_eol']) | |
| return self | |
| def predict(self, rows): | |
| out = self.days.predict(rows[FEATURES]) | |
| out['p42'] = self.clf.predict_proba(rows[self.feats])[:, 1] | |
| return out | |
| class LogBagUCB(LogBagFeats): | |
| """Rank by mean + k*std across the bagged estimators: a battery the ensemble | |
| disagrees about gets an inclusion boost (missing it costs ~4x including it). | |
| N still comes from sum of mean p42, so the count stays calibrated.""" | |
| def __init__(self, extra, k=0.5): | |
| super().__init__(extra) | |
| self.k = k | |
| def predict(self, rows): | |
| out = self.days.predict(rows[FEATURES]) | |
| X = rows[self.feats] | |
| Xi = pd.DataFrame(X, columns=self.feats) | |
| probs = np.stack([est.predict_proba(Xi.iloc[:, feats])[:, 1] | |
| for est, feats in zip(self.clf.estimators_, | |
| self.clf.estimators_features_)]) | |
| mean, std = probs.mean(axis=0), probs.std(axis=0) | |
| out['p42'] = np.clip(mean + self.k * std, 0, 1) | |
| out['p42_sum'] = mean.sum() | |
| return out | |
| class LogBagLog(LogBagFeats): | |
| """Same features, but log1p-compress the heavy-tailed ones so the linear | |
| model is not dominated by extreme staleness/gap values.""" | |
| SKEWED = ['staleness', 'gap_max_60', 'n_30'] | |
| def _tx(self, rows): | |
| X = rows[self.feats].copy() | |
| for c in self.SKEWED: | |
| if c in X: | |
| X[c] = np.log1p(X[c].clip(lower=0)) | |
| return X | |
| def fit(self, train): | |
| from sklearn.ensemble import BaggingClassifier | |
| X, y = self._tx(train), train['dies42'] | |
| base = make_pipeline(SimpleImputer(strategy='median'), | |
| StandardScaler(), | |
| LogisticRegression(max_iter=2000, C=1.0)) | |
| self.clf = BaggingClassifier(base, n_estimators=self.n_estimators, | |
| random_state=0) | |
| self.clf.fit(X, y) | |
| dying = train[train['dies42'] == 1] | |
| self.days = DayQuantiles().fit(dying[FEATURES], dying['days_to_eol']) | |
| return self | |
| def predict(self, rows): | |
| out = self.days.predict(rows[FEATURES]) | |
| out['p42'] = self.clf.predict_proba(self._tx(rows))[:, 1] | |
| return out | |
| class WienerFHT: | |
| """ | |
| First-hitting-time RUL: temp-corrected voltage is Brownian motion with | |
| per-device drift; EOL is the first passage of THRESH = 2.40 V (validated, | |
| sd 0.034 across all observed deaths). | |
| P(T <= t) for barrier distance d > 0, drift m (toward barrier), variance s2: | |
| F(t) = Phi((m t - d)/sqrt(s2 t)) + exp(2 m d / s2) Phi(-(m t + d)/sqrt(s2 t)) | |
| valid for any sign of m. Devices dark for `stale` days are conditioned on | |
| having survived the silence: p42 = (F(stale+42) - F(stale)) / (1 - F(stale)) - | |
| uncertainty through the reporting gap is handled by the process itself. | |
| Per-device (m, s2) are shrunk toward population medians (hierarchical-lite); | |
| day quantiles come from the conditional first-passage CDF. | |
| """ | |
| THRESH = 2.40 | |
| K_SHRINK = 6.0 | |
| def fit(self, train): | |
| ok = train['wnr_n'] >= 3 | |
| self.m_pop = float(np.nanmedian(train.loc[ok, 'wnr_m'])) if ok.any() else 1e-4 | |
| self.s2_pop = float(np.nanmedian(train.loc[ok, 'wnr_s2'])) if ok.any() else 1e-4 | |
| dying = train[train['dies42'] == 1] | |
| self.days = DayQuantiles().fit(dying[FEATURES], dying['days_to_eol']) | |
| return self | |
| def predict(self, rows): | |
| from scipy.stats import norm | |
| from scipy.special import log_ndtr | |
| n = len(rows) | |
| w = rows['wnr_n'].fillna(0).values | |
| m = np.where(w > 0, rows['wnr_m'].fillna(self.m_pop).values, self.m_pop) | |
| s2 = np.where(w > 0, rows['wnr_s2'].fillna(self.s2_pop).values, self.s2_pop) | |
| m = (w * m + self.K_SHRINK * self.m_pop) / (w + self.K_SHRINK) | |
| s2 = np.maximum((w * s2 + self.K_SHRINK * self.s2_pop) / (w + self.K_SHRINK), | |
| 1e-8) | |
| d = rows['v_corr_last'].values - self.THRESH | |
| d = np.where(np.isnan(d), 0.35, d) # never-measured device | |
| at_barrier = d <= 1e-6 | |
| d = np.maximum(d, 1e-6) | |
| stale = rows['staleness'].fillna(0).clip(lower=0).values | |
| def F(times): | |
| """first-passage CDF, times: devices x k""" | |
| sig = np.sqrt(s2[:, None] * times) | |
| z1 = (m[:, None] * times - d[:, None]) / sig | |
| z2 = -(m[:, None] * times + d[:, None]) / sig | |
| with np.errstate(over='ignore'): | |
| term2 = np.exp(np.minimum( | |
| 2.0 * m[:, None] * d[:, None] / s2[:, None] + log_ndtr(z2), 0.0)) | |
| return np.clip(norm.cdf(z1) + term2, 0.0, 1.0) | |
| t_grid = np.arange(1, WINDOW_DAYS + 1, dtype=float) | |
| Ft = F(stale[:, None] + t_grid[None, :]) | |
| F0 = np.where(stale > 0, F(np.maximum(stale, 1e-6)[:, None])[:, 0], 0.0) | |
| cond = np.clip((Ft - F0[:, None]) / np.maximum(1.0 - F0, 1e-9)[:, None], | |
| 0.0, 1.0) | |
| p42 = np.where(at_barrier, 1.0, cond[:, -1]) | |
| out = self.days.predict(rows[FEATURES]) | |
| out['p42'] = p42 | |
| qcdf = cond / np.maximum(p42[:, None], 1e-12) | |
| for q in DAY_QUANTILES: | |
| idx = (qcdf >= q).argmax(axis=1) | |
| hit = qcdf[:, -1] >= q | |
| day = np.where(hit, t_grid[idx], WINDOW_DAYS).astype(float) | |
| out[f'day_q{q}'] = np.where(at_barrier, 0.0, day) | |
| return out | |
| class LogWienerBlend: | |
| """logbag p42 blended with the Wiener FHT p42.""" | |
| def __init__(self, mode='mean'): | |
| self.mode = mode | |
| def fit(self, train): | |
| self.a = SklearnRUL('logbag').fit(train) | |
| self.b = WienerFHT().fit(train) | |
| return self | |
| def predict(self, rows): | |
| out = self.a.predict(rows) | |
| pw = self.b.predict(rows)['p42'] | |
| if self.mode == 'mean': | |
| out['p42'] = 0.5 * (out['p42'] + pw) | |
| elif self.mode == 'stale': | |
| stale = rows['staleness'].fillna(0).values | |
| out['p42'] = np.where(stale > 14, np.maximum(out['p42'], pw), out['p42']) | |
| return out | |
| class ConformalCut: | |
| """ | |
| Conformal-risk inclusion: wraps logbag and, at each refit, sets the score | |
| cutoff to the risk_q quantile of the model's scores on *dying* calibration | |
| pairs from the most recent closed origins. Scale-free by construction: the | |
| cutoff moves with whatever scale the refit produces. mode 'split' holds the | |
| calibration block out of training; 'in' trains on everything (slightly | |
| optimistic scores, but twice the recency). | |
| """ | |
| def __init__(self, risk_q=0.15, mode='in', calib_days=120): | |
| self.risk_q = risk_q | |
| self.mode = mode | |
| self.calib_days = calib_days | |
| def fit(self, train): | |
| t_max = train['origin'].max() | |
| calib = train[train['origin'] > t_max - pd.Timedelta(days=self.calib_days)] | |
| fit_on = train if self.mode == 'in' else \ | |
| train[train['origin'] <= t_max - pd.Timedelta(days=self.calib_days)] | |
| if len(fit_on) < 500 or fit_on['dies42'].sum() < 10: | |
| fit_on = train | |
| self.model = SklearnRUL('logbag').fit(fit_on) | |
| dead = calib[calib['dies42'] == 1] | |
| if len(dead) >= 8: | |
| scores = self.model.predict(dead)['p42'] | |
| self.cut = float(np.quantile(scores, self.risk_q)) | |
| else: | |
| self.cut = 0.5 | |
| return self | |
| def predict(self, rows): | |
| out = self.model.predict(rows) | |
| out['p_cut'] = self.cut | |
| return out | |
| class CohortGate: | |
| """ | |
| logbag everywhere; on devices whose install batch has a calibrated monthly | |
| clock (cohort_rel_age_m present), lift p42 to a cohort-hazard logistic's | |
| probability when it is higher. mode 'silent' lifts only devices the voltage | |
| model scores low (<0.2) - the miss bucket - leaving confident scores alone. | |
| """ | |
| CFEATS = ['cohort_rel_age_m', 'cohort_overdue_m', 'age_days'] | |
| def __init__(self, mode='all'): | |
| self.mode = mode | |
| def fit(self, train): | |
| self.base = SklearnRUL('logbag').fit(train) | |
| cov = train[train['cohort_rel_age_m'].notna()] | |
| self.cmodel = None | |
| if len(cov) >= 300 and cov['dies42'].sum() >= 10: | |
| self.cmodel = make_pipeline(SimpleImputer(strategy='median'), | |
| StandardScaler(), | |
| LogisticRegression(max_iter=2000, C=1.0)) | |
| self.cmodel.fit(cov[self.CFEATS], cov['dies42']) | |
| return self | |
| def predict(self, rows): | |
| out = self.base.predict(rows) | |
| if self.cmodel is None: | |
| return out | |
| covered = rows['cohort_rel_age_m'].notna().values | |
| if not covered.any(): | |
| return out | |
| pc = np.zeros(len(rows)) | |
| pc[covered] = self.cmodel.predict_proba( | |
| rows.loc[covered, self.CFEATS])[:, 1] | |
| p = out['p42'].values | |
| lift = covered & (pc > p) | |
| if self.mode == 'silent': | |
| lift &= p < 0.2 | |
| out['p42'] = np.where(lift, pc, p) | |
| return out | |
| class LogbagDH: | |
| """ | |
| Champion WHO (logbag p42, identical training pairs) + hazard WHEN: day | |
| quantiles from DiscreteHazard's conditional bucket CDF instead of the | |
| 4-knot GBR quantile models. Isolates the value of a better death-day | |
| estimator without touching inclusion. | |
| """ | |
| def fit(self, train): | |
| self.who = SklearnRUL('logbag').fit(train) | |
| aug = train.copy() | |
| aug['duration'] = train['days_to_eol'].fillna(10000.0).clip(lower=0.5) | |
| aug['event'] = train['dies42'].astype(int) | |
| self.when = DiscreteHazard().fit(aug) | |
| return self | |
| def predict(self, rows): | |
| out = self.when.predict(rows) # day_q from hazard CDF | |
| out['p42'] = self.who.predict(rows)['p42'].values | |
| return out | |
| MODELS = { | |
| 'rank-v': RankVoltage, | |
| 'logistic': lambda: SklearnRUL('logistic'), | |
| 'logcal': lambda: SklearnRUL('logcal'), | |
| 'logbag': lambda: SklearnRUL('logbag'), | |
| 'gbm': lambda: SklearnRUL('gbm'), | |
| 'aft': WeibullAFT, | |
| 'logbag_rw120': lambda: WeightedBagLogistic(half_life=120.0), | |
| 'logbag_rw240': lambda: WeightedBagLogistic(half_life=240.0), | |
| 'dthaz': DiscreteHazard, | |
| 'blend': BlendRUL, | |
| 'logbag_c03': lambda: LogBagC(0.3), | |
| 'logbag_c3': lambda: LogBagC(3.0), | |
| 'logbag_c01': lambda: LogBagC(0.1), | |
| 'logbag_open': LogBagOpen, | |
| 'blend_dh': BlendDH, | |
| 'logbag_aux': lambda: LogBagFeats(['rng_90', 'vstd_30', 'rng_ratio', 'gap_max_60']), | |
| 'logbag_aux2': lambda: LogBagFeats(['rng_30', 'rng_90', 'vstd_30']), | |
| 'rank_rf': RankBlendRF, | |
| 'silentfix015': lambda: SilentFix(0.15), | |
| 'silentfix03': lambda: SilentFix(0.3), | |
| 'silentfix06': lambda: SilentFix(0.6), | |
| 'logbag_proj': lambda: LogBagFeats(['v_proj']), | |
| 'logbag_rank': lambda: LogBagFeats(['r_vlast', 'r_tau']), | |
| 'logbag_rankproj': lambda: LogBagFeats(['v_proj', 'r_vlast', 'r_tau']), | |
| 'aux_rv': lambda: LogBagFeats(['rng_90', 'vstd_30']), | |
| 'aux_rvg': lambda: LogBagFeats(['rng_90', 'vstd_30', 'gap_max_60']), | |
| 'aux_rvr': lambda: LogBagFeats(['rng_90', 'vstd_30', 'rng_ratio']), | |
| 'aux_all': lambda: LogBagFeats(['rng_90', 'vstd_30', 'rng_ratio', 'gap_max_60', | |
| 'hot_90', 'oob_90']), | |
| 'aux_proj': lambda: LogBagFeats(['rng_90', 'vstd_30', 'rng_ratio', 'gap_max_60', | |
| 'v_proj']), | |
| 'aux_rank': lambda: LogBagFeats(['rng_90', 'vstd_30', 'rng_ratio', 'gap_max_60', | |
| 'r_vlast', 'r_tau']), | |
| 'aux_bag30': lambda: LogBagFeats(['rng_90', 'vstd_30', 'rng_ratio', 'gap_max_60'], n_estimators=30), | |
| 'aux_bag50': lambda: LogBagFeats(['rng_90', 'vstd_30', 'rng_ratio', 'gap_max_60'], n_estimators=50), | |
| 'dthaz_aux': lambda: DiscreteHazard(['rng_90', 'vstd_30', 'rng_ratio', 'gap_max_60']), | |
| 'blend_dhaux': BlendDHAux, | |
| 'aux_ucb05': lambda: LogBagUCB(['rng_90', 'vstd_30', 'rng_ratio', 'gap_max_60'], k=0.5), | |
| 'aux_ucb10': lambda: LogBagUCB(['rng_90', 'vstd_30', 'rng_ratio', 'gap_max_60'], k=1.0), | |
| 'aux_log': lambda: LogBagLog(['rng_90', 'vstd_30', 'rng_ratio', 'gap_max_60']), | |
| 'wiener': WienerFHT, | |
| 'lw_mean': lambda: LogWienerBlend('mean'), | |
| 'lw_stale': lambda: LogWienerBlend('stale'), | |
| 'crt_in15': lambda: ConformalCut(0.15, 'in'), | |
| 'crt_in25': lambda: ConformalCut(0.25, 'in'), | |
| 'crt_sp15': lambda: ConformalCut(0.15, 'split'), | |
| 'crt_in40': lambda: ConformalCut(0.40, 'in'), | |
| 'crt_in50': lambda: ConformalCut(0.50, 'in'), | |
| 'logbag_fht': lambda: LogBagFeats(['fht_days', 'wnr_m', 'wnr_s2']), | |
| 'logbag_fht2': lambda: LogBagFeats(['fht_days']), | |
| 'cg_all': lambda: CohortGate('all'), | |
| 'cg_silent': lambda: CohortGate('silent'), | |
| 'logbag_dh': LogbagDH, | |
| 'logbag_lah': LogbagLAH, | |
| 'logbag_lah_ds': lambda: LogbagLAH(domain_split=True), | |
| 'logbag_cw': CostWeightBag, | |
| } | |
| def resolve_model(name): | |
| """'logbag:bags=30,q6=1' -> SklearnRUL variant; plain names via MODELS.""" | |
| if name in MODELS: | |
| return MODELS[name] | |
| return lambda: SklearnRUL(name) | |
| # ------------------------------------------------------------------------- planners | |
| def assemble(selected_days, locations, start_time, sort_key=None): | |
| parked_day = start_time + pd.Timedelta(days=WINDOW_DAYS + 1) | |
| scheduled = set(selected_days.index) | |
| parked = [b for b in locations['battery'] if b not in scheduled] | |
| plan = pd.concat([ | |
| pd.DataFrame({'day': selected_days.values, 'battery': selected_days.index}), | |
| pd.DataFrame({'day': parked_day, 'battery': parked}), | |
| ], ignore_index=True) | |
| # within a day the evaluator visits in row order: group by building then room, | |
| # otherwise interleaved buildings pay repeated building-changes and travel. | |
| # sort_key overrides the building order (per-battery rank, e.g. NN route); | |
| # parked rows fall past end_time and are cut by the evaluator, so their | |
| # order is irrelevant. | |
| loc = locations.set_index('battery') | |
| plan['building'] = plan['battery'].map(loc['building']) | |
| plan['room'] = plan['battery'].map(loc['room']) | |
| if sort_key is not None: | |
| plan['building'] = plan['battery'].map(sort_key).fillna(10 ** 9) | |
| plan = (plan.sort_values(['day', 'building', 'room'], kind='stable') | |
| .drop(columns=['building', 'room']).reset_index(drop=True)) | |
| check_plan_valid(plan, locations, start_time=start_time) | |
| return plan | |
| def plan_onepd(pred, included, locations, start_time, day_q): | |
| order = pred.loc[included, 'p42'].sort_values(ascending=False) | |
| days = start_time + pd.to_timedelta(np.arange(len(order)), unit='D') | |
| return assemble(pd.Series(days, index=order.index), locations, start_time) | |
| def plan_sched(pred, included, locations, start_time, day_q): | |
| offs = pred.loc[included, f'day_q{day_q}'].round().clip(0, WINDOW_DAYS) | |
| days = start_time + pd.to_timedelta(offs.values, unit='D') | |
| return assemble(pd.Series(days, index=offs.index), locations, start_time) | |
| def plan_batch(pred, included, locations, start_time, day_q, max_groups_per_day=2): | |
| offs = pred.loc[included, f'day_q{day_q}'].round().clip(0, WINDOW_DAYS) | |
| bldg = locations.set_index('battery')['building'].reindex(offs.index) | |
| # one visit per building at that group's earliest predicted day | |
| group_day = offs.groupby(bldg).min().sort_values() | |
| # cap building-groups per day; push overflow to the next free day | |
| used = {} | |
| final = {} | |
| for building, day in group_day.items(): | |
| d = int(day) | |
| while used.get(d, 0) >= max_groups_per_day and d < WINDOW_DAYS: | |
| d += 1 | |
| used[d] = used.get(d, 0) + 1 | |
| final[building] = d | |
| offs = bldg.map(final).astype(float) | |
| days = start_time + pd.to_timedelta(offs.values, unit='D') | |
| return assemble(pd.Series(days, index=offs.index), locations, start_time) | |
| def _batch_days(offs, locations, start_time, max_groups_per_day=2): | |
| """Shared batching pass: one visit per building at the group's earliest day.""" | |
| bldg = locations.set_index('battery')['building'].reindex(offs.index) | |
| group_day = offs.groupby(bldg).min().sort_values() | |
| used, final = {}, {} | |
| for building, day in group_day.items(): | |
| d = int(day) | |
| while used.get(d, 0) >= max_groups_per_day and d < WINDOW_DAYS: | |
| d += 1 | |
| used[d] = used.get(d, 0) + 1 | |
| final[building] = d | |
| offs = bldg.map(final).astype(float) | |
| days = start_time + pd.to_timedelta(offs.values, unit='D') | |
| return assemble(pd.Series(days, index=offs.index), locations, start_time) | |
| EARLY, LATE = 0.5, 10.0 | |
| # The evaluator's true emergency queue opens at the END OF THE WEEK containing | |
| # end_time (day 48 for the all-Monday train scenarios), +1 day per queued miss. | |
| # Exact pricing (48/50/52) was tested and REJECTED (phase 21): it only flips | |
| # decisions at near-data-end origins and loses there - the underprice acts as | |
| # a regularizer against over-including marginal survivors. Keep 45. | |
| EMERGENCY_DAY = WINDOW_DAYS + 3 | |
| # The evaluator prices a swap on a device with no observed EOL against | |
| # (location end_time + settings.unobserved_eol_days). That setting is | |
| # per-scenario and READ FROM THE SCENARIO at runtime; it was hardcoded to the | |
| # default 30.0 in two places here, and submit.py imports plan_tsroute from | |
| # this module, so the hardcode shipped. Every train scenario happens to use | |
| # 30.0, which is exactly why local CV could never catch it - same failure | |
| # shape as the hardcoded 42-day park that repark_for_window exists to undo. | |
| # plan_tsroute now overwrites this from the settings it is handed. | |
| UNOBSERVED_EOL_DAYS = 30.0 | |
| CENSOR_AWARE = True # price unobservable (post-data-end) deaths via imputation | |
| PARK_MOBILITY = 0.0 # extra cost per expected emergency (travel/overtime) | |
| def plan_expcost(pred, included, locations, start_time, day_q): | |
| """ | |
| Include/park and swap-day both chosen by expected cost under the model's | |
| death distribution. `included` and `day_q` are ignored. | |
| """ | |
| t = np.arange(1, WINDOW_DAYS + 1) | |
| pmf = pred[[f'pd_{i}' for i in t]].values # devices x 42 | |
| p_surv = np.clip(1.0 - pmf.sum(axis=1), 0.0, 1.0) | |
| e_far = pred['e_far'].values | |
| d = np.arange(0, WINDOW_DAYS + 1) | |
| delta = d[None, :, None] - t[None, None, :] # 1 x 43 x 42 | |
| swap_penalty = np.where(delta >= 0, LATE * delta, EARLY * (-delta)) | |
| cost = (pmf[:, None, :] * swap_penalty).sum(axis=2) # devices x 43 | |
| cost += p_surv[:, None] * EARLY * np.maximum(e_far[:, None] - d[None, :], 0) | |
| cost_park = (pmf * (LATE * (EMERGENCY_DAY - t))[None, :]).sum(axis=1) | |
| best_day = cost.argmin(axis=1) | |
| include = cost[np.arange(len(cost)), best_day] < cost_park | |
| offs = pd.Series(best_day[include].astype(float), index=pred.index[include]) | |
| if offs.empty: | |
| return assemble(pd.Series(dtype='datetime64[ns]'), locations, start_time) | |
| return _batch_days(offs, locations, start_time) | |
| def plan_topsum(pred, included, locations, start_time, day_q): | |
| """ | |
| Adaptive inclusion: take the top-N batteries by p42 with N = alpha * sum(p42). | |
| sum(p42) tracks the expected death count even when the probability scale | |
| drifts between refits, unlike a fixed threshold. alpha arrives via p_star. | |
| """ | |
| alpha = plan_topsum.alpha | |
| n = int(round(alpha * pred['p42'].sum())) | |
| chosen = pred['p42'].nlargest(n).index | |
| offs = pred.loc[chosen, f'day_q{day_q}'].round().clip(0, WINDOW_DAYS) | |
| if offs.empty: | |
| return assemble(pd.Series(dtype='datetime64[ns]'), locations, start_time) | |
| return _batch_days(offs, locations, start_time) | |
| plan_topsum.alpha = 1.2 | |
| # ---------------------------------------------------------- expected-cost planning | |
| def cost_curves(pred, locations, start_time, blend=0.0): | |
| """ | |
| Per-battery expected timing cost for swapping on each day d in 0..42, plus the | |
| cost of parking (emergency if it dies in-window). | |
| Death-day CDF within the window is piecewise-linear through the predicted | |
| conditional quantiles. The surviving mass pays early-swap at 0.5/day against the | |
| evaluator's imputed EOL (device end_time + unobserved_eol_days). | |
| """ | |
| qs = list(DAY_QUANTILES) | |
| tq = pred[[f'day_q{q}' for q in qs]].values # devices x quantiles | |
| tq = np.maximum.accumulate(np.clip(tq, 0, WINDOW_DAYS), axis=1) # monotone | |
| p42 = pred['p42'].values | |
| grid = np.arange(0, WINDOW_DAYS + 1, dtype=float) | |
| n = len(pred) | |
| cdf = np.empty((n, len(grid))) | |
| xs = np.concatenate([[0.0], np.zeros(len(qs)), [WINDOW_DAYS]]) | |
| ys = np.array([0.0] + qs + [1.0]) | |
| for i in range(n): | |
| xs[1:-1] = tq[i] | |
| cdf[i] = np.interp(grid, xs, ys) | |
| if blend > 0: | |
| # the day models are trained on typical dying batteries and predict "dies | |
| # soon" too confidently for marginal ones; pull low-p42 batteries' | |
| # conditional CDF toward uniform, which defers their optimal swap day | |
| w = 1.0 - blend * (1.0 - p42[:, None]) | |
| uniform = (grid / WINDOW_DAYS)[None, :] | |
| cdf = w * cdf + (1.0 - w) * uniform | |
| pmf = np.diff(cdf, axis=1) # devices x 42, mass at t=1..42 | |
| t = np.arange(1, WINDOW_DAYS + 1, dtype=float) | |
| end = locations.set_index('battery')['end_time'].dt.tz_localize(None) | |
| # unclipped: for long-stale devices the imputed EOL (last data + 30d) can | |
| # precede the window, and the evaluator then charges a swap as LATE at | |
| # 10/day - the old clip(lower=43) made exactly those devices look free | |
| e_far = ((end.reindex(pred.index) - start_time).dt.total_seconds() / 86400 | |
| + UNOBSERVED_EOL_DAYS).values | |
| # censoring-aware: deaths after the split's data end are never in the | |
| # shipped eol_times, so the evaluator cannot charge late/emergency for | |
| # them - it prices those devices via the end_time+30 imputation instead | |
| # (i.e. our survivor branch). Move unobservable death mass to the | |
| # survivor branch and drop it from the parking (emergency) estimate. | |
| # Inert whenever data extends >= 42d past the origin (all train panel | |
| # origins); bites on near-data-end scenarios (runtime splits, s_42-47). | |
| if CENSOR_AWARE: | |
| D = (end.max() - start_time).total_seconds() / 86400 | |
| pmf = pmf * (t <= D)[None, :] | |
| obs_frac = pmf.sum(axis=1) | |
| delta = grid[None, :, None] - t[None, None, :] # 1 x 43 x 42 | |
| pen = np.where(delta >= 0, LATE * delta, EARLY * (-delta)) | |
| curves = p42[:, None] * (pmf[:, None, :] * pen).sum(axis=2) | |
| surv_delta = e_far[:, None] - grid[None, :] | |
| curves += (1 - p42 * obs_frac)[:, None] * np.where(surv_delta >= 0, | |
| EARLY * surv_delta, | |
| LATE * -surv_delta) | |
| # an emergency also pays its own travel/overtime, not just late-days; | |
| # PARK_MOBILITY adds that per expected observable emergency | |
| park = (p42 * (pmf * (LATE * (EMERGENCY_DAY - t))[None, :]).sum(axis=1) | |
| + PARK_MOBILITY * p42 * obs_frac) | |
| return curves, park | |
| def plan_ecday(pred, included, locations, start_time, day_q): | |
| """Include iff min-day expected cost beats parking; swap at the argmin day.""" | |
| curves, park = cost_curves(pred, locations, start_time) | |
| best_day = curves.argmin(axis=1) | |
| keep = curves[np.arange(len(curves)), best_day] < park | |
| offs = pd.Series(best_day[keep].astype(float), index=pred.index[keep]) | |
| if offs.empty: | |
| return assemble(pd.Series(dtype='datetime64[ns]'), locations, start_time) | |
| return _batch_days(offs, locations, start_time) | |
| # --------------------------------------------------------------- mobility simulator | |
| def mobility_cost(day_batteries, locations, travel_hours, settings, start_time): | |
| """ | |
| Travel + overtime + daily/weekly limit cost of a visit schedule, replicating | |
| evaluate_plan's state machine exactly (verified against it with eol_times=None). | |
| day_batteries: {day_offset: [battery, ...]} with per-day visit order preserved. | |
| """ | |
| base, base_room = settings.base_location, settings.base_room | |
| loc = locations.set_index('battery') | |
| total = 0.0 | |
| room = base_room | |
| week_used = 0.0 | |
| week_start = 0 | |
| # Evaluator quirk, replicated deliberately: end_previous_day computes the old | |
| # day's overtime including the return-to-base leg, but do_action then adds that | |
| # same leg to the NEW day's clock - so each return leg counts toward both | |
| # adjacent visit-days' overtime, and lands in the week of the *next* visit day. | |
| carry = None # return travel awaiting attribution; None until first transition | |
| end_day = WINDOW_DAYS + (6 - (start_time + pd.Timedelta(days=WINDOW_DAYS)).weekday()) | |
| for d in range(0, end_day + 1): | |
| if d >= week_start + 7: | |
| if week_used >= settings.worker_limit_weekly_hours: | |
| total += settings.worker_limit_weekly_penalty | |
| week_used, week_start = 0.0, d | |
| batts = day_batteries.get(d, []) | |
| if not batts: | |
| continue | |
| if carry is None: | |
| # first visit day: if not day 0, the initial end_previous_day travels | |
| # base->base | |
| carry = 0.0 if d == 0 else float(travel_hours.at[base, base]) | |
| total += carry | |
| hours = 0.0 | |
| building = base | |
| for b in batts: | |
| tb, tr = loc.at[b, 'building'], loc.at[b, 'room'] | |
| if tb != building: | |
| hours += settings.time_per_building_change_hours + travel_hours.at[building, tb] | |
| building = tb | |
| if tr != room: | |
| hours += settings.time_per_room_change_hours | |
| room = tr | |
| hours += settings.time_per_battery_hours | |
| ret = float(travel_hours.at[building, base]) | |
| clock = carry + hours + ret | |
| total += hours + ret | |
| total += max(clock - settings.overtime_start, 0.0) * settings.overtime_penalty_factor | |
| if clock > settings.worker_limit_daily_hours: | |
| total += settings.worker_limit_daily_penalty | |
| week_used += carry + hours | |
| carry = ret | |
| if carry: | |
| week_used += carry | |
| if week_used >= settings.worker_limit_weekly_hours: | |
| total += settings.worker_limit_weekly_penalty | |
| return total | |
| def _nn_building_order(bldgs, travel_hours, base): | |
| """Nearest-neighbor route over a day's buildings starting from base: | |
| {building: visit_rank}. Deterministic (ties broken by name).""" | |
| left = sorted(set(bldgs)) | |
| order, cur = {}, base | |
| for i in range(len(left)): | |
| nxt = min(left, key=lambda b: (float(travel_hours.at[cur, b]), b)) | |
| order[nxt] = i | |
| left.remove(nxt) | |
| cur = nxt | |
| return order | |
| def optimize_days(groups, curves, locations, travel_hours, settings, start_time, | |
| restarts=None, iters=None): | |
| restarts = restarts if restarts is not None else optimize_days.restarts | |
| iters = iters if iters is not None else optimize_days.iters | |
| """ | |
| Local search over building-group visit days: shift moves, merge moves (jump a | |
| group onto another group's day), and deterministic random restarts. | |
| Objective = expected timing cost (model curves) + exact mobility cost. | |
| """ | |
| loc_b = locations.set_index('battery')['building'] | |
| gbldg = {g: loc_b.at[bs[0]] for g, bs in groups.items()} | |
| def timing(day_of): | |
| return sum(curves.at[b, day_of[g]] for g, bs in groups.items() for b in bs) | |
| def mobility(day_of): | |
| day_groups = {} | |
| for g in sorted(day_of, key=lambda g: (day_of[g], g)): | |
| day_groups.setdefault(day_of[g], []).append(g) | |
| if optimize_days.nn_order: | |
| # visit a day's buildings in nearest-neighbor route order instead | |
| # of name order (matters only on 3+ building days: with a | |
| # symmetric travel matrix a 2-building day is order-invariant) | |
| for gs in day_groups.values(): | |
| rank = _nn_building_order([gbldg[g] for g in gs], travel_hours, | |
| settings.base_location) | |
| gs.sort(key=lambda g: (rank[gbldg[g]], g)) | |
| day_batteries = {d: [b for g in gs for b in groups[g]] | |
| for d, gs in day_groups.items()} | |
| return mobility_cost(day_batteries, locations, travel_hours, | |
| settings, start_time) | |
| def climb(day_of): | |
| score = timing(day_of) + mobility(day_of) | |
| for _ in range(iters): | |
| improved = False | |
| for g in groups: | |
| others = {day_of[o] for o in groups if o != g} | |
| candidates = ({day_of[g] + s for s in | |
| (-10, -7, -5, -3, -2, -1, 1, 2, 3, 5, 7, 10)} | others) | |
| for d in candidates: | |
| if not 0 <= d <= WINDOW_DAYS or d == day_of[g]: | |
| continue | |
| trial = dict(day_of, **{g: int(d)}) | |
| s = timing(trial) + mobility(trial) | |
| if s < score - 1e-9: | |
| day_of, score, improved = trial, s, True | |
| if not improved: | |
| break | |
| return day_of, score | |
| base = {g: int(min(curves.loc[bs].values.argmin(axis=1))) | |
| for g, bs in groups.items()} | |
| best, best_score = climb(base) | |
| rng = np.random.RandomState(0) | |
| for _ in range(restarts): | |
| start = {g: int(np.clip(base[g] + rng.randint(-6, 7), 0, WINDOW_DAYS)) | |
| for g in groups} | |
| cand, s = climb(start) | |
| if s < best_score: | |
| best, best_score = cand, s | |
| if optimize_days.constructive: | |
| # limit-aware constructive start (phase 18): the argmin start piles | |
| # urgent groups into the same days/weeks and the +-10d shift moves may | |
| # not escape the resulting overtime/limit basin. Greedily place groups | |
| # in urgency order, each on the day minimizing the EXACT partial cost | |
| # (timing + real mobility incl. limit penalties), then climb from it. | |
| cons = {} | |
| for g in sorted(groups, key=base.get): | |
| best_d, best_s = None, np.inf | |
| for dcand in range(0, WINDOW_DAYS + 1): | |
| trial = dict(cons, **{g: dcand}) | |
| s = (sum(curves.at[b, trial[gg]] for gg in trial for b in groups[gg]) | |
| + mobility({gg: trial[gg] for gg in trial})) | |
| if s < best_s: | |
| best_d, best_s = dcand, s | |
| cons[g] = best_d | |
| cand, s = climb(cons) | |
| if s < best_score: | |
| best, best_score = cand, s | |
| return best | |
| optimize_days.restarts = 2 | |
| optimize_days.iters = 60 | |
| optimize_days.constructive = False | |
| optimize_days.nn_order = False # visit a day's buildings in NN route order | |
| def plan_ecroute(pred, included, locations, start_time, day_q, | |
| travel_costs=None, settings=None, margin=None): | |
| """ | |
| ecday selection, then local search over building-group days minimizing | |
| (exact mobility cost) + (expected timing cost from the model's curves). | |
| margin > 1 includes more marginal batteries (insurance); reuses p_star slot. | |
| """ | |
| margin = margin if margin else (plan_ecroute.margin or 1.0) | |
| curves, park = cost_curves(pred, locations, start_time, | |
| blend=plan_ecroute.blend) | |
| best_day = curves.argmin(axis=1) | |
| keep = curves[np.arange(len(curves)), best_day] < margin * park | |
| chosen = pred.index[keep] | |
| if len(chosen) == 0: | |
| return assemble(pd.Series(dtype='datetime64[ns]'), locations, start_time) | |
| curves = pd.DataFrame(curves[keep], index=chosen) | |
| bldg = locations.set_index('battery')['building'].reindex(chosen) | |
| room = locations.set_index('battery')['room'] | |
| groups = {g: sorted(members.index, key=lambda b: room.get(b, '')) | |
| for g, members in curves.groupby(bldg)} | |
| travel_hours = travel_costs.set_index(['from', 'to'])['hours'].unstack() | |
| day_of = optimize_days(groups, curves, locations, travel_hours, | |
| settings, start_time) | |
| offs = bldg.map(day_of).astype(float) | |
| days = start_time + pd.to_timedelta(offs.values, unit='D') | |
| return assemble(pd.Series(days, index=offs.index), locations, start_time) | |
| plan_ecroute.margin = 1.0 | |
| plan_ecroute.blend = 0.0 | |
| def plan_tsroute(pred, included, locations, start_time, day_q, | |
| travel_costs=None, settings=None): | |
| """ | |
| Hybrid: topsum inclusion (count-based, robust to probability-scale drift) | |
| with expected-cost day placement and mobility routing. | |
| p_be: break-even top-up - also include every battery with p42 >= p_be | |
| (a miss costs ~4x a false inclusion, so p_be ~ 0.2 is +EV) [0 = off] | |
| floor_day/p_conf: batteries with p42 < p_conf may not be scheduled before | |
| floor_day - defers uncertain insurance swaps that would otherwise | |
| pay early-replacement for weeks [floor_day 0 = off] | |
| """ | |
| if settings is not None: | |
| global UNOBSERVED_EOL_DAYS | |
| UNOBSERVED_EOL_DAYS = float(settings.unobserved_eol_days) | |
| psum = pred['p42_sum'].iloc[0] if 'p42_sum' in pred else pred['p42'].sum() | |
| if plan_tsroute.floor_scale > 0: | |
| # cold-start inclusion floor: sum(p42) tracks prevalence only once the | |
| # training era has seen it - at warm-up origins it undercounts ~4x and | |
| # the count rule parks visibly-dying batteries (46% of all-48 late | |
| # cost). Deaths RECORDED in the last 42d are observable at plan time | |
| # (the harness itself drops eol <= origin devices) and estimate the | |
| # coming window's prevalence without trusting the probability scale. | |
| psum = max(psum, plan_tsroute.floor_scale * plan_tsroute.recent_deaths) | |
| # consume-once appetite boost: armed by the model's predict (T3 warmup | |
| # gate) for the plan that immediately follows, then self-resets so a | |
| # fallback path can never inherit a stale boost | |
| boost = plan_tsroute.alpha_boost | |
| plan_tsroute.alpha_boost = 1.0 | |
| n = int(round(plan_tsroute.alpha * boost * psum)) | |
| order_col = 'rank_score' if 'rank_score' in pred.columns else 'p42' | |
| chosen = pred[order_col].nlargest(n).index | |
| if plan_tsroute.p_be > 0: | |
| topups = pred.index[pred['p42'] >= plan_tsroute.p_be] | |
| chosen = chosen.union(topups) | |
| curves, park = cost_curves(pred, locations, start_time) | |
| if plan_tsroute.ec_margin > 0: | |
| # break-even top-up: include every battery whose best swap day is | |
| # cheaper than parking it. The false-swap price is 0.5/day against the | |
| # imputed EOL (end_time+30), so this set is tiny when data extends far | |
| # past the window (train dev block) and widens automatically when the | |
| # scenario sits near the split's data end (runtime), where false swaps | |
| # cost almost nothing but misses still cost 10/day. | |
| # ec_pmax guards the p42/curve estimation error: the top-up only fires | |
| # for batteries whose worst-case cost of a wrong inclusion (false swap | |
| # at day 42) is at most ec_pmax - EV says include, price caps the risk. | |
| # Real-dev CV rejected the ungated rule (+24, worst origin +461); the | |
| # counterfactual cheap-price CV shows -76 where wrong costs ~19. | |
| end = locations.set_index('battery')['end_time'].dt.tz_localize(None) | |
| e_far = ((end.reindex(pred.index) - start_time).dt.total_seconds() / 86400 | |
| + UNOBSERVED_EOL_DAYS).values | |
| # top-up only fresh devices (imputed EOL beyond the window): for stale | |
| # ones the survivor branch is a bookkeeping fiction - swapping "on the | |
| # imputed day" scores ~0 but buys nothing and costs real mobility | |
| # (panel: admitting them turned s_23/s_41 from ties into losses) | |
| fp_price = EARLY * (e_far - WINDOW_DAYS) | |
| be = pred.index[(e_far >= WINDOW_DAYS) | |
| & (curves.min(axis=1) < plan_tsroute.ec_margin * park) | |
| & (fp_price <= plan_tsroute.ec_pmax)] | |
| chosen = chosen.union(be) | |
| if len(chosen) == 0: | |
| return assemble(pd.Series(dtype='datetime64[ns]'), locations, start_time) | |
| if plan_tsroute.floor_day > 0: | |
| uncertain = (pred['p42'] < plan_tsroute.p_conf).values | |
| curves[np.ix_(uncertain, np.arange(plan_tsroute.floor_day))] = np.inf | |
| curves = pd.DataFrame(curves, index=pred.index).loc[chosen] | |
| bldg = locations.set_index('battery')['building'].reindex(chosen) | |
| room = locations.set_index('battery')['room'] | |
| key = bldg | |
| if plan_tsroute.split_days > 0: | |
| # one visit day per building forces urgent and insurance swaps in the | |
| # same building onto a compromise day; sub-group by best-day bucket so | |
| # the optimizer MAY split a building across days (merge moves can | |
| # always rejoin them at zero travel cost - same-day groups share the | |
| # mobility simulation) | |
| bucket = curves.values.argmin(axis=1) // int(plan_tsroute.split_days) | |
| key = bldg.astype(str) + '~' + pd.Series(bucket, index=curves.index).astype(str) | |
| groups = {g: sorted(m.index, key=lambda b: room.get(b, '')) | |
| for g, m in curves.groupby(key)} | |
| travel_hours = travel_costs.set_index(['from', 'to'])['hours'].unstack() | |
| day_of = optimize_days(groups, curves * plan_tsroute.timing_weight, | |
| locations, travel_hours, settings, start_time) | |
| offs = key.map(day_of).astype(float) | |
| days = start_time + pd.to_timedelta(offs.values, unit='D') | |
| sort_key = None | |
| if optimize_days.nn_order: | |
| # emit rows in the same NN building order the mobility sim priced | |
| base_b = settings.base_location | |
| gb = {g: bldg.loc[bs[0]] for g, bs in groups.items()} | |
| ranks = {} | |
| for d in set(day_of.values()): | |
| gs = [g for g in day_of if day_of[g] == d] | |
| ranks[d] = _nn_building_order([gb[g] for g in gs], travel_hours, base_b) | |
| sort_key = pd.Series({b: ranks[day_of[g]][gb[g]] | |
| for g, bs in groups.items() for b in bs}) | |
| return assemble(pd.Series(days, index=offs.index), locations, start_time, | |
| sort_key=sort_key) | |
| plan_tsroute.alpha = 0.8 | |
| plan_tsroute.split_days = 0 | |
| plan_tsroute.timing_weight = 1.0 # scales timing curves in optimize_days' objective | |
| plan_tsroute.alpha_boost = 1.0 # consume-once; see the count line above | |
| plan_tsroute.p_be = 0.0 | |
| plan_tsroute.floor_day = 0 | |
| plan_tsroute.p_conf = 0.5 | |
| plan_tsroute.ec_margin = 0.0 | |
| plan_tsroute.ec_pmax = 25.0 | |
| plan_tsroute.floor_scale = 0.0 # 0 = off; k in max(sum_p42, k * recent_deaths) | |
| plan_tsroute.recent_deaths = 0.0 # deaths recorded in (origin-42d, origin]; set per origin | |
| def plan_tspig(pred, included, locations, start_time, day_q, | |
| travel_costs=None, settings=None): | |
| """ | |
| tsroute + exact-marginal membership repair. After the day search: | |
| drop pass - remove any included battery whose expected timing benefit at its | |
| assigned day is worse than parking, counting the exact mobility | |
| saving (a group that empties refunds its whole trip); | |
| add pass - piggyback parked batteries onto buildings already being visited | |
| when benefit at that day exceeds the exact mobility delta | |
| (usually just battery-handling time), then whole new building | |
| groups when their pooled benefit pays for the trip. | |
| Inclusion thereby becomes routing-aware instead of count-only. | |
| """ | |
| n = int(round(plan_tspig.alpha * pred['p42'].sum())) | |
| chosen = pred['p42'].nlargest(n).index | |
| curves_np, park_np = cost_curves(pred, locations, start_time) | |
| curves_all = pd.DataFrame(curves_np, index=pred.index) | |
| park = pd.Series(park_np, index=pred.index) | |
| loc = locations.set_index('battery') | |
| bldg_all, room_all = loc['building'], loc['room'] | |
| travel_hours = travel_costs.set_index(['from', 'to'])['hours'].unstack() | |
| if len(chosen) == 0: | |
| return assemble(pd.Series(dtype='datetime64[ns]'), locations, start_time) | |
| curves = curves_all.loc[chosen] | |
| bldg = bldg_all.reindex(chosen) | |
| groups = {g: sorted(m.index, key=lambda b: room_all.get(b, '')) | |
| for g, m in curves.groupby(bldg)} | |
| day_of = optimize_days(groups, curves, locations, travel_hours, | |
| settings, start_time) | |
| def mobility(gs, days): | |
| day_batteries = {} | |
| for g in sorted(days, key=lambda g: (days[g], g)): | |
| if gs.get(g): | |
| day_batteries.setdefault(days[g], []).extend(gs[g]) | |
| return mobility_cost(day_batteries, locations, travel_hours, | |
| settings, start_time) | |
| cur_mob = mobility(groups, day_of) | |
| in_plan = set().union(*groups.values()) | |
| for _ in range(4): | |
| improved = False | |
| # drop pass | |
| for g in list(groups): | |
| for b in list(groups[g]): | |
| trial = dict(groups, **{g: [x for x in groups[g] if x != b]}) | |
| dm = mobility(trial, day_of) - cur_mob | |
| if park.at[b] - curves_all.at[b, day_of[g]] + dm < -1e-9: | |
| groups, cur_mob = trial, cur_mob + dm | |
| in_plan.discard(b) | |
| improved = True | |
| # piggyback adds within visited buildings | |
| active = {g for g in groups if groups[g]} | |
| cands = [b for b in pred.index | |
| if b not in in_plan and bldg_all.get(b) in active] | |
| for b in sorted(cands, key=lambda b: -pred.at[b, 'p42']): | |
| g = bldg_all.get(b) | |
| if not groups.get(g): | |
| continue | |
| trial = dict(groups, **{g: sorted(groups[g] + [b], | |
| key=lambda x: room_all.get(x, ''))}) | |
| dm = mobility(trial, day_of) - cur_mob | |
| if curves_all.at[b, day_of[g]] - park.at[b] + dm < -1e-9: | |
| groups, cur_mob = trial, cur_mob + dm | |
| in_plan.add(b) | |
| improved = True | |
| # whole new building groups whose pooled benefit pays for the trip | |
| outside = pred.index.difference(list(in_plan)) | |
| for g, members in bldg_all.reindex(outside).groupby(bldg_all.reindex(outside)): | |
| if groups.get(g): | |
| continue | |
| gain = park.reindex(members.index) - curves_all.loc[members.index].min(axis=1) | |
| keep = gain[gain > 0].index | |
| if len(keep) == 0: | |
| continue | |
| day = int(curves_all.loc[keep].sum(axis=0).values.argmin()) | |
| trial = dict(groups, **{g: sorted(keep, key=lambda x: room_all.get(x, ''))}) | |
| tdays = dict(day_of, **{g: day}) | |
| dm = mobility(trial, tdays) - cur_mob | |
| delta = (curves_all.loc[keep, day] - park.reindex(keep)).sum() + dm | |
| if delta < -1e-9: | |
| groups, day_of, cur_mob = trial, tdays, cur_mob + dm | |
| in_plan.update(keep) | |
| improved = True | |
| if not improved: | |
| break | |
| groups = {g: bs for g, bs in groups.items() if bs} | |
| if not groups: | |
| return assemble(pd.Series(dtype='datetime64[ns]'), locations, start_time) | |
| # final day re-optimization with the repaired membership | |
| day_of = optimize_days(groups, curves_all.loc[sorted(in_plan)], locations, | |
| travel_hours, settings, start_time) | |
| offs, idx = [], [] | |
| for g, bs in groups.items(): | |
| for b in bs: | |
| idx.append(b) | |
| offs.append(float(day_of[g])) | |
| days = start_time + pd.to_timedelta(offs, unit='D') | |
| return assemble(pd.Series(days, index=idx), locations, start_time) | |
| plan_tspig.alpha = 0.8 | |
| def plan_tspig2(pred, included, locations, start_time, day_q, | |
| travel_costs=None, settings=None): | |
| """ | |
| Rank-gated piggyback: run tsroute on the usual top-N (alpha), then extend | |
| inclusion to batteries ranked within alpha2 * sum(p42) whose building is | |
| already visited, scheduling them on that building's existing day. Extra | |
| inclusions are chosen by rank (scale-free), not expected value; only the | |
| mobility context decides whether the insurance is cheap enough. | |
| """ | |
| n = int(round(plan_tspig2.alpha * pred['p42'].sum())) | |
| chosen = pred['p42'].nlargest(n).index | |
| if len(chosen) == 0: | |
| return assemble(pd.Series(dtype='datetime64[ns]'), locations, start_time) | |
| curves_np, _ = cost_curves(pred, locations, start_time) | |
| curves_all = pd.DataFrame(curves_np, index=pred.index) | |
| loc = locations.set_index('battery') | |
| bldg_all, room_all = loc['building'], loc['room'] | |
| travel_hours = travel_costs.set_index(['from', 'to'])['hours'].unstack() | |
| curves = curves_all.loc[chosen] | |
| bldg = bldg_all.reindex(chosen) | |
| groups = {g: sorted(m.index, key=lambda b: room_all.get(b, '')) | |
| for g, m in curves.groupby(bldg)} | |
| day_of = optimize_days(groups, curves, locations, travel_hours, | |
| settings, start_time) | |
| n2 = int(round(plan_tspig2.alpha2 * pred['p42'].sum())) | |
| extras = pred['p42'].nlargest(n2).index.difference(chosen) | |
| offs, idx = [], [] | |
| for g, bs in groups.items(): | |
| for b in bs: | |
| idx.append(b); offs.append(float(day_of[g])) | |
| for b in extras: | |
| g = bldg_all.get(b) | |
| if g in day_of: | |
| idx.append(b); offs.append(float(day_of[g])) | |
| days = start_time + pd.to_timedelta(offs, unit='D') | |
| return assemble(pd.Series(days, index=idx), locations, start_time) | |
| plan_tspig2.alpha = 0.8 | |
| plan_tspig2.alpha2 = 1.3 | |
| def plan_tscut(pred, included, locations, start_time, day_q, | |
| travel_costs=None, settings=None): | |
| """tsroute scheduling, but inclusion = p42 >= the model's conformal cutoff.""" | |
| chosen = pred.index[pred['p42'] >= pred['p_cut'].iloc[0]] | |
| if len(chosen) == 0: | |
| return assemble(pd.Series(dtype='datetime64[ns]'), locations, start_time) | |
| curves, _ = cost_curves(pred, locations, start_time) | |
| curves = pd.DataFrame(curves, index=pred.index).loc[chosen] | |
| bldg = locations.set_index('battery')['building'].reindex(chosen) | |
| room = locations.set_index('battery')['room'] | |
| groups = {g: sorted(m.index, key=lambda b: room.get(b, '')) | |
| for g, m in curves.groupby(bldg)} | |
| travel_hours = travel_costs.set_index(['from', 'to'])['hours'].unstack() | |
| day_of = optimize_days(groups, curves, locations, travel_hours, | |
| settings, start_time) | |
| offs = bldg.map(day_of).astype(float) | |
| days = start_time + pd.to_timedelta(offs.values, unit='D') | |
| return assemble(pd.Series(days, index=offs.index), locations, start_time) | |
| PLANNERS ={'onepd': plan_onepd, 'sched': plan_sched, 'batch': plan_batch, | |
| 'expcost': plan_expcost, 'topsum': plan_topsum, | |
| 'ecday': plan_ecday, 'ecroute': plan_ecroute, 'tsroute': plan_tsroute, | |
| 'tspig': plan_tspig, 'tspig2': plan_tspig2, | |
| 'tscut': plan_tscut} | |
| # ------------------------------------------------------------------------ harness | |
| def training_pairs(table, origin_time): | |
| closed = table['origin'] <= origin_time - pd.Timedelta(days=WINDOW_DAYS) | |
| sub = table[closed] | |
| if QUARANTINE and 'v_corr_last' in sub.columns: | |
| # drop "looks dead, labeled survives" rows: devices already below the | |
| # death threshold whose death never made it into eol_times (26 ghost | |
| # trajectories in train). Feeding them as negatives teaches the model | |
| # that textbook plunges survive - label noise on the hardest pattern. | |
| poison = (sub['dies42'] == 0) & (sub['v_corr_last'] < 2.48) | |
| sub = sub[~poison] | |
| return sub | |
| def censored_pairs(table, origin_time): | |
| """All prior pairs with duration censored at origin_time (for AFT).""" | |
| prior = table[table['origin'] < origin_time].copy() | |
| eol = prior['eol'] | |
| end = eol.where(eol.notna() & (eol <= origin_time), origin_time) | |
| prior['duration'] = ((end - prior['origin']).dt.total_seconds() / 86400).clip(lower=0.5) | |
| prior['event'] = (eol.notna() & (eol <= origin_time)).astype(int) | |
| return prior | |
| def walk_forward(table, scenario_ctx, origins, configs): | |
| """configs: list of dicts(model, planner, p_star, day_q, name).""" | |
| # device-level EOL ledger for the cold-start floor: every death is | |
| # observable from the moment it happens (eol <= origin), unlike training | |
| # labels, which need the full 42d window closed | |
| dev_eol = table[['device_id', 'eol']].dropna().drop_duplicates('device_id') | |
| fitted = {} | |
| records = [] | |
| for idx in origins: | |
| ctx = scenario_ctx[idx] | |
| origin_time = ctx['start_time'] | |
| recent_deaths = int(((dev_eol['eol'] > origin_time - pd.Timedelta(days=WINDOW_DAYS)) | |
| & (dev_eol['eol'] <= origin_time)).sum()) | |
| # unique dead devices visible to training: every death <= origin lands | |
| # as a positive in some closed window (the 4d synthetic grid reaches | |
| # back >42d), so this equals the training set's unique positives | |
| train_deaths = int((dev_eol['eol'] <= origin_time).sum()) | |
| rows = table[table['origin_idx'] == idx].set_index('device_id') | |
| # label-free prevalence signal for the earliest origins, where even | |
| # recent_deaths lags (recording delay): fresh devices whose corrected | |
| # voltage crossed the 2.45 death threshold within the last 42d | |
| zone_young = int(((rows['days_below_245'] >= 0) | |
| & (rows['days_below_245'] <= WINDOW_DAYS) | |
| & (rows['staleness'] <= 7)).sum()) \ | |
| if 'days_below_245' in rows.columns else 0 | |
| preds = {} | |
| for model_name in {c['model'] for c in configs}: | |
| if (model_name, idx) not in fitted: | |
| factory = resolve_model(model_name) | |
| if model_name in ('aft', 'dthaz', 'dthaz_aux', 'logbag_open', 'blend_dh', 'blend_dhaux'): | |
| train = censored_pairs(table, origin_time) | |
| else: | |
| train = training_pairs(table, origin_time) | |
| fitted[(model_name, idx)] = factory().fit(train) | |
| preds[model_name] = fitted[(model_name, idx)].predict(rows) | |
| for cfg in configs: | |
| pred = preds[cfg['model']] | |
| if cfg['planner'] == 'topsum': | |
| PLANNERS['topsum'].alpha = cfg['p_star'] # p_star carries alpha | |
| if cfg['planner'] == 'ecroute': | |
| PLANNERS['ecroute'].margin = cfg['p_star'] or 1.0 # p_star carries margin | |
| PLANNERS['ecroute'].blend = cfg.get('blend', 0.0) | |
| if cfg['planner'] == 'tspig': | |
| PLANNERS['tspig'].alpha = cfg['p_star'] or 0.8 | |
| if cfg['planner'] == 'tspig2': | |
| PLANNERS['tspig2'].alpha = cfg['p_star'] or 0.8 | |
| PLANNERS['tspig2'].alpha2 = cfg.get('alpha2', 1.3) | |
| if cfg['planner'] == 'tsroute': | |
| PLANNERS['tsroute'].alpha = cfg['p_star'] or 0.8 # p_star carries alpha | |
| PLANNERS['tsroute'].p_be = cfg.get('p_be', 0.0) | |
| PLANNERS['tsroute'].floor_day = cfg.get('floor_day', 0) | |
| PLANNERS['tsroute'].p_conf = cfg.get('p_conf', 0.5) | |
| PLANNERS['tsroute'].ec_margin = cfg.get('ec_margin', 0.0) | |
| PLANNERS['tsroute'].ec_pmax = cfg.get('ec_pmax', 25.0) | |
| PLANNERS['tsroute'].split_days = cfg.get('split_days', 0) | |
| PLANNERS['tsroute'].floor_scale = cfg.get('floor', 0.0) | |
| # floor_gate: apply the floor only while the training set has | |
| # too few observed deaths to calibrate sum(p42) (the cold-start | |
| # failure); 0 = ungated. floor_zone: strengthen the prevalence | |
| # signal with the label-free death-zone count. | |
| gate = cfg.get('floor_gate', 0) | |
| prev = max(recent_deaths, zone_young) if cfg.get('floor_zone') \ | |
| else recent_deaths | |
| PLANNERS['tsroute'].recent_deaths = ( | |
| float(prev) if (gate == 0 or train_deaths < gate) else 0.0) | |
| global PARK_MOBILITY | |
| PARK_MOBILITY = cfg.get('park_mobility', 0.0) | |
| global EMERGENCY_DAY | |
| EMERGENCY_DAY = cfg.get('emergency_day', WINDOW_DAYS + 3) | |
| optimize_days.restarts = int(cfg.get('restarts', 2)) | |
| optimize_days.iters = int(cfg.get('iters', 60)) | |
| optimize_days.constructive = bool(cfg.get('constructive', False)) | |
| optimize_days.nn_order = bool(cfg.get('nn', False)) | |
| included = pred.index[pred['p42'] >= cfg['p_star']] | |
| kwargs = {} | |
| if cfg['planner'] in ('ecroute', 'tsroute', 'tspig', 'tspig2', 'tscut'): | |
| kwargs = dict(travel_costs=ctx['travel_costs'], | |
| settings=ctx['settings']) | |
| plan = PLANNERS[cfg['planner']](pred, included, ctx['locs'], | |
| origin_time, cfg['day_q'], **kwargs) | |
| _, _, overall = evaluate_plan( | |
| plan, ctx['locs'], ctx['travel_costs'], ctx['settings'], | |
| eol_times=ctx['eol'], start_time=origin_time, verbose=0) | |
| rs = rank_stats(pred, plan, ctx['eol'], origin_time) | |
| records.append(dict(overall, **rs, origin=idx, **cfg)) | |
| auc = {m: roc_auc_score(rows['dies42'], p['p42']) | |
| for m, p in preds.items() if rows['dies42'].sum() > 0} | |
| print(f"s_{idx}: " + ' '.join(f'{m}={a:.3f}' for m, a in auc.items()), | |
| file=sys.stderr) | |
| return pd.DataFrame(records) | |
| RANK_NS = (12, 20) | |
| def rank_stats(pred, plan, eol, start_time, order_col=None): | |
| """Ranking precision at the operating point, per origin. | |
| Measurement law #1 from the research loop: mean cost and AUC both hide | |
| whether a change moved the ORDER or just the depth of the cut. A model | |
| can hold AUC 0.947 and still have recall 0.000 on the subgroup that | |
| actually drives late cost. So every protocol here reports, alongside the | |
| cost: | |
| rec12/rec20 ordering only - share of the window's deaths inside the | |
| top 12/20 of the score, independent of how many the | |
| planner chose to swap; | |
| n_sched what the planner actually bought (in-window swaps); | |
| caught/prec the operating point: deaths among those swaps, and the | |
| share of swaps that were real deaths; | |
| miss_rank median rank of the deaths that were missed - the number | |
| that says whether a miss was near the cut or invisible. | |
| """ | |
| order = pred[order_col] if order_col and order_col in pred.columns else ( | |
| pred['rank_score'] if 'rank_score' in pred.columns else pred['p42']) | |
| order = order.sort_values(ascending=False) | |
| window_end = start_time + pd.Timedelta(days=WINDOW_DAYS) | |
| e = eol.reindex(order.index) | |
| deaths = set(e.index[(e > start_time) & (e <= window_end)]) | |
| out = {'n_deaths': len(deaths)} | |
| if not deaths: | |
| return out | |
| ranks = {b: r for r, b in enumerate(order.index, start=1)} | |
| dr = sorted(ranks[b] for b in deaths if b in ranks) | |
| for n in RANK_NS: | |
| out[f'rec{n}'] = sum(1 for r in dr if r <= n) / len(dr) | |
| missed = [r for r in dr if r > max(RANK_NS)] | |
| out['miss_rank'] = float(np.median(missed)) if missed else np.nan | |
| sched = set(plan.loc[plan['day'] <= window_end, 'battery']) | |
| out['n_sched'] = len(sched) | |
| out['caught'] = len(sched & deaths) | |
| out['prec'] = out['caught'] / len(sched) if sched else np.nan | |
| out['recall'] = out['caught'] / len(deaths) | |
| return out | |
| def load_context(dataset_path): | |
| locations, timeseries, eol_times, scenarios = load_dataset(dataset_path / 'train') | |
| ctx = {} | |
| gen = iterate_scenarios(locations, timeseries, eol_times, scenarios) | |
| for idx, (scenario, locs, cut, eol) in enumerate(gen): | |
| ctx[idx] = dict(start_time=pd.Timestamp(scenario['start_time']), | |
| locs=locs, eol=eol, | |
| travel_costs=scenario['travel_costs'], | |
| settings=scenario['settings']) | |
| return ctx | |
| def summarize(df, label): | |
| df['name'] = (df['model'] + '/' + df['planner'] + '/p' + | |
| df['p_star'].astype(str) + '/q' + df['day_q'].astype(str)) | |
| for key, tag in [('blend', 'b'), ('p_be', 'be'), ('floor_day', 'f'), ('p_conf', 'c'), | |
| ('alpha2', 'a2'), ('emergency_day', 'e'), ('restarts', 'r'), | |
| ('iters', 'i'), ('ec_margin', 'ec'), ('ec_pmax', 'pm'), | |
| ('split_days', 'sd'), ('park_mobility', 'pk'), ('floor', 'fl'), | |
| ('floor_gate', 'fg'), ('floor_zone', 'fz'), ('constructive', 'cn'), | |
| ('nn', 'nn')]: | |
| if key in df.columns and df[key].nunique(dropna=False) > 1: | |
| df['name'] += '/' + tag + df[key].fillna(0).astype(str) | |
| mean = df.groupby('name')['total_cost'].agg(['mean', 'std']).sort_values('mean') | |
| print(f'\n=== {label} ===') | |
| print(mean.round(1).to_string()) | |
| # component decomposition by default (phase 19): late/early/ops drove | |
| # every real insight since the leaderboard comparison - stop reburying it | |
| comps = [c for c in ('late_swap', 'early_swap') if c in df.columns] | |
| ops = [c for c in ('travel', 'overtime', 'daily_limit', 'weekly_limit', | |
| 'battery_swap', 'building_change', 'room_change') | |
| if c in df.columns] | |
| if comps: | |
| dec = df.groupby('name')[comps].mean() | |
| if ops: | |
| dec['ops'] = df.groupby('name')[ops].mean().sum(axis=1) | |
| print(dec.round(1).reindex(mean.index).to_string()) | |
| # ranking precision at the operating N (measurement law #1): cost alone | |
| # cannot say whether a change reordered the list or just cut it deeper | |
| rk = [c for c in ('rec12', 'rec20', 'recall', 'prec', 'n_sched', | |
| 'miss_rank', 'n_deaths') if c in df.columns] | |
| if rk: | |
| g = df.groupby('name') | |
| r = g[rk].mean() | |
| # pool over origins rather than averaging per-origin ratios: a | |
| # 1-death origin must not outvote a 12-death one | |
| if {'caught', 'n_deaths', 'n_sched'} <= set(df.columns): | |
| r['recall'] = g['caught'].sum() / g['n_deaths'].sum() | |
| r['prec'] = g['caught'].sum() / g['n_sched'].sum() | |
| print(r.round(3).reindex(mean.index).to_string()) | |
| return mean | |
| ALL_ORIGINS = range(8, 42) # merged dev+test block (protocol 2026-08-18) | |
| def selection_stability(df, n_random=2000, n_blocks=6, seed=0): | |
| """ | |
| Repeated-split champion selection over the merged origin block. | |
| Training is walk-forward and time-locked, so only the *selection* step is | |
| resampled: choose the best config on half the origins, measure it on the | |
| other half. Random iid splits estimate selection frequency; blocked splits | |
| (contiguous chunks) respect the correlation between origins whose 42-day | |
| windows overlap. Acceptance rule (EXPERIMENTS.md): a challenger should be | |
| selected in >= 60% of random splits AND be modal under blocked splits. | |
| """ | |
| from itertools import combinations | |
| df = df.copy() | |
| df['name'] = (df['model'] + '/' + df['planner'] + '/p' + | |
| df['p_star'].astype(str)) | |
| # distinguish configs that differ only in planner knobs (same tags as summarize) | |
| for key, tag in [('blend', 'b'), ('p_be', 'be'), ('floor_day', 'f'), ('p_conf', 'c'), | |
| ('alpha2', 'a2'), ('emergency_day', 'e'), ('restarts', 'r'), | |
| ('iters', 'i'), ('ec_margin', 'ec'), ('ec_pmax', 'pm'), | |
| ('split_days', 'sd'), ('park_mobility', 'pk'), ('floor', 'fl'), | |
| ('floor_gate', 'fg'), ('floor_zone', 'fz'), ('constructive', 'cn'), | |
| ('nn', 'nn')]: | |
| if key in df.columns and df[key].nunique(dropna=False) > 1: | |
| df['name'] += '/' + tag + df[key].fillna(0).astype(str) | |
| pv = df.pivot_table(index='origin', columns='name', values='total_cost') | |
| pv = pv.sort_index() | |
| names = pv.columns.tolist() | |
| X = pv.values | |
| n = len(pv) | |
| rng = np.random.RandomState(seed) | |
| def run(splits, label): | |
| # decided-splits-only (phase 19): a challenger byte-identical to the | |
| # incumbent on most origins used to lose every all-tie split to | |
| # alphabetical argmin order - selection% was mechanically capped | |
| # (phase 11 had to argue around this by hand). Ties are now excluded | |
| # from the denominator and reported separately. | |
| sel = {m: 0 for m in names} | |
| best = {m: 0 for m in names} | |
| regrets, tied = [], 0 | |
| for A in splits: | |
| B = np.setdiff1d(np.arange(n), A) | |
| ma, mb = X[A].mean(0), X[B].mean(0) | |
| if ma.max() - ma.min() < 1e-9: | |
| tied += 1 | |
| continue | |
| i = ma.argmin() | |
| j = mb.argmin() | |
| sel[names[i]] += 1 | |
| best[names[j]] += 1 | |
| regrets.append(mb[i] - mb[j]) | |
| k = max(1, len(splits) - tied) | |
| print(f'\n--- selection stability: {label} ({len(splits)} splits, ' | |
| f'{tied} all-tie excluded) ---') | |
| print(f"{'config':40s} {'selected%':>9s} {'best-holdout%':>13s} {'mean':>8s}") | |
| for m in sorted(names, key=lambda m: -sel[m]): | |
| print(f'{m:40s} {100*sel[m]/k:9.1f} {100*best[m]/k:13.1f} ' | |
| f'{X[:, names.index(m)].mean():8.1f}') | |
| if regrets: | |
| print(f'mean selection regret {np.mean(regrets):.1f} ' | |
| f'(90th pct {np.percentile(regrets, 90):.1f})') | |
| half = n // 2 | |
| run([rng.choice(n, half, replace=False) for _ in range(n_random)], | |
| f'random {half}/{n - half}') | |
| blocks = np.array_split(np.arange(n), n_blocks) | |
| run([np.concatenate([blocks[i] for i in c]) | |
| for c in combinations(range(n_blocks), n_blocks // 2)], | |
| f'blocked ({n_blocks} chunks choose {n_blocks // 2})') | |
| def main(): | |
| dataset_path = Path(os.environ.get('BATTERYSWAP_DATASET_PATH', 'dataset')) | |
| table = load_features(dataset_path) | |
| # derived: voltage projected through the reporting gap by the device's own | |
| # slope, and leak-free within-origin fleet ranks (data <= origin only) | |
| table['v_proj'] = table['v_last'] + table['slope_lastk'] * table['staleness'] | |
| table['r_vlast'] = table.groupby('origin_idx')['v_last'].rank(pct=True) | |
| table['r_tau'] = table.groupby('origin_idx')['tau_projected'].rank(pct=True) | |
| try: | |
| from batteryswap_example.aux_features import load as load_aux | |
| aux = load_aux(dataset_path) | |
| dup = [c for c in aux.columns if c in table.columns | |
| and c not in ('device_id', 'origin_idx')] | |
| table = table.merge(aux.drop(columns=dup), | |
| on=['device_id', 'origin_idx'], how='left') | |
| except FileNotFoundError: | |
| pass | |
| try: | |
| from batteryswap_example.fht_features import load as load_fht | |
| table = table.merge(load_fht(dataset_path), on=['device_id', 'origin_idx'], | |
| how='left') | |
| table['fht_days'] = ((table['v_corr_last'] - 2.40) | |
| / table['wnr_m'].clip(lower=1e-4) | |
| - table['staleness']).clip(-300, 600) | |
| except FileNotFoundError: | |
| pass | |
| ctx = load_context(dataset_path) | |
| phase = sys.argv[1] if len(sys.argv) > 1 else 'dev' | |
| if phase == 'dev': | |
| configs = [] | |
| for model in MODELS: | |
| for planner in PLANNERS: | |
| for p_star in (0.1, 0.2, 0.35): | |
| for day_q in (0.05, 0.20): | |
| configs.append(dict(model=model, planner=planner, | |
| p_star=p_star, day_q=day_q)) | |
| df = walk_forward(table, ctx, DEV_ORIGINS, configs) | |
| df.to_csv('/tmp/dev_results.csv', index=False) | |
| summarize(df, f'DEV s_8..s_23 ({len(configs)} configs)') | |
| elif phase == 'panel': | |
| # merged-block evaluation (protocol of 2026-08-18): score configs on all | |
| # 34 usable origins, then repeated-split selection stability | |
| import json | |
| configs = json.loads(Path('/tmp/panel_configs.json').read_text()) | |
| df = walk_forward(table, ctx, ALL_ORIGINS, configs) | |
| df.to_csv('/tmp/panel_results.csv', index=False) | |
| summarize(df, f'PANEL s_{ALL_ORIGINS[0]}..s_{ALL_ORIGINS[-1]}') | |
| selection_stability(df) | |
| else: | |
| # phase 'dev2' or 'test': configs from /tmp/<phase>_configs.json | |
| import json | |
| configs = json.loads(Path(f'/tmp/{phase}_configs.json').read_text()) | |
| origins = DEV_ORIGINS if phase.startswith('dev') else TEST_ORIGINS | |
| df = walk_forward(table, ctx, origins, configs) | |
| df.to_csv(f'/tmp/{phase}_results.csv', index=False) | |
| summarize(df, f'{phase.upper()} origins {list(origins)[0]}..{list(origins)[-1]}') | |
| if __name__ == '__main__': | |
| main() | |