Thibaut Forest Claude Opus 5 (1M context) commited on
Commit
fbe4c36
·
1 Parent(s): 30e8503

Classifier-family sweep: RF/ExtraTrees/MLP + rank-hybrid all rejected by CV

Browse files

RandomForest (default/deep/class-balanced), ExtraTrees, and a small MLP all
lose 170-820 dev points to bagged logistic. RF ranks better at some origins
(AUC 0.94 vs 0.85 at s_23) but its compressed probabilities break topsum's
count; a rank_rf hybrid preserving logbag's probability scale while using
mean-rank ordering still loses (+92). Champion unchanged.

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

Files changed (2) hide show
  1. EXPERIMENTS.md +17 -0
  2. batteryswap_example/models_cv.py +153 -6
EXPERIMENTS.md CHANGED
@@ -133,6 +133,23 @@ untested); `logbag_aux*` variants await their feature columns in the table.
133
  **Conclusion**: every axis is flat or worse around the champion - remaining cost
134
  is structural (fast-plunge deaths), not hyperparameters.
135
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  ## Trajectory
137
 
138
  | champion | dev | test | combined (34) | all-48 metric | public LB |
 
133
  **Conclusion**: every axis is flat or worse around the champion - remaining cost
134
  is structural (fast-plunge deaths), not hyperparameters.
135
 
136
+ ### Classifier-family addendum (RandomForest and friends)
137
+
138
+ | model | dev | Δ | note |
139
+ |---|---|---|---|
140
+ | RandomForest (500, leaf 5) | 2273.1 | +199 | ranks well (AUC 0.94 at s_23 vs logbag 0.85) but probabilities too compressed for topsum's count |
141
+ | RF deep (leaf 1, 1000) | 2325.9 | +252 | |
142
+ | RF class-balanced | 2890.2 | +816 | worst config ever tested - inflated Σp42 over-includes massively |
143
+ | ExtraTrees | 2245.3 | +171 | |
144
+ | MLP (32,16, early stop) | 2768.7 | +695 | |
145
+ | rank_rf hybrid (RF ranking, logbag scale - Σp42 preserved) | 2166.2 | +92 | RF's ranking edge does not transfer across origins even with the scale problem removed |
146
+
147
+ Complete p42-classifier ledger: logistic-family (champion), HGB/GBM x4,
148
+ Weibull AFT, discrete hazard (tied), RF x3, ExtraTrees, MLP, and five blends -
149
+ linear + bagging wins throughout. With ~1000 positives, one dominant monotone
150
+ signal (voltage level), and a planner that needs a stable probability scale,
151
+ that is the expected outcome.
152
+
153
  ## Trajectory
154
 
155
  | champion | dev | test | combined (34) | all-48 metric | public LB |
batteryswap_example/models_cv.py CHANGED
@@ -141,6 +141,31 @@ class SklearnRUL:
141
  C=self.opts.get('C', 1.0)))
142
  self.clf = BaggingClassifier(
143
  base, n_estimators=int(self.opts.get('bags', 15)), random_state=0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  self.clf.fit(X, y)
145
 
146
  dying = train[train['dies42'] == 1]
@@ -158,6 +183,25 @@ class SklearnRUL:
158
  return out
159
 
160
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  class RankVoltage:
162
  """Control: score by v_last only; p42 is a rank in [0,1], day from DayQuantiles."""
163
 
@@ -230,11 +274,14 @@ class DiscreteHazard:
230
  BUCKET = 7.0
231
  K = 6 # buckets cover days (7k, 7(k+1)], k=0..5
232
 
 
 
 
233
  def fit(self, train_censored):
234
  dur = train_censored['duration'].clip(upper=WINDOW_DAYS).values
235
  event = ((train_censored['event'] == 1) &
236
  (train_censored['duration'] <= WINDOW_DAYS)).values
237
- X = train_censored[FEATURES].values
238
  rows, ys, ks = [], [], []
239
  for k in range(self.K):
240
  lo, hi = self.BUCKET * k, self.BUCKET * (k + 1)
@@ -260,7 +307,7 @@ class DiscreteHazard:
260
  return self
261
 
262
  def predict(self, rows):
263
- X = rows[FEATURES].values
264
  n = len(rows)
265
  haz = np.empty((n, self.K))
266
  for k in range(self.K):
@@ -334,6 +381,79 @@ class BlendDH:
334
  return out
335
 
336
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
337
  class WeightedBagLogistic:
338
  """Bagged logistic with recency-weighted samples: pairs from recent origins
339
  count more, on the theory that fleet composition and degradation mix drift."""
@@ -417,8 +537,9 @@ class LogBagC:
417
  class LogBagFeats:
418
  """logbag with extra feature columns (aux usage-dynamics table)."""
419
 
420
- def __init__(self, extra):
421
  self.feats = FEATURES + extra
 
422
 
423
  def fit(self, train):
424
  from sklearn.ensemble import BaggingClassifier
@@ -426,7 +547,7 @@ class LogBagFeats:
426
  base = make_pipeline(SimpleImputer(strategy='median'),
427
  StandardScaler(),
428
  LogisticRegression(max_iter=2000, C=1.0))
429
- self.clf = BaggingClassifier(base, n_estimators=15, random_state=0)
430
  self.clf.fit(X, y)
431
  dying = train[train['dies42'] == 1]
432
  self.days = DayQuantiles().fit(dying[FEATURES], dying['days_to_eol'])
@@ -456,6 +577,26 @@ MODELS = {
456
  'blend_dh': BlendDH,
457
  'logbag_aux': lambda: LogBagFeats(['rng_90', 'vstd_30', 'rng_ratio', 'gap_max_60']),
458
  'logbag_aux2': lambda: LogBagFeats(['rng_30', 'rng_90', 'vstd_30']),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
459
  }
460
 
461
 
@@ -809,7 +950,8 @@ def plan_tsroute(pred, included, locations, start_time, day_q,
809
  floor_day - defers uncertain insurance swaps that would otherwise
810
  pay early-replacement for weeks [floor_day 0 = off]
811
  """
812
- n = int(round(plan_tsroute.alpha * pred['p42'].sum()))
 
813
  chosen = pred['p42'].nlargest(n).index
814
  if plan_tsroute.p_be > 0:
815
  topups = pred.index[pred['p42'] >= plan_tsroute.p_be]
@@ -1029,7 +1171,7 @@ def walk_forward(table, scenario_ctx, origins, configs):
1029
  for model_name in {c['model'] for c in configs}:
1030
  if (model_name, idx) not in fitted:
1031
  factory = resolve_model(model_name)
1032
- if model_name in ('aft', 'dthaz', 'logbag_open', 'blend_dh'):
1033
  train = censored_pairs(table, origin_time)
1034
  else:
1035
  train = training_pairs(table, origin_time)
@@ -1105,6 +1247,11 @@ def summarize(df, label):
1105
  def main():
1106
  dataset_path = Path(os.environ.get('BATTERYSWAP_DATASET_PATH', 'dataset'))
1107
  table = load_features(dataset_path)
 
 
 
 
 
1108
  try:
1109
  from batteryswap_example.aux_features import load as load_aux
1110
  table = table.merge(load_aux(dataset_path), on=['device_id', 'origin_idx'],
 
141
  C=self.opts.get('C', 1.0)))
142
  self.clf = BaggingClassifier(
143
  base, n_estimators=int(self.opts.get('bags', 15)), random_state=0)
144
+ elif self.kind == 'rf':
145
+ from sklearn.ensemble import RandomForestClassifier
146
+ cw = 'balanced_subsample' if self.opts.get('bal') else None
147
+ self.clf = make_pipeline(
148
+ SimpleImputer(strategy='median'),
149
+ RandomForestClassifier(
150
+ n_estimators=int(self.opts.get('trees', 500)),
151
+ min_samples_leaf=int(self.opts.get('leaf', 5)),
152
+ max_features='sqrt', class_weight=cw,
153
+ random_state=0, n_jobs=-1))
154
+ elif self.kind == 'xtrees':
155
+ from sklearn.ensemble import ExtraTreesClassifier
156
+ self.clf = make_pipeline(
157
+ SimpleImputer(strategy='median'),
158
+ ExtraTreesClassifier(n_estimators=500, min_samples_leaf=5,
159
+ max_features='sqrt', random_state=0,
160
+ n_jobs=-1))
161
+ elif self.kind == 'mlp':
162
+ from sklearn.neural_network import MLPClassifier
163
+ self.clf = make_pipeline(
164
+ SimpleImputer(strategy='median'),
165
+ StandardScaler(),
166
+ MLPClassifier(hidden_layer_sizes=(32, 16), alpha=1e-2,
167
+ max_iter=1000, early_stopping=True,
168
+ random_state=0))
169
  self.clf.fit(X, y)
170
 
171
  dying = train[train['dies42'] == 1]
 
183
  return out
184
 
185
 
186
+ class RankBlendRF:
187
+ """logbag's probability scale with (logbag+RF) mean-rank ordering: keeps
188
+ sum(p42) exactly (topsum count unchanged) while letting the forest's ranking
189
+ reorder WHO is included - RF ranks better at some origins but its raw
190
+ probabilities are too compressed for count-tracking."""
191
+
192
+ def fit(self, train):
193
+ self.a = SklearnRUL('logbag').fit(train)
194
+ self.b = SklearnRUL('rf').fit(train)
195
+ return self
196
+
197
+ def predict(self, rows):
198
+ out = self.a.predict(rows)
199
+ mean_rank = (out['p42'].rank() + self.b.predict(rows)['p42'].rank()) / 2
200
+ vals = np.sort(out['p42'].values) # same multiset of values
201
+ out['p42'] = pd.Series(vals, index=mean_rank.sort_values().index).reindex(out.index)
202
+ return out
203
+
204
+
205
  class RankVoltage:
206
  """Control: score by v_last only; p42 is a rank in [0,1], day from DayQuantiles."""
207
 
 
274
  BUCKET = 7.0
275
  K = 6 # buckets cover days (7k, 7(k+1)], k=0..5
276
 
277
+ def __init__(self, extra=None):
278
+ self.feats = FEATURES + (extra or [])
279
+
280
  def fit(self, train_censored):
281
  dur = train_censored['duration'].clip(upper=WINDOW_DAYS).values
282
  event = ((train_censored['event'] == 1) &
283
  (train_censored['duration'] <= WINDOW_DAYS)).values
284
+ X = train_censored[self.feats].values
285
  rows, ys, ks = [], [], []
286
  for k in range(self.K):
287
  lo, hi = self.BUCKET * k, self.BUCKET * (k + 1)
 
307
  return self
308
 
309
  def predict(self, rows):
310
+ X = rows[self.feats].values
311
  n = len(rows)
312
  haz = np.empty((n, self.K))
313
  for k in range(self.K):
 
381
  return out
382
 
383
 
384
+ class LogBagUCB(LogBagFeats):
385
+ """Rank by mean + k*std across the bagged estimators: a battery the ensemble
386
+ disagrees about gets an inclusion boost (missing it costs ~4x including it).
387
+ N still comes from sum of mean p42, so the count stays calibrated."""
388
+
389
+ def __init__(self, extra, k=0.5):
390
+ super().__init__(extra)
391
+ self.k = k
392
+
393
+ def predict(self, rows):
394
+ out = self.days.predict(rows[FEATURES])
395
+ X = rows[self.feats]
396
+ Xi = pd.DataFrame(X, columns=self.feats)
397
+ probs = np.stack([est.predict_proba(Xi.iloc[:, feats])[:, 1]
398
+ for est, feats in zip(self.clf.estimators_,
399
+ self.clf.estimators_features_)])
400
+ mean, std = probs.mean(axis=0), probs.std(axis=0)
401
+ out['p42'] = np.clip(mean + self.k * std, 0, 1)
402
+ out['p42_sum'] = mean.sum()
403
+ return out
404
+
405
+
406
+ class LogBagLog(LogBagFeats):
407
+ """Same features, but log1p-compress the heavy-tailed ones so the linear
408
+ model is not dominated by extreme staleness/gap values."""
409
+
410
+ SKEWED = ['staleness', 'gap_max_60', 'n_30']
411
+
412
+ def _tx(self, rows):
413
+ X = rows[self.feats].copy()
414
+ for c in self.SKEWED:
415
+ if c in X:
416
+ X[c] = np.log1p(X[c].clip(lower=0))
417
+ return X
418
+
419
+ def fit(self, train):
420
+ from sklearn.ensemble import BaggingClassifier
421
+ X, y = self._tx(train), train['dies42']
422
+ base = make_pipeline(SimpleImputer(strategy='median'),
423
+ StandardScaler(),
424
+ LogisticRegression(max_iter=2000, C=1.0))
425
+ self.clf = BaggingClassifier(base, n_estimators=self.n_estimators,
426
+ random_state=0)
427
+ self.clf.fit(X, y)
428
+ dying = train[train['dies42'] == 1]
429
+ self.days = DayQuantiles().fit(dying[FEATURES], dying['days_to_eol'])
430
+ return self
431
+
432
+ def predict(self, rows):
433
+ out = self.days.predict(rows[FEATURES])
434
+ out['p42'] = self.clf.predict_proba(self._tx(rows))[:, 1]
435
+ return out
436
+
437
+
438
+ class BlendDHAux:
439
+ """Mean of logbag_aux (closed windows) and DiscreteHazard-with-aux
440
+ (censored open windows): decorrelated training sets, shared feature set."""
441
+
442
+ def fit(self, train_censored):
443
+ # recover the scored origin T: censored durations end exactly at T
444
+ tc = train_censored
445
+ T = (tc['origin'] + pd.to_timedelta(tc['duration'], unit='D')).max()
446
+ closed = tc[tc['origin'] <= T - pd.Timedelta(days=WINDOW_DAYS)]
447
+ self.a = LogBagFeats(['rng_90', 'vstd_30', 'rng_ratio', 'gap_max_60']).fit(closed)
448
+ self.b = DiscreteHazard(['rng_90', 'vstd_30', 'rng_ratio', 'gap_max_60']).fit(train_censored)
449
+ return self
450
+
451
+ def predict(self, rows):
452
+ out = self.a.predict(rows)
453
+ out['p42'] = 0.5 * (out['p42'] + self.b.predict(rows)['p42'])
454
+ return out
455
+
456
+
457
  class WeightedBagLogistic:
458
  """Bagged logistic with recency-weighted samples: pairs from recent origins
459
  count more, on the theory that fleet composition and degradation mix drift."""
 
537
  class LogBagFeats:
538
  """logbag with extra feature columns (aux usage-dynamics table)."""
539
 
540
+ def __init__(self, extra, n_estimators=15):
541
  self.feats = FEATURES + extra
542
+ self.n_estimators = n_estimators
543
 
544
  def fit(self, train):
545
  from sklearn.ensemble import BaggingClassifier
 
547
  base = make_pipeline(SimpleImputer(strategy='median'),
548
  StandardScaler(),
549
  LogisticRegression(max_iter=2000, C=1.0))
550
+ self.clf = BaggingClassifier(base, n_estimators=self.n_estimators, random_state=0)
551
  self.clf.fit(X, y)
552
  dying = train[train['dies42'] == 1]
553
  self.days = DayQuantiles().fit(dying[FEATURES], dying['days_to_eol'])
 
577
  'blend_dh': BlendDH,
578
  'logbag_aux': lambda: LogBagFeats(['rng_90', 'vstd_30', 'rng_ratio', 'gap_max_60']),
579
  'logbag_aux2': lambda: LogBagFeats(['rng_30', 'rng_90', 'vstd_30']),
580
+ 'rank_rf': RankBlendRF,
581
+ 'logbag_proj': lambda: LogBagFeats(['v_proj']),
582
+ 'logbag_rank': lambda: LogBagFeats(['r_vlast', 'r_tau']),
583
+ 'logbag_rankproj': lambda: LogBagFeats(['v_proj', 'r_vlast', 'r_tau']),
584
+ 'aux_rv': lambda: LogBagFeats(['rng_90', 'vstd_30']),
585
+ 'aux_rvg': lambda: LogBagFeats(['rng_90', 'vstd_30', 'gap_max_60']),
586
+ 'aux_rvr': lambda: LogBagFeats(['rng_90', 'vstd_30', 'rng_ratio']),
587
+ 'aux_all': lambda: LogBagFeats(['rng_90', 'vstd_30', 'rng_ratio', 'gap_max_60',
588
+ 'hot_90', 'oob_90']),
589
+ 'aux_proj': lambda: LogBagFeats(['rng_90', 'vstd_30', 'rng_ratio', 'gap_max_60',
590
+ 'v_proj']),
591
+ 'aux_rank': lambda: LogBagFeats(['rng_90', 'vstd_30', 'rng_ratio', 'gap_max_60',
592
+ 'r_vlast', 'r_tau']),
593
+ 'aux_bag30': lambda: LogBagFeats(['rng_90', 'vstd_30', 'rng_ratio', 'gap_max_60'], n_estimators=30),
594
+ 'aux_bag50': lambda: LogBagFeats(['rng_90', 'vstd_30', 'rng_ratio', 'gap_max_60'], n_estimators=50),
595
+ 'dthaz_aux': lambda: DiscreteHazard(['rng_90', 'vstd_30', 'rng_ratio', 'gap_max_60']),
596
+ 'blend_dhaux': BlendDHAux,
597
+ 'aux_ucb05': lambda: LogBagUCB(['rng_90', 'vstd_30', 'rng_ratio', 'gap_max_60'], k=0.5),
598
+ 'aux_ucb10': lambda: LogBagUCB(['rng_90', 'vstd_30', 'rng_ratio', 'gap_max_60'], k=1.0),
599
+ 'aux_log': lambda: LogBagLog(['rng_90', 'vstd_30', 'rng_ratio', 'gap_max_60']),
600
  }
601
 
602
 
 
950
  floor_day - defers uncertain insurance swaps that would otherwise
951
  pay early-replacement for weeks [floor_day 0 = off]
952
  """
953
+ psum = pred['p42_sum'].iloc[0] if 'p42_sum' in pred else pred['p42'].sum()
954
+ n = int(round(plan_tsroute.alpha * psum))
955
  chosen = pred['p42'].nlargest(n).index
956
  if plan_tsroute.p_be > 0:
957
  topups = pred.index[pred['p42'] >= plan_tsroute.p_be]
 
1171
  for model_name in {c['model'] for c in configs}:
1172
  if (model_name, idx) not in fitted:
1173
  factory = resolve_model(model_name)
1174
+ if model_name in ('aft', 'dthaz', 'dthaz_aux', 'logbag_open', 'blend_dh', 'blend_dhaux'):
1175
  train = censored_pairs(table, origin_time)
1176
  else:
1177
  train = training_pairs(table, origin_time)
 
1247
  def main():
1248
  dataset_path = Path(os.environ.get('BATTERYSWAP_DATASET_PATH', 'dataset'))
1249
  table = load_features(dataset_path)
1250
+ # derived: voltage projected through the reporting gap by the device's own
1251
+ # slope, and leak-free within-origin fleet ranks (data <= origin only)
1252
+ table['v_proj'] = table['v_last'] + table['slope_lastk'] * table['staleness']
1253
+ table['r_vlast'] = table.groupby('origin_idx')['v_last'].rank(pct=True)
1254
+ table['r_tau'] = table.groupby('origin_idx')['tau_projected'].rank(pct=True)
1255
  try:
1256
  from batteryswap_example.aux_features import load as load_aux
1257
  table = table.merge(load_aux(dataset_path), on=['device_id', 'origin_idx'],