Thibaut Forest Claude Opus 5 (1M context) commited on
Commit ·
e3fa964
1
Parent(s): 2ba75d6
Phase 15: swap-truncation/ghost/quarantine all rejected by panel; label 'noise' is the label process
Browse files- EXPERIMENTS.md +30 -0
- batteryswap_example/features.py +55 -6
- batteryswap_example/models_cv.py +21 -1
EXPERIMENTS.md
CHANGED
|
@@ -482,6 +482,36 @@ New champion: **logbag / tsroute alpha=0.8 + break-even top-up (m0.8/pm25,
|
|
| 482 |
fresh-only) + split_days=7 + censoring-aware two-sided cost curves**.
|
| 483 |
Panel 1679.2 (from 1715.8 pre-phase-11).
|
| 484 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 485 |
## Test-look ledger
|
| 486 |
|
| 487 |
8 looks total (now historical - see protocol change above); look 9 = the
|
|
|
|
| 482 |
fresh-only) + split_days=7 + censoring-aware two-sided cost curves**.
|
| 483 |
Panel 1679.2 (from 1715.8 pre-phase-11).
|
| 484 |
|
| 485 |
+
## Phase 15 - undocumented swaps & unlabeled deaths (all three fixes rejected; the "noise" is the label process)
|
| 486 |
+
|
| 487 |
+
User finding (voltage_all_devices notebook): timeseries extend beyond battery
|
| 488 |
+
lifetimes - unrecorded preventive swaps. Parallel forensics (3 agents)
|
| 489 |
+
established the full scale: **31 devices with undocumented swaps** (32 events,
|
| 490 |
+
clustering on building maintenance days - 2025-07-14 x8 and 2025-07-23 x7 in
|
| 491 |
+
b_afdd8b1a066d, 2026-05-08 x6), detectable by a rule with a wide dead zone
|
| 492 |
+
(consecutive-day corrected-voltage jump from <= 2.85 up to >= 3.00: identical
|
| 493 |
+
33-event set for any jump threshold 0.10-0.25 V; real swaps jump >= 0.28,
|
| 494 |
+
nearest noise landing 2.989). Beyond the 2 suspected unlabeled deaths: **26
|
| 495 |
+
confirmed ghosts** (crossed 2.45, never recovered, stopped reporting, no EOL;
|
| 496 |
+
dropout days cluster on batch-removal dates: 2025-12-07 x13, 2026-02-19 x10)
|
| 497 |
+
plus 32 deaths-in-progress still limping below 2.45 at data end.
|
| 498 |
+
|
| 499 |
+
Three CV-adjudicated treatments (panel, sd7 champion 1679.2):
|
| 500 |
+
|
| 501 |
+
| treatment | panel | why it failed |
|
| 502 |
+
|---|---|---|
|
| 503 |
+
| A: truncate history at detected swap (age_days restarts, windows never span the swap) | 1684.5 (+5.3; ties except s_9 +101, s_14 +122, s_32 -43) | the age error only touches age_days (weak, linear); cohort features were already rejected; truncation shortens post-swap history and loses more than it fixes |
|
| 504 |
+
| B: exclude ghosts from plans (p42=0 if below 2.45 > 42d, still < 2.55, no EOL) | **2226.0 (+547)** | WRONG PREMISE: EOL is the ~2.40 crossing and batteries spend >42d in the 2.45->2.40 twilight - the rule zeroed genuinely dying devices |
|
| 505 |
+
| C: quarantine "looks dead, labeled survives" training rows (3,027 rows) | **2519.1 (+840)**, per-origin AUC collapses to ~0.51 | those negatives are LOAD-BEARING: they teach the model that deep-low voltage does not reliably produce a recorded death - which is exactly what the metric scores |
|
| 506 |
+
|
| 507 |
+
Conclusion: what looks like label noise IS the label-generating process, and
|
| 508 |
+
the incumbent model has already learned it (via staleness and the low-voltage
|
| 509 |
+
negatives). The evaluator scores recorded deaths, not physical deaths; ghosts
|
| 510 |
+
are unscoreable, and their "survives" labels are the correct training signal
|
| 511 |
+
for that objective. detect_swaps() and the GHOST_ZERO/QUARANTINE knobs remain
|
| 512 |
+
in the code, inert by default, with the production table and runtime paths
|
| 513 |
+
unchanged. Champion unchanged: panel 1679.2, all-48 Docker 2146.9.
|
| 514 |
+
|
| 515 |
## Test-look ledger
|
| 516 |
|
| 517 |
8 looks total (now historical - see protocol change above); look 9 = the
|
batteryswap_example/features.py
CHANGED
|
@@ -43,10 +43,37 @@ def slope(days: np.ndarray, values: np.ndarray) -> float:
|
|
| 43 |
return float(np.polyfit(x.astype(float), values, 1)[0])
|
| 44 |
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
def features_at_origin(daily: pd.DataFrame, devices: pd.DataFrame,
|
| 47 |
-
origin: pd.Timestamp) -> pd.DataFrame:
|
| 48 |
-
"""Feature rows for every device with any reading at or before origin.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
h = daily[daily['day'] <= origin]
|
|
|
|
|
|
|
|
|
|
| 50 |
|
| 51 |
g = h.groupby('device_id')
|
| 52 |
last = g.tail(1).set_index('device_id')
|
|
@@ -86,7 +113,11 @@ def features_at_origin(daily: pd.DataFrame, devices: pd.DataFrame,
|
|
| 86 |
feats['accel'] = feats['slope_30'] - feats['slope_90']
|
| 87 |
|
| 88 |
install = devices.set_index('battery')['start_time'].dt.tz_localize(None)
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
|
| 91 |
return feats
|
| 92 |
|
|
@@ -141,7 +172,8 @@ def master_curve(daily: pd.DataFrame, dead_eol: pd.Series, beta: float,
|
|
| 141 |
|
| 142 |
|
| 143 |
def physics_features(daily: pd.DataFrame, origin: pd.Timestamp,
|
| 144 |
-
beta: float, curve, match: bool = True
|
|
|
|
| 145 |
"""
|
| 146 |
Physics-model features.
|
| 147 |
v_corr_last - latest temp-corrected voltage (full history, no window)
|
|
@@ -153,12 +185,24 @@ def physics_features(daily: pd.DataFrame, origin: pd.Timestamp,
|
|
| 153 |
tau_hat/fit_err - trajectory registration (match=True only; expensive)
|
| 154 |
"""
|
| 155 |
all_h = daily[daily['day'] <= origin]
|
|
|
|
|
|
|
|
|
|
| 156 |
last = all_h.sort_values('day').groupby('device_id').tail(1).set_index('device_id')
|
| 157 |
v_corr = last['v'] - beta * (last['t'] - 20.0)
|
| 158 |
stale = (origin - last['day']).dt.days
|
| 159 |
|
| 160 |
out = pd.DataFrame({'v_corr_last': v_corr})
|
| 161 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
# plunge precursors: volatility of the corrected voltage rises BEFORE the
|
| 163 |
# level falls (AUC 0.83/0.81 on the voltage-silent dying cohort, where the
|
| 164 |
# level itself is blind). These are the fast-plunge early-warning features.
|
|
@@ -241,13 +285,18 @@ def build(dataset_path: Path, out_path: Path) -> pd.DataFrame:
|
|
| 241 |
alive = eol_times.isna() | (eol_times > origin)
|
| 242 |
alive_ids = eol_times.index[alive]
|
| 243 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
f = features_at_origin(daily, locations, origin)
|
| 245 |
f = f.reindex(alive_ids) # NaN feature rows kept: planner must handle them
|
| 246 |
|
| 247 |
# physics features: beta and curve from information <= origin only.
|
| 248 |
# Synthetic origins get the light variant (no trajectory matching).
|
| 249 |
-
past = betas[betas['week_end'] <= origin]['beta']
|
| 250 |
-
beta = float(past.median()) if len(past) >= 20 else BETA_DEFAULT
|
| 251 |
dead = eol_times[eol_times.notna() & (eol_times <= origin)]
|
| 252 |
curve = master_curve(dall[dall['day'] <= origin], dead, beta)
|
| 253 |
f = f.join(physics_features(dall, origin, beta, curve, match=not is_synth))
|
|
|
|
| 43 |
return float(np.polyfit(x.astype(float), values, 1)[0])
|
| 44 |
|
| 45 |
|
| 46 |
+
def detect_swaps(dall: pd.DataFrame, beta: float) -> pd.Series:
|
| 47 |
+
"""
|
| 48 |
+
Last UNDOCUMENTED battery-swap day per device, from daily corrected
|
| 49 |
+
voltage <= whatever the caller masked to (leak-free if dall is masked to
|
| 50 |
+
the origin). A swap = consecutive daily readings jumping up by >= 0.15 V
|
| 51 |
+
from a degraded level (<= 2.85) onto the fresh plateau (>= 3.00). The
|
| 52 |
+
landing gate is load-bearing: the detected set is identical for jump
|
| 53 |
+
thresholds 0.10-0.25 (real swaps jump >= 0.28 V, nearest noise landing
|
| 54 |
+
2.989), so the rule sits in a wide dead zone. Devices without a detected
|
| 55 |
+
swap are absent from the result.
|
| 56 |
+
"""
|
| 57 |
+
d = dall.sort_values(['device_id', 'day'])
|
| 58 |
+
vc = d['v'] - beta * (d['t'] - 20.0)
|
| 59 |
+
same = d['device_id'].eq(d['device_id'].shift())
|
| 60 |
+
jump = (vc - vc.shift() >= 0.15) & (vc.shift() <= 2.85) & (vc >= 3.00) & same
|
| 61 |
+
return d.loc[jump].groupby('device_id')['day'].max()
|
| 62 |
+
|
| 63 |
+
|
| 64 |
def features_at_origin(daily: pd.DataFrame, devices: pd.DataFrame,
|
| 65 |
+
origin: pd.Timestamp, swaps: pd.Series = None) -> pd.DataFrame:
|
| 66 |
+
"""Feature rows for every device with any reading at or before origin.
|
| 67 |
+
|
| 68 |
+
swaps: optional device -> last-swap-day map (detect_swaps). History before
|
| 69 |
+
a device's swap belongs to the PREVIOUS battery: it is dropped so slopes,
|
| 70 |
+
baselines and windows describe the battery actually in the slot, and
|
| 71 |
+
age_days restarts at the swap.
|
| 72 |
+
"""
|
| 73 |
h = daily[daily['day'] <= origin]
|
| 74 |
+
if swaps is not None and len(swaps):
|
| 75 |
+
cut = h['device_id'].map(swaps)
|
| 76 |
+
h = h[cut.isna() | (h['day'] >= cut)]
|
| 77 |
|
| 78 |
g = h.groupby('device_id')
|
| 79 |
last = g.tail(1).set_index('device_id')
|
|
|
|
| 113 |
feats['accel'] = feats['slope_30'] - feats['slope_90']
|
| 114 |
|
| 115 |
install = devices.set_index('battery')['start_time'].dt.tz_localize(None)
|
| 116 |
+
eff_install = install.reindex(feats.index)
|
| 117 |
+
if swaps is not None and len(swaps):
|
| 118 |
+
sw = swaps.reindex(feats.index)
|
| 119 |
+
eff_install = eff_install.where(sw.isna() | (eff_install >= sw), sw)
|
| 120 |
+
feats['age_days'] = (origin - eff_install).dt.days
|
| 121 |
|
| 122 |
return feats
|
| 123 |
|
|
|
|
| 172 |
|
| 173 |
|
| 174 |
def physics_features(daily: pd.DataFrame, origin: pd.Timestamp,
|
| 175 |
+
beta: float, curve, match: bool = True,
|
| 176 |
+
swaps: pd.Series = None) -> pd.DataFrame:
|
| 177 |
"""
|
| 178 |
Physics-model features.
|
| 179 |
v_corr_last - latest temp-corrected voltage (full history, no window)
|
|
|
|
| 185 |
tau_hat/fit_err - trajectory registration (match=True only; expensive)
|
| 186 |
"""
|
| 187 |
all_h = daily[daily['day'] <= origin]
|
| 188 |
+
if swaps is not None and len(swaps):
|
| 189 |
+
cut = all_h['device_id'].map(swaps)
|
| 190 |
+
all_h = all_h[cut.isna() | (all_h['day'] >= cut)]
|
| 191 |
last = all_h.sort_values('day').groupby('device_id').tail(1).set_index('device_id')
|
| 192 |
v_corr = last['v'] - beta * (last['t'] - 20.0)
|
| 193 |
stale = (origin - last['day']).dt.days
|
| 194 |
|
| 195 |
out = pd.DataFrame({'v_corr_last': v_corr})
|
| 196 |
|
| 197 |
+
# ghost marker: days since the corrected voltage first crossed below 2.45
|
| 198 |
+
# (post-swap history only). A real recorded death follows the crossing
|
| 199 |
+
# within days; a crossing >42d old with no EOL on file means the recording
|
| 200 |
+
# process abandoned this device - it will never be scored as a death.
|
| 201 |
+
vc_all = all_h['v'] - beta * (all_h['t'] - 20.0)
|
| 202 |
+
below = all_h.loc[vc_all < 2.45]
|
| 203 |
+
first_below = below.groupby('device_id')['day'].min()
|
| 204 |
+
out['days_below_245'] = (origin - first_below.reindex(out.index)).dt.days
|
| 205 |
+
|
| 206 |
# plunge precursors: volatility of the corrected voltage rises BEFORE the
|
| 207 |
# level falls (AUC 0.83/0.81 on the voltage-silent dying cohort, where the
|
| 208 |
# level itself is blind). These are the fast-plunge early-warning features.
|
|
|
|
| 285 |
alive = eol_times.isna() | (eol_times > origin)
|
| 286 |
alive_ids = eol_times.index[alive]
|
| 287 |
|
| 288 |
+
# NOTE (phase 15): detect_swaps truncation was tested here and NOT
|
| 289 |
+
# adopted - panel 1684.5 vs 1679.2. The undocumented-swap age error
|
| 290 |
+
# touches only age_days (weak, linear); the machinery stays available
|
| 291 |
+
# via the swaps= parameter but production builds run without it.
|
| 292 |
+
past = betas[betas['week_end'] <= origin]['beta']
|
| 293 |
+
beta = float(past.median()) if len(past) >= 20 else BETA_DEFAULT
|
| 294 |
+
|
| 295 |
f = features_at_origin(daily, locations, origin)
|
| 296 |
f = f.reindex(alive_ids) # NaN feature rows kept: planner must handle them
|
| 297 |
|
| 298 |
# physics features: beta and curve from information <= origin only.
|
| 299 |
# Synthetic origins get the light variant (no trajectory matching).
|
|
|
|
|
|
|
| 300 |
dead = eol_times[eol_times.notna() & (eol_times <= origin)]
|
| 301 |
curve = master_curve(dall[dall['day'] <= origin], dead, beta)
|
| 302 |
f = f.join(physics_features(dall, origin, beta, curve, match=not is_synth))
|
batteryswap_example/models_cv.py
CHANGED
|
@@ -183,9 +183,21 @@ class SklearnRUL:
|
|
| 183 |
X = rows[FEATURES]
|
| 184 |
out = self.days.predict(X)
|
| 185 |
out['p42'] = self.clf.predict_proba(X)[:, 1]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
return out
|
| 187 |
|
| 188 |
|
|
|
|
|
|
|
|
|
|
| 189 |
SILENT_V = 2.55
|
| 190 |
SILENT_FEATURES = ['rng_30', 'vstd_30', 'tau_projected', 'cohort_rel_age_m',
|
| 191 |
'age_days', 'v_corr_last']
|
|
@@ -1484,7 +1496,15 @@ PLANNERS ={'onepd': plan_onepd, 'sched': plan_sched, 'batch': plan_batch,
|
|
| 1484 |
|
| 1485 |
def training_pairs(table, origin_time):
|
| 1486 |
closed = table['origin'] <= origin_time - pd.Timedelta(days=WINDOW_DAYS)
|
| 1487 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1488 |
|
| 1489 |
|
| 1490 |
def censored_pairs(table, origin_time):
|
|
|
|
| 183 |
X = rows[FEATURES]
|
| 184 |
out = self.days.predict(X)
|
| 185 |
out['p42'] = self.clf.predict_proba(X)[:, 1]
|
| 186 |
+
if GHOST_ZERO and 'days_below_245' in rows.columns:
|
| 187 |
+
# a device below the 2.45 death threshold for >42d with no EOL on
|
| 188 |
+
# file will never be scored as a death (26 such ghosts in train) -
|
| 189 |
+
# swapping it is a pure false-positive cost. Require it to STILL
|
| 190 |
+
# be in the death zone: days_below_245 is sticky from the first
|
| 191 |
+
# crossing, and a dip-then-recovery must stay eligible.
|
| 192 |
+
ghost = ((rows['days_below_245'] > WINDOW_DAYS)
|
| 193 |
+
& (rows['v_corr_last'] < 2.55)).values
|
| 194 |
+
out.loc[ghost, 'p42'] = 0.0
|
| 195 |
return out
|
| 196 |
|
| 197 |
|
| 198 |
+
GHOST_ZERO = False # phase-15 candidate: exclude label-process ghosts from plans
|
| 199 |
+
QUARANTINE = False # phase-15 candidate: drop looks-dead-labeled-alive training rows
|
| 200 |
+
|
| 201 |
SILENT_V = 2.55
|
| 202 |
SILENT_FEATURES = ['rng_30', 'vstd_30', 'tau_projected', 'cohort_rel_age_m',
|
| 203 |
'age_days', 'v_corr_last']
|
|
|
|
| 1496 |
|
| 1497 |
def training_pairs(table, origin_time):
|
| 1498 |
closed = table['origin'] <= origin_time - pd.Timedelta(days=WINDOW_DAYS)
|
| 1499 |
+
sub = table[closed]
|
| 1500 |
+
if QUARANTINE and 'v_corr_last' in sub.columns:
|
| 1501 |
+
# drop "looks dead, labeled survives" rows: devices already below the
|
| 1502 |
+
# death threshold whose death never made it into eol_times (26 ghost
|
| 1503 |
+
# trajectories in train). Feeding them as negatives teaches the model
|
| 1504 |
+
# that textbook plunges survive - label noise on the hardest pattern.
|
| 1505 |
+
poison = (sub['dies42'] == 0) & (sub['v_corr_last'] < 2.48)
|
| 1506 |
+
sub = sub[~poison]
|
| 1507 |
+
return sub
|
| 1508 |
|
| 1509 |
|
| 1510 |
def censored_pairs(table, origin_time):
|