Phase 40: port the research loop's CV discipline + ship in-fleet pooling
Browse filesCV protocols move out of /tmp into batteryswap_example/cv.py, with the
champion config itself promoted from /tmp/all48.py into cv.CHAMPION:
all48 full-fleet walk-forward, three-regime scorecard
static the deployment gate - one foreign-fleet fit, no refits, 11s a
seed, ORDERING questions only (this construction manufactures a
sum(p42) undercount; phase 26 believed it and public went
1661 -> 2189)
screen offline model screen grouped by BATTERY, never by scenario/row
models_cv.rank_stats now runs inside walk_forward, so dev/panel/test also
report rec12/rec20, operating-point recall+precision, the count actually
bought, and the median rank of missed deaths beside the cost. Base
reproduces 1661.7 / 2379.7 / 1679.2 / 604.8 byte-exact - the additions are
diagnostic only.
What the new metrics show: the panel's missed deaths sit at median rank 57,
45 ranks past the cut. That is the closed appetite axis as a measurement
rather than an argument. screen shows scenario grouping inflates rf's
margin over logistic 8x on recall@12 (+0.023 -> +0.194); the effective n is
80 batteries with a death, not 1130 positive rows.
Three feature variants measured on both instruments, all rejected:
vrel +138.7, vcorr +32.7, zone +104.9 on all48. vcorr's +32.7 reproduces
the phase-34 +32.8 to within 0.1. vrel is the discipline working - it won
the static gate by the widest margin of the three (-24.6, W143/L66, 4 of 5
seeds) and won it entirely through the count (116 fewer swaps, 3 more
deaths, rec12/rec20 flat), which is the one reading that protocol is
documented as invalid for.
Latent deployment bug fixed: cost_curves and plan_tsroute hardcoded the
evaluator's unobserved_eol_days as 30.0 while the evaluator reads it
per-scenario - and submit.py imports plan_tsroute, so the hardcode shipped.
All 48 train scenarios use 30.0, which is exactly why no local protocol
could catch it: same shape as the hardcoded 42-day park that
repark_for_window exists to undo.
Also ships in-fleet pooling for held-out splits (HELDOUT_POOL, default on):
the target split's own closed-window pairs are pooled with the foreign 82
and refit on a cadence. Rehearsed end-to-end on the 2x held-out fixture -
79560 rows in 287s, pooling active on both splits at 164 own deaths vs 82
foreign, no budget degradation, no fallback. Degrades to the static foreign
fit (the prior champion) on any overrun, never to the pickle.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- EXPERIMENTS.md +228 -0
- batteryswap_example/cv.py +347 -0
- batteryswap_example/data/train_features.parquet +2 -2
- batteryswap_example/features.py +25 -0
- batteryswap_example/models_cv.py +80 -4
- batteryswap_example/submit.py +125 -24
|
@@ -35,6 +35,13 @@ All numbers are mean `total_cost` per scenario from the official `evaluate_plan`
|
|
| 35 |
spent the test block. Per-phase "dev"/"test" columns below refer to these.
|
| 36 |
- **Reference points** (test block): shipped example 6779 · defer-everything 2512
|
| 37 |
· lowest-voltage-15 heuristic 2034 · oracle (true EOL) 186.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
## Phase 0 - understanding the problem
|
| 40 |
|
|
@@ -1796,3 +1803,224 @@ negatives when it has 800+; the effect does not attenuate with data, it
|
|
| 1796 |
separates the two. dq_g20 is intrinsically a cold-start mechanism and cannot be
|
| 1797 |
made always-on - which, given static_uniq=82, means it cannot be made to matter
|
| 1798 |
in deployment at all.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
spent the test block. Per-phase "dev"/"test" columns below refer to these.
|
| 36 |
- **Reference points** (test block): shipped example 6779 · defer-everything 2512
|
| 37 |
· lowest-voltage-15 heuristic 2034 · oracle (true EOL) 186.
|
| 38 |
+
- **Protocols live in `batteryswap_example/cv.py`** (phase 40), not in `/tmp`:
|
| 39 |
+
`all48` (full-fleet walk-forward, three-regime scorecard), `static` (the
|
| 40 |
+
deployment gate - one foreign-fleet fit, no refits, ORDERING questions only),
|
| 41 |
+
`screen` (offline model screen grouped by BATTERY, never by scenario or row).
|
| 42 |
+
All three report ranking precision at the operating N beside the cost: cost
|
| 43 |
+
alone cannot distinguish a change that reordered the list from one that only
|
| 44 |
+
cut it deeper, and AUC 0.947 has already coexisted with recall 0.000.
|
| 45 |
|
| 46 |
## Phase 0 - understanding the problem
|
| 47 |
|
|
|
|
| 1803 |
separates the two. dq_g20 is intrinsically a cold-start mechanism and cannot be
|
| 1804 |
made always-on - which, given static_uniq=82, means it cannot be made to matter
|
| 1805 |
in deployment at all.
|
| 1806 |
+
|
| 1807 |
+
## Phase 38 - the label process reverse-engineered (2026-08-21)
|
| 1808 |
+
|
| 1809 |
+
User question ("the voltage continues past the EOL line - if the battery fails
|
| 1810 |
+
we should not have voltage anymore") exposed a documented error and, chasing
|
| 1811 |
+
it, the exact rule that generates `eol_times.csv`.
|
| 1812 |
+
|
| 1813 |
+
### Correction: post-EOL telemetry is the SAME battery, not a replacement
|
| 1814 |
+
|
| 1815 |
+
Phase 16 recorded: "All 82 dead slots keep reporting past EOL (mean 107d, max
|
| 1816 |
+
305d): **replacement batteries** under the same device_id", marked
|
| 1817 |
+
human-verified. The durations are right (measured: median 85 d, mean 105, max
|
| 1818 |
+
305). **The interpretation is wrong.** Median voltage tracked against
|
| 1819 |
+
days-since-EOL over all 82:
|
| 1820 |
+
|
| 1821 |
+
| window | -90..-61 | -60..-31 | -30..-8 | -7..0 | +1..14 | +15..30 | +31..60 | +91..300 |
|
| 1822 |
+
|---|---|---|---|---|---|---|---|---|
|
| 1823 |
+
| median V | 2.562 | 2.510 | 2.454 | **2.398** | 2.379 | 2.363 | 2.352 | 2.295 |
|
| 1824 |
+
|
| 1825 |
+
Smooth through the line, no discontinuity, and only **3 of 78** devices ever
|
| 1826 |
+
reach fresh-battery level (>2.9 V) afterwards. Phase 16's own named example
|
| 1827 |
+
`d_57e99ff00802` reports to 2026-05-13 as claimed and never exceeds 2.559 V.
|
| 1828 |
+
|
| 1829 |
+
Genuine swaps DO exist and are unmistakable - 34 censored devices jump to
|
| 1830 |
+
>=2.95 V and hold (caveat 1 verified, ~30 claimed). They just never occur at
|
| 1831 |
+
recorded EOLs. **Consequence**: a feature window spanning a recorded EOL does
|
| 1832 |
+
not mix two batteries; segmenting history at recorded EOLs is unnecessary. The
|
| 1833 |
+
only genuine two-battery contamination is those 34 censored resets.
|
| 1834 |
+
|
| 1835 |
+
### The rule: gated 10-30 C, threshold 2.40 V, sustained crossing
|
| 1836 |
+
|
| 1837 |
+
`smooth_series`'s 10-30 C band is the ORGANISERS' default
|
| 1838 |
+
(batteryswap_public/utils.py:42) but is never called by the evaluator - we
|
| 1839 |
+
reimplemented it and inherited the constants. Never swept until now. Which
|
| 1840 |
+
(band, threshold) best reproduces the recorded EOL dates:
|
| 1841 |
+
|
| 1842 |
+
| band | median lag at 2.40 | coverage |
|
| 1843 |
+
|---|---|---|
|
| 1844 |
+
| 0-50 | 6.0 d | 100% |
|
| 1845 |
+
| 5-35 | 6.0 d | 100% |
|
| 1846 |
+
| 8-32 | 4.0 d | 100% |
|
| 1847 |
+
| **10-30** | **3.0 d** | **100%** |
|
| 1848 |
+
| 12-28 | 3.0 d | 98% |
|
| 1849 |
+
| 15-25 | 5.5 d | 88% |
|
| 1850 |
+
|
| 1851 |
+
2.42 is far worse everywhere (10-23 d); 2.38 loses coverage. **(10-30, 2.40)
|
| 1852 |
+
is the unique pair with 100% coverage at minimum lag.** The gate and the
|
| 1853 |
+
threshold are one definition, not two choices that happen to agree - a voltage
|
| 1854 |
+
reading is meaningless without a temperature. Independently, 10-30 is also the
|
| 1855 |
+
tightest band retaining all 82 deaths (12-28 drops to 77, 15-25 to 70) and
|
| 1856 |
+
peaks voltage AUC at 0.9604.
|
| 1857 |
+
|
| 1858 |
+
**EOL marks the SUSTAINED crossing.** Median lag 3 d (IQR 3-6), but 12 of 81
|
| 1859 |
+
devices lag >21 d, max 163. Those are not measurement gaps: during the lag they
|
| 1860 |
+
have a median of 23 gated readings and **83% go back above 2.40** (mean 57% of
|
| 1861 |
+
readings above). They wobble. The lag is NOT predictable from origin-time
|
| 1862 |
+
features: RF leave-out Spearman **+0.006** (p=0.95), R2 -0.193, and flagging
|
| 1863 |
+
the tail hits base rate exactly. Volatility runs the wrong way (vstd_30 rho
|
| 1864 |
+
-0.194). Characterisation, not a lever.
|
| 1865 |
+
|
| 1866 |
+
**No calendar structure**: EOL day-of-week is flat (weekend share 26% vs 29%
|
| 1867 |
+
chance), so no human/administrative recording process; month is confounded with
|
| 1868 |
+
fleet aging by window exposure; 68 distinct dates for 82 deaths is random
|
| 1869 |
+
collision. Calendar features rejected as a memorisation trap - fleet-history
|
| 1870 |
+
facts cannot transfer to splits at other dates.
|
| 1871 |
+
|
| 1872 |
+
**Consequence for relabelling (user-asked): do not.** The evaluator scores
|
| 1873 |
+
against `eol_times.csv` verbatim, the label is tight (100% within 7 d of a
|
| 1874 |
+
sub-2.40 gated reading, median 0), and the nearest relabelling - adding the ~26
|
| 1875 |
+
ghost deaths - was measured harmful at six thresholds in phase 37. Residual
|
| 1876 |
+
error is entirely trajectory-forecast error with no label-process component,
|
| 1877 |
+
which retro-explains phase 17's label-arrival-hazard rejection: arrival is
|
| 1878 |
+
near-deterministic, so there was no hazard to model.
|
| 1879 |
+
|
| 1880 |
+
Artifacts: `notebooks/all_batteries.ipynb` (all 461 devices, categorised
|
| 1881 |
+
DEAD-SILENT 18 / DEAD-VISIBLE 64 / RESET 34 / GHOST 17 / ADMIN-GONE 67 /
|
| 1882 |
+
HEALTHY 261), `notebooks/silent_deaths.ipynb`.
|
| 1883 |
+
|
| 1884 |
+
## Phase 39 - four hypotheses closed, four measurement laws (2026-08-21)
|
| 1885 |
+
|
| 1886 |
+
Five parallel sessions. Every line closed negative; the durable output is the
|
| 1887 |
+
laws.
|
| 1888 |
+
|
| 1889 |
+
| hypothesis | verdict |
|
| 1890 |
+
|---|---|
|
| 1891 |
+
| install-batch death clock | CLOSED on 3 implementations. The famous "AUC 0.91 on silent pairs" rests on **4 positives** (phase 10 already knew: all-covered 0.584, dark bucket 0.426-0.410 - worse than chance). Coverage was never the blocker: 83-95% monthly at late origins. Gated rank-only form measured **+97.9 harm**. |
|
| 1892 |
+
| `temp_mean` (30 d, pre-filter) | Mechanism replicates (deaths 18.67 C vs survivors 20.18) but **absorbed**: -1.1pp recall@12 on production. Fifth temperature rejection; the beta correction already occupies the channel. Gains +6.4pp on a champion with no temperature handling. |
|
| 1893 |
+
| battery-type clustering | Types are real (13x risk spread surviving level-removal) but miss rate is FLAT across types. Cannot buy recall. |
|
| 1894 |
+
| dv42 voltage-drop regression | CLOSED on 4 implementations. **Out-of-time R2 is negative everywhere** (-0.117 / -0.029 / -0.172): the drop cannot be forecast. The score was a differently-parameterised linear classifier anchored on voltage - ties the incumbent at N=12. Censoring-aware variant exactly inert (3% of rows); quantile variant worst on a full panel. Phase 10's wall from the other side. |
|
| 1895 |
+
|
| 1896 |
+
### The four measurement laws
|
| 1897 |
+
|
| 1898 |
+
All the same shape: **pick the denominator that matches the question.**
|
| 1899 |
+
|
| 1900 |
+
1. **Report recall at the operating N, not AUC.** With ~400 negatives/origin,
|
| 1901 |
+
AUC 0.93-0.95 says nothing about a rule taking 12. Silent deaths measured
|
| 1902 |
+
AUC 0.947 ("p42's strongest region") and recall@12 **0.000 of 24**, median
|
| 1903 |
+
rank 60. Same fact, one currency.
|
| 1904 |
+
2. **A candidate must beat the incumbent ranker ON ORDERING before any
|
| 1905 |
+
architecture can make blending it safe.** "Gated + rank-only" excludes
|
| 1906 |
+
calibration and count damage but NOT ordering damage: reordering a good list
|
| 1907 |
+
with a chance-level signal costs in proportion to the weight (+97.9 at
|
| 1908 |
+
w=0.05). No safe non-zero weight exists for a ~0.5-AUC feature.
|
| 1909 |
+
3. **Condition on the outcome, not row shares.** Any partition (building,
|
| 1910 |
+
cohort, type, room) looks significant against row shares because missed
|
| 1911 |
+
deaths concentrate where deaths are. chi2 13.00 -> 2.67 on conditioning.
|
| 1912 |
+
4. **Always report the test panel.** Requiring a recent in-band reading on test
|
| 1913 |
+
rows deleted 18% of deaths and swung v_now recall@12 from **0.800 to 0.382**
|
| 1914 |
+
- larger than every modelling effect measured that day, and it reversed the
|
| 1915 |
+
ranking of every arm. A recency filter deletes the silent population by
|
| 1916 |
+
construction.
|
| 1917 |
+
|
| 1918 |
+
### Caution on the blind-harness champion lineage
|
| 1919 |
+
|
| 1920 |
+
Two sessions independently found raw current voltage BEATS their champion's own
|
| 1921 |
+
ranker at the operating N (.721 vs .668; .703 vs .672). On production the
|
| 1922 |
+
reverse holds (.572 vs .429, +14.3pp). That lineage was ablated on COST under a
|
| 1923 |
+
threshold rule where every feature change moves depth as well as order, so a
|
| 1924 |
+
4-feature set losing to `last_voltage` on ordering is more likely over-pruned
|
| 1925 |
+
than optimal. **Do not port anything from it without re-running the ablation on
|
| 1926 |
+
recall at fixed N.**
|
| 1927 |
+
|
| 1928 |
+
## Phase 40 - the research loop's CV discipline, ported into this repo
|
| 1929 |
+
|
| 1930 |
+
Every prior phase re-derived its harness in `/tmp` and lost it. The three
|
| 1931 |
+
measurement disciplines the blind loop paid leaderboard points to learn are now
|
| 1932 |
+
repo-resident in `batteryswap_example/cv.py`, with the champion config itself
|
| 1933 |
+
promoted out of `/tmp/all48.py` into `cv.CHAMPION`.
|
| 1934 |
+
|
| 1935 |
+
venv/bin/python -m batteryswap_example.cv all48 [base|vcorr|zone|vrel]
|
| 1936 |
+
venv/bin/python -m batteryswap_example.cv static [variant] [n_seeds]
|
| 1937 |
+
venv/bin/python -m batteryswap_example.cv screen
|
| 1938 |
+
|
| 1939 |
+
**1. Ranking precision at the operating N, reported beside the cost.**
|
| 1940 |
+
`models_cv.rank_stats` now runs inside `walk_forward`, so `dev`/`panel`/`test`
|
| 1941 |
+
and the new protocols all emit rec12, rec20, operating-point recall/precision,
|
| 1942 |
+
the count actually bought, and the median rank of missed deaths.
|
| 1943 |
+
`summarize()` prints them, pooling caught/deaths over origins rather than
|
| 1944 |
+
averaging per-origin ratios (a 1-death origin must not outvote a 12-death one).
|
| 1945 |
+
|
| 1946 |
+
Base reproduces **1661.7 / 2379.7 / 1679.2 / 604.8** exactly, so the additions
|
| 1947 |
+
are diagnostic only. What they show that eight phases of cost-only reporting
|
| 1948 |
+
could not:
|
| 1949 |
+
|
| 1950 |
+
| block | n | total | rec12 | rec20 | recall | prec | sched | median miss rank |
|
| 1951 |
+
|---|---|---|---|---|---|---|---|---|
|
| 1952 |
+
| warmup s_0-7 | 8 | 2379.7 | 0.526 | 0.735 | 0.763 | 0.570 | 19.8 | 30.5 |
|
| 1953 |
+
| panel s_8-41 | 34 | 1679.2 | 0.572 | 0.675 | 0.571 | 0.458 | 11.3 | **57.0** |
|
| 1954 |
+
| nearend s_42-47 | 6 | 604.8 | 0.837 | 0.935 | 0.893 | 0.250 | 16.7 | 50.8 |
|
| 1955 |
+
| ALL-48 | 48 | 1661.7 | 0.597 | 0.718 | 0.641 | 0.453 | 13.4 | 49.2 |
|
| 1956 |
+
|
| 1957 |
+
**The panel's missed deaths sit at median rank 57**, 45 ranks past the cut. That
|
| 1958 |
+
is the closed appetite axis stated as a measurement rather than as an argument:
|
| 1959 |
+
no value of N reaches rank 57, which is why +395 swaps on the real fleet caught
|
| 1960 |
+
zero. It also localises the warm-up floor - rec12 0.526 there but recall 0.763,
|
| 1961 |
+
i.e. the floor buys catches with depth (19.8 swaps), not with order.
|
| 1962 |
+
|
| 1963 |
+
**2. `screen`: leave-BATTERY-out vs leave-SCENARIO-out, side by side.**
|
| 1964 |
+
66503 closed pairs, 461 batteries, 1130 positive rows from **80 batteries**.
|
| 1965 |
+
The effective n is 80, not 1130.
|
| 1966 |
+
|
| 1967 |
+
| model | battery AUC | battery rec12 | scenario AUC | scenario rec12 |
|
| 1968 |
+
|---|---|---|---|---|
|
| 1969 |
+
| logistic | 0.9669 | 0.541 | 0.9716 | 0.639 |
|
| 1970 |
+
| logbag | 0.9669 | 0.535 | 0.9718 | 0.641 |
|
| 1971 |
+
| rf | 0.9745 | 0.564 | **0.9946** | **0.832** |
|
| 1972 |
+
| gbm | 0.9749 | 0.540 | 0.9921 | 0.786 |
|
| 1973 |
+
|
| 1974 |
+
Scenario grouping inflates rf's margin over logistic from +0.008 to +0.023 AUC
|
| 1975 |
+
and from +0.023 to +0.194 rec12 - an 8x exaggeration of the tree advantage,
|
| 1976 |
+
because holding out a scenario leaves the same batteries in train and test.
|
| 1977 |
+
Levels are not comparable across columns (a battery fold ranks ~92 devices, a
|
| 1978 |
+
scenario fold ~430); the margin within a column is. Selection reads the battery
|
| 1979 |
+
column. This is the offline-screen counterpart of what all-48 already knew: the
|
| 1980 |
+
harness rejected `rankavg` (+55.3) after cross-fleet CV had confirmed it at
|
| 1981 |
+
-18.4, p=0.0002.
|
| 1982 |
+
|
| 1983 |
+
**3. `static`: the deployment gate, made measurable.**
|
| 1984 |
+
Fit ONE model on half the buildings' fully-closed pairs, plan every origin of
|
| 1985 |
+
the other half with it - no refits, no accumulating own history, cold-start
|
| 1986 |
+
floor off (82 foreign deaths keeps gate 20 permanently shut in deployment).
|
| 1987 |
+
That is submit.py's held-out branch. 11 s a seed, so variants are screened
|
| 1988 |
+
across 5 building splits, not one.
|
| 1989 |
+
|
| 1990 |
+
Its levels are NOT comparable to all48 (half the fleet, half the deaths, and
|
| 1991 |
+
the count rule scales with the fleet) and its appetite readings are known-bad -
|
| 1992 |
+
this construction manufactured the sum(p42) undercount that phase 20 refuted
|
| 1993 |
+
and phase 26 believed (public 1661 -> 2189). **Ordering only, variant vs
|
| 1994 |
+
variant.** Both facts are enforced in the printout.
|
| 1995 |
+
|
| 1996 |
+
### What the two instruments said about three feature variants
|
| 1997 |
+
|
| 1998 |
+
| variant | static gate (5 seeds, paired) | mechanism on the gate | all48 | verdict |
|
| 1999 |
+
|---|---|---|---|---|
|
| 2000 |
+
| `vrel` (+vrel, vmin7_rank) | **-24.6** +/- 9.3, W143/L66 | caught 460 -> 463, **sched 1176 -> 1060**, rec12/rec20 flat | **+138.7** | reject |
|
| 2001 |
+
| `vcorr` (+v_corr_last, thresh_dist) | -24.8 +/- 13.5, W115/L98 | caught 460 -> **478**, sched 1176 -> 1191, rec20 +0.007 | **+32.7** | reject |
|
| 2002 |
+
| `zone` (+z_days, z_in, z_rate) | +12.3, prec 0.391 -> 0.349 | sched 4.9 -> 5.6 | +104.9 (phase 34) | reject |
|
| 2003 |
+
|
| 2004 |
+
Champion unchanged. Two things are worth keeping from the exercise.
|
| 2005 |
+
|
| 2006 |
+
`vcorr`'s +32.7 reproduces the phase-34 measurement of +32.8 to within 0.1 - a
|
| 2007 |
+
third independent confirmation that the all-48 harness is deterministic and
|
| 2008 |
+
sound.
|
| 2009 |
+
|
| 2010 |
+
`vrel` is the discipline working. It won the gate by the widest margin of the
|
| 2011 |
+
three (W143/L66, 4 of 5 seeds) and it won it **entirely through the count**:
|
| 2012 |
+
116 fewer swaps for 3 more deaths, with rec12 and rec20 flat to three decimals.
|
| 2013 |
+
The gate's own docstring says a count reading from this protocol is invalid,
|
| 2014 |
+
and the native path then charged +138.7 for it. Had the gate been read the way
|
| 2015 |
+
phase 26 read its ancestor, this would have shipped.
|
| 2016 |
+
|
| 2017 |
+
### Latent deployment bug fixed: `unobserved_eol_days`
|
| 2018 |
+
|
| 2019 |
+
`cost_curves` and `plan_tsroute` hardcoded the evaluator's imputed-EOL offset
|
| 2020 |
+
as `+ 30.0` while the evaluator reads `settings.unobserved_eol_days`
|
| 2021 |
+
per-scenario - and submit.py imports `plan_tsroute` from this module, so the
|
| 2022 |
+
hardcode shipped. All 48 train scenarios use 30.0, which is precisely why no
|
| 2023 |
+
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.
|
|
@@ -0,0 +1,347 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Repo-resident CV protocols (ported from the blind research loop, 2026-08-21).
|
| 2 |
+
|
| 3 |
+
This replaces the ad-hoc `/tmp/all48*.py` scripts that every prior phase had to
|
| 4 |
+
re-derive, and folds in the three measurement disciplines the blind loop paid
|
| 5 |
+
real leaderboard points to learn.
|
| 6 |
+
|
| 7 |
+
**1. Report ranking precision at the operating N, not AUC.**
|
| 8 |
+
A model can hold AUC 0.947 and recall 0.000 on the subgroup that drives the
|
| 9 |
+
late cost (the voltage-silent deaths: median rank 60 of ~450). Mean cost
|
| 10 |
+
hides whether a change reordered the list or just cut it deeper. Every
|
| 11 |
+
protocol here prints rec12/rec20, the operating-point precision, and the
|
| 12 |
+
median rank of missed deaths beside the cost. Implemented in
|
| 13 |
+
`models_cv.rank_stats`, so `dev`/`panel`/`test` get it too.
|
| 14 |
+
|
| 15 |
+
**2. Group by BATTERY, not by scenario or row.**
|
| 16 |
+
Each battery contributes one near-duplicate row per closed origin (~3600
|
| 17 |
+
rows from ~460 batteries), so holding out a SCENARIO leaves the same
|
| 18 |
+
batteries in train and test and tree models score their own memorised
|
| 19 |
+
signatures. Measured in the blind loop: leave-scenario-out put HGB ahead of
|
| 20 |
+
linear (AUC .9907 vs .9843); leave-battery-out REVERSED it (.9769 vs .9845)
|
| 21 |
+
and the cost harness agreed with the clean split. The effective sample size
|
| 22 |
+
is ~460, not ~3600. `screen` runs both groupings side by side so the gap
|
| 23 |
+
stays visible.
|
| 24 |
+
|
| 25 |
+
**3. The deployment gate: would this fire in the static branch?**
|
| 26 |
+
The scored splits are fleets the model has never seen; on them `submit.py`
|
| 27 |
+
fits ONE model on the foreign train pool and never refits - no per-origin
|
| 28 |
+
refit, no accumulating own history, no cold start (the pool always holds 82
|
| 29 |
+
deaths, so every `train_deaths < 20` gate is permanently shut). The walk-
|
| 30 |
+
forward harness does the opposite. A mechanism can be worth tens of points
|
| 31 |
+
at `all48` and exactly zero where it is scored; that has already happened
|
| 32 |
+
once (a validated -49.8 warm-up mechanism, structurally unreachable in
|
| 33 |
+
deployment). `static` runs the deployment protocol locally on a foreign
|
| 34 |
+
half-fleet so the gate is measurable instead of argued.
|
| 35 |
+
|
| 36 |
+
Validity boundary, learned twice the hard way: the half-fleet construction
|
| 37 |
+
manufactures a sum(p42) undercount that does NOT exist on the real held-out
|
| 38 |
+
fleet (phase 20 refuted a foreign floor with it; phase 26 adopted
|
| 39 |
+
ALPHA_STATIC=1.5 from it and public went 1661 -> 2189). Use `static` for
|
| 40 |
+
ORDERING questions only. Never read an appetite/count decision off it.
|
| 41 |
+
|
| 42 |
+
Usage
|
| 43 |
+
venv/bin/python -m batteryswap_example.cv all48 [variant]
|
| 44 |
+
venv/bin/python -m batteryswap_example.cv static [variant]
|
| 45 |
+
venv/bin/python -m batteryswap_example.cv screen
|
| 46 |
+
"""
|
| 47 |
+
import sys
|
| 48 |
+
import time
|
| 49 |
+
from pathlib import Path
|
| 50 |
+
|
| 51 |
+
import numpy as np
|
| 52 |
+
import pandas as pd
|
| 53 |
+
|
| 54 |
+
from batteryswap_public.evaluate import evaluate_plan
|
| 55 |
+
|
| 56 |
+
from batteryswap_example import models_cv as mc
|
| 57 |
+
|
| 58 |
+
WINDOW_DAYS = int(mc.WINDOW_DAYS)
|
| 59 |
+
# pinned at import: variants build off this, never off the possibly
|
| 60 |
+
# already-mutated mc.FEATURES (a second seed used to append twice)
|
| 61 |
+
BASE_FEATURES = list(mc.FEATURES)
|
| 62 |
+
|
| 63 |
+
# The deployed champion, in the repo instead of in a /tmp script.
|
| 64 |
+
# Mirrors submit.py's rolling branch: logbag / tsroute alpha 0.8 + break-even
|
| 65 |
+
# top-up (ec_margin 0.8, ec_pmax 25) + 7d building split + censor-aware
|
| 66 |
+
# pricing + the gated cold-start floor (k=2.5, gate 20).
|
| 67 |
+
CHAMPION = dict(model='logbag', planner='tsroute', p_star=0.8, day_q=0.05,
|
| 68 |
+
ec_margin=0.8, ec_pmax=25.0, split_days=7,
|
| 69 |
+
floor=2.5, floor_gate=20, floor_zone=1)
|
| 70 |
+
|
| 71 |
+
BLOCKS = (('warmup s_0-7', lambda o: o <= 7),
|
| 72 |
+
('panel s_8-41', lambda o: (o >= 8) & (o <= 41)),
|
| 73 |
+
('nearend s_42-47', lambda o: o >= 42),
|
| 74 |
+
('ALL-48', lambda o: o >= 0))
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def load_table(dataset_path=Path('dataset')):
|
| 78 |
+
"""The one table build. Every protocol starts here so a variant can never
|
| 79 |
+
silently differ from the champion by a feature-prep line."""
|
| 80 |
+
t = mc.load_features(dataset_path)
|
| 81 |
+
t['v_proj'] = t['v_last'] + t['slope_lastk'] * t['staleness']
|
| 82 |
+
# within-origin fleet ranks: leak-free (data <= origin only)
|
| 83 |
+
t['r_vlast'] = t.groupby('origin_idx')['v_last'].rank(pct=True)
|
| 84 |
+
t['r_tau'] = t.groupby('origin_idx')['tau_projected'].rank(pct=True)
|
| 85 |
+
return t
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def scorecard(df, label):
|
| 89 |
+
"""Three-regime scorecard with ranking precision beside the cost."""
|
| 90 |
+
print(f'\n=== {label} ===')
|
| 91 |
+
hdr = (f"{'block':16s} {'n':>3s} {'total':>8s} {'late':>7s} {'early':>6s} "
|
| 92 |
+
f"{'rec12':>6s} {'rec20':>6s} {'recall':>6s} {'prec':>5s} "
|
| 93 |
+
f"{'sched':>5s} {'miss':>5s}")
|
| 94 |
+
print(hdr)
|
| 95 |
+
for name, sel in BLOCKS:
|
| 96 |
+
d = df[sel(df['origin'])]
|
| 97 |
+
if d.empty:
|
| 98 |
+
continue
|
| 99 |
+
deaths, caught = d['n_deaths'].sum(), d['caught'].sum()
|
| 100 |
+
sched = d['n_sched'].sum()
|
| 101 |
+
print(f"{name:16s} {len(d):3d} {d['total_cost'].mean():8.1f} "
|
| 102 |
+
f"{d['late_swap'].mean():7.1f} {d['early_swap'].mean():6.1f} "
|
| 103 |
+
f"{d['rec12'].mean():6.3f} {d['rec20'].mean():6.3f} "
|
| 104 |
+
f"{caught / max(deaths, 1):6.3f} {caught / max(sched, 1):5.3f} "
|
| 105 |
+
f"{d['n_sched'].mean():5.1f} "
|
| 106 |
+
f"{d['miss_rank'].median():5.1f}")
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
# --------------------------------------------------------------------------
|
| 110 |
+
# protocol 1: full-fleet walk-forward, s_0..s_47
|
| 111 |
+
# --------------------------------------------------------------------------
|
| 112 |
+
|
| 113 |
+
def run_all48(variant='base', origins=range(48), dataset_path=Path('dataset')):
|
| 114 |
+
t = load_table(dataset_path)
|
| 115 |
+
mc.FEATURES = list(VARIANTS[variant](t))
|
| 116 |
+
ctx = mc.load_context(dataset_path)
|
| 117 |
+
df = mc.walk_forward(t, ctx, origins, [dict(CHAMPION)])
|
| 118 |
+
df['variant'] = variant
|
| 119 |
+
return df
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
# --------------------------------------------------------------------------
|
| 123 |
+
# protocol 2: the deployment gate - one static fit on a foreign fleet
|
| 124 |
+
# --------------------------------------------------------------------------
|
| 125 |
+
|
| 126 |
+
def fleet_halves(locations, seed=0):
|
| 127 |
+
"""Split the 24 buildings into two disjoint fleets.
|
| 128 |
+
|
| 129 |
+
Split by BUILDING, not by device: rooms and buildings share install
|
| 130 |
+
batches and thermal environment, so a device-level split leaves the
|
| 131 |
+
target fleet's own siblings in the training pool and the fit is no longer
|
| 132 |
+
foreign in the way deployment's is.
|
| 133 |
+
"""
|
| 134 |
+
b = np.sort(locations['building'].unique())
|
| 135 |
+
rng = np.random.RandomState(seed)
|
| 136 |
+
perm = rng.permutation(len(b))
|
| 137 |
+
a = set(b[perm[:len(b) // 2]])
|
| 138 |
+
return a, set(b[perm[len(b) // 2:]])
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def run_static(variant='base', seed=0, origins=range(48),
|
| 142 |
+
dataset_path=Path('dataset')):
|
| 143 |
+
"""Deployment protocol: fit once on fleet A's fully-closed pairs, then plan
|
| 144 |
+
every one of fleet B's origins with that single model. No refits, no
|
| 145 |
+
accumulating own history, no cold-start gate - exactly submit.py's
|
| 146 |
+
held-out branch."""
|
| 147 |
+
t = load_table(dataset_path)
|
| 148 |
+
mc.FEATURES = list(VARIANTS[variant](t))
|
| 149 |
+
locations, timeseries, eol_times, scenarios = mc.load_dataset(
|
| 150 |
+
dataset_path / 'train')
|
| 151 |
+
fleet_a, fleet_b = fleet_halves(locations, seed)
|
| 152 |
+
dev_bldg = locations.drop_duplicates('battery').set_index('battery')['building']
|
| 153 |
+
t['bldg'] = t['device_id'].map(dev_bldg)
|
| 154 |
+
|
| 155 |
+
# the foreign pool: every fleet-A pair whose 42d window has closed by the
|
| 156 |
+
# end of the data. Deployment's pool is likewise fully adjudicated at
|
| 157 |
+
# build time and independent of the target origin.
|
| 158 |
+
closed = t['origin'] <= t['origin'].max() - pd.Timedelta(days=WINDOW_DAYS)
|
| 159 |
+
foreign = t[closed & t['bldg'].isin(fleet_a)]
|
| 160 |
+
model = mc.SklearnRUL(CHAMPION['model']).fit(foreign)
|
| 161 |
+
uniq = int(foreign.loc[foreign['dies42'] == 1, 'device_id'].nunique())
|
| 162 |
+
print(f'static foreign fit: {len(foreign)} pairs, {uniq} unique deaths, '
|
| 163 |
+
f'{len(fleet_a)} buildings | target fleet {len(fleet_b)} buildings',
|
| 164 |
+
file=sys.stderr)
|
| 165 |
+
|
| 166 |
+
p = mc.plan_tsroute
|
| 167 |
+
p.alpha, p.p_be, p.floor_day, p.p_conf = CHAMPION['p_star'], 0.0, 0, 0.5
|
| 168 |
+
p.ec_margin, p.ec_pmax = CHAMPION['ec_margin'], CHAMPION['ec_pmax']
|
| 169 |
+
p.split_days = CHAMPION['split_days']
|
| 170 |
+
# the cold-start floor is permanently shut in deployment (82 foreign
|
| 171 |
+
# deaths >= gate 20). Leaving it live here would measure a mechanism the
|
| 172 |
+
# scored path can never reach.
|
| 173 |
+
p.floor_scale, p.recent_deaths = 0.0, 0.0
|
| 174 |
+
|
| 175 |
+
recs = []
|
| 176 |
+
for idx, (scenario, locs, cut, eol) in enumerate(mc.iterate_scenarios(
|
| 177 |
+
locations, timeseries, eol_times, scenarios)):
|
| 178 |
+
if idx not in origins:
|
| 179 |
+
continue
|
| 180 |
+
T = pd.Timestamp(scenario['start_time'])
|
| 181 |
+
locs_b = locs[locs['building'].isin(fleet_b)]
|
| 182 |
+
rows = (t[t['origin_idx'] == idx].set_index('device_id')
|
| 183 |
+
.reindex(locs_b['battery']).dropna(subset=['v_last']))
|
| 184 |
+
if rows.empty:
|
| 185 |
+
continue
|
| 186 |
+
locs_b = locs_b[locs_b['battery'].isin(rows.index)]
|
| 187 |
+
# the evaluator prices unobserved EOLs off `locations`, so the eol
|
| 188 |
+
# series has to be cut to the target fleet too
|
| 189 |
+
eol_b = eol.reindex(locs_b['battery'])
|
| 190 |
+
pred = model.predict(rows)
|
| 191 |
+
plan = mc.plan_tsroute(pred, None, locs_b, T, CHAMPION['day_q'],
|
| 192 |
+
travel_costs=scenario['travel_costs'],
|
| 193 |
+
settings=scenario['settings'])
|
| 194 |
+
_, _, overall = evaluate_plan(plan, locs_b, scenario['travel_costs'],
|
| 195 |
+
scenario['settings'], eol_times=eol_b,
|
| 196 |
+
start_time=T, verbose=0)
|
| 197 |
+
rs = mc.rank_stats(pred, plan, eol_b, T)
|
| 198 |
+
recs.append(dict(overall, **rs, origin=idx, variant=variant, seed=seed))
|
| 199 |
+
print(f"s_{idx}: total {overall['total_cost']:7.1f} "
|
| 200 |
+
f"late {overall['late_swap']:6.1f} deaths {rs.get('n_deaths', 0):2d} "
|
| 201 |
+
f"caught {rs.get('caught', 0):2d}/{rs.get('n_sched', 0):2d} "
|
| 202 |
+
f"rec12 {rs.get('rec12', float('nan')):.2f}", flush=True,
|
| 203 |
+
file=sys.stderr)
|
| 204 |
+
return pd.DataFrame(recs)
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
# --------------------------------------------------------------------------
|
| 208 |
+
# protocol 3: offline model screen, grouped by battery
|
| 209 |
+
# --------------------------------------------------------------------------
|
| 210 |
+
|
| 211 |
+
def screen(kinds=('logistic', 'logbag', 'rf', 'gbm'), n_folds=5, seed=0,
|
| 212 |
+
dataset_path=Path('dataset')):
|
| 213 |
+
"""Leave-BATTERY-out vs leave-SCENARIO-out on the same pairs and the same
|
| 214 |
+
models. The gap between the two columns is the contamination; trust the
|
| 215 |
+
battery column. Headline is recall at the operating N, AUC secondary."""
|
| 216 |
+
from sklearn.metrics import roc_auc_score
|
| 217 |
+
|
| 218 |
+
t = load_table(dataset_path)
|
| 219 |
+
mc.FEATURES = list(BASE_FEATURES)
|
| 220 |
+
closed = t[t['origin'] <= t['origin'].max() - pd.Timedelta(days=WINDOW_DAYS)]
|
| 221 |
+
devs = np.sort(closed['device_id'].unique())
|
| 222 |
+
rng = np.random.RandomState(seed)
|
| 223 |
+
dev_fold = pd.Series(rng.permutation(len(devs)) % n_folds, index=devs)
|
| 224 |
+
org = np.sort(closed['origin_idx'].unique())
|
| 225 |
+
org_fold = pd.Series(np.arange(len(org)) % n_folds, index=org)
|
| 226 |
+
|
| 227 |
+
n_dev_pos = closed.loc[closed['dies42'] == 1, 'device_id'].nunique()
|
| 228 |
+
print(f'pairs {len(closed)} batteries {len(devs)} origins {len(org)} '
|
| 229 |
+
f'positives {int(closed["dies42"].sum())} rows / {n_dev_pos} batteries')
|
| 230 |
+
print('effective n is the BATTERY count, not the row count - regularisation '
|
| 231 |
+
'tuned against rows is ~8x too weak here.\n')
|
| 232 |
+
|
| 233 |
+
schemes = {'battery': closed['device_id'].map(dev_fold),
|
| 234 |
+
'scenario': closed['origin_idx'].map(org_fold)}
|
| 235 |
+
out = {}
|
| 236 |
+
for scheme, fold in schemes.items():
|
| 237 |
+
for kind in kinds:
|
| 238 |
+
aucs, r12, r20 = [], [], []
|
| 239 |
+
for f in range(n_folds):
|
| 240 |
+
tr, te = closed[fold != f], closed[fold == f]
|
| 241 |
+
if te['dies42'].sum() < 3:
|
| 242 |
+
continue
|
| 243 |
+
m = mc.SklearnRUL(kind).fit(tr)
|
| 244 |
+
p = m.predict(te)['p42']
|
| 245 |
+
aucs.append(roc_auc_score(te['dies42'], p))
|
| 246 |
+
# recall at the operating depth, per origin, scaled to the
|
| 247 |
+
# held-out fleet's size (12/461 of the fleet)
|
| 248 |
+
for oi, g in te.groupby('origin_idx'):
|
| 249 |
+
y = g['dies42'].values
|
| 250 |
+
if y.sum() == 0:
|
| 251 |
+
continue
|
| 252 |
+
n = max(1, int(round(12 * len(g) / 461)))
|
| 253 |
+
order = np.argsort(-p.loc[g.index].values)
|
| 254 |
+
r12.append(y[order[:n]].sum() / y.sum())
|
| 255 |
+
r20.append(y[order[:max(1, int(round(20 * len(g) / 461)))]].sum()
|
| 256 |
+
/ y.sum())
|
| 257 |
+
out[(scheme, kind)] = (np.mean(aucs), np.mean(r12), np.mean(r20))
|
| 258 |
+
|
| 259 |
+
# LEVELS ARE NOT COMPARABLE ACROSS COLUMNS. A battery fold holds ~1/5 of
|
| 260 |
+
# the fleet at every origin (recall at depth 12/461 means top-2 of ~92);
|
| 261 |
+
# a scenario fold holds the whole fleet at 1/5 of the origins (top-11 of
|
| 262 |
+
# ~430). Same depth fraction, very different discreteness. What IS
|
| 263 |
+
# comparable is each model's margin over the linear reference WITHIN a
|
| 264 |
+
# column - that margin is the thing a model-selection decision reads.
|
| 265 |
+
ref = kinds[0]
|
| 266 |
+
print(f"{'model':10s} " + ' '.join(f'{s + " " + m:>16s}' for s in schemes
|
| 267 |
+
for m in ('AUC', 'rec12')))
|
| 268 |
+
for kind in kinds:
|
| 269 |
+
cells = []
|
| 270 |
+
for s in schemes:
|
| 271 |
+
a, r, _ = out[(s, kind)]
|
| 272 |
+
cells += [f'{a:16.4f}', f'{r:16.3f}']
|
| 273 |
+
print(f'{kind:10s} ' + ' '.join(cells))
|
| 274 |
+
print(f'\nmargin over {ref} (the decision-relevant quantity):')
|
| 275 |
+
for kind in kinds[1:]:
|
| 276 |
+
cells = []
|
| 277 |
+
for s in schemes:
|
| 278 |
+
a, r, _ = out[(s, kind)]
|
| 279 |
+
a0, r0, _ = out[(s, ref)]
|
| 280 |
+
cells += [f'{a - a0:+16.4f}', f'{r - r0:+16.3f}']
|
| 281 |
+
print(f'{kind:10s} ' + ' '.join(cells))
|
| 282 |
+
print('\nA model whose margin shrinks or reverses when the grouping goes '
|
| 283 |
+
'from scenario to battery was reading memorised battery identity. '
|
| 284 |
+
'Selection follows the battery column.')
|
| 285 |
+
return out
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
# --------------------------------------------------------------------------
|
| 289 |
+
|
| 290 |
+
def _base(t):
|
| 291 |
+
return BASE_FEATURES
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def _zone(t):
|
| 295 |
+
dbz = t['days_below_245']
|
| 296 |
+
t['z_days'] = dbz.fillna(-1.0)
|
| 297 |
+
t['z_in'] = (dbz >= 0).astype(float)
|
| 298 |
+
t['z_rate'] = np.where(dbz >= 0, (2.45 - t['v_corr_last']) / np.clip(dbz, 1, None), 0.0)
|
| 299 |
+
return BASE_FEATURES + ['z_days', 'z_in', 'z_rate']
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
def _vcorr(t):
|
| 303 |
+
return BASE_FEATURES + ['v_corr_last', 'thresh_dist']
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
def _vrel(t):
|
| 307 |
+
# phase-38 fleet-relative features: a held-out fleet's absolute voltage
|
| 308 |
+
# scale is not the train fleet's, but its within-fleet ordering is
|
| 309 |
+
return BASE_FEATURES + [c for c in ('vrel', 'vmin7_rank')
|
| 310 |
+
if c in t.columns]
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
VARIANTS = {'base': _base, 'zone': _zone, 'vcorr': _vcorr, 'vrel': _vrel}
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
def main():
|
| 317 |
+
proto = sys.argv[1] if len(sys.argv) > 1 else 'all48'
|
| 318 |
+
variant = sys.argv[2] if len(sys.argv) > 2 else 'base'
|
| 319 |
+
t0 = time.time()
|
| 320 |
+
if proto == 'all48':
|
| 321 |
+
df = run_all48(variant)
|
| 322 |
+
df.to_csv(f'/tmp/cv_all48_{variant}.csv', index=False)
|
| 323 |
+
scorecard(df, f'all48 / {variant} / full-fleet walk-forward')
|
| 324 |
+
elif proto == 'static':
|
| 325 |
+
# one building split is one draw; a variant comparison needs several.
|
| 326 |
+
# 11s a seed, so there is no excuse for reading a single one.
|
| 327 |
+
seeds = range(int(sys.argv[3]) if len(sys.argv) > 3 else 5)
|
| 328 |
+
df = pd.concat([run_static(variant, seed=s) for s in seeds],
|
| 329 |
+
ignore_index=True)
|
| 330 |
+
df.to_csv(f'/tmp/cv_static_{variant}.csv', index=False)
|
| 331 |
+
for s in seeds:
|
| 332 |
+
scorecard(df[df['seed'] == s], f'static / {variant} / seed {s}')
|
| 333 |
+
scorecard(df, f'static / {variant} / DEPLOYMENT GATE, {len(seeds)} '
|
| 334 |
+
f'building splits pooled')
|
| 335 |
+
print('Levels are NOT comparable to all48 (half the fleet, half the '
|
| 336 |
+
'deaths, and the count rule scales with the fleet). Read this '
|
| 337 |
+
'protocol only as variant-vs-variant, ORDERING only - never for '
|
| 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]')
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
if __name__ == '__main__':
|
| 347 |
+
main()
|
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:4d7e31ddec4d82eec9e062848ed0c019811068947a3bd6ccf7e42d6f7d8e7376
|
| 3 |
+
size 6159081
|
|
@@ -112,6 +112,31 @@ def features_at_origin(daily: pd.DataFrame, devices: pd.DataFrame,
|
|
| 112 |
|
| 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):
|
|
|
|
| 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
|
| 127 |
+
# never selected; three independent measurements found that making those
|
| 128 |
+
# devices visible instead is a monotone loss. Do not add fillna() or
|
| 129 |
+
# na_option= to the rank.
|
| 130 |
+
#
|
| 131 |
+
# Population note: the denominator is whatever has history at or before the
|
| 132 |
+
# origin, dead-but-not-yet-dropped devices included. That is identical in
|
| 133 |
+
# the training-table build and in the runtime path, which is what matters -
|
| 134 |
+
# both sides see the same population definition.
|
| 135 |
+
h7 = h[h['day'] > origin - pd.Timedelta(days=7)]
|
| 136 |
+
feats['v_min_7'] = h7.groupby('device_id')['v'].min()
|
| 137 |
+
feats['vrel'] = feats['v_last'] - feats['v_last'].median()
|
| 138 |
+
feats['vmin7_rank'] = feats['v_min_7'].rank(pct=True)
|
| 139 |
+
|
| 140 |
install = devices.set_index('battery')['start_time'].dt.tz_localize(None)
|
| 141 |
eff_install = install.reindex(feats.index)
|
| 142 |
if swaps is not None and len(swaps):
|
|
@@ -23,7 +23,6 @@ Planners turn (p42, day_q) into a plan:
|
|
| 23 |
"""
|
| 24 |
|
| 25 |
from pathlib import Path
|
| 26 |
-
import itertools
|
| 27 |
import os
|
| 28 |
import sys
|
| 29 |
import warnings
|
|
@@ -47,6 +46,14 @@ FEATURES = ['v_last', 'v_drop', 'slope_30', 'slope_90', 'v_min_30',
|
|
| 47 |
'staleness', 'age_days',
|
| 48 |
'slope_lastk', 'drop_recent', 'below_frac', 'accel', 'n_30',
|
| 49 |
'tau_projected']
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
# vstd_30/rng_30 (plunge-precursor volatility, AUC 0.83/0.81 on the silent
|
| 51 |
# cohort) rejected as GLOBAL features: dev +17, test -0.7 - the signal is
|
| 52 |
# diluted across the healthy majority. Used instead by the SilentFix override.
|
|
@@ -1077,6 +1084,15 @@ EARLY, LATE = 0.5, 10.0
|
|
| 1077 |
# decisions at near-data-end origins and loses there - the underprice acts as
|
| 1078 |
# a regularizer against over-including marginal survivors. Keep 45.
|
| 1079 |
EMERGENCY_DAY = WINDOW_DAYS + 3
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1080 |
CENSOR_AWARE = True # price unobservable (post-data-end) deaths via imputation
|
| 1081 |
PARK_MOBILITY = 0.0 # extra cost per expected emergency (travel/overtime)
|
| 1082 |
|
|
@@ -1166,7 +1182,7 @@ def cost_curves(pred, locations, start_time, blend=0.0):
|
|
| 1166 |
# precede the window, and the evaluator then charges a swap as LATE at
|
| 1167 |
# 10/day - the old clip(lower=43) made exactly those devices look free
|
| 1168 |
e_far = ((end.reindex(pred.index) - start_time).dt.total_seconds() / 86400
|
| 1169 |
-
+
|
| 1170 |
|
| 1171 |
# censoring-aware: deaths after the split's data end are never in the
|
| 1172 |
# shipped eol_times, so the evaluator cannot charge late/emergency for
|
|
@@ -1418,6 +1434,9 @@ def plan_tsroute(pred, included, locations, start_time, day_q,
|
|
| 1418 |
floor_day - defers uncertain insurance swaps that would otherwise
|
| 1419 |
pay early-replacement for weeks [floor_day 0 = off]
|
| 1420 |
"""
|
|
|
|
|
|
|
|
|
|
| 1421 |
psum = pred['p42_sum'].iloc[0] if 'p42_sum' in pred else pred['p42'].sum()
|
| 1422 |
if plan_tsroute.floor_scale > 0:
|
| 1423 |
# cold-start inclusion floor: sum(p42) tracks prevalence only once the
|
|
@@ -1448,7 +1467,7 @@ def plan_tsroute(pred, included, locations, start_time, day_q,
|
|
| 1448 |
# counterfactual cheap-price CV shows -76 where wrong costs ~19.
|
| 1449 |
end = locations.set_index('battery')['end_time'].dt.tz_localize(None)
|
| 1450 |
e_far = ((end.reindex(pred.index) - start_time).dt.total_seconds() / 86400
|
| 1451 |
-
+
|
| 1452 |
# top-up only fresh devices (imputed EOL beyond the window): for stale
|
| 1453 |
# ones the survivor branch is a bookkeeping fiction - swapping "on the
|
| 1454 |
# imputed day" scores ~0 but buys nothing and costs real mobility
|
|
@@ -1799,7 +1818,8 @@ def walk_forward(table, scenario_ctx, origins, configs):
|
|
| 1799 |
_, _, overall = evaluate_plan(
|
| 1800 |
plan, ctx['locs'], ctx['travel_costs'], ctx['settings'],
|
| 1801 |
eol_times=ctx['eol'], start_time=origin_time, verbose=0)
|
| 1802 |
-
|
|
|
|
| 1803 |
|
| 1804 |
auc = {m: roc_auc_score(rows['dies42'], p['p42'])
|
| 1805 |
for m, p in preds.items() if rows['dies42'].sum() > 0}
|
|
@@ -1808,6 +1828,49 @@ def walk_forward(table, scenario_ctx, origins, configs):
|
|
| 1808 |
return pd.DataFrame(records)
|
| 1809 |
|
| 1810 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1811 |
def load_context(dataset_path):
|
| 1812 |
locations, timeseries, eol_times, scenarios = load_dataset(dataset_path / 'train')
|
| 1813 |
ctx = {}
|
|
@@ -1845,6 +1908,19 @@ def summarize(df, label):
|
|
| 1845 |
if ops:
|
| 1846 |
dec['ops'] = df.groupby('name')[ops].mean().sum(axis=1)
|
| 1847 |
print(dec.round(1).reindex(mean.index).to_string())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1848 |
return mean
|
| 1849 |
|
| 1850 |
|
|
|
|
| 23 |
"""
|
| 24 |
|
| 25 |
from pathlib import Path
|
|
|
|
| 26 |
import os
|
| 27 |
import sys
|
| 28 |
import warnings
|
|
|
|
| 46 |
'staleness', 'age_days',
|
| 47 |
'slope_lastk', 'drop_recent', 'below_frac', 'accel', 'n_30',
|
| 48 |
'tau_projected']
|
| 49 |
+
# NOT in FEATURES, deliberately: 'vrel', 'vmin7_rank'. features.py computes
|
| 50 |
+
# both (and v_min_7), and the training table carries them, so enabling them is
|
| 51 |
+
# a one-line change here. Held back from the pooling submission so that
|
| 52 |
+
# submission isolates ONE change and stays attributable - phase 28 stacked four
|
| 53 |
+
# and needed two further phases to untangle the damage. Evidence as of
|
| 54 |
+
# 2026-08-21 is also unsettled: vrel survived a leave-one-out ablation on the
|
| 55 |
+
# blind harness, vmin7_rank was ablated OUT (-12.8), and a static-fit gate
|
| 56 |
+
# showed the whole feature-set swap swinging +55 to -109 depending on fit pool.
|
| 57 |
# vstd_30/rng_30 (plunge-precursor volatility, AUC 0.83/0.81 on the silent
|
| 58 |
# cohort) rejected as GLOBAL features: dev +17, test -0.7 - the signal is
|
| 59 |
# diluted across the healthy majority. Used instead by the SilentFix override.
|
|
|
|
| 1084 |
# decisions at near-data-end origins and loses there - the underprice acts as
|
| 1085 |
# a regularizer against over-including marginal survivors. Keep 45.
|
| 1086 |
EMERGENCY_DAY = WINDOW_DAYS + 3
|
| 1087 |
+
# The evaluator prices a swap on a device with no observed EOL against
|
| 1088 |
+
# (location end_time + settings.unobserved_eol_days). That setting is
|
| 1089 |
+
# per-scenario and READ FROM THE SCENARIO at runtime; it was hardcoded to the
|
| 1090 |
+
# default 30.0 in two places here, and submit.py imports plan_tsroute from
|
| 1091 |
+
# this module, so the hardcode shipped. Every train scenario happens to use
|
| 1092 |
+
# 30.0, which is exactly why local CV could never catch it - same failure
|
| 1093 |
+
# shape as the hardcoded 42-day park that repark_for_window exists to undo.
|
| 1094 |
+
# plan_tsroute now overwrites this from the settings it is handed.
|
| 1095 |
+
UNOBSERVED_EOL_DAYS = 30.0
|
| 1096 |
CENSOR_AWARE = True # price unobservable (post-data-end) deaths via imputation
|
| 1097 |
PARK_MOBILITY = 0.0 # extra cost per expected emergency (travel/overtime)
|
| 1098 |
|
|
|
|
| 1182 |
# precede the window, and the evaluator then charges a swap as LATE at
|
| 1183 |
# 10/day - the old clip(lower=43) made exactly those devices look free
|
| 1184 |
e_far = ((end.reindex(pred.index) - start_time).dt.total_seconds() / 86400
|
| 1185 |
+
+ UNOBSERVED_EOL_DAYS).values
|
| 1186 |
|
| 1187 |
# censoring-aware: deaths after the split's data end are never in the
|
| 1188 |
# shipped eol_times, so the evaluator cannot charge late/emergency for
|
|
|
|
| 1434 |
floor_day - defers uncertain insurance swaps that would otherwise
|
| 1435 |
pay early-replacement for weeks [floor_day 0 = off]
|
| 1436 |
"""
|
| 1437 |
+
if settings is not None:
|
| 1438 |
+
global UNOBSERVED_EOL_DAYS
|
| 1439 |
+
UNOBSERVED_EOL_DAYS = float(settings.unobserved_eol_days)
|
| 1440 |
psum = pred['p42_sum'].iloc[0] if 'p42_sum' in pred else pred['p42'].sum()
|
| 1441 |
if plan_tsroute.floor_scale > 0:
|
| 1442 |
# cold-start inclusion floor: sum(p42) tracks prevalence only once the
|
|
|
|
| 1467 |
# counterfactual cheap-price CV shows -76 where wrong costs ~19.
|
| 1468 |
end = locations.set_index('battery')['end_time'].dt.tz_localize(None)
|
| 1469 |
e_far = ((end.reindex(pred.index) - start_time).dt.total_seconds() / 86400
|
| 1470 |
+
+ UNOBSERVED_EOL_DAYS).values
|
| 1471 |
# top-up only fresh devices (imputed EOL beyond the window): for stale
|
| 1472 |
# ones the survivor branch is a bookkeeping fiction - swapping "on the
|
| 1473 |
# imputed day" scores ~0 but buys nothing and costs real mobility
|
|
|
|
| 1818 |
_, _, overall = evaluate_plan(
|
| 1819 |
plan, ctx['locs'], ctx['travel_costs'], ctx['settings'],
|
| 1820 |
eol_times=ctx['eol'], start_time=origin_time, verbose=0)
|
| 1821 |
+
rs = rank_stats(pred, plan, ctx['eol'], origin_time)
|
| 1822 |
+
records.append(dict(overall, **rs, origin=idx, **cfg))
|
| 1823 |
|
| 1824 |
auc = {m: roc_auc_score(rows['dies42'], p['p42'])
|
| 1825 |
for m, p in preds.items() if rows['dies42'].sum() > 0}
|
|
|
|
| 1828 |
return pd.DataFrame(records)
|
| 1829 |
|
| 1830 |
|
| 1831 |
+
RANK_NS = (12, 20)
|
| 1832 |
+
|
| 1833 |
+
|
| 1834 |
+
def rank_stats(pred, plan, eol, start_time, order_col=None):
|
| 1835 |
+
"""Ranking precision at the operating point, per origin.
|
| 1836 |
+
|
| 1837 |
+
Measurement law #1 from the research loop: mean cost and AUC both hide
|
| 1838 |
+
whether a change moved the ORDER or just the depth of the cut. A model
|
| 1839 |
+
can hold AUC 0.947 and still have recall 0.000 on the subgroup that
|
| 1840 |
+
actually drives late cost. So every protocol here reports, alongside the
|
| 1841 |
+
cost:
|
| 1842 |
+
|
| 1843 |
+
rec12/rec20 ordering only - share of the window's deaths inside the
|
| 1844 |
+
top 12/20 of the score, independent of how many the
|
| 1845 |
+
planner chose to swap;
|
| 1846 |
+
n_sched what the planner actually bought (in-window swaps);
|
| 1847 |
+
caught/prec the operating point: deaths among those swaps, and the
|
| 1848 |
+
share of swaps that were real deaths;
|
| 1849 |
+
miss_rank median rank of the deaths that were missed - the number
|
| 1850 |
+
that says whether a miss was near the cut or invisible.
|
| 1851 |
+
"""
|
| 1852 |
+
order = pred[order_col] if order_col and order_col in pred.columns else (
|
| 1853 |
+
pred['rank_score'] if 'rank_score' in pred.columns else pred['p42'])
|
| 1854 |
+
order = order.sort_values(ascending=False)
|
| 1855 |
+
window_end = start_time + pd.Timedelta(days=WINDOW_DAYS)
|
| 1856 |
+
e = eol.reindex(order.index)
|
| 1857 |
+
deaths = set(e.index[(e > start_time) & (e <= window_end)])
|
| 1858 |
+
out = {'n_deaths': len(deaths)}
|
| 1859 |
+
if not deaths:
|
| 1860 |
+
return out
|
| 1861 |
+
ranks = {b: r for r, b in enumerate(order.index, start=1)}
|
| 1862 |
+
dr = sorted(ranks[b] for b in deaths if b in ranks)
|
| 1863 |
+
for n in RANK_NS:
|
| 1864 |
+
out[f'rec{n}'] = sum(1 for r in dr if r <= n) / len(dr)
|
| 1865 |
+
missed = [r for r in dr if r > max(RANK_NS)]
|
| 1866 |
+
out['miss_rank'] = float(np.median(missed)) if missed else np.nan
|
| 1867 |
+
sched = set(plan.loc[plan['day'] <= window_end, 'battery'])
|
| 1868 |
+
out['n_sched'] = len(sched)
|
| 1869 |
+
out['caught'] = len(sched & deaths)
|
| 1870 |
+
out['prec'] = out['caught'] / len(sched) if sched else np.nan
|
| 1871 |
+
out['recall'] = out['caught'] / len(deaths)
|
| 1872 |
+
return out
|
| 1873 |
+
|
| 1874 |
def load_context(dataset_path):
|
| 1875 |
locations, timeseries, eol_times, scenarios = load_dataset(dataset_path / 'train')
|
| 1876 |
ctx = {}
|
|
|
|
| 1908 |
if ops:
|
| 1909 |
dec['ops'] = df.groupby('name')[ops].mean().sum(axis=1)
|
| 1910 |
print(dec.round(1).reindex(mean.index).to_string())
|
| 1911 |
+
# ranking precision at the operating N (measurement law #1): cost alone
|
| 1912 |
+
# cannot say whether a change reordered the list or just cut it deeper
|
| 1913 |
+
rk = [c for c in ('rec12', 'rec20', 'recall', 'prec', 'n_sched',
|
| 1914 |
+
'miss_rank', 'n_deaths') if c in df.columns]
|
| 1915 |
+
if rk:
|
| 1916 |
+
g = df.groupby('name')
|
| 1917 |
+
r = g[rk].mean()
|
| 1918 |
+
# pool over origins rather than averaging per-origin ratios: a
|
| 1919 |
+
# 1-death origin must not outvote a 12-death one
|
| 1920 |
+
if {'caught', 'n_deaths', 'n_sched'} <= set(df.columns):
|
| 1921 |
+
r['recall'] = g['caught'].sum() / g['n_deaths'].sum()
|
| 1922 |
+
r['prec'] = g['caught'].sum() / g['n_sched'].sum()
|
| 1923 |
+
print(r.round(3).reindex(mean.index).to_string())
|
| 1924 |
return mean
|
| 1925 |
|
| 1926 |
|
|
@@ -1,10 +1,14 @@
|
|
| 1 |
"""
|
| 2 |
Adaptive submission generator.
|
| 3 |
|
| 4 |
-
Held-out splits (fleet not in the packaged train table):
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
Train-fleet splits: refit at every scenario origin on the split's own
|
| 10 |
self-masked history, identical to the walk-forward procedure validated
|
|
@@ -43,6 +47,49 @@ from batteryswap_example import models_cv
|
|
| 43 |
|
| 44 |
TRAIN_TABLE = Path(__file__).parent / 'data' / 'train_features.parquet'
|
| 45 |
GRID_STEP_DAYS = 4
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
MODEL_KIND = 'logbag'
|
| 47 |
# Inclusion appetite, tuned by local CV: alpha sweep 0.7-1.3 on dev has a clear
|
| 48 |
# minimum at 0.8 (beats every alternative on 11-12 of 16 origins), confirmed on
|
|
@@ -108,13 +155,21 @@ CLIFF_MIN_N = 20
|
|
| 108 |
|
| 109 |
|
| 110 |
def build_split_table(locations, eol_times, scen_starts, daily, dall, betas,
|
| 111 |
-
log=print):
|
| 112 |
-
"""(device, origin) training pairs from this split's own history.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
t0 = dall['day'].min() + pd.Timedelta(days=120)
|
| 114 |
-
grid = pd.date_range(t0, max(scen_starts), freq=f'{
|
| 115 |
|
| 116 |
rows = []
|
| 117 |
for origin in grid:
|
|
|
|
|
|
|
|
|
|
| 118 |
alive = eol_times.isna() | (eol_times > origin)
|
| 119 |
f = features_at_origin(daily, locations, origin).reindex(eol_times.index[alive])
|
| 120 |
|
|
@@ -206,6 +261,7 @@ def adaptive_split_plans(split, split_path, log=print, deadline=None):
|
|
| 206 |
if base is not None else 1.0)
|
| 207 |
|
| 208 |
static_model, static_uniq, cliff_counts = None, 0, None
|
|
|
|
| 209 |
if base is not None and own_fleet < 0.5:
|
| 210 |
# PHASE 24 (static fit): on a held-out split, per-origin refits with
|
| 211 |
# the target's own masked history are a statistical tie against ONE
|
|
@@ -220,6 +276,31 @@ def adaptive_split_plans(split, split_path, log=print, deadline=None):
|
|
| 220 |
log(f' static foreign fit: {len(foreign)} pairs, {static_uniq} unique '
|
| 221 |
f'deaths (own-fleet frac {own_fleet:.2f})')
|
| 222 |
cliff_counts = cliff_day_counts(timeseries) if CLIFF_SCALE != 1.0 else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
else:
|
| 224 |
# target split IS (mostly) the train fleet: keep the rolling
|
| 225 |
# walk-forward path - its own labels are the answer key and must stay
|
|
@@ -241,8 +322,8 @@ def adaptive_split_plans(split, split_path, log=print, deadline=None):
|
|
| 241 |
|
| 242 |
plans = []
|
| 243 |
fitted = {}
|
| 244 |
-
for scenario, locs, cut, eol in iterate_scenarios(
|
| 245 |
-
locations, timeseries, eol_times, scenarios):
|
| 246 |
T = pd.Timestamp(scenario['start_time'])
|
| 247 |
|
| 248 |
if deadline is not None and time.time() > deadline:
|
|
@@ -263,22 +344,42 @@ def adaptive_split_plans(split, split_path, log=print, deadline=None):
|
|
| 263 |
log(f' WARNING {scenario["name"]}: planning_window_days='
|
| 264 |
f'{stg.planning_window_days} != {WINDOW_DAYS}')
|
| 265 |
|
| 266 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
model, train_uniq = static_model, static_uniq
|
| 268 |
-
|
| 269 |
-
#
|
| 270 |
-
#
|
| 271 |
-
#
|
| 272 |
-
#
|
| 273 |
-
#
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
|
| 283 |
feats = features_at_origin(daily, locs, T)
|
| 284 |
past = betas[betas['week_end'] <= T]['beta']
|
|
|
|
| 1 |
"""
|
| 2 |
Adaptive submission generator.
|
| 3 |
|
| 4 |
+
Held-out splits (fleet not in the packaged train table): the foreign train pool
|
| 5 |
+
POOLED with the split's own closed-window pairs, refit on a cadence as the pool
|
| 6 |
+
grows. Phase 24b had switched the own-history half off after the half-fleet
|
| 7 |
+
holdout called static-vs-rolling a tie - the instrument later retired, and its
|
| 8 |
+
small target fleet distorts exactly this comparison. Degrades to the plain
|
| 9 |
+
static foreign fit (the prior champion) if the build or the refits run long;
|
| 10 |
+
never to the pickle, which is far below it. Set BATTERYSWAP_HELDOUT_POOL=0 to
|
| 11 |
+
restore the pure static behaviour.
|
| 12 |
|
| 13 |
Train-fleet splits: refit at every scenario origin on the split's own
|
| 14 |
self-masked history, identical to the walk-forward procedure validated
|
|
|
|
| 47 |
|
| 48 |
TRAIN_TABLE = Path(__file__).parent / 'data' / 'train_features.parquet'
|
| 49 |
GRID_STEP_DAYS = 4
|
| 50 |
+
# Held-out in-fleet pooling. The static branch (phase 24b) fits ONE model on
|
| 51 |
+
# the 82 foreign train deaths and discards the target split's own history
|
| 52 |
+
# entirely. But that split ships its own eol_times.csv, and every death with
|
| 53 |
+
# eol <= T - and every training pair whose 42d window CLOSED before T - was
|
| 54 |
+
# observable at planning time (iterate_scenarios reads the same file for the
|
| 55 |
+
# same reason). On a ~2x fleet those in-fleet pairs outnumber the foreign 82
|
| 56 |
+
# by the late origins, and they are the only signal that can correct a
|
| 57 |
+
# fleet-level shift the foreign fit never saw.
|
| 58 |
+
#
|
| 59 |
+
# Phase 24b measured static-vs-rolling as a tie and switched this off - on the
|
| 60 |
+
# half-fleet holdout, the instrument later retired precisely because its small
|
| 61 |
+
# target fleet distorts exactly this comparison.
|
| 62 |
+
#
|
| 63 |
+
# NOT LOCALLY MEASURABLE: the panel and all-48 run the ROLLING branch (the
|
| 64 |
+
# train fleet is its own fleet), so the static branch never executes locally
|
| 65 |
+
# and this change is byte-identical on every local metric by construction.
|
| 66 |
+
# It is gated on the phase-27 plumbing rehearsal instead, and on the fail-safe
|
| 67 |
+
# below - never on a local score, because there cannot be one.
|
| 68 |
+
#
|
| 69 |
+
# Runtime is the reason it was affordable to skip: build_split_table at the
|
| 70 |
+
# native 4-day grid was ~17 min for 461 devices, so ~35 for a 2x fleet, twice
|
| 71 |
+
# over, against a 30-minute hard limit. HELDOUT_GRID_STEP_DAYS coarsens the
|
| 72 |
+
# grid for this path only; the pairs it drops are pairs we do not have at all
|
| 73 |
+
# today. If the build overruns HELDOUT_BUILD_BUDGET_S we degrade to the plain
|
| 74 |
+
# static foreign fit - i.e. to the CURRENT CHAMPION - and never to the pickle
|
| 75 |
+
# fallback, which is far below it. Set HELDOUT_POOL=0 to disable.
|
| 76 |
+
HELDOUT_POOL = os.environ.get('BATTERYSWAP_HELDOUT_POOL', '1') != '0'
|
| 77 |
+
HELDOUT_GRID_STEP_DAYS = int(os.environ.get('BATTERYSWAP_HELDOUT_GRID', '16'))
|
| 78 |
+
HELDOUT_BUILD_BUDGET_S = float(os.environ.get('BATTERYSWAP_HELDOUT_BUDGET_S', '360'))
|
| 79 |
+
# Refit cadence. The rehearsal showed the table build is cheap (7s at grid 16d
|
| 80 |
+
# on 461 devices) and the PER-ORIGIN REFITS dominate (~4.5s each x 48). At a
|
| 81 |
+
# ~2x held-out fleet that is ~8 min/split, ~16 for both, against a 20-min
|
| 82 |
+
# budget - too close for comfort when the real fleet size is unobservable from
|
| 83 |
+
# here. Reusing one fit across REFIT_EVERY consecutive origins halves the cost
|
| 84 |
+
# for almost no information: consecutive origins are days apart and the pool
|
| 85 |
+
# grows by a handful of closed pairs between them. 1 = refit at every origin.
|
| 86 |
+
# MEASURED (Docker, 2x fleet of 922 devices/split, both splits): at every-2
|
| 87 |
+
# origins a split costs ~10 min and the pair lands at 20m11s - over budget, and
|
| 88 |
+
# the guard correctly degraded private from s_31 onward, which silently throws
|
| 89 |
+
# away the pooling benefit on a third of that split. Refits are ~23 s each at
|
| 90 |
+
# this fleet size and dominate everything (table build is 22 s). Every-4 halves
|
| 91 |
+
# them again: ~5.5 min/split, ~11 min for the pair, comfortably inside 20.
|
| 92 |
+
HELDOUT_REFIT_EVERY = int(os.environ.get('BATTERYSWAP_HELDOUT_REFIT_EVERY', '4'))
|
| 93 |
MODEL_KIND = 'logbag'
|
| 94 |
# Inclusion appetite, tuned by local CV: alpha sweep 0.7-1.3 on dev has a clear
|
| 95 |
# minimum at 0.8 (beats every alternative on 11-12 of 16 origins), confirmed on
|
|
|
|
| 155 |
|
| 156 |
|
| 157 |
def build_split_table(locations, eol_times, scen_starts, daily, dall, betas,
|
| 158 |
+
log=print, step_days=GRID_STEP_DAYS, deadline=None):
|
| 159 |
+
"""(device, origin) training pairs from this split's own history.
|
| 160 |
+
|
| 161 |
+
deadline: wall-clock epoch after which the build aborts with TimeoutError.
|
| 162 |
+
The held-out path passes one so an overrun degrades to the static foreign
|
| 163 |
+
fit (the current champion) instead of eating the whole split's budget.
|
| 164 |
+
"""
|
| 165 |
t0 = dall['day'].min() + pd.Timedelta(days=120)
|
| 166 |
+
grid = pd.date_range(t0, max(scen_starts), freq=f'{step_days}D')
|
| 167 |
|
| 168 |
rows = []
|
| 169 |
for origin in grid:
|
| 170 |
+
if deadline is not None and time.time() > deadline:
|
| 171 |
+
raise TimeoutError(
|
| 172 |
+
f'split-table build exceeded budget at {len(rows)}/{len(grid)} origins')
|
| 173 |
alive = eol_times.isna() | (eol_times > origin)
|
| 174 |
f = features_at_origin(daily, locations, origin).reindex(eol_times.index[alive])
|
| 175 |
|
|
|
|
| 261 |
if base is not None else 1.0)
|
| 262 |
|
| 263 |
static_model, static_uniq, cliff_counts = None, 0, None
|
| 264 |
+
pool = None
|
| 265 |
if base is not None and own_fleet < 0.5:
|
| 266 |
# PHASE 24 (static fit): on a held-out split, per-origin refits with
|
| 267 |
# the target's own masked history are a statistical tie against ONE
|
|
|
|
| 276 |
log(f' static foreign fit: {len(foreign)} pairs, {static_uniq} unique '
|
| 277 |
f'deaths (own-fleet frac {own_fleet:.2f})')
|
| 278 |
cliff_counts = cliff_day_counts(timeseries) if CLIFF_SCALE != 1.0 else None
|
| 279 |
+
|
| 280 |
+
# In-fleet pooling for the held-out path. Same label discipline as the
|
| 281 |
+
# rolling branch: own pairs enter only where the 42d window closed
|
| 282 |
+
# before T (asserted below), foreign pairs are a different fleet whose
|
| 283 |
+
# labels were all known when this submission was built. static_model
|
| 284 |
+
# stays live as the degradation target.
|
| 285 |
+
if HELDOUT_POOL:
|
| 286 |
+
t_build = time.time()
|
| 287 |
+
try:
|
| 288 |
+
own = build_split_table(
|
| 289 |
+
locations, eol_times, scen_starts, daily, dall, betas,
|
| 290 |
+
log=log, step_days=HELDOUT_GRID_STEP_DAYS,
|
| 291 |
+
deadline=t_build + HELDOUT_BUILD_BUDGET_S)
|
| 292 |
+
own['foreign'] = False
|
| 293 |
+
f2 = foreign[np.intersect1d(foreign.columns, own.columns)].copy()
|
| 294 |
+
f2['foreign'] = True
|
| 295 |
+
pool = pd.concat([own, f2], ignore_index=True)
|
| 296 |
+
own_uniq = int(own.loc[own['dies42'] == 1, 'device_id'].nunique())
|
| 297 |
+
log(f' in-fleet pooling ON: +{len(own)} own pairs, {own_uniq} '
|
| 298 |
+
f'own deaths vs {static_uniq} foreign '
|
| 299 |
+
f'({time.time()-t_build:.0f}s, grid {HELDOUT_GRID_STEP_DAYS}d)')
|
| 300 |
+
except Exception as e:
|
| 301 |
+
# degrade to the CURRENT CHAMPION, never to the pickle
|
| 302 |
+
log(f' WARNING in-fleet pooling skipped ({e}); '
|
| 303 |
+
f'staying on the static foreign fit')
|
| 304 |
else:
|
| 305 |
# target split IS (mostly) the train fleet: keep the rolling
|
| 306 |
# walk-forward path - its own labels are the answer key and must stay
|
|
|
|
| 322 |
|
| 323 |
plans = []
|
| 324 |
fitted = {}
|
| 325 |
+
for si, (scenario, locs, cut, eol) in enumerate(iterate_scenarios(
|
| 326 |
+
locations, timeseries, eol_times, scenarios)):
|
| 327 |
T = pd.Timestamp(scenario['start_time'])
|
| 328 |
|
| 329 |
if deadline is not None and time.time() > deadline:
|
|
|
|
| 344 |
log(f' WARNING {scenario["name"]}: planning_window_days='
|
| 345 |
f'{stg.planning_window_days} != {WINDOW_DAYS}')
|
| 346 |
|
| 347 |
+
# Mid-loop degradation: if the per-origin refits are eating the split
|
| 348 |
+
# budget, drop to the static foreign fit for the REMAINING origins
|
| 349 |
+
# rather than letting the loop-level deadline raise, which lands in the
|
| 350 |
+
# pickle fallback - far below the static champion.
|
| 351 |
+
if (pool is not None and static_model is not None and deadline is not None
|
| 352 |
+
and time.time() > deadline - 4 * 60):
|
| 353 |
+
log(f' {scenario["name"]}: refit budget low - degrading to the '
|
| 354 |
+
f'static foreign fit for the remaining origins')
|
| 355 |
+
pool = None
|
| 356 |
+
|
| 357 |
+
if pool is None:
|
| 358 |
+
# no in-fleet pairs (pooling off, build overran, or budget low)
|
| 359 |
model, train_uniq = static_model, static_uniq
|
| 360 |
+
else:
|
| 361 |
+
# Cadence applies to the HELD-OUT path only (static_model set).
|
| 362 |
+
# The train path keeps its per-T key so the all-48 checksum is
|
| 363 |
+
# untouched. Reusing a bucket's fit at a later origin only ever
|
| 364 |
+
# uses FEWER closed pairs than were available - conservative, never
|
| 365 |
+
# a leak.
|
| 366 |
+
fkey = (si // HELDOUT_REFIT_EVERY) if static_model is not None else T
|
| 367 |
+
if fkey not in fitted:
|
| 368 |
+
# LABEL INTEGRITY: own pairs only where the window closed
|
| 369 |
+
# before T (the split's eol_times is its answer key). Foreign
|
| 370 |
+
# train-fleet pairs are used in full: masking them was CV
|
| 371 |
+
# hygiene misapplied to deployment, starving early origins to
|
| 372 |
+
# 3-20 unique training deaths out of the 82 available (the
|
| 373 |
+
# cold-start mechanism). This is the assertion that keeps the
|
| 374 |
+
# held-out path honest now that it reads the split's own
|
| 375 |
+
# eol_times: every own label used was observable at T.
|
| 376 |
+
closed = pool['origin'] <= T - pd.Timedelta(days=WINDOW_DAYS)
|
| 377 |
+
train = pool[closed | pool['foreign']]
|
| 378 |
+
own_pairs = train[~train['foreign']]
|
| 379 |
+
assert (own_pairs['origin'] + pd.Timedelta(days=WINDOW_DAYS) <= T).all()
|
| 380 |
+
uniq = int(train.loc[train['dies42'] == 1, 'device_id'].nunique())
|
| 381 |
+
fitted[fkey] = (SklearnRUL(MODEL_KIND).fit(train), uniq)
|
| 382 |
+
model, train_uniq = fitted[fkey]
|
| 383 |
|
| 384 |
feats = features_at_origin(daily, locs, T)
|
| 385 |
past = betas[betas['week_end'] <= T]['beta']
|