Thibaut Forest
Claude Opus 5 (1M context)
Phase 41-42: fixed-N ablation rejects the vrel port; public says pooling is a wash
41a82cd Download batteryswap_example/features.py from thibautforest/BatterySwapAI2026-Example: direct link, hf CLI and curl.
- Browser
- Download file 17.8 kB
-
https://huggingface.co/thibautforest/BatterySwapAI2026-Example/resolve/main/batteryswap_example/features.py
- Command line
-
hf download hf://thibautforest/BatterySwapAI2026-Example/batteryswap_example/features.py
-
curl -L -o features.py https://huggingface.co/thibautforest/BatterySwapAI2026-Example/resolve/main/batteryswap_example/features.py
17.8 kB
| """ | |
| Precompute the (device, origin) feature/label table used by every model. | |
| One row per (alive device, scenario origin). Features use only data at or before | |
| the origin; no fleet-wide statistics are computed here, so slicing at an origin | |
| cannot leak the future. Labels use the full eol_times and are for | |
| training/evaluation only - the planner never reads them. | |
| Cached to dataset/train/features.parquet (rebuild with --force). | |
| """ | |
| from pathlib import Path | |
| import os | |
| import sys | |
| import numpy as np | |
| import pandas as pd | |
| from batteryswap_public.utils import load_dataset | |
| MIN_TEMP = 10 | |
| MAX_TEMP = 30 | |
| WINDOW_DAYS = 42 | |
| def daily_voltage(timeseries: pd.DataFrame) -> pd.DataFrame: | |
| """In-band daily median voltage per device: (device_id, day, v).""" | |
| df = timeseries.reset_index() | |
| # device_id is categorical in the parquet; observed=False groupbys would emit | |
| # the full device x day cartesian product with NaN voltages | |
| df['device_id'] = df['device_id'].astype(str) | |
| df = df[(df['temperature'] > MIN_TEMP) & (df['temperature'] < MAX_TEMP)] | |
| df['day'] = df['end_time'].dt.normalize() | |
| out = df.groupby(['device_id', 'day'])['voltage'].median().rename('v').reset_index() | |
| return out.dropna(subset=['v']).sort_values('day') | |
| def slope(days: np.ndarray, values: np.ndarray) -> float: | |
| """Per-day slope; NaN when under 2 points.""" | |
| if len(values) < 2: | |
| return np.nan | |
| x = (days - days[-1]) / np.timedelta64(1, 'D') | |
| return float(np.polyfit(x.astype(float), values, 1)[0]) | |
| def detect_swaps(dall: pd.DataFrame, beta: float) -> pd.Series: | |
| """ | |
| Last UNDOCUMENTED battery-swap day per device, from daily corrected | |
| voltage <= whatever the caller masked to (leak-free if dall is masked to | |
| the origin). A swap = consecutive daily readings jumping up by >= 0.15 V | |
| from a degraded level (<= 2.85) onto the fresh plateau (>= 3.00). The | |
| landing gate is load-bearing: the detected set is identical for jump | |
| thresholds 0.10-0.25 (real swaps jump >= 0.28 V, nearest noise landing | |
| 2.989), so the rule sits in a wide dead zone. Devices without a detected | |
| swap are absent from the result. | |
| """ | |
| d = dall.sort_values(['device_id', 'day']) | |
| vc = d['v'] - beta * (d['t'] - 20.0) | |
| same = d['device_id'].eq(d['device_id'].shift()) | |
| jump = (vc - vc.shift() >= 0.15) & (vc.shift() <= 2.85) & (vc >= 3.00) & same | |
| return d.loc[jump].groupby('device_id')['day'].max() | |
| def features_at_origin(daily: pd.DataFrame, devices: pd.DataFrame, | |
| origin: pd.Timestamp, swaps: pd.Series = None) -> pd.DataFrame: | |
| """Feature rows for every device with any reading at or before origin. | |
| swaps: optional device -> last-swap-day map (detect_swaps). History before | |
| a device's swap belongs to the PREVIOUS battery: it is dropped so slopes, | |
| baselines and windows describe the battery actually in the slot, and | |
| age_days restarts at the swap. | |
| """ | |
| h = daily[daily['day'] <= origin] | |
| if swaps is not None and len(swaps): | |
| cut = h['device_id'].map(swaps) | |
| h = h[cut.isna() | (h['day'] >= cut)] | |
| g = h.groupby('device_id') | |
| last = g.tail(1).set_index('device_id') | |
| feats = pd.DataFrame({'v_last': last['v']}) | |
| feats['staleness'] = (origin - last['day']).dt.days | |
| # per-device historical reference: median over first 90 observed days | |
| first_day = g['day'].min() | |
| early = h[h['day'] <= h['device_id'].map(first_day) + pd.Timedelta(days=90)] | |
| feats['v_ref'] = early.groupby('device_id')['v'].median() | |
| feats['v_drop'] = feats['v_ref'] - feats['v_last'] | |
| for w in (30, 90): | |
| hw = h[h['day'] > origin - pd.Timedelta(days=w)] | |
| gw = hw.groupby('device_id') | |
| feats[f'slope_{w}'] = gw.apply( | |
| lambda u: slope(u['day'].values, u['v'].values), include_groups=False) | |
| feats[f'v_min_{w}'] = gw['v'].min() | |
| feats[f'n_{w}'] = gw['v'].size() | |
| # window-free slope over the last K observations: sparse traces (the worst | |
| # recall misses) have <2 points in any fixed window but still have history | |
| lastk = g.tail(10) | |
| feats['slope_lastk'] = lastk.groupby('device_id').apply( | |
| lambda u: slope(u['day'].values, u['v'].values), include_groups=False) | |
| # recent drop: median of days -28..-7 minus the latest value (trend, not level) | |
| prev = h[(h['day'] > origin - pd.Timedelta(days=28)) & | |
| (h['day'] <= origin - pd.Timedelta(days=7))] | |
| feats['drop_recent'] = prev.groupby('device_id')['v'].median() - feats['v_last'] | |
| # fraction of the last 30 observations below the personal baseline - 0.15 | |
| last30 = g.tail(30).set_index('device_id') | |
| below = last30['v'] < (feats['v_ref'].reindex(last30.index) - 0.15) | |
| feats['below_frac'] = below.groupby(level=0).mean() | |
| feats['accel'] = feats['slope_30'] - feats['slope_90'] | |
| # ---- fleet-relative encoding (REJECTED - phase 41) ---------------------- | |
| # Ported from the blind-harness champion, where vrel and vmin7_rank were | |
| # the two largest single-feature wins. Both are computed at PLAN time over | |
| # the fleet actually in front of the planner, so they absorb a fleet-level | |
| # voltage shift that the absolute features (v_last, v_min_30, ...) cannot. | |
| # The motivation was TRANSFER to a held-out fleet. | |
| # | |
| # NOT IN models_cv.FEATURES, and they must not be added. Re-ablated on | |
| # recall at FIXED N (the phase-39 directive), which is the only way to see | |
| # a feature's effect on ordering separately from its effect on the topsum | |
| # count. Deaths caught, paired per origin, bootstrap 95% CI: | |
| # | |
| # native all48 static gate (5 splits) | |
| # vrel+vmin7 N=12 -7 [-15,+0] N=12 +4 [-2,+10] | |
| # N=20 +5 [ -1,+11] N=20 -1 [-9, +7] | |
| # | |
| # The sign flips with N on BOTH instruments and every CI straddles zero. | |
| # The ordering is untouched at 36-41 of 48 native origins and 228 of 240 | |
| # static ones - these features barely enter the ranking at all. The one CI | |
| # that excludes zero is vrel alone, native, N=12: -6 [-12,-1], negative. | |
| # Cost confirms it: all48 1800.4 vs 1661.7 (+138.7). | |
| # | |
| # Left computed rather than deleted only to avoid regenerating the | |
| # packaged parquet on submission day; the two columns cost ~1s inside | |
| # build_split_table's 360s budget. Delete them when there is slack. | |
| # | |
| # Deliberately NOT filled here. The classifier pipeline median-imputes and | |
| # then scales, which parks a stale/dark device at "unremarkable" so it is | |
| # never selected; three independent measurements found that making those | |
| # devices visible instead is a monotone loss. Do not add fillna() or | |
| # na_option= to the rank. | |
| # | |
| # Population note: the denominator is whatever has history at or before the | |
| # origin, dead-but-not-yet-dropped devices included. That is identical in | |
| # the training-table build and in the runtime path, which is what matters - | |
| # both sides see the same population definition. | |
| h7 = h[h['day'] > origin - pd.Timedelta(days=7)] | |
| feats['v_min_7'] = h7.groupby('device_id')['v'].min() | |
| feats['vrel'] = feats['v_last'] - feats['v_last'].median() | |
| feats['vmin7_rank'] = feats['v_min_7'].rank(pct=True) | |
| install = devices.set_index('battery')['start_time'].dt.tz_localize(None) | |
| eff_install = install.reindex(feats.index) | |
| if swaps is not None and len(swaps): | |
| sw = swaps.reindex(feats.index) | |
| eff_install = eff_install.where(sw.isna() | (eff_install >= sw), sw) | |
| feats['age_days'] = (origin - eff_install).dt.days | |
| return feats | |
| # ------------------------------------------------------------- physics model | |
| # Empirically validated on the train split: temperature moves voltage by | |
| # ~+5 mV/degC within a device-week, and the 82 observed deaths all cross | |
| # ~2.40 V, approaching it along a shared discharge curve (sd 0.034 V at EOL). | |
| TAU_MAX = 300 # curve support, days before EOL | |
| FIT_WINDOW = 120 # days of history used for trajectory matching | |
| BETA_DEFAULT = 0.005 | |
| def daily_all(timeseries: pd.DataFrame) -> pd.DataFrame: | |
| """Daily median voltage AND temperature per device, all readings kept.""" | |
| df = timeseries.reset_index() | |
| df['device_id'] = df['device_id'].astype(str) | |
| df['day'] = df['end_time'].dt.normalize() | |
| out = df.groupby(['device_id', 'day']).agg( | |
| v=('voltage', 'median'), t=('temperature', 'median')).reset_index() | |
| return out.dropna(subset=['v']).sort_values('day') | |
| def weekly_betas(daily: pd.DataFrame) -> pd.DataFrame: | |
| """Per (device, week) voltage-vs-temperature slope; 'week_end' for cutoffs.""" | |
| d = daily.copy() | |
| d['week'] = d['day'].dt.to_period('W') | |
| def beta_of(g): | |
| if len(g) < 5 or g['t'].std() < 1: | |
| return np.nan | |
| return np.polyfit(g['t'], g['v'], 1)[0] | |
| b = d.groupby(['device_id', 'week']).apply(beta_of, include_groups=False).dropna() | |
| b = b.rename('beta').reset_index() | |
| b['week_end'] = b['week'].dt.end_time.dt.normalize() | |
| return b | |
| def master_curve(daily: pd.DataFrame, dead_eol: pd.Series, beta: float, | |
| min_dead: int = 5) -> 'np.ndarray | None': | |
| """Median temp-corrected voltage at each days-before-EOL in 0..TAU_MAX.""" | |
| if len(dead_eol) < min_dead: | |
| return None | |
| d = daily[daily['device_id'].isin(dead_eol.index)].copy() | |
| d['v_corr'] = d['v'] - beta * (d['t'] - 20.0) | |
| d['tau'] = (d['device_id'].map(dead_eol).dt.normalize() - d['day']).dt.days | |
| d = d[(d['tau'] >= 0) & (d['tau'] <= TAU_MAX)] | |
| med = d.groupby('tau')['v_corr'].median() | |
| grid = np.arange(0, TAU_MAX + 1) | |
| curve = np.interp(grid, med.index.values, med.values) | |
| return np.maximum.accumulate(curve) # enforce monotone non-decreasing in tau | |
| def physics_features(daily: pd.DataFrame, origin: pd.Timestamp, | |
| beta: float, curve, match: bool = True, | |
| swaps: pd.Series = None) -> pd.DataFrame: | |
| """ | |
| Physics-model features. | |
| v_corr_last - latest temp-corrected voltage (full history, no window) | |
| thresh_dist - v_corr_last minus the curve's EOL voltage | |
| tau_projected - C^-1(v_corr_last) minus staleness: days-to-EOL projected | |
| forward through the silence. The key feature for devices | |
| that go dark and keep discharging - a stale healthy-ish | |
| reading minus months of silence can mean "due now". | |
| tau_hat/fit_err - trajectory registration (match=True only; expensive) | |
| """ | |
| all_h = daily[daily['day'] <= origin] | |
| if swaps is not None and len(swaps): | |
| cut = all_h['device_id'].map(swaps) | |
| all_h = all_h[cut.isna() | (all_h['day'] >= cut)] | |
| last = all_h.sort_values('day').groupby('device_id').tail(1).set_index('device_id') | |
| v_corr = last['v'] - beta * (last['t'] - 20.0) | |
| stale = (origin - last['day']).dt.days | |
| out = pd.DataFrame({'v_corr_last': v_corr}) | |
| # ghost marker: days since the corrected voltage first crossed below 2.45 | |
| # (post-swap history only). A real recorded death follows the crossing | |
| # within days; a crossing >42d old with no EOL on file means the recording | |
| # process abandoned this device - it will never be scored as a death. | |
| vc_all = all_h['v'] - beta * (all_h['t'] - 20.0) | |
| below = all_h.loc[vc_all < 2.45] | |
| first_below = below.groupby('device_id')['day'].min() | |
| out['days_below_245'] = (origin - first_below.reindex(out.index)).dt.days | |
| # plunge precursors: volatility of the corrected voltage rises BEFORE the | |
| # level falls (AUC 0.83/0.81 on the voltage-silent dying cohort, where the | |
| # level itself is blind). These are the fast-plunge early-warning features. | |
| h30 = all_h[all_h['day'] > origin - pd.Timedelta(days=30)].copy() | |
| h30['v_corr'] = h30['v'] - beta * (h30['t'] - 20.0) | |
| g30 = h30.groupby('device_id')['v_corr'] | |
| out['vstd_30'] = g30.std() | |
| out['rng_30'] = g30.max() - g30.min() | |
| if curve is not None: | |
| grid = np.arange(0, TAU_MAX + 1) | |
| tau_at_last = np.interp(v_corr.values, curve, grid) | |
| out['thresh_dist'] = v_corr - curve[0] | |
| out['tau_projected'] = tau_at_last - stale.values | |
| else: | |
| out['thresh_dist'] = np.nan | |
| out['tau_projected'] = np.nan | |
| out['tau_hat'] = np.nan | |
| out['fit_err'] = np.nan | |
| if match and curve is not None: | |
| h = all_h[all_h['day'] > origin - pd.Timedelta(days=FIT_WINDOW)].copy() | |
| h['v_corr'] = h['v'] - beta * (h['t'] - 20.0) | |
| h['delta'] = (origin - h['day']).dt.days | |
| taus = np.arange(1, TAU_MAX - FIT_WINDOW) | |
| for dev, g in h.groupby('device_id'): | |
| if len(g) >= 5: | |
| idx = taus[:, None] + g['delta'].values[None, :] | |
| err = np.abs(g['v_corr'].values[None, :] - curve[idx]).mean(axis=1) | |
| best = err.argmin() | |
| out.at[dev, 'tau_hat'] = float(taus[best]) | |
| out.at[dev, 'fit_err'] = float(err[best]) | |
| return out | |
| def cohort_features(devices: pd.DataFrame, eol_times: pd.Series, | |
| origin: pd.Timestamp) -> pd.DataFrame: | |
| """ | |
| Install-cohort calibrated clock. Batteries from the same install batch die at | |
| nearly the same age (batch explains ~90% of lifetime variance), so | |
| age - median(batch lifetimes observed so far) predicts death even when the | |
| voltage has not moved yet (AUC 0.91 on voltage-silent pairs). | |
| Uses only deaths <= origin. | |
| """ | |
| install = devices.set_index('battery')['start_time'].dt.tz_localize(None) | |
| dead = eol_times[eol_times.notna() & (eol_times <= origin)] | |
| life = (dead - install.reindex(dead.index)).dt.days.rename('life') | |
| out = pd.DataFrame(index=install.index) | |
| age = (origin - install).dt.days | |
| for col, key in [('cohort_rel_age', install), | |
| ('cohort_rel_age_m', install.dt.to_period('M'))]: | |
| batch_life = life.groupby(key.reindex(life.index)).agg(['median', 'size']) | |
| med = key.map(batch_life['median'].where(batch_life['size'] >= 2)) | |
| out[col] = age - med | |
| # neutral-when-unknown encoding: fires only past the batch's observed | |
| # lifetime, exactly 0 otherwise - no imputation noise for the majority | |
| out[col.replace('rel_age', 'overdue')] = (age - med).clip(lower=0).fillna(0) | |
| out.index.name = 'device_id' | |
| return out | |
| def build(dataset_path: Path, out_path: Path) -> pd.DataFrame: | |
| locations, timeseries, eol_times, scenarios = load_dataset(dataset_path / 'train') | |
| daily = daily_voltage(timeseries) | |
| dall = daily_all(timeseries) | |
| betas = weekly_betas(dall) | |
| # real scenario origins, plus synthetic training-only origins: a denser grid | |
| # (every 4 days) extended back before s_0, so every observed death lands in | |
| # training windows and positive pairs are ~3x more numerous. Synthetic rows are | |
| # never scored - they only feed training_pairs(). | |
| real_starts = [pd.Timestamp(sc['start_time']) for sc in scenarios] | |
| synth = pd.date_range('2025-02-15', max(real_starts), freq='4D') | |
| synth = [t for t in synth if t not in set(real_starts)] | |
| origins = ([(idx, t, False) for idx, t in enumerate(real_starts)] + | |
| [(1000 + i, t, True) for i, t in enumerate(synth)]) | |
| rows = [] | |
| for idx, origin, is_synth in origins: | |
| alive = eol_times.isna() | (eol_times > origin) | |
| alive_ids = eol_times.index[alive] | |
| # NOTE (phase 15): detect_swaps truncation was tested here and NOT | |
| # adopted - panel 1684.5 vs 1679.2. The undocumented-swap age error | |
| # touches only age_days (weak, linear); the machinery stays available | |
| # via the swaps= parameter but production builds run without it. | |
| past = betas[betas['week_end'] <= origin]['beta'] | |
| beta = float(past.median()) if len(past) >= 20 else BETA_DEFAULT | |
| f = features_at_origin(daily, locations, origin) | |
| f = f.reindex(alive_ids) # NaN feature rows kept: planner must handle them | |
| # physics features: beta and curve from information <= origin only. | |
| # Synthetic origins get the light variant (no trajectory matching). | |
| dead = eol_times[eol_times.notna() & (eol_times <= origin)] | |
| curve = master_curve(dall[dall['day'] <= origin], dead, beta) | |
| f = f.join(physics_features(dall, origin, beta, curve, match=not is_synth)) | |
| f = f.join(cohort_features(locations, eol_times, origin)) | |
| f['synthetic'] = is_synth | |
| f['origin_idx'] = idx | |
| f['origin'] = origin | |
| eol = eol_times.reindex(alive_ids) | |
| f['eol'] = eol.values | |
| f['dies42'] = ((eol.notna()) & (eol <= origin + pd.Timedelta(days=WINDOW_DAYS))).astype(int).values | |
| f['days_to_eol'] = ((eol - origin).dt.total_seconds() / 86400).values | |
| f['window_closed'] = origin + pd.Timedelta(days=WINDOW_DAYS) <= pd.Timestamp('2026-07-31') | |
| rows.append(f.reset_index().rename(columns={'index': 'device_id'})) | |
| print(f'origin {idx} {origin.date()}: {len(f)} alive, ' | |
| f'{f["dies42"].sum()} die in window', file=sys.stderr) | |
| table = pd.concat(rows, ignore_index=True) | |
| table.to_parquet(out_path) | |
| return table | |
| def load(dataset_path: Path = Path('dataset'), force: bool = False) -> pd.DataFrame: | |
| out_path = dataset_path / 'train' / 'features.parquet' | |
| if out_path.exists() and not force: | |
| return pd.read_parquet(out_path) | |
| return build(dataset_path, out_path) | |
| if __name__ == '__main__': | |
| force = '--force' in sys.argv | |
| dataset_path = Path(os.environ.get('BATTERYSWAP_DATASET_PATH', 'dataset')) | |
| t = load(dataset_path, force=force) | |
| print(t.groupby('origin_idx')['dies42'].agg(['size', 'sum']).T) | |
| print(t.describe().round(3).to_string()) | |