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 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
- feats['age_days'] = (origin - install.reindex(feats.index)).dt.days
 
 
 
 
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) -> pd.DataFrame:
 
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
- return table[closed]
 
 
 
 
 
 
 
 
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):