Thibaut Forest Claude Opus 5 (1M context) commited on
Commit
41a82cd
·
1 Parent(s): d5b03a1

Phase 41-42: fixed-N ablation rejects the vrel port; public says pooling is a wash

Browse files

Phase 41 discharges phase 39's standing directive ("do not port anything from
that lineage without re-running the ablation on recall at fixed N"). Adds
cv.ablate: holds N fixed and varies only the score, so a feature's effect on
ORDERING is separated from its effect on the topsum count. No planner and no
evaluator - recall at fixed N and the rank distribution of misses are
functions of the score alone.

Deaths caught at fixed N, paired per origin, bootstrap 95% CI:

native N=12 native N=20 static N=12 static N=20
vrel alone -6 [-12,-1] +4 [-1,+9] +6 [+0,+13] -1 [-9,+7]
vmin7_rank alone -1 [ -5,+2] +4 [+0,+9] -5 [-11,+1] +0 [-4,+4]
both -7 [-15,+0] +5 [-1,+11] +4 [ -2,+10] -1 [-9,+7]
v_last alone -39 [-57,-22] -8 [-20,+5] +7 [-10,+25] +5 [-10,+20]

REJECT the port. The sign flips with N on both instruments, every CI straddles
zero, and the ordering is byte-identical at 36-41 of 48 native origins and
228-230 of 240 static ones - these features barely enter the ranking. The one
CI excluding zero is vrel alone, native, N=12, and it is negative. The +138.7
cost penalty measured in phase 40 was therefore almost entirely depth, exactly
as the directive predicted.

The phase-39 claim reproduces and is protocol-specific: v_last alone TIES the
13-feature champion on a held-out fleet (better mean rank, 11.9 vs 13.3) and
is -8.6pp recall@12 natively. Not an over-pruned feature set - the champion's
12 extra features are worth ~8.6pp when refit on the native fleet and ~nothing
to a single static fit on a foreign one.

Phase 42: public scored the phase-40 pooling submission at 1665.89 vs the
1661.45 champion, +4.44. 9 fewer swaps, 363 fewer early-days, 2.4 fewer ops
hours, +51 late-days. A wash with the wrong sign and no recall gain. The
static branch went from 82 training deaths to 246 (164 of them in-fleet and
correctly embargoed) and the ordering did not improve, which retracts the
closing argument phase 41 made for pooling an hour earlier.

Together the two results close the training-data axis: on an unseen fleet the
ranking is essentially determined by raw voltage, and neither richer features
nor 3x more in-fleet data moves it.

Standing champion remains 3c7b883 at 1661.45.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

EXPERIMENTS.md CHANGED
@@ -2024,3 +2024,114 @@ local protocol could ever catch it: the same shape as the hardcoded 42-day park
2024
  that `repark_for_window` exists to undo. Now a module global that
2025
  `plan_tsroute` overwrites from the settings it is handed; verified to
2026
  propagate (30 -> 90) and to leave all-48 byte-identical at 1661.7.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2024
  that `repark_for_window` exists to undo. Now a module global that
2025
  `plan_tsroute` overwrites from the settings it is handed; verified to
2026
  propagate (30 -> 90) and to leave all-48 byte-identical at 1661.7.
2027
+
2028
+ ## Phase 41 - the phase-39 directive discharged: fleet-relative port REJECTED
2029
+
2030
+ Phase 39 left a standing rule: *"Do not port anything from [the blind-harness
2031
+ champion] lineage without re-running the ablation on recall at fixed N."* The
2032
+ `vrel` / `vmin7_rank` port in `features.py` was exactly the case it describes,
2033
+ and it was sitting half-landed - computed by `features.py` and shipped in the
2034
+ packaged parquet, absent from `models_cv.FEATURES`.
2035
+
2036
+ **Why the earlier cost measurement did not discharge the directive.** Under the
2037
+ topsum rule a feature moves `p42`, `p42` moves `sum(p42)`, and `sum(p42)` moves
2038
+ `N`. So a cost delta is the sum of an ordering change and a depth change and
2039
+ cannot separate them - which is how the blind lineage concluded a 4-feature set
2040
+ beat its own champion. `cv.ablate` holds N fixed and varies only the score. No
2041
+ planner and no evaluator are involved: recall at fixed N and the rank
2042
+ distribution of misses are functions of the score alone.
2043
+
2044
+ ### Deaths caught at fixed N, paired per origin, bootstrap 95% CI
2045
+
2046
+ | variant | native N=12 | native N=20 | static N=12 | static N=20 |
2047
+ |---|---|---|---|---|
2048
+ | `vrel` alone | **-6 [-12,-1]** | +4 [-1,+9] | +6 [+0,+13] | -1 [-9,+7] |
2049
+ | `vmin7_rank` alone | -1 [-5,+2] | +4 [+0,+9] | -5 [-11,+1] | +0 [-4,+4] |
2050
+ | both | -7 [-15,+0] | +5 [-1,+11] | +4 [-2,+10] | -1 [-9,+7] |
2051
+ | `v_last` alone | **-39 [-57,-22]** | -8 [-20,+5] | +7 [-10,+25] | +5 [-10,+20] |
2052
+
2053
+ 454 deaths over 48 native origins; 976 over 240 origin-seeds on the gate.
2054
+
2055
+ **Verdict: reject.** The sign flips with N on both instruments and every CI for
2056
+ the port straddles zero. The single CI that excludes zero is `vrel` alone,
2057
+ native, N=12 - and it is **negative**. Decisive corroboration: the ordering is
2058
+ *byte-identical* at 36-41 of 48 native origins and at 228-230 of 240 static
2059
+ ones. These features barely enter the ranking. The +138.7 cost penalty measured
2060
+ in phase 40 was therefore almost entirely a depth effect, exactly as the
2061
+ directive predicted, and the -24.6 the static gate liked was the same depth
2062
+ effect with the opposite sign.
2063
+
2064
+ ### The phase-39 claim reproduces - and it is protocol-specific, not over-pruning
2065
+
2066
+ `v_last` alone was the specific claim: two blind sessions found raw current
2067
+ voltage beating their champion's own ranker at the operating N.
2068
+
2069
+ - **Static gate**: `v_last` alone is statistically **tied** with the full
2070
+ 13-feature champion (+7 [-10,+25] at N=12), with a *better* mean rank
2071
+ (11.9 vs 13.3) and a much better missed-death rank (48 vs 65).
2072
+ - **Native**: `v_last` alone is **-39 [-57,-22]** at N=12, rec@12 0.478 vs
2073
+ 0.564 - **-8.6pp**. Phase 39 measured +14.3pp on production; same sign, same
2074
+ magnitude class, independently reproduced.
2075
+
2076
+ So the lineage's finding was real *for its protocol* - one static fit on a
2077
+ foreign fleet - and does not survive the refit path. The cause is not an
2078
+ over-pruned feature set and not the threshold rule phase 39 suspected:
2079
+ **the champion's 12 features beyond `v_last` are worth ~8.6pp recall@12 when
2080
+ the model is refit on the native fleet and worth approximately nothing to a
2081
+ single static fit on a foreign one.**
2082
+
2083
+ That is a statement about training data, not about features, and it read at
2084
+ the time as the first independent argument for what phase 40 shipped: in-fleet
2085
+ pooling gives the static branch 164 own deaths against the foreign 82. **The
2086
+ public leaderboard refuted that reading within the hour - see phase 42.**
2087
+ Tripling the static fit's death count bought no recall at all, so training
2088
+ volume is not the binding constraint the argument assumed.
2089
+
2090
+ `features.py` keeps computing both columns (deleting them means regenerating
2091
+ the packaged parquet on submission day for ~1s inside a 360s build budget) but
2092
+ now carries the rejection and these numbers inline, so the port cannot be
2093
+ re-attempted by inspection.
2094
+
2095
+ ## Phase 42 - PUBLIC: in-fleet pooling is a wash (1665.89 vs 1661.45)
2096
+
2097
+ Submission d5b03a1 (phase 40, `HELDOUT_POOL=1`). This push also carried phases
2098
+ 34-37, which had never reached the remote - but they touch submit.py by one
2099
+ deleted line, so the held-out path's behaviour change is pooling.
2100
+
2101
+ | | pooled (d5b03a1) | champion (3c7b883) | delta |
2102
+ |---|---|---|---|
2103
+ | **total** | **1665.89** | **1661.45** | **+4.44** |
2104
+ | late_swap | 1222.92 | 1212.29 | +10.63 (**+51 late-days**) |
2105
+ | early_swap | 133.69 | 137.47 | -3.78 (-363 early-days) |
2106
+ | ops | 309.28 | 311.69 | -2.41 |
2107
+ | swaps | 571 | 580 | -9 |
2108
+
2109
+ All four inversions land on integers (571 swaps, 5870 late-days, 12834
2110
+ early-days, 54 penalised days), so public is still 48 scenarios at default
2111
+ settings - which also confirms the phase-40 `unobserved_eol_days` fix was inert
2112
+ here, as predicted.
2113
+
2114
+ **Verdict: a wash, marginally negative.** Pooling made the model slightly less
2115
+ eager - 9 fewer swaps, 363 fewer early-days, 2.4 fewer ops hours - and paid 51
2116
+ late-days for it. Net +4.4 on a 1661 baseline is 0.27%, inside any reasonable
2117
+ noise band, but the sign is wrong and there is no recall gain hiding in it.
2118
+
2119
+ **The finding is not "pooling is neutral", it is where the neutrality comes
2120
+ from.** The static branch went from 82 foreign deaths to 246 (82 foreign + 164
2121
+ own, in-fleet, same-fleet, correctly embargoed). That is 3x the training
2122
+ signal, on the target fleet itself, and **the ordering did not improve**. Put
2123
+ beside phase 41 - where `v_last` alone ties the full 13-feature champion on a
2124
+ held-out fleet - the two results say the same thing from opposite directions:
2125
+
2126
+ > On a fleet the model has not seen, the ranking is essentially determined by
2127
+ > raw voltage, and neither richer features nor 3x more in-fleet training data
2128
+ > moves it. The information needed to rank better is not in what we are
2129
+ > feeding the model.
2130
+
2131
+ This closes the training-data axis the way phases 1/6/28 closed the appetite
2132
+ axis. The remaining late cost is the voltage-silent population (recall 0.000 at
2133
+ the operating N, median rank 60 of ~450), and it is invisible to every signal
2134
+ in the current feature space - which is exactly what the phase-38/39 silent-
2135
+ death campaign concluded before it was closed.
2136
+
2137
+ Standing champion remains **3c7b883 at 1661.45**.
batteryswap_example/cv.py CHANGED
@@ -310,7 +310,148 @@ def _vrel(t):
310
  if c in t.columns]
311
 
312
 
313
- VARIANTS = {'base': _base, 'zone': _zone, 'vcorr': _vcorr, 'vrel': _vrel}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
314
 
315
 
316
  def main():
@@ -338,6 +479,11 @@ def main():
338
  'an appetite/count decision (see module docstring).')
339
  elif proto == 'screen':
340
  screen()
 
 
 
 
 
341
  else:
342
  raise SystemExit(f'unknown protocol {proto}')
343
  print(f'\n[{time.time() - t0:.0f}s]')
 
310
  if c in t.columns]
311
 
312
 
313
+ def _vrel_only(t):
314
+ return BASE_FEATURES + ['vrel']
315
+
316
+
317
+ def _vmin7_only(t):
318
+ return BASE_FEATURES + ['vmin7_rank']
319
+
320
+
321
+ def _v_last_only(t):
322
+ # the phase-39 claim under test: two blind sessions found RAW CURRENT
323
+ # VOLTAGE alone beating their champion's ranker at the operating N
324
+ # (.721 vs .668, .703 vs .672). If that reproduces here, our feature set
325
+ # is over-pruned; if it does not, that lineage's ablation was an artifact
326
+ # of its threshold rule and nothing from it should be ported.
327
+ return ['v_last']
328
+
329
+
330
+ VARIANTS = {'base': _base, 'zone': _zone, 'vcorr': _vcorr, 'vrel': _vrel,
331
+ 'vrel_only': _vrel_only, 'vmin7_only': _vmin7_only,
332
+ 'v_last_only': _v_last_only}
333
+
334
+ ABLATION = ('base', 'vrel_only', 'vmin7_only', 'vrel', 'v_last_only')
335
+
336
+
337
+ # --------------------------------------------------------------------------
338
+ # protocol 4: fixed-N ablation - ORDERING, with the count held out of it
339
+ # --------------------------------------------------------------------------
340
+
341
+ def _order_stats(p42, deaths, ns):
342
+ order = p42.sort_values(ascending=False)
343
+ ranks = {b: r for r, b in enumerate(order.index, start=1)}
344
+ dr = sorted(ranks[b] for b in deaths if b in ranks)
345
+ out = {'n_deaths': len(dr), 'mean_rank': float(np.mean(dr))}
346
+ # COUNTS, not ratios: pooling caught/deaths across origins is the only
347
+ # correct denominator (a 1-death origin must not outvote a 12-death one)
348
+ for n in ns:
349
+ out[f'c{n}'] = sum(1 for r in dr if r <= n)
350
+ tail = [r for r in dr if r > max(ns)]
351
+ out['miss_rank'] = float(np.median(tail)) if tail else np.nan
352
+ return out
353
+
354
+
355
+ def ablate(variants=ABLATION, protocol='all48', origins=range(48),
356
+ ns=(8, 12, 16, 20, 30), seeds=(0, 1, 2, 3, 4),
357
+ dataset_path=Path('dataset')):
358
+ """Phase-39's standing directive, made runnable.
359
+
360
+ A feature ablation scored on COST cannot answer whether a feature helped:
361
+ under the topsum rule the feature moves p42, p42 moves sum(p42), and
362
+ sum(p42) moves N - so depth and order change together and the cost delta
363
+ is their sum. That is precisely how the blind lineage concluded a
364
+ 4-feature set beat its own champion. This protocol holds N FIXED and
365
+ varies only the score, so the only thing being measured is the ordering.
366
+
367
+ No planner and no evaluator are involved: recall at fixed N and the rank
368
+ distribution of missed deaths are functions of the score alone.
369
+ """
370
+ t = load_table(dataset_path)
371
+ recs = []
372
+ if protocol == 'all48':
373
+ ctx = mc.load_context(dataset_path)
374
+ for idx in origins:
375
+ c = ctx[idx]
376
+ T = c['start_time']
377
+ tr = mc.training_pairs(t, T)
378
+ if tr['dies42'].sum() < 3:
379
+ continue
380
+ rows = (t[t['origin_idx'] == idx].set_index('device_id')
381
+ .reindex(c['locs']['battery']).dropna(subset=['v_last']))
382
+ eol = c['eol'].reindex(rows.index)
383
+ deaths = set(eol.index[(eol > T)
384
+ & (eol <= T + pd.Timedelta(days=WINDOW_DAYS))])
385
+ if not deaths:
386
+ continue
387
+ for v in variants:
388
+ mc.FEATURES = list(VARIANTS[v](t))
389
+ p = mc.SklearnRUL(CHAMPION['model']).fit(tr).predict(rows)['p42']
390
+ recs.append(dict(_order_stats(p, deaths, ns),
391
+ origin=idx, variant=v, seed=0))
392
+ print(f's_{idx}: {len(deaths)} deaths', flush=True, file=sys.stderr)
393
+ else:
394
+ locations, timeseries, eol_times, scenarios = mc.load_dataset(
395
+ dataset_path / 'train')
396
+ dev_b = locations.drop_duplicates('battery').set_index('battery')['building']
397
+ t['bldg'] = t['device_id'].map(dev_b)
398
+ closed = t['origin'] <= t['origin'].max() - pd.Timedelta(days=WINDOW_DAYS)
399
+ allsc = list(mc.iterate_scenarios(locations, timeseries, eol_times,
400
+ scenarios))
401
+ for seed in seeds:
402
+ fleet_a, fleet_b = fleet_halves(locations, seed)
403
+ foreign = t[closed & t['bldg'].isin(fleet_a)]
404
+ models = {}
405
+ for v in variants:
406
+ mc.FEATURES = list(VARIANTS[v](t))
407
+ models[v] = mc.SklearnRUL(CHAMPION['model']).fit(foreign)
408
+ for idx in origins:
409
+ scenario, locs, cut, eol = allsc[idx]
410
+ T = pd.Timestamp(scenario['start_time'])
411
+ locs_b = locs[locs['building'].isin(fleet_b)]
412
+ rows = (t[t['origin_idx'] == idx].set_index('device_id')
413
+ .reindex(locs_b['battery']).dropna(subset=['v_last']))
414
+ e = eol.reindex(rows.index)
415
+ deaths = set(e.index[(e > T)
416
+ & (e <= T + pd.Timedelta(days=WINDOW_DAYS))])
417
+ if not deaths:
418
+ continue
419
+ for v in variants:
420
+ mc.FEATURES = list(VARIANTS[v](t))
421
+ p = models[v].predict(rows)['p42']
422
+ recs.append(dict(_order_stats(p, deaths, ns),
423
+ origin=idx, variant=v, seed=seed))
424
+ print(f'seed {seed} done', flush=True, file=sys.stderr)
425
+ return pd.DataFrame(recs), ns
426
+
427
+
428
+ def ablation_report(df, ns, label, ref='base'):
429
+ print(f'\n=== {label} ===')
430
+ g = df.groupby('variant')
431
+ deaths = g['n_deaths'].sum()
432
+ print(f"{'variant':13s} {'deaths':>6s} " +
433
+ ' '.join(f'rec@{n:<3d}' for n in ns) +
434
+ f" {'mean_rk':>7s} {'miss_rk':>7s}")
435
+ for v in df['variant'].unique():
436
+ d = df[df['variant'] == v]
437
+ cells = ' '.join(f'{d[f"c{n}"].sum() / deaths[v]:6.3f}' for n in ns)
438
+ print(f'{v:13s} {int(deaths[v]):6d} {cells} '
439
+ f'{d["mean_rank"].mean():7.1f} {d["miss_rank"].median():7.1f}')
440
+
441
+ # paired against the reference, per (seed, origin): the only comparison
442
+ # that survives the origin-to-origin variance in how many deaths there are
443
+ b = df[df['variant'] == ref].set_index(['seed', 'origin'])
444
+ print(f'\npaired vs {ref} (per origin, deaths caught at fixed N):')
445
+ for v in df['variant'].unique():
446
+ if v == ref:
447
+ continue
448
+ o = df[df['variant'] == v].set_index(['seed', 'origin'])
449
+ cells = []
450
+ for n in ns:
451
+ d = (o[f'c{n}'] - b[f'c{n}']).dropna()
452
+ w, l = int((d > 0).sum()), int((d < 0).sum())
453
+ cells.append(f'{d.sum():+4.0f} ({w}W/{l}L)')
454
+ print(f' {v:13s} ' + ' '.join(cells))
455
 
456
 
457
  def main():
 
479
  'an appetite/count decision (see module docstring).')
480
  elif proto == 'screen':
481
  screen()
482
+ elif proto == 'ablate':
483
+ which = sys.argv[2] if len(sys.argv) > 2 else 'all48'
484
+ df, ns = ablate(protocol=which)
485
+ df.to_csv(f'/tmp/cv_ablate_{which}.csv', index=False)
486
+ ablation_report(df, ns, f'fixed-N ablation / {which}')
487
  else:
488
  raise SystemExit(f'unknown protocol {proto}')
489
  print(f'\n[{time.time() - t0:.0f}s]')
batteryswap_example/features.py CHANGED
@@ -112,15 +112,31 @@ def features_at_origin(daily: pd.DataFrame, devices: pd.DataFrame,
112
 
113
  feats['accel'] = feats['slope_30'] - feats['slope_90']
114
 
115
- # ---- fleet-relative encoding -------------------------------------------
116
  # Ported from the blind-harness champion, where vrel and vmin7_rank were
117
  # the two largest single-feature wins. Both are computed at PLAN time over
118
  # the fleet actually in front of the planner, so they absorb a fleet-level
119
  # voltage shift that the absolute features (v_last, v_min_30, ...) cannot.
120
- # The motivation is TRANSFER to a held-out fleet: on the native fleet there
121
- # is no shift to absorb, which is why the one prior local test called them
122
- # "redundant with aux features" (EXPERIMENTS.md, in-origin fleet ranks,
123
- # 2055). That verdict cannot see the deployment regime.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  #
125
  # Deliberately NOT filled here. The classifier pipeline median-imputes and
126
  # then scales, which parks a stale/dark device at "unremarkable" so it is
 
112
 
113
  feats['accel'] = feats['slope_30'] - feats['slope_90']
114
 
115
+ # ---- fleet-relative encoding (REJECTED - phase 41) ----------------------
116
  # Ported from the blind-harness champion, where vrel and vmin7_rank were
117
  # the two largest single-feature wins. Both are computed at PLAN time over
118
  # the fleet actually in front of the planner, so they absorb a fleet-level
119
  # voltage shift that the absolute features (v_last, v_min_30, ...) cannot.
120
+ # The motivation was TRANSFER to a held-out fleet.
121
+ #
122
+ # NOT IN models_cv.FEATURES, and they must not be added. Re-ablated on
123
+ # recall at FIXED N (the phase-39 directive), which is the only way to see
124
+ # a feature's effect on ordering separately from its effect on the topsum
125
+ # count. Deaths caught, paired per origin, bootstrap 95% CI:
126
+ #
127
+ # native all48 static gate (5 splits)
128
+ # vrel+vmin7 N=12 -7 [-15,+0] N=12 +4 [-2,+10]
129
+ # N=20 +5 [ -1,+11] N=20 -1 [-9, +7]
130
+ #
131
+ # The sign flips with N on BOTH instruments and every CI straddles zero.
132
+ # The ordering is untouched at 36-41 of 48 native origins and 228 of 240
133
+ # static ones - these features barely enter the ranking at all. The one CI
134
+ # that excludes zero is vrel alone, native, N=12: -6 [-12,-1], negative.
135
+ # Cost confirms it: all48 1800.4 vs 1661.7 (+138.7).
136
+ #
137
+ # Left computed rather than deleted only to avoid regenerating the
138
+ # packaged parquet on submission day; the two columns cost ~1s inside
139
+ # build_split_table's 360s budget. Delete them when there is slack.
140
  #
141
  # Deliberately NOT filled here. The classifier pipeline median-imputes and
142
  # then scales, which parks a stale/dark device at "unremarkable" so it is