CarlAlbertCode commited on
Commit
99bfa29
·
1 Parent(s): 2bb61d5

Submission 008 - hierarchical Wiener first-passage rank residual plus emergency-cost pricing and full-horizon capacity lookback over the byte-preserved public V05 base

Browse files

Wraps the public V05 artifact (63d71091) unchanged and adds three mechanisms,
each validated over 48 dates x 27 bases (1296 paired cases):

hierarchical Wiener first-passage rank residual, weight 0.15
capacity_lookback_days 14 -> 42
emergency_operational_scale 0 -> 0.5

Combined: -79.85 total, paired t -2.81, all 27 bases improve, both calendar
halves negative, block-bootstrap CI [-133.29, -25.55], P(improvement) 0.9984.
Every one of the nine cost components improves: late -55.58, early -13.21,
daily -7.25, travel -1.08, overtime -1.68, weekly -0.62. TP 6.208 -> 6.418,
misses 3.250 -> 3.040.

Offline replay under --network none: 19,890 rows, 370-381 s per split,
peak 1.53 GiB, byte-identical submission.csv across runs.

The temperature/identity rerank from submission 007 is not used.

.gitignore CHANGED
@@ -16,6 +16,7 @@ submission_artifacts/*
16
  !submission_artifacts/planner.json
17
  !submission_artifacts/identity_planner.json
18
  !submission_artifacts/temperature_planner.json
 
19
 
20
  # Model files
21
  *.cbm
@@ -25,6 +26,7 @@ submission_artifacts/*
25
  !submission_artifacts/identity_planner.joblib
26
  !submission_artifacts/temperature_planner.joblib
27
  !submission_artifacts/v05_planner.joblib
 
28
 
29
  # Local training logs
30
  catboost_info/
 
16
  !submission_artifacts/planner.json
17
  !submission_artifacts/identity_planner.json
18
  !submission_artifacts/temperature_planner.json
19
+ !submission_artifacts/hierarchical_fpt_planner.json
20
 
21
  # Model files
22
  *.cbm
 
26
  !submission_artifacts/identity_planner.joblib
27
  !submission_artifacts/temperature_planner.joblib
28
  !submission_artifacts/v05_planner.joblib
29
+ !submission_artifacts/hierarchical_fpt_planner.joblib
30
 
31
  # Local training logs
32
  catboost_info/
script.py CHANGED
@@ -45,7 +45,7 @@ def main() -> None:
45
  artifact_path = Path(
46
  os.environ.get(
47
  "BATTERYSWAP_PLANNER_PATH",
48
- "submission_artifacts/temperature_planner.joblib",
49
  )
50
  )
51
  splits = [
 
45
  artifact_path = Path(
46
  os.environ.get(
47
  "BATTERYSWAP_PLANNER_PATH",
48
+ "submission_artifacts/hierarchical_fpt_planner.joblib",
49
  )
50
  )
51
  splits = [
src/batteryswapai/hierarchical_fpt.py ADDED
@@ -0,0 +1,331 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Frozen hierarchical Wiener first-passage residual for the public V05 planner.
2
+
3
+ This module is inference-only. It estimates a battery's terminal drift from
4
+ twelve non-overlapping weekly increments of the evaluator-exact smoothed median
5
+ voltage, shrinks that drift toward a leave-target-building pool, and uses the
6
+ analytic Wiener first-passage probability as a 15% rank residual. It never
7
+ changes V05's probability multiset, RUL heads, quota, timing policy, or planner.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import math
13
+ from dataclasses import dataclass, field
14
+
15
+ import numpy as np
16
+ import pandas as pd
17
+ from scipy.stats import norm, rankdata
18
+
19
+ from .competition_planner import CompetitionPlanner
20
+ from .identity_ensemble import (
21
+ CausalHistoryCache,
22
+ CausalHistoryView,
23
+ planner_freshness_factors,
24
+ )
25
+
26
+
27
+ SCHEMA_VERSION = 1
28
+ HORIZON_DAYS = 42.0
29
+ EOL_VOLTAGE = 2.40
30
+ TERMINAL_WEEKS = 12
31
+ MIN_WEEKLY_INCREMENTS = 4
32
+ MAX_STATE_STALENESS_DAYS = 7.0
33
+ RANK_RESIDUAL_WEIGHT = 0.15
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class FPTSnapshotFeatures:
38
+ last_smooth_voltage: np.ndarray
39
+ smooth_staleness_days: np.ndarray
40
+ weekly_increment_count: np.ndarray
41
+ eb_drift_v_per_day: np.ndarray
42
+ pooled_diffusion_v_per_sqrt_day: np.ndarray
43
+ eb_device_weight: np.ndarray
44
+ hit_probability_42d: np.ndarray
45
+ reliable: np.ndarray
46
+
47
+
48
+ def terminal_weekly_increments(
49
+ series: pd.Series,
50
+ cutoff: pd.Timestamp | str,
51
+ ) -> tuple[float, float, np.ndarray]:
52
+ """Return the last pre-cut state and twelve disjoint seven-day changes.
53
+
54
+ The strict calendar-day cut matters because official scenarios start at
55
+ midnight. A cutoff-day aggregate could otherwise include later readings
56
+ from that same day when a full series is used for an offline audit.
57
+ """
58
+
59
+ cutoff_day = pd.Timestamp(cutoff).normalize()
60
+ if series.empty:
61
+ return math.nan, math.inf, np.asarray([], dtype=float)
62
+ work = pd.Series(
63
+ pd.to_numeric(series, errors="coerce").to_numpy(float),
64
+ index=pd.DatetimeIndex(series.index).normalize(),
65
+ ).sort_index(kind="stable")
66
+ if work.index.has_duplicates:
67
+ raise ValueError("smoothed voltage series has duplicate calendar days")
68
+ past = work.loc[work.index < cutoff_day].dropna()
69
+ if past.empty:
70
+ return math.nan, math.inf, np.asarray([], dtype=float)
71
+ last_day = pd.Timestamp(past.index[-1])
72
+ anchors = pd.DatetimeIndex(
73
+ [
74
+ last_day - pd.Timedelta(days=7 * offset)
75
+ for offset in range(TERMINAL_WEEKS, -1, -1)
76
+ ]
77
+ )
78
+ values = work.reindex(anchors).to_numpy(float)
79
+ increments = np.diff(values)
80
+ increments = increments[np.isfinite(increments)]
81
+ staleness = float((cutoff_day - last_day) / pd.Timedelta(days=1))
82
+ return float(past.iloc[-1]), staleness, increments
83
+
84
+
85
+ def _robust_scale(values: np.ndarray) -> float:
86
+ values = np.asarray(values, dtype=float)
87
+ center = float(np.median(values))
88
+ return float(1.4826 * np.median(np.abs(values - center)))
89
+
90
+
91
+ def wiener_hit_probability(
92
+ initial_voltage: float,
93
+ drift_v_per_day: float,
94
+ diffusion_v_per_sqrt_day: float,
95
+ horizon_days: float = HORIZON_DAYS,
96
+ ) -> float:
97
+ """Analytic probability that drifted Brownian voltage hits 2.40 V."""
98
+
99
+ distance = float(initial_voltage - EOL_VOLTAGE)
100
+ if distance <= 0.0:
101
+ return 1.0
102
+ diffusion = max(float(diffusion_v_per_sqrt_day), 1e-6)
103
+ horizon = float(horizon_days)
104
+ scale = diffusion * math.sqrt(horizon)
105
+ drift = float(drift_v_per_day)
106
+ first = norm.cdf((-drift * horizon - distance) / scale)
107
+ log_second = (
108
+ -2.0 * drift * distance / (diffusion * diffusion)
109
+ + norm.logcdf((drift * horizon - distance) / scale)
110
+ )
111
+ second = math.exp(min(float(log_second), 0.0))
112
+ return float(np.clip(first + second, 0.0, 1.0))
113
+
114
+
115
+ def hierarchical_fpt_features(
116
+ history: CausalHistoryView,
117
+ batteries: np.ndarray,
118
+ buildings: np.ndarray,
119
+ cutoff: pd.Timestamp | str,
120
+ ) -> FPTSnapshotFeatures:
121
+ """Compute the exact frozen residual features for one causal landmark."""
122
+
123
+ batteries = np.asarray(batteries, dtype=object)
124
+ buildings = np.asarray(buildings, dtype=object).astype(str)
125
+ if len(batteries) != len(buildings):
126
+ raise ValueError("battery and building arrays have different lengths")
127
+ state = np.full(len(batteries), np.nan)
128
+ staleness = np.full(len(batteries), np.inf)
129
+ changes: list[np.ndarray] = []
130
+ for position, battery in enumerate(batteries):
131
+ x0, gap, increments = terminal_weekly_increments(
132
+ history.smooth_lookup.get(str(battery), pd.Series(dtype=float)), cutoff
133
+ )
134
+ state[position], staleness[position] = x0, gap
135
+ changes.append(increments)
136
+
137
+ hit = np.full(len(batteries), np.nan)
138
+ drift_hat = np.full(len(batteries), np.nan)
139
+ diffusion_hat = np.full(len(batteries), np.nan)
140
+ shrinkage = np.full(len(batteries), np.nan)
141
+ for building in np.unique(buildings):
142
+ target = buildings == building
143
+ outer = ~target
144
+ pooled_parts = [
145
+ changes[position]
146
+ for position in np.flatnonzero(outer)
147
+ if len(changes[position])
148
+ ]
149
+ if not pooled_parts:
150
+ continue
151
+ pooled = np.concatenate(pooled_parts)
152
+ pooled_drift = float(np.median(pooled) / 7.0)
153
+ diffusion = max(
154
+ _robust_scale(pooled - 7.0 * pooled_drift) / math.sqrt(7.0),
155
+ 1e-6,
156
+ )
157
+ outer_devices = np.asarray(
158
+ [
159
+ position
160
+ for position in np.flatnonzero(outer)
161
+ if len(changes[position]) >= MIN_WEEKLY_INCREMENTS
162
+ ],
163
+ dtype=int,
164
+ )
165
+ if outer_devices.size:
166
+ raw_drifts = np.asarray(
167
+ [np.median(changes[position]) / 7.0 for position in outer_devices]
168
+ )
169
+ between_variance = _robust_scale(raw_drifts) ** 2
170
+ noise_variance = np.asarray(
171
+ [
172
+ diffusion**2 / (7.0 * len(changes[position]))
173
+ for position in outer_devices
174
+ ]
175
+ )
176
+ prior_variance = max(
177
+ float(between_variance - np.median(noise_variance)), 0.0
178
+ )
179
+ else:
180
+ prior_variance = 0.0
181
+
182
+ for position in np.flatnonzero(target):
183
+ count = len(changes[position])
184
+ if (
185
+ count < MIN_WEEKLY_INCREMENTS
186
+ or not np.isfinite(state[position])
187
+ or staleness[position] > MAX_STATE_STALENESS_DAYS
188
+ ):
189
+ continue
190
+ raw_drift = float(np.median(changes[position]) / 7.0)
191
+ observation_variance = diffusion**2 / (7.0 * count)
192
+ weight = (
193
+ prior_variance / (prior_variance + observation_variance)
194
+ if prior_variance > 0.0
195
+ else 0.0
196
+ )
197
+ drift = weight * raw_drift + (1.0 - weight) * pooled_drift
198
+ hit[position] = wiener_hit_probability(state[position], drift, diffusion)
199
+ drift_hat[position] = drift
200
+ diffusion_hat[position] = diffusion
201
+ shrinkage[position] = weight
202
+
203
+ reliable = np.isfinite(hit)
204
+ return FPTSnapshotFeatures(
205
+ last_smooth_voltage=state,
206
+ smooth_staleness_days=staleness,
207
+ weekly_increment_count=np.asarray([len(value) for value in changes], dtype=int),
208
+ eb_drift_v_per_day=drift_hat,
209
+ pooled_diffusion_v_per_sqrt_day=diffusion_hat,
210
+ eb_device_weight=shrinkage,
211
+ hit_probability_42d=hit,
212
+ reliable=reliable,
213
+ )
214
+
215
+
216
+ def rerank_fpt_risk(
217
+ baseline: np.ndarray,
218
+ hit_probability: np.ndarray,
219
+ reliable: np.ndarray,
220
+ freshness: np.ndarray,
221
+ batteries: np.ndarray,
222
+ ) -> np.ndarray:
223
+ """Apply the frozen 15% residual without changing either risk multiset."""
224
+
225
+ baseline = np.asarray(baseline, dtype=float)
226
+ hit_probability = np.asarray(hit_probability, dtype=float)
227
+ reliable = np.asarray(reliable, dtype=bool)
228
+ freshness = np.asarray(freshness, dtype=float)
229
+ batteries = np.asarray(batteries, dtype=object)
230
+ lengths = {len(baseline), len(hit_probability), len(reliable), len(freshness), len(batteries)}
231
+ if len(lengths) != 1:
232
+ raise ValueError("FPT rerank inputs have different lengths")
233
+ if not np.isfinite(baseline).all() or not np.isfinite(freshness).all():
234
+ raise ValueError("base risks and freshness factors must be finite")
235
+ if not np.isfinite(hit_probability[reliable]).all():
236
+ raise ValueError("reliable FPT probabilities must be finite")
237
+
238
+ treatment = baseline.copy()
239
+ for factor in np.sort(np.unique(freshness)):
240
+ positions = np.flatnonzero((freshness == factor) & reliable)
241
+ if len(positions) < 2:
242
+ continue
243
+ base_rank = rankdata(baseline[positions], method="average") / len(positions)
244
+ fpt_rank = rankdata(hit_probability[positions], method="average") / len(positions)
245
+ score = (
246
+ (1.0 - RANK_RESIDUAL_WEIGHT) * base_rank
247
+ + RANK_RESIDUAL_WEIGHT * fpt_rank
248
+ )
249
+ order = np.lexsort((batteries[positions].astype(str), score))
250
+ treatment[positions[order]] = np.sort(baseline[positions])
251
+
252
+ if not np.array_equal(np.sort(treatment), np.sort(baseline)):
253
+ raise AssertionError("FPT residual changed the raw risk multiset")
254
+ if not np.array_equal(
255
+ np.sort(treatment * freshness), np.sort(baseline * freshness)
256
+ ):
257
+ raise AssertionError("FPT residual changed planner-effective risk multiset")
258
+ return treatment
259
+
260
+
261
+ @dataclass
262
+ class HierarchicalFPTPlanner:
263
+ """Serializable wrapper around the exact public V05 planner artifact."""
264
+
265
+ base_planner: CompetitionPlanner
266
+ base_artifact_sha256: str
267
+ schema_version: int = SCHEMA_VERSION
268
+ _history_cache: CausalHistoryCache | None = field(
269
+ default=None, init=False, repr=False, compare=False
270
+ )
271
+
272
+ def reset_split(self, split_id: str | None = None) -> None:
273
+ self._history_cache = CausalHistoryCache(split_id=split_id)
274
+
275
+ def plan_scenario(
276
+ self,
277
+ visible_history: pd.DataFrame,
278
+ snapshot: pd.DataFrame,
279
+ locations: pd.DataFrame,
280
+ travel_costs: pd.DataFrame,
281
+ settings,
282
+ start_time: pd.Timestamp | str,
283
+ ) -> pd.DataFrame:
284
+ if self.schema_version != SCHEMA_VERSION:
285
+ raise RuntimeError(
286
+ f"hierarchical FPT schema {self.schema_version} != runtime {SCHEMA_VERSION}"
287
+ )
288
+ if self._history_cache is None:
289
+ self.reset_split(None)
290
+ assert self._history_cache is not None
291
+ start = pd.Timestamp(start_time)
292
+ history = self._history_cache.update(visible_history, start)
293
+ batteries = snapshot["battery"].astype(str).to_numpy(object)
294
+ if not np.array_equal(
295
+ batteries, locations["battery"].astype(str).to_numpy(object)
296
+ ):
297
+ raise AssertionError("snapshot and locations battery order differs")
298
+
299
+ base_risk = self.base_planner.model.predict_event_risk(snapshot)
300
+ predicted_rul = self.base_planner.model.predict_rul(snapshot)
301
+ predicted_survivor = self.base_planner.model.predict_survivor_rul(snapshot)
302
+ if predicted_survivor is None:
303
+ predicted_survivor = np.full(
304
+ len(snapshot), float(settings.planning_window_days)
305
+ )
306
+ freshness = planner_freshness_factors(
307
+ snapshot["data_gap_days"].to_numpy(float), self.base_planner.policy
308
+ )
309
+ features = hierarchical_fpt_features(
310
+ history,
311
+ batteries,
312
+ snapshot["building"].astype(str).to_numpy(object),
313
+ start,
314
+ )
315
+ treatment_risk = rerank_fpt_risk(
316
+ base_risk,
317
+ features.hit_probability_42d,
318
+ features.reliable,
319
+ freshness,
320
+ batteries,
321
+ )
322
+ return self.base_planner.plan_snapshot(
323
+ snapshot,
324
+ locations,
325
+ travel_costs,
326
+ settings,
327
+ start,
328
+ predicted_rul=predicted_rul,
329
+ predicted_risk=treatment_risk,
330
+ predicted_survivor_rul=predicted_survivor,
331
+ )
submission_artifacts/hierarchical_fpt_planner.joblib ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6e9262359591119c4979e1c21c13b02d842d7c04474a182fda5c2bcdcbc9a8a1
3
+ size 16262133
submission_artifacts/hierarchical_fpt_planner.json ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "artifact": "submission_artifacts/hierarchical_fpt_planner.joblib",
3
+ "artifact_sha256": "6e9262359591119c4979e1c21c13b02d842d7c04474a182fda5c2bcdcbc9a8a1",
4
+ "schema_version": 1,
5
+ "base_artifact": "public/v05:submission_artifacts/planner.joblib",
6
+ "base_artifact_sha256": "63d71091d7f46e8fb11798b7c2be936cc7e0022954824a4eb98c07dc1bf9b0b3",
7
+ "configuration": {
8
+ "eol_voltage": 2.4,
9
+ "horizon_days": 42.0,
10
+ "terminal_nonoverlap_weeks": 12,
11
+ "minimum_weekly_increments": 4,
12
+ "maximum_state_staleness_days": 7.0,
13
+ "rank_residual_weight": 0.15,
14
+ "capacity_lookback_days": 42,
15
+ "emergency_operational_scale": 0.5,
16
+ "outer_pool": "leave current target building out",
17
+ "smoothing": "strict 10<T<30; daily median count>=5; rolling seven-calendar-day median min_periods=3; terminal calendar day strictly before cutoff"
18
+ },
19
+ "promotion_evidence": {
20
+ "official_48": "artifacts/hierarchical_fpt_official.json",
21
+ "robust_48x27": "artifacts/hierarchical_fpt_grid.cases.json"
22
+ },
23
+ "source_sha256": {
24
+ "src/batteryswapai/hierarchical_fpt.py": "51874f5fde70a2c868e5300cce3648d778279cfdb1d64ee3af5f6a30683eca0b",
25
+ "src/batteryswapai/identity_ensemble.py": "fa1f9aeaa89fba1bc5fc437c478c876544f2973960db18ef2c83f0042cc9df4a",
26
+ "src/batteryswapai/competition_planner.py": "a56bfe549a3d6fcfe59217f7bd8a647533a4484fdbb26fe3ef8f1d6c84035a98",
27
+ "scripts/experiment_hierarchical_fpt.py": "1ce854654813752930194413bcd5dff44984d105493a36c42e10945e23fa6b60",
28
+ "scripts/evaluate_hierarchical_fpt_grid.py": "90f764da5df050e37af15fc47524c629cce2b021de34b0eab39095383639a51e",
29
+ "scripts/build_hierarchical_fpt_submission.py": "39d4cc094642b6dbf0a03a9a5abff84c2639eb622ac117de32f04e5b6f0e8f78",
30
+ "script.py": "81d4ee862ef14125bd4e6f0c77ec4582a89a4deb95c50dd4c78b84124979628c",
31
+ "Dockerfile": "84a3f71b2ba664f91ceb8a2980dd5397a002eed02af9fdd264325caf89158573",
32
+ "requirements.txt": "53d9c582b98b392c0517dc87c78949cd13b99b3f09ebfce601d0c8eadd4f2db3"
33
+ },
34
+ "runtime_versions": {
35
+ "batteryswap_public": "0.3.4",
36
+ "joblib": "1.5.3",
37
+ "numpy": "2.2.6",
38
+ "pandas": "2.3.3",
39
+ "scipy": "1.14.1",
40
+ "scikit-learn": "1.7.2"
41
+ }
42
+ }