Thibaut Forest Claude Fable 5 commited on
Commit
2cabcac
·
1 Parent(s): 9438aac

Phase 49: four new ordering-signal families opened and closed - longitudinal cross-origin, interactions, tau re-rank, temporal ensembling; the marginal-rank defect is missing information, not noise

Browse files
Files changed (2) hide show
  1. EXPERIMENTS.md +80 -0
  2. exp_x29_new_signal_sweep.py +214 -0
EXPERIMENTS.md CHANGED
@@ -2498,3 +2498,83 @@ p42_sum untouched so the count rule is byte-identical).
2498
  Local champion is now **cert-first at 1623.7** (from 1661.7). Public look not
2499
  yet spent; submission is the user's call. Expected public value if the wobble
2500
  rate transfers: ~-35 +/- (fires 0.46 devices/scenario locally).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2498
  Local champion is now **cert-first at 1623.7** (from 1661.7). Public look not
2499
  yet spent; submission is the user's call. Expected public value if the wobble
2500
  rate transfers: ~-35 +/- (fires 0.46 devices/scenario locally).
2501
+
2502
+ ## Phase 49 - four new ordering-signal families opened and closed in one sweep
2503
+
2504
+ Directive (user, 2026-08-22): test new ideas against the champion, be
2505
+ innovative, try brand-new approaches. Everything below is a fixed-N ordering
2506
+ screen (`cv.ablate` discipline, both instruments where applicable, paired per
2507
+ origin, bootstrap 95% CI), so no depth confound; costs on the 1661.7 scale
2508
+ (cv.run_all48 does not route through submit.py's cert-first). Screen code:
2509
+ `exp_x29_new_signal_sweep.py` [long|tau|tavg]. Phase 48 is reserved for the
2510
+ cert-first public adjudication (parallel session).
2511
+
2512
+ ### (A) Longitudinal cross-origin features - the first untouched signal family
2513
+
2514
+ The feature table is a 168-origin snapshot grid (~3-4d apart) per device; every
2515
+ existing feature reads one 30/90d window at ONE origin, and no phase had ever
2516
+ read ACROSS origins. Three features, all strictly backward-looking
2517
+ (merge_asof, snapshots < T): `mom28`/`mom56` = within-fleet rank change of
2518
+ v_last vs 28/56d ago; `wolf` = count of snapshots >= 42d ago in the bottom
2519
+ decile (device provably survived - it is alive at T).
2520
+
2521
+ The raw signal is REAL: bottom-decile chronics (wolf > 2) die at 20.7% vs
2522
+ 15.5% for newcomers, and dying devices' fleet rank slides (median mom28 -0.025
2523
+ vs -0.007 for survivors). The model cannot use it:
2524
+
2525
+ | variant | native N=12 | gate N=12 | verdict |
2526
+ |---|---|---|---|
2527
+ | mom | -5 [-13,+3] | -7 [-14,+0] | reject (gate N=16/20 significantly negative) |
2528
+ | wolf | **-24 [-42,-7]** | **-14 [-27,-1]** | reject |
2529
+ | momwolf | -27 [-47,-7] | -15 [-30,-1] | reject |
2530
+
2531
+ Mechanism: the 30d-window slope/drop/accel features already price the
2532
+ trajectory in; the cross-origin rank version duplicates them with extra noise,
2533
+ and `wolf` duplicates the level (chronics ARE low). Effective n ~80 punishes
2534
+ every redundant column.
2535
+
2536
+ ### (B) Interaction terms - the linear champion offered its first products
2537
+
2538
+ `z(v_last)*z(slope_30)`, `z(v_last)*z(vstd_30)`, `z(v_min_7)*z(accel)`
2539
+ (within-origin z-scores, leak-free). Flat everywhere: native +2/-2/+5/+2
2540
+ across N, gate +0/-1/-5/-1, every CI straddling zero. There is no exploitable
2541
+ interaction structure - the phase-45 ceiling holds against nonlinearity in
2542
+ feature space, not just against nonlinear learners.
2543
+
2544
+ ### (C) tau descent-band re-rank - phase 43's open candidate, closed
2545
+
2546
+ The knn screen left one open item: logbag underweights `tau_projected` in the
2547
+ descent band where its misses live (24/36/44 of 132 panel misses in tau's
2548
+ top-3/5/10); EV-negative as extra swaps, never tested as a pure REORDER at
2549
+ fixed N. Tested three transforms (band rank-blend, global rank-blend,
2550
+ promotion-only): native **-45 of 454 at N=12 (7W/29L)**, -53 at N=8. The gate
2551
+ is mildly positive (+3..+11) - the same protocol-specificity signature as
2552
+ `v_last` in phase 41: a single static foreign fit underuses the timer, the
2553
+ weekly refit already extracts it. The gate's sign must never override a
2554
+ native loss. Candidate closed for the native path.
2555
+
2556
+ ### (D) Temporal score ensembling - the variance question, settled directly
2557
+
2558
+ Rank-average the current origin's fit with the previous 1-2 origins' fits
2559
+ applied to the same rows (adjacent origins are near-identical draws of the
2560
+ ~80-death training set). Dead flat: tavg2 -3 at N=12 (0W/3L), tavg3 -4
2561
+ (1W/5L) - refit-to-refit orderings barely differ. With phase 44's flat C
2562
+ sweep (regularisation not binding) and logbag never separating from plain
2563
+ logistic (bagging variance not binding), this is the third independent leg:
2564
+
2565
+ > **The marginal-rank defect is missing information, not estimation noise.
2566
+ > Any future ordering candidate must bring a NEW signal, not a better
2567
+ > estimate of the current ones.**
2568
+
2569
+ (It also moots the deployment-gate objection - there is no fit noise to
2570
+ smooth, in any branch.)
2571
+
2572
+ ### Standing
2573
+
2574
+ Four families opened, four closed, all with mechanisms. The information
2575
+ ceiling of phase 45 now covers: re-weightings (45), replacement sets (47),
2576
+ cross-origin dynamics, interactions, timer blends, and temporal ensembles
2577
+ (49). What remains is what phase 44 said: a signal outside this feature
2578
+ space, or the declined forward-read. Local champion unchanged (cert-first
2579
+ 1623.7 / base 1661.7); public champion **3c7b883 at 1661.45**. Deadline
2580
+ 2026-08-23.
exp_x29_new_signal_sweep.py ADDED
@@ -0,0 +1,214 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phase-49 sweep: four new ordering-signal families, all REJECTED.
2
+
3
+ Usage: venv/bin/python exp_x29_new_signal_sweep.py [long|tau|tavg]
4
+
5
+ long A. LONGITUDINAL cross-origin features - the per-device history ACROSS
6
+ the 168-origin snapshot grid (~3-4d apart), untouched by any prior
7
+ phase. mom28/mom56 = fleet-rank change vs 28/56d ago; wolf = count
8
+ of snapshots >=42d ago in the bottom decile, survived to T.
9
+ B. INTERACTIONS - within-origin z-score products (the champion is
10
+ linear with zero interaction terms).
11
+ tau C. Descent-band tau_projected re-rank at fixed N (the phase-43 knn
12
+ screen's "open deployment-regime candidate", never tested as a
13
+ pure reorder).
14
+ tavg D. Temporal score ensembling - rank-average the current fit with the
15
+ previous 1-2 origins' fits applied to current rows (fit-noise
16
+ smoothing; effective n ~80 deaths).
17
+
18
+ Verdicts (fixed-N deaths caught, paired per origin, bootstrap 95% CI):
19
+ A: wolf -24 [-42,-7] native N=12, -14 [-27,-1] gate; momwolf/allnew worse;
20
+ mom -5 [-13,+3] native, significantly negative gate N=16/20. The raw
21
+ signal EXISTS (bottom-decile chronics die 20.7% vs 15.5%; dying devices'
22
+ rank slides -0.025 median in 28d) but the 30d-window slope/drop features
23
+ already price it in - the rank version adds noise, not information.
24
+ B: inter flat everywhere (+2/-2 native, +0/-1 gate) - no exploitable
25
+ interaction structure; the phase-45 linear ceiling again.
26
+ C: native -45 of 454 at N=12 (7W/29L) - decisive. Gate mildly positive
27
+ (+3..+11), the same protocol-specificity signature as v_last in phase
28
+ 41: the weekly refit already extracts the timer. Do not trust the gate's
29
+ sign against a native loss.
30
+ D: dead flat (tavg2 -3 at N=12, 0W/3L; tavg3 -4, 1W/5L) - adjacent 7d-apart
31
+ refits produce near-identical orderings. Third leg of the
32
+ variance-vs-information triangulation (with phase 44's flat C sweep and
33
+ logbag==logistic): the marginal-rank defect is MISSING INFORMATION, not
34
+ estimation noise. Any future ordering candidate must bring a NEW signal,
35
+ not a better estimate of the current ones.
36
+ """
37
+ import sys
38
+
39
+ import numpy as np
40
+ import pandas as pd
41
+
42
+ from batteryswap_example import cv
43
+ from batteryswap_example import models_cv as mc
44
+
45
+ NS = (8, 12, 16, 20, 30)
46
+
47
+
48
+ # ---------------------------------------------------------------- A + B ----
49
+
50
+ def _ensure_longitudinal(t):
51
+ if 'mom28' in t.columns:
52
+ return
53
+ snap = (t[['device_id', 'origin', 'r_vlast']]
54
+ .sort_values('origin').reset_index(drop=True))
55
+ snap['danger'] = (snap['r_vlast'] < 0.10).astype(float)
56
+ snap = snap.sort_values(['device_id', 'origin'])
57
+ snap['cum_danger'] = snap.groupby('device_id')['danger'].cumsum()
58
+ snap = snap.sort_values('origin')
59
+ base = t[['device_id', 'origin']].copy()
60
+ base['_row'] = np.arange(len(base))
61
+
62
+ def asof(offset_days, col, tol):
63
+ left = base.sort_values('origin').copy()
64
+ left['key'] = left['origin'] - pd.Timedelta(days=offset_days)
65
+ left = left.sort_values('key')
66
+ m = pd.merge_asof(left, snap[['device_id', 'origin', col]]
67
+ .rename(columns={'origin': 'snap_origin'})
68
+ .sort_values('snap_origin'),
69
+ left_on='key', right_on='snap_origin',
70
+ by='device_id', direction='backward',
71
+ tolerance=pd.Timedelta(days=tol))
72
+ return m.sort_values('_row')[col].values
73
+
74
+ r28 = asof(28, 'r_vlast', 10)
75
+ r56 = asof(56, 'r_vlast', 10)
76
+ cur = t['r_vlast'].values
77
+ t['mom28'] = np.where(np.isnan(r28), 0.0, cur - r28)
78
+ t['mom56'] = np.where(np.isnan(r56), 0.0, cur - r56)
79
+ cd = asof(42, 'cum_danger', 400)
80
+ t['wolf'] = np.where(np.isnan(cd), 0.0, cd)
81
+
82
+
83
+ def _ensure_inter(t):
84
+ if 'ix_v_slope' in t.columns:
85
+ return
86
+
87
+ def z(col):
88
+ g = t.groupby('origin_idx')[col]
89
+ return (t[col] - g.transform('mean')) / g.transform('std').replace(0, 1)
90
+
91
+ zv, zs = z('v_last'), z('slope_30')
92
+ zm7, za = z('v_min_7'), z('accel')
93
+ zvs = z('vstd_30')
94
+ t['ix_v_slope'] = (zv * zs).clip(-30, 30).fillna(0)
95
+ t['ix_v_vstd'] = (zv * zvs).clip(-30, 30).fillna(0)
96
+ t['ix_m7_accel'] = (zm7 * za).clip(-30, 30).fillna(0)
97
+
98
+
99
+ LONG_VARIANTS = {
100
+ 'mom': lambda t: cv.BASE_FEATURES + ['mom28', 'mom56'],
101
+ 'wolf': lambda t: cv.BASE_FEATURES + ['wolf'],
102
+ 'momwolf': lambda t: cv.BASE_FEATURES + ['mom28', 'mom56', 'wolf'],
103
+ 'inter': lambda t: cv.BASE_FEATURES + ['ix_v_slope', 'ix_v_vstd',
104
+ 'ix_m7_accel'],
105
+ 'allnew': lambda t: cv.BASE_FEATURES + ['mom28', 'mom56', 'wolf',
106
+ 'ix_v_slope', 'ix_v_vstd',
107
+ 'ix_m7_accel'],
108
+ }
109
+
110
+
111
+ def run_long():
112
+ cv.VARIANTS.update(LONG_VARIANTS)
113
+ # ablate() slices training_pairs BEFORE the variant loop, so the columns
114
+ # must exist at load time (same latent limitation bites the zone variant)
115
+ orig = cv.load_table
116
+
117
+ def load_plus(dataset_path=cv.Path('dataset')):
118
+ t = orig(dataset_path)
119
+ _ensure_longitudinal(t)
120
+ _ensure_inter(t)
121
+ return t
122
+
123
+ cv.load_table = load_plus
124
+ var = ('base',) + tuple(LONG_VARIANTS)
125
+ df, ns = cv.ablate(variants=var, protocol='all48')
126
+ df.to_csv('/tmp/x29_long_all48.csv', index=False)
127
+ cv.ablation_report(df, ns, 'native all48 / longitudinal + interactions')
128
+ df2, ns2 = cv.ablate(variants=var, protocol='static')
129
+ df2.to_csv('/tmp/x29_long_static.csv', index=False)
130
+ cv.ablation_report(df2, ns2, 'static gate / longitudinal + interactions')
131
+
132
+
133
+ # -------------------------------------------------------------------- C ----
134
+
135
+ def _tau_transforms(rows, p42):
136
+ s = pd.DataFrame({'p42': p42})
137
+ s['rp'] = s['p42'].rank(pct=True)
138
+ tau = rows['tau_projected'].reindex(s.index)
139
+ vc = rows['v_corr_last'].reindex(s.index)
140
+ rt = (-tau).rank(pct=True)
141
+ band = (vc < 2.55) & tau.notna()
142
+ out = {'base': s['p42']}
143
+ b = s['rp'].copy()
144
+ b[band] = 0.5 * s['rp'][band] + 0.5 * rt[band]
145
+ out['blend_b'] = b
146
+ a = s['rp'].copy()
147
+ m = tau.notna()
148
+ a[m] = 0.5 * s['rp'][m] + 0.5 * rt[m]
149
+ out['blend_all'] = a
150
+ p = s['rp'].copy()
151
+ p[band] = np.maximum(s['rp'][band], rt[band])
152
+ out['promote'] = p
153
+ return out
154
+
155
+
156
+ def _native_scores(score_fn):
157
+ """Shared walk: fit champion per origin, emit _order_stats per variant."""
158
+ t = cv.load_table()
159
+ ctx = mc.load_context(cv.Path('dataset'))
160
+ mc.FEATURES = list(cv.BASE_FEATURES)
161
+ recs, models = [], {}
162
+ for idx in range(48):
163
+ c = ctx[idx]
164
+ T = c['start_time']
165
+ tr = mc.training_pairs(t, T)
166
+ if tr['dies42'].sum() < 3:
167
+ continue
168
+ models[idx] = mc.SklearnRUL(cv.CHAMPION['model']).fit(tr)
169
+ rows = (t[t['origin_idx'] == idx].set_index('device_id')
170
+ .reindex(c['locs']['battery']).dropna(subset=['v_last']))
171
+ eol = c['eol'].reindex(rows.index)
172
+ deaths = set(eol.index[(eol > T)
173
+ & (eol <= T + pd.Timedelta(days=42))])
174
+ if not deaths:
175
+ continue
176
+ for name, score in score_fn(models, idx, rows).items():
177
+ recs.append(dict(cv._order_stats(score, deaths, NS),
178
+ origin=idx, variant=name, seed=0))
179
+ print(f's_{idx} done', file=sys.stderr, flush=True)
180
+ return pd.DataFrame(recs)
181
+
182
+
183
+ def run_tau():
184
+ def fn(models, idx, rows):
185
+ p = models[idx].predict(rows)['p42']
186
+ return _tau_transforms(rows, p)
187
+
188
+ df = _native_scores(fn)
189
+ df.to_csv('/tmp/x29_tau_all48.csv', index=False)
190
+ cv.ablation_report(df, NS, 'native all48 / tau descent-band re-rank')
191
+
192
+
193
+ # -------------------------------------------------------------------- D ----
194
+
195
+ def run_tavg():
196
+ def fn(models, idx, rows):
197
+ p_now = models[idx].predict(rows)['p42']
198
+ r_now = p_now.rank(pct=True)
199
+ scores = {'base': p_now}
200
+ prev = [models[j].predict(rows)['p42'].rank(pct=True)
201
+ for j in (idx - 1, idx - 2) if j in models]
202
+ if len(prev) >= 1:
203
+ scores['tavg2'] = 0.6 * r_now + 0.4 * prev[0]
204
+ if len(prev) >= 2:
205
+ scores['tavg3'] = 0.5 * r_now + 0.3 * prev[0] + 0.2 * prev[1]
206
+ return scores
207
+
208
+ df = _native_scores(fn)
209
+ df.to_csv('/tmp/x29_tavg_all48.csv', index=False)
210
+ cv.ablation_report(df, NS, 'native all48 / temporal score ensembling')
211
+
212
+
213
+ if __name__ == '__main__':
214
+ {'long': run_long, 'tau': run_tau, 'tavg': run_tavg}[sys.argv[1]]()