CarlAlbertCode commited on
Commit
3888717
·
1 Parent(s): 1a5021f

Restore the proven src tree: drop 15 unused modules from submission 010

Browse files
.gitignore CHANGED
@@ -32,3 +32,4 @@ submission_artifacts/*
32
  # Local training logs
33
  catboost_info/
34
  submission.csv
 
 
32
  # Local training logs
33
  catboost_info/
34
  submission.csv
35
+ !submission_artifacts/weekly_planner.joblib
src/batteryswapai/audit.py DELETED
@@ -1,92 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import numpy as np
4
- import pandas as pd
5
-
6
- from .schema import InferredSchema, leakage_columns
7
-
8
-
9
- def _safe_quantiles(series: pd.Series) -> dict[str, float | None]:
10
- values = pd.to_numeric(series, errors="coerce").dropna()
11
- if values.empty:
12
- return {"p01": None, "p10": None, "p50": None, "p90": None, "p99": None}
13
- quantiles = values.quantile([0.01, 0.10, 0.50, 0.90, 0.99])
14
- return {
15
- "p01": float(quantiles.loc[0.01]),
16
- "p10": float(quantiles.loc[0.10]),
17
- "p50": float(quantiles.loc[0.50]),
18
- "p90": float(quantiles.loc[0.90]),
19
- "p99": float(quantiles.loc[0.99]),
20
- }
21
-
22
-
23
- def audit_longitudinal_table(df: pd.DataFrame, schema: InferredSchema) -> dict:
24
- report: dict = {
25
- "rows": int(len(df)),
26
- "columns": int(len(df.columns)),
27
- "possible_leakage_columns": leakage_columns(df, schema.target),
28
- }
29
-
30
- if schema.entity_id:
31
- counts = df.groupby(schema.entity_id, dropna=False).size()
32
- report["entities"] = {
33
- "count": int(counts.size),
34
- "rows_per_entity": _safe_quantiles(counts),
35
- }
36
-
37
- if schema.timestamp:
38
- timestamps = pd.to_datetime(df[schema.timestamp], errors="coerce", utc=True)
39
- valid = timestamps.dropna()
40
- if not valid.empty:
41
- report["time"] = {
42
- "start": valid.min().isoformat(),
43
- "end": valid.max().isoformat(),
44
- "span_days": float((valid.max() - valid.min()).total_seconds() / 86400.0),
45
- }
46
-
47
- if schema.entity_id:
48
- ordered = pd.DataFrame({"entity": df[schema.entity_id], "time": timestamps}).dropna()
49
- ordered = ordered.sort_values(["entity", "time"])
50
- cadence = ordered.groupby("entity", sort=False)["time"].diff().dt.total_seconds() / 3600.0
51
- report.setdefault("time", {})["cadence_hours"] = _safe_quantiles(cadence)
52
-
53
- if schema.target:
54
- target = pd.to_numeric(df[schema.target], errors="coerce")
55
- report["target"] = {
56
- "non_null": int(target.notna().sum()),
57
- "quantiles": _safe_quantiles(target),
58
- "negative_count": int((target < 0).sum()),
59
- "zero_count": int((target == 0).sum()),
60
- }
61
- if schema.entity_id and schema.timestamp:
62
- temp = pd.DataFrame(
63
- {
64
- "entity": df[schema.entity_id],
65
- "time": pd.to_datetime(df[schema.timestamp], errors="coerce", utc=True),
66
- "target": target,
67
- }
68
- ).dropna().sort_values(["entity", "time"])
69
- delta = temp.groupby("entity", sort=False)["target"].diff()
70
- report["target"]["delta_quantiles"] = _safe_quantiles(delta)
71
- report["target"]["fraction_increasing"] = float((delta > 1e-9).mean()) if delta.notna().any() else None
72
-
73
- if schema.voltage:
74
- voltage = pd.to_numeric(df[schema.voltage], errors="coerce")
75
- report["voltage"] = {"quantiles": _safe_quantiles(voltage)}
76
- if schema.entity_id and schema.timestamp:
77
- temp = pd.DataFrame(
78
- {
79
- "entity": df[schema.entity_id],
80
- "time": pd.to_datetime(df[schema.timestamp], errors="coerce", utc=True),
81
- "voltage": voltage,
82
- }
83
- ).dropna().sort_values(["entity", "time"])
84
- jumps = temp.groupby("entity", sort=False)["voltage"].diff()
85
- report["voltage"]["jump_quantiles"] = _safe_quantiles(jumps)
86
- report["voltage"]["jumps_over_0_25v"] = int((jumps >= 0.25).sum())
87
- report["voltage"]["jumps_over_0_35v"] = int((jumps >= 0.35).sum())
88
-
89
- if schema.site_id:
90
- report["sites"] = int(df[schema.site_id].nunique(dropna=True))
91
-
92
- return report
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/batteryswapai/cli.py DELETED
@@ -1,77 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import argparse
4
- import json
5
- from pathlib import Path
6
-
7
- from .audit import audit_longitudinal_table
8
- from .config import load_config
9
- from .io import download_dataset, load_named_table
10
- from .pipeline import inspect_dataset, run_cv
11
- from .schema import resolve_schema
12
- from .synthetic import make_synthetic_dataset
13
-
14
-
15
- def _parser() -> argparse.ArgumentParser:
16
- parser = argparse.ArgumentParser(prog="bsa")
17
- sub = parser.add_subparsers(dest="command", required=True)
18
-
19
- download = sub.add_parser("download", help="Download the public Hugging Face dataset")
20
- download.add_argument("--config", default="configs/default.yaml")
21
-
22
- inspect = sub.add_parser("inspect", help="Inspect local tables and infer semantic columns")
23
- inspect.add_argument("--config", default="configs/default.yaml")
24
- inspect.add_argument("--table", default=None)
25
-
26
- cv = sub.add_parser("cv", help="Run leakage-safe grouped cross validation")
27
- cv.add_argument("--config", default="configs/default.yaml")
28
- cv.add_argument("--table", default=None)
29
-
30
- audit = sub.add_parser("audit", help="Audit longitudinal structure, cadence, target and voltage resets")
31
- audit.add_argument("--config", default="configs/default.yaml")
32
- audit.add_argument("--table", default=None)
33
-
34
- synthetic = sub.add_parser("synthetic", help="Generate a local smoke-test dataset")
35
- synthetic.add_argument("--output", default="data/raw/synthetic.csv")
36
- synthetic.add_argument("--devices", type=int, default=24)
37
- synthetic.add_argument("--days", type=int, default=180)
38
- return parser
39
-
40
-
41
- def main() -> None:
42
- args = _parser().parse_args()
43
- if args.command == "download":
44
- config = load_config(args.config)
45
- path = download_dataset(
46
- config["dataset"]["repo_id"],
47
- config["dataset"]["data_dir"],
48
- config["dataset"].get("revision", "main"),
49
- )
50
- print(path)
51
- return
52
-
53
- if args.command == "inspect":
54
- print(json.dumps(inspect_dataset(args.config, args.table), indent=2, default=str))
55
- return
56
-
57
- if args.command == "cv":
58
- print(json.dumps(run_cv(args.config, args.table), indent=2))
59
- return
60
-
61
- if args.command == "audit":
62
- config = load_config(args.config)
63
- _, frame = load_named_table(config["dataset"]["data_dir"], args.table)
64
- schema = resolve_schema(frame, config["schema"])
65
- print(json.dumps(audit_longitudinal_table(frame, schema), indent=2, default=str))
66
- return
67
-
68
- if args.command == "synthetic":
69
- output = Path(args.output)
70
- output.parent.mkdir(parents=True, exist_ok=True)
71
- make_synthetic_dataset(args.devices, args.days).to_csv(output, index=False)
72
- print(output)
73
- return
74
-
75
-
76
- if __name__ == "__main__":
77
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/batteryswapai/config.py DELETED
@@ -1,11 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from pathlib import Path
4
- from typing import Any
5
-
6
- import yaml
7
-
8
-
9
- def load_config(path: str | Path) -> dict[str, Any]:
10
- with Path(path).open("r", encoding="utf-8") as handle:
11
- return yaml.safe_load(handle)
 
 
 
 
 
 
 
 
 
 
 
 
src/batteryswapai/features.py DELETED
@@ -1,225 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import math
4
- from dataclasses import dataclass
5
-
6
- import numpy as np
7
- import pandas as pd
8
-
9
- from .schema import leakage_columns
10
-
11
-
12
- SIGNAL_NAME_WEIGHTS = (
13
- "battery",
14
- "voltage",
15
- "vbat",
16
- "temperature",
17
- "temp",
18
- "rssi",
19
- "signal",
20
- "current",
21
- "power",
22
- "humidity",
23
- )
24
-
25
-
26
- @dataclass(frozen=True)
27
- class FeatureResult:
28
- frame: pd.DataFrame
29
- feature_columns: list[str]
30
-
31
-
32
- def _signal_score(name: str) -> int:
33
- lowered = name.lower()
34
- return sum(max(1, 20 - index) for index, token in enumerate(SIGNAL_NAME_WEIGHTS) if token in lowered)
35
-
36
-
37
- def choose_numeric_signals(
38
- df: pd.DataFrame,
39
- exclude: set[str],
40
- max_signals: int,
41
- ) -> list[str]:
42
- candidates = []
43
- for column in df.columns:
44
- if column in exclude or not pd.api.types.is_numeric_dtype(df[column]):
45
- continue
46
- variance = float(pd.to_numeric(df[column], errors="coerce").var(skipna=True) or 0.0)
47
- if not np.isfinite(variance) or variance <= 0:
48
- continue
49
- candidates.append((column, _signal_score(column), variance))
50
- candidates.sort(key=lambda item: (item[1], math.log1p(item[2])), reverse=True)
51
- return [column for column, _, _ in candidates[:max_signals]]
52
-
53
-
54
- def daily_panel(
55
- df: pd.DataFrame,
56
- entity_col: str,
57
- timestamp_col: str,
58
- numeric_signals: list[str],
59
- passthrough_cols: list[str] | None = None,
60
- ) -> pd.DataFrame:
61
- passthrough_cols = passthrough_cols or []
62
- frame = df.copy()
63
- frame[timestamp_col] = pd.to_datetime(frame[timestamp_col], errors="coerce", utc=True)
64
- frame = frame.dropna(subset=[entity_col, timestamp_col]).sort_values([entity_col, timestamp_col])
65
- frame["_day"] = frame[timestamp_col].dt.floor("D")
66
-
67
- aggregation: dict[str, str] = {column: "mean" for column in numeric_signals}
68
- for column in passthrough_cols:
69
- if column in frame.columns and column not in aggregation:
70
- aggregation[column] = "last"
71
- panel = frame.groupby([entity_col, "_day"], as_index=False, sort=False).agg(aggregation)
72
- counts = frame.groupby([entity_col, "_day"], sort=False).size().rename("observations_day").reset_index()
73
- panel = panel.merge(counts, on=[entity_col, "_day"], how="left")
74
- return panel.rename(columns={"_day": timestamp_col})
75
-
76
-
77
- def _add_calendar_lags(
78
- panel: pd.DataFrame,
79
- entity_col: str,
80
- timestamp_col: str,
81
- signals: list[str],
82
- lags_days: tuple[int, ...],
83
- ) -> tuple[pd.DataFrame, list[str]]:
84
- result = panel.copy()
85
- feature_columns: list[str] = []
86
- right = result[[entity_col, timestamp_col, *signals]].copy()
87
- right = right.rename(columns={timestamp_col: "_source_time"})
88
- right = right.sort_values(["_source_time", entity_col])
89
-
90
- for lag in lags_days:
91
- left = result[[entity_col, timestamp_col]].copy()
92
- left["_row_id"] = np.arange(len(left))
93
- left["_lookup_time"] = left[timestamp_col] - pd.Timedelta(days=lag)
94
- left = left.sort_values(["_lookup_time", entity_col])
95
- tolerance_days = max(1, min(3, lag // 7 if lag >= 7 else 1))
96
-
97
- matched = pd.merge_asof(
98
- left,
99
- right,
100
- left_on="_lookup_time",
101
- right_on="_source_time",
102
- by=entity_col,
103
- direction="backward",
104
- tolerance=pd.Timedelta(days=tolerance_days),
105
- ).sort_values("_row_id")
106
-
107
- actual_age_col = f"lag_age_{lag}d"
108
- source_time = pd.to_datetime(matched["_source_time"], errors="coerce", utc=True).reset_index(drop=True)
109
- current_time = result[timestamp_col].reset_index(drop=True)
110
- result[actual_age_col] = (current_time - source_time).dt.total_seconds() / 86400.0
111
- feature_columns.append(actual_age_col)
112
-
113
- for signal in signals:
114
- lag_col = f"{signal}__lag_{lag}d"
115
- delta_col = f"{signal}__delta_{lag}d"
116
- rate_col = f"{signal}__rate_{lag}d"
117
- result[lag_col] = matched[signal].to_numpy()
118
- result[delta_col] = result[signal] - result[lag_col]
119
- denominator = result[actual_age_col].replace(0.0, np.nan)
120
- result[rate_col] = result[delta_col] / denominator
121
- feature_columns.extend([lag_col, delta_col, rate_col])
122
-
123
- return result, feature_columns
124
-
125
-
126
- def _add_time_rolling_features(
127
- panel: pd.DataFrame,
128
- entity_col: str,
129
- timestamp_col: str,
130
- signals: list[str],
131
- windows_days: tuple[int, ...],
132
- ) -> tuple[pd.DataFrame, list[str]]:
133
- result = panel.copy()
134
- feature_columns: list[str] = []
135
- index = pd.MultiIndex.from_arrays(
136
- [result[entity_col], result[timestamp_col]],
137
- names=[entity_col, timestamp_col],
138
- )
139
-
140
- for window in windows_days:
141
- min_periods = max(2, window // 3)
142
- stats = (
143
- result.groupby(entity_col, sort=False)
144
- .rolling(f"{window}D", on=timestamp_col, min_periods=min_periods)[signals]
145
- .agg(["mean", "std", "min", "max"])
146
- )
147
- for signal in signals:
148
- for stat in ("mean", "std", "min", "max"):
149
- column = f"{signal}__{stat}_{window}d"
150
- result[column] = stats[(signal, stat)].reindex(index).to_numpy()
151
- feature_columns.append(column)
152
-
153
- return result, feature_columns
154
-
155
-
156
- def build_features(
157
- df: pd.DataFrame,
158
- entity_col: str,
159
- timestamp_col: str,
160
- target_col: str | None = None,
161
- site_col: str | None = None,
162
- max_signals: int = 12,
163
- lags_days: tuple[int, ...] = (1, 3, 7, 14, 30, 60, 90),
164
- windows_days: tuple[int, ...] = (3, 7, 14, 30, 60, 90),
165
- min_history_days: int = 7,
166
- include_entity_id: bool = False,
167
- include_site_id: bool = True,
168
- ) -> FeatureResult:
169
- blocked = set(leakage_columns(df, target_col)) | {entity_col, timestamp_col}
170
- if site_col:
171
- blocked.add(site_col)
172
- signals = choose_numeric_signals(df, blocked, max_signals=max_signals)
173
- if not signals:
174
- raise ValueError("No usable numeric sensor signals were found")
175
-
176
- passthrough = [column for column in (target_col, site_col) if column]
177
- panel = daily_panel(df, entity_col, timestamp_col, signals, passthrough)
178
- panel = panel.sort_values([entity_col, timestamp_col]).reset_index(drop=True)
179
-
180
- grouped = panel.groupby(entity_col, sort=False)
181
- first_seen = grouped[timestamp_col].transform("min")
182
- panel["age_days"] = (panel[timestamp_col] - first_seen).dt.total_seconds() / 86400.0
183
- panel["days_since_prev_observation"] = (
184
- grouped[timestamp_col].diff().dt.total_seconds() / 86400.0
185
- )
186
- panel["dow"] = panel[timestamp_col].dt.dayofweek.astype("int8")
187
- panel["month_sin"] = np.sin(2 * np.pi * panel[timestamp_col].dt.month / 12.0)
188
- panel["month_cos"] = np.cos(2 * np.pi * panel[timestamp_col].dt.month / 12.0)
189
-
190
- feature_columns = [
191
- "age_days",
192
- "days_since_prev_observation",
193
- "dow",
194
- "month_sin",
195
- "month_cos",
196
- "observations_day",
197
- *signals,
198
- ]
199
-
200
- panel, lag_features = _add_calendar_lags(
201
- panel,
202
- entity_col=entity_col,
203
- timestamp_col=timestamp_col,
204
- signals=signals,
205
- lags_days=lags_days,
206
- )
207
- feature_columns.extend(lag_features)
208
-
209
- panel, rolling_features = _add_time_rolling_features(
210
- panel,
211
- entity_col=entity_col,
212
- timestamp_col=timestamp_col,
213
- signals=signals,
214
- windows_days=windows_days,
215
- )
216
- feature_columns.extend(rolling_features)
217
-
218
- if include_entity_id:
219
- feature_columns.append(entity_col)
220
- if include_site_id and site_col and site_col in panel.columns:
221
- feature_columns.append(site_col)
222
-
223
- panel = panel[panel["age_days"] >= min_history_days].copy()
224
- feature_columns = [column for column in feature_columns if column in panel.columns]
225
- return FeatureResult(frame=panel, feature_columns=feature_columns)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/batteryswapai/io.py DELETED
@@ -1,63 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from pathlib import Path
4
-
5
- import pandas as pd
6
- from huggingface_hub import snapshot_download
7
-
8
- SUPPORTED_SUFFIXES = {".csv", ".parquet", ".json", ".jsonl", ".feather"}
9
-
10
-
11
- def download_dataset(repo_id: str, data_dir: str | Path, revision: str = "main") -> Path:
12
- destination = Path(data_dir)
13
- destination.mkdir(parents=True, exist_ok=True)
14
- snapshot_download(
15
- repo_id=repo_id,
16
- repo_type="dataset",
17
- revision=revision,
18
- local_dir=destination,
19
- )
20
- return destination
21
-
22
-
23
- def discover_tables(data_dir: str | Path) -> list[Path]:
24
- root = Path(data_dir)
25
- if not root.exists():
26
- return []
27
- return sorted(path for path in root.rglob("*") if path.is_file() and path.suffix.lower() in SUPPORTED_SUFFIXES)
28
-
29
-
30
- def read_table(path: str | Path) -> pd.DataFrame:
31
- path = Path(path)
32
- suffix = path.suffix.lower()
33
- if suffix == ".csv":
34
- return pd.read_csv(path)
35
- if suffix == ".parquet":
36
- return pd.read_parquet(path)
37
- if suffix == ".json":
38
- try:
39
- return pd.read_json(path)
40
- except ValueError:
41
- return pd.read_json(path, lines=True)
42
- if suffix == ".jsonl":
43
- return pd.read_json(path, lines=True)
44
- if suffix == ".feather":
45
- return pd.read_feather(path)
46
- raise ValueError(f"Unsupported data file: {path}")
47
-
48
-
49
- def load_named_table(data_dir: str | Path, name_hint: str | None = None) -> tuple[Path, pd.DataFrame]:
50
- tables = discover_tables(data_dir)
51
- if not tables:
52
- raise FileNotFoundError(f"No supported tables found under {data_dir}")
53
-
54
- if name_hint:
55
- matches = [path for path in tables if name_hint.lower() in path.name.lower()]
56
- if len(matches) == 1:
57
- return matches[0], read_table(matches[0])
58
- if len(matches) > 1:
59
- matches.sort(key=lambda path: path.stat().st_size, reverse=True)
60
- return matches[0], read_table(matches[0])
61
-
62
- tables.sort(key=lambda path: path.stat().st_size, reverse=True)
63
- return tables[0], read_table(tables[0])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/batteryswapai/lifecycles.py DELETED
@@ -1,65 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import numpy as np
4
- import pandas as pd
5
-
6
-
7
- def assign_lifecycles(
8
- df: pd.DataFrame,
9
- entity_col: str,
10
- timestamp_col: str,
11
- voltage_col: str,
12
- reset_jump_volts: float = 0.35,
13
- min_points: int = 14,
14
- min_duration_days: int = 7,
15
- ) -> pd.DataFrame:
16
- frame = df.copy()
17
- frame[timestamp_col] = pd.to_datetime(frame[timestamp_col], errors="coerce", utc=True)
18
- frame = frame.dropna(subset=[entity_col, timestamp_col]).sort_values([entity_col, timestamp_col])
19
-
20
- voltage = pd.to_numeric(frame[voltage_col], errors="coerce")
21
- jump = voltage.groupby(frame[entity_col], sort=False).diff()
22
- replacement = jump >= reset_jump_volts
23
- episode = replacement.groupby(frame[entity_col], sort=False).cumsum().astype("int64")
24
- frame["lifecycle_id"] = frame[entity_col].astype(str) + "::" + episode.astype(str)
25
-
26
- stats = frame.groupby("lifecycle_id", sort=False).agg(
27
- lifecycle_points=(timestamp_col, "size"),
28
- lifecycle_start=(timestamp_col, "min"),
29
- lifecycle_end=(timestamp_col, "max"),
30
- )
31
- stats["lifecycle_duration_days"] = (
32
- (stats["lifecycle_end"] - stats["lifecycle_start"]).dt.total_seconds() / 86400.0
33
- )
34
- valid_ids = stats.index[
35
- (stats["lifecycle_points"] >= min_points)
36
- & (stats["lifecycle_duration_days"] >= min_duration_days)
37
- ]
38
- return frame[frame["lifecycle_id"].isin(valid_ids)].copy()
39
-
40
-
41
- def add_rul_labels(
42
- df: pd.DataFrame,
43
- entity_col: str,
44
- timestamp_col: str,
45
- lifecycle_col: str = "lifecycle_id",
46
- censor_last_episode: bool = True,
47
- ) -> pd.DataFrame:
48
- frame = df.copy()
49
- frame[timestamp_col] = pd.to_datetime(frame[timestamp_col], errors="coerce", utc=True)
50
- ends = frame.groupby(lifecycle_col, sort=False)[timestamp_col].transform("max")
51
- frame["rul_days"] = (ends - frame[timestamp_col]).dt.total_seconds() / 86400.0
52
- frame["event_observed"] = 1
53
-
54
- if censor_last_episode:
55
- last_lifecycle = (
56
- frame.groupby(entity_col, sort=False)[lifecycle_col]
57
- .last()
58
- .astype(str)
59
- .to_dict()
60
- )
61
- is_last = frame.apply(lambda row: str(row[lifecycle_col]) == last_lifecycle.get(row[entity_col]), axis=1)
62
- frame.loc[is_last, "event_observed"] = 0
63
- frame.loc[is_last, "rul_days"] = np.nan
64
-
65
- return frame
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/batteryswapai/metrics.py DELETED
@@ -1,15 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import numpy as np
4
- from sklearn.metrics import mean_absolute_error, mean_squared_error
5
-
6
-
7
- def regression_metrics(y_true, y_pred) -> dict[str, float]:
8
- y_true = np.asarray(y_true, dtype=float)
9
- y_pred = np.asarray(y_pred, dtype=float)
10
- return {
11
- "mae": float(mean_absolute_error(y_true, y_pred)),
12
- "rmse": float(np.sqrt(mean_squared_error(y_true, y_pred))),
13
- "bias": float(np.mean(y_pred - y_true)),
14
- "p90_abs_error": float(np.quantile(np.abs(y_pred - y_true), 0.90)),
15
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/batteryswapai/models.py DELETED
@@ -1,151 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from dataclasses import dataclass
4
- from typing import Any
5
-
6
- import numpy as np
7
- import pandas as pd
8
- from sklearn.ensemble import ExtraTreesRegressor, HistGradientBoostingRegressor
9
-
10
- from .metrics import regression_metrics
11
- from .validation import group_folds
12
-
13
-
14
- @dataclass
15
- class TrainedEnsemble:
16
- histogram: HistGradientBoostingRegressor
17
- extra_trees: ExtraTreesRegressor
18
- feature_columns: list[str]
19
- categorical_columns: list[str]
20
- encoded_columns: list[str]
21
- fill_values: pd.Series
22
- weights: tuple[float, float] = (0.5, 0.5)
23
-
24
- def predict(self, frame: pd.DataFrame) -> np.ndarray:
25
- features, _, _ = encode_features(
26
- frame[self.feature_columns],
27
- self.categorical_columns,
28
- encoded_columns=self.encoded_columns,
29
- fill_values=self.fill_values,
30
- )
31
- histogram_prediction = self.histogram.predict(features)
32
- tree_prediction = self.extra_trees.predict(features)
33
- prediction = self.weights[0] * histogram_prediction + self.weights[1] * tree_prediction
34
- return np.clip(prediction, 0.0, None)
35
-
36
-
37
- def _categorical_columns(frame: pd.DataFrame, feature_columns: list[str]) -> list[str]:
38
- categorical = []
39
- for column in feature_columns:
40
- dtype = frame[column].dtype
41
- if (
42
- pd.api.types.is_object_dtype(dtype)
43
- or pd.api.types.is_string_dtype(dtype)
44
- or isinstance(dtype, pd.CategoricalDtype)
45
- ):
46
- categorical.append(column)
47
- return categorical
48
-
49
-
50
- def encode_features(
51
- frame: pd.DataFrame,
52
- categorical_columns: list[str],
53
- *,
54
- encoded_columns: list[str] | None = None,
55
- fill_values: pd.Series | None = None,
56
- ) -> tuple[pd.DataFrame, list[str], pd.Series]:
57
- result = frame.copy()
58
- for column in categorical_columns:
59
- if column in result:
60
- result[column] = result[column].astype("string").fillna("__missing__")
61
- result = pd.get_dummies(result, columns=categorical_columns, dummy_na=False, dtype=float)
62
- result = result.apply(pd.to_numeric, errors="coerce").replace([np.inf, -np.inf], np.nan)
63
- if encoded_columns is None:
64
- encoded_columns = result.columns.tolist()
65
- result = result.reindex(columns=encoded_columns)
66
- if fill_values is None:
67
- fill_values = result.median(numeric_only=True).fillna(0.0)
68
- result = result.fillna(fill_values).fillna(0.0).astype(float)
69
- return result, encoded_columns, fill_values
70
-
71
-
72
- def _estimators(model_config: dict[str, Any], seed_offset: int = 0):
73
- histogram_config = {
74
- "loss": "absolute_error",
75
- "learning_rate": 0.055,
76
- "max_iter": 320,
77
- "max_leaf_nodes": 31,
78
- "min_samples_leaf": 25,
79
- "l2_regularization": 2.0,
80
- "random_state": 2026 + seed_offset,
81
- **model_config.get("hist_gradient_boosting", {}),
82
- }
83
- extra_config = {
84
- "n_estimators": 280,
85
- "min_samples_leaf": 3,
86
- "max_features": 0.8,
87
- "n_jobs": -1,
88
- "random_state": 2026 + seed_offset,
89
- **model_config.get("extra_trees", {}),
90
- }
91
- return HistGradientBoostingRegressor(**histogram_config), ExtraTreesRegressor(**extra_config)
92
-
93
-
94
- def cross_validate(
95
- frame: pd.DataFrame,
96
- feature_columns: list[str],
97
- target_col: str,
98
- group_col: str,
99
- model_config: dict[str, Any],
100
- folds: int = 5,
101
- ) -> tuple[pd.DataFrame, dict[str, float]]:
102
- work = frame.dropna(subset=[target_col]).reset_index(drop=True).copy()
103
- categorical = _categorical_columns(work, feature_columns)
104
- target = pd.to_numeric(work[target_col], errors="coerce").astype(float)
105
-
106
- oof = np.full(len(work), np.nan, dtype=float)
107
- for fold_number, fold in enumerate(group_folds(work, group_col=group_col, folds=folds)):
108
- histogram, extra_trees = _estimators(model_config, seed_offset=fold_number)
109
- train_frame = work.iloc[fold.train_idx][feature_columns]
110
- valid_frame = work.iloc[fold.valid_idx][feature_columns]
111
- x_train, encoded_columns, fill_values = encode_features(train_frame, categorical)
112
- x_valid, _, _ = encode_features(
113
- valid_frame,
114
- categorical,
115
- encoded_columns=encoded_columns,
116
- fill_values=fill_values,
117
- )
118
- y_train = target.iloc[fold.train_idx]
119
- histogram.fit(x_train, y_train)
120
- extra_trees.fit(x_train, y_train)
121
- prediction = 0.5 * histogram.predict(x_valid) + 0.5 * extra_trees.predict(x_valid)
122
- oof[fold.valid_idx] = np.clip(prediction, 0.0, None)
123
-
124
- metrics = regression_metrics(target, oof)
125
- output = work[[target_col, group_col]].copy()
126
- output["prediction"] = oof
127
- output["abs_error"] = np.abs(output["prediction"] - output[target_col])
128
- return output, metrics
129
-
130
-
131
- def train_ensemble(
132
- frame: pd.DataFrame,
133
- feature_columns: list[str],
134
- target_col: str,
135
- model_config: dict[str, Any],
136
- ) -> TrainedEnsemble:
137
- work = frame.dropna(subset=[target_col]).copy()
138
- categorical = _categorical_columns(work, feature_columns)
139
- features, encoded_columns, fill_values = encode_features(work[feature_columns], categorical)
140
- target = pd.to_numeric(work[target_col], errors="coerce").astype(float)
141
- histogram, extra_trees = _estimators(model_config)
142
- histogram.fit(features, target)
143
- extra_trees.fit(features, target)
144
- return TrainedEnsemble(
145
- histogram,
146
- extra_trees,
147
- feature_columns,
148
- categorical,
149
- encoded_columns,
150
- fill_values,
151
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/batteryswapai/pipeline.py DELETED
@@ -1,74 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import json
4
- from pathlib import Path
5
-
6
- import pandas as pd
7
-
8
- from .config import load_config
9
- from .features import build_features
10
- from .io import load_named_table
11
- from .models import cross_validate, train_ensemble
12
- from .schema import describe_table, leakage_columns, resolve_schema
13
-
14
-
15
- def inspect_dataset(config_path: str | Path, table_hint: str | None = None) -> dict:
16
- config = load_config(config_path)
17
- path, frame = load_named_table(config["dataset"]["data_dir"], table_hint)
18
- schema = resolve_schema(frame, config["schema"])
19
- report = {
20
- "file": str(path),
21
- "table": describe_table(frame),
22
- "schema": schema.as_dict(),
23
- "possible_leakage_columns": leakage_columns(frame, schema.target),
24
- }
25
- return report
26
-
27
-
28
- def prepare_training_frame(config_path: str | Path, table_hint: str | None = None):
29
- config = load_config(config_path)
30
- path, frame = load_named_table(config["dataset"]["data_dir"], table_hint)
31
- schema = resolve_schema(frame, config["schema"])
32
- if not schema.timestamp or not schema.entity_id:
33
- raise ValueError("Could not infer timestamp and entity ID. Set them in configs/default.yaml")
34
- if not schema.target:
35
- raise ValueError("Could not infer an RUL target. Set schema.target or construct lifecycle labels first")
36
-
37
- feature_cfg = config["features"]
38
- result = build_features(
39
- frame,
40
- entity_col=schema.entity_id,
41
- timestamp_col=schema.timestamp,
42
- target_col=schema.target,
43
- site_col=schema.site_id,
44
- max_signals=int(feature_cfg["max_signals"]),
45
- lags_days=tuple(feature_cfg["lags_days"]),
46
- windows_days=tuple(feature_cfg["windows_days"]),
47
- min_history_days=int(feature_cfg["min_history_days"]),
48
- include_entity_id=bool(feature_cfg["include_entity_id"]),
49
- include_site_id=bool(feature_cfg["include_site_id"]),
50
- )
51
- return config, path, schema, result
52
-
53
-
54
- def run_cv(config_path: str | Path, table_hint: str | None = None) -> dict:
55
- config, _, schema, result = prepare_training_frame(config_path, table_hint)
56
- oof, metrics = cross_validate(
57
- result.frame,
58
- result.feature_columns,
59
- target_col=schema.target,
60
- group_col=schema.entity_id,
61
- model_config=config["model"],
62
- folds=int(config["validation"]["folds"]),
63
- )
64
- artifacts = Path("artifacts")
65
- artifacts.mkdir(exist_ok=True)
66
- oof.to_csv(artifacts / "oof_predictions.csv", index=False)
67
- (artifacts / "cv_metrics.json").write_text(json.dumps(metrics, indent=2), encoding="utf-8")
68
- return metrics
69
-
70
-
71
- def fit_final(config_path: str | Path, table_hint: str | None = None):
72
- config, _, schema, result = prepare_training_frame(config_path, table_hint)
73
- model = train_ensemble(result.frame, result.feature_columns, schema.target, config["model"])
74
- return model, schema, result
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/batteryswapai/scheduling.py DELETED
@@ -1,81 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from dataclasses import dataclass
4
-
5
- import numpy as np
6
- import pandas as pd
7
-
8
-
9
- @dataclass(frozen=True)
10
- class ScheduleConfig:
11
- failure_cost: float = 100.0
12
- early_replacement_cost_per_day: float = 0.08
13
- visit_cost: float = 15.0
14
- max_replacements_per_day: int = 25
15
- horizon_days: int = 120
16
-
17
-
18
- def expected_replacement_cost(
19
- replacement_day: int,
20
- failure_samples: np.ndarray,
21
- config: ScheduleConfig,
22
- ) -> float:
23
- failures_before_visit = failure_samples < replacement_day
24
- failure_cost = config.failure_cost * failures_before_visit.mean()
25
- unused_life = np.maximum(failure_samples - replacement_day, 0.0)
26
- early_cost = config.early_replacement_cost_per_day * unused_life.mean()
27
- return float(failure_cost + early_cost)
28
-
29
-
30
- def choose_replacement_days(
31
- predictions: pd.DataFrame,
32
- id_col: str,
33
- median_col: str = "rul_days",
34
- uncertainty_fraction: float = 0.25,
35
- config: ScheduleConfig | None = None,
36
- seed: int = 2026,
37
- ) -> pd.DataFrame:
38
- config = config or ScheduleConfig()
39
- rng = np.random.default_rng(seed)
40
- rows = []
41
-
42
- for _, row in predictions.iterrows():
43
- median = max(float(row[median_col]), 0.1)
44
- sigma = max(median * uncertainty_fraction, 1.0)
45
- samples = np.clip(rng.normal(median, sigma, size=2000), 0.0, None)
46
- costs = [expected_replacement_cost(day, samples, config) for day in range(config.horizon_days + 1)]
47
- best_day = int(np.argmin(costs))
48
- rows.append({id_col: row[id_col], "replacement_day": best_day, "expected_risk_cost": costs[best_day]})
49
-
50
- schedule = pd.DataFrame(rows).sort_values(["replacement_day", "expected_risk_cost"], ascending=[True, False])
51
- scheduled = []
52
- day_load: dict[int, int] = {}
53
- for _, row in schedule.iterrows():
54
- day = int(row["replacement_day"])
55
- while day_load.get(day, 0) >= config.max_replacements_per_day and day < config.horizon_days:
56
- day += 1
57
- day_load[day] = day_load.get(day, 0) + 1
58
- record = row.to_dict()
59
- record["replacement_day"] = day
60
- scheduled.append(record)
61
- return pd.DataFrame(scheduled).sort_values("replacement_day").reset_index(drop=True)
62
-
63
-
64
- def group_site_visits(
65
- schedule: pd.DataFrame,
66
- site_col: str,
67
- tolerance_days: int = 3,
68
- ) -> pd.DataFrame:
69
- if site_col not in schedule.columns:
70
- return schedule
71
- result = schedule.copy().sort_values([site_col, "replacement_day"])
72
- for _, index in result.groupby(site_col, sort=False).groups.items():
73
- positions = list(index)
74
- anchor = int(result.loc[positions[0], "replacement_day"])
75
- for position in positions[1:]:
76
- current = int(result.loc[position, "replacement_day"])
77
- if current - anchor <= tolerance_days:
78
- result.loc[position, "replacement_day"] = anchor
79
- else:
80
- anchor = current
81
- return result.sort_values("replacement_day").reset_index(drop=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/batteryswapai/schema.py DELETED
@@ -1,127 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from dataclasses import dataclass, asdict
4
- from typing import Iterable
5
-
6
- import numpy as np
7
- import pandas as pd
8
-
9
-
10
- ROLE_HINTS = {
11
- "timestamp": ("timestamp", "datetime", "recorded_at", "created_at", "time", "date"),
12
- "entity_id": ("sensor_id", "device_id", "serial_number", "serial", "sensor", "device", "uuid", "mac"),
13
- "target": ("remaining_useful_life", "remaining_life", "rul_days", "rul", "remaining_days", "target"),
14
- "voltage": ("battery_voltage", "voltage", "vbat", "battery_v", "volt"),
15
- "site_id": ("building_id", "site_id", "location_id", "building", "site", "location"),
16
- "event": ("battery_replaced", "replacement", "replaced", "failure", "failed", "event"),
17
- }
18
-
19
- FUTURE_NAME_FRAGMENTS = (
20
- "rul",
21
- "remaining",
22
- "future",
23
- "failure_time",
24
- "failure_date",
25
- "end_time",
26
- "end_date",
27
- "next_replacement",
28
- )
29
-
30
-
31
- def _norm(value: str) -> str:
32
- return "".join(char.lower() if char.isalnum() else "_" for char in value).strip("_")
33
-
34
-
35
- def _score_name(column: str, hints: Iterable[str]) -> int:
36
- normalized = _norm(column)
37
- score = 0
38
- for rank, hint in enumerate(hints):
39
- if normalized == hint:
40
- score = max(score, 100 - rank)
41
- elif hint in normalized:
42
- score = max(score, 60 - rank)
43
- return score
44
-
45
-
46
- @dataclass(frozen=True)
47
- class InferredSchema:
48
- timestamp: str | None
49
- entity_id: str | None
50
- target: str | None
51
- voltage: str | None
52
- site_id: str | None
53
- event: str | None
54
-
55
- def as_dict(self) -> dict[str, str | None]:
56
- return asdict(self)
57
-
58
-
59
- def infer_schema(df: pd.DataFrame) -> InferredSchema:
60
- candidates: dict[str, str | None] = {}
61
- for role, hints in ROLE_HINTS.items():
62
- ranked = sorted(
63
- ((column, _score_name(column, hints)) for column in df.columns),
64
- key=lambda item: item[1],
65
- reverse=True,
66
- )
67
- candidates[role] = ranked[0][0] if ranked and ranked[0][1] > 0 else None
68
-
69
- if candidates["timestamp"] is None:
70
- for column in df.columns:
71
- if pd.api.types.is_datetime64_any_dtype(df[column]):
72
- candidates["timestamp"] = column
73
- break
74
-
75
- if candidates["entity_id"] is None:
76
- object_columns = [
77
- column
78
- for column in df.columns
79
- if pd.api.types.is_object_dtype(df[column]) or isinstance(df[column].dtype, pd.CategoricalDtype)
80
- ]
81
- if object_columns:
82
- ratios = []
83
- n = max(len(df), 1)
84
- for column in object_columns:
85
- unique_ratio = df[column].nunique(dropna=True) / n
86
- score = 1.0 - abs(unique_ratio - 0.05)
87
- ratios.append((column, score))
88
- candidates["entity_id"] = max(ratios, key=lambda item: item[1])[0]
89
-
90
- return InferredSchema(**candidates)
91
-
92
-
93
- def resolve_schema(df: pd.DataFrame, config: dict) -> InferredSchema:
94
- inferred = infer_schema(df)
95
- resolved = {}
96
- for role in ROLE_HINTS:
97
- configured = config.get(role, "auto")
98
- value = getattr(inferred, role) if configured in (None, "auto") else configured
99
- if value is not None and value not in df.columns:
100
- raise ValueError(f"Configured {role} column '{value}' does not exist")
101
- resolved[role] = value
102
- return InferredSchema(**resolved)
103
-
104
-
105
- def leakage_columns(df: pd.DataFrame, target: str | None = None) -> list[str]:
106
- blocked: list[str] = []
107
- for column in df.columns:
108
- normalized = _norm(column)
109
- if target and column == target:
110
- blocked.append(column)
111
- continue
112
- if any(fragment in normalized for fragment in FUTURE_NAME_FRAGMENTS):
113
- blocked.append(column)
114
- return sorted(set(blocked))
115
-
116
-
117
- def describe_table(df: pd.DataFrame) -> dict:
118
- return {
119
- "rows": int(len(df)),
120
- "columns": int(len(df.columns)),
121
- "duplicate_rows": int(df.duplicated().sum()),
122
- "missing_fraction": {
123
- column: float(value)
124
- for column, value in df.isna().mean().sort_values(ascending=False).head(15).items()
125
- },
126
- "dtypes": {column: str(dtype) for column, dtype in df.dtypes.items()},
127
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/batteryswapai/submission.py DELETED
@@ -1,19 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from pathlib import Path
4
-
5
- import pandas as pd
6
-
7
-
8
- def write_predictions(
9
- ids: pd.Series,
10
- predictions,
11
- output_path: str | Path,
12
- id_name: str,
13
- prediction_name: str = "rul_days",
14
- ) -> Path:
15
- path = Path(output_path)
16
- path.parent.mkdir(parents=True, exist_ok=True)
17
- output = pd.DataFrame({id_name: ids.to_numpy(), prediction_name: predictions})
18
- output.to_csv(path, index=False)
19
- return path
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/batteryswapai/synthetic.py DELETED
@@ -1,40 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import numpy as np
4
- import pandas as pd
5
-
6
-
7
- def make_synthetic_dataset(
8
- n_devices: int = 24,
9
- days: int = 180,
10
- seed: int = 2026,
11
- ) -> pd.DataFrame:
12
- rng = np.random.default_rng(seed)
13
- start = pd.Timestamp("2025-01-01", tz="UTC")
14
- rows = []
15
-
16
- for device in range(n_devices):
17
- lifetime = int(rng.integers(days // 2, days + 60))
18
- base_voltage = rng.normal(3.62, 0.025)
19
- temperature_base = rng.normal(21.0, 3.0)
20
- site = f"site_{device % 5}"
21
- for day in range(days):
22
- timestamp = start + pd.Timedelta(days=day)
23
- health = max(0.0, 1.0 - day / lifetime)
24
- knee = 1.0 / (1.0 + np.exp(-(day - 0.82 * lifetime) / 7.0))
25
- voltage = base_voltage - 0.10 * (1.0 - health) - 0.48 * knee + rng.normal(0, 0.012)
26
- temperature = temperature_base + 5.0 * np.sin(2 * np.pi * day / 365.0) + rng.normal(0, 1.0)
27
- observations = max(1, int(rng.poisson(24)))
28
- rul = max(float(lifetime - day), 0.0)
29
- rows.append(
30
- {
31
- "device_id": f"device_{device:03d}",
32
- "site_id": site,
33
- "timestamp": timestamp,
34
- "battery_voltage": voltage,
35
- "temperature": temperature,
36
- "message_count": observations,
37
- "rul_days": rul,
38
- }
39
- )
40
- return pd.DataFrame(rows)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/batteryswapai/uncertainty.py DELETED
@@ -1,91 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from dataclasses import dataclass
4
-
5
- import numpy as np
6
- import pandas as pd
7
- from sklearn.ensemble import HistGradientBoostingRegressor
8
-
9
- from .models import _categorical_columns, encode_features
10
-
11
-
12
- @dataclass
13
- class QuantileRULModel:
14
- models: dict[float, HistGradientBoostingRegressor]
15
- feature_columns: list[str]
16
- categorical_columns: list[str]
17
- encoded_columns: list[str]
18
- fill_values: pd.Series
19
-
20
- def predict(self, frame: pd.DataFrame) -> pd.DataFrame:
21
- features, _, _ = encode_features(
22
- frame[self.feature_columns],
23
- self.categorical_columns,
24
- encoded_columns=self.encoded_columns,
25
- fill_values=self.fill_values,
26
- )
27
- output = {}
28
- for quantile, model in sorted(self.models.items()):
29
- output[f"q{int(round(quantile * 100)):02d}"] = np.clip(model.predict(features), 0.0, None)
30
- result = pd.DataFrame(output, index=frame.index)
31
- columns = list(result.columns)
32
- result[columns] = np.maximum.accumulate(result[columns].to_numpy(), axis=1)
33
- return result
34
-
35
-
36
- def fit_quantile_models(
37
- frame: pd.DataFrame,
38
- feature_columns: list[str],
39
- target_col: str,
40
- categorical_columns: list[str] | None = None,
41
- quantiles: tuple[float, ...] = (0.1, 0.5, 0.9),
42
- iterations: int = 700,
43
- depth: int = 8,
44
- learning_rate: float = 0.04,
45
- seed: int = 2026,
46
- ) -> QuantileRULModel:
47
- work = frame.dropna(subset=[target_col]).copy()
48
- categorical_columns = categorical_columns or _categorical_columns(work, feature_columns)
49
- features, encoded_columns, fill_values = encode_features(
50
- work[feature_columns], categorical_columns
51
- )
52
- target = pd.to_numeric(work[target_col], errors="coerce").astype(float)
53
- models = {}
54
- for quantile in quantiles:
55
- model = HistGradientBoostingRegressor(
56
- loss="quantile",
57
- quantile=quantile,
58
- max_iter=iterations,
59
- max_leaf_nodes=max(7, 2**depth - 1),
60
- learning_rate=learning_rate,
61
- l2_regularization=2.0,
62
- random_state=seed,
63
- )
64
- model.fit(features, target)
65
- models[quantile] = model
66
- return QuantileRULModel(
67
- models,
68
- feature_columns,
69
- categorical_columns,
70
- encoded_columns,
71
- fill_values,
72
- )
73
-
74
-
75
- def conformal_radius(y_true, y_pred, coverage: float = 0.9) -> float:
76
- residuals = np.abs(np.asarray(y_true, dtype=float) - np.asarray(y_pred, dtype=float))
77
- residuals = residuals[np.isfinite(residuals)]
78
- if residuals.size == 0:
79
- raise ValueError("No finite residuals available for conformal calibration")
80
- return float(np.quantile(residuals, coverage, method="higher"))
81
-
82
-
83
- def add_conformal_interval(predictions, radius: float) -> pd.DataFrame:
84
- median = np.asarray(predictions, dtype=float)
85
- return pd.DataFrame(
86
- {
87
- "lower": np.clip(median - radius, 0.0, None),
88
- "median": np.clip(median, 0.0, None),
89
- "upper": np.clip(median + radius, 0.0, None),
90
- }
91
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/batteryswapai/validation.py DELETED
@@ -1,38 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from dataclasses import dataclass
4
-
5
- import numpy as np
6
- import pandas as pd
7
- from sklearn.model_selection import GroupKFold
8
-
9
-
10
- @dataclass(frozen=True)
11
- class Fold:
12
- train_idx: np.ndarray
13
- valid_idx: np.ndarray
14
-
15
-
16
- def group_folds(df: pd.DataFrame, group_col: str, folds: int = 5) -> list[Fold]:
17
- groups = df[group_col].astype(str)
18
- unique_groups = groups.nunique()
19
- if unique_groups < 2:
20
- raise ValueError("At least two groups are required for leakage-safe validation")
21
- n_splits = min(folds, unique_groups)
22
- splitter = GroupKFold(n_splits=n_splits)
23
- dummy = np.zeros(len(df))
24
- return [Fold(train, valid) for train, valid in splitter.split(dummy, groups=groups)]
25
-
26
-
27
- def temporal_holdout(
28
- df: pd.DataFrame,
29
- timestamp_col: str,
30
- valid_fraction: float = 0.2,
31
- ) -> Fold:
32
- timestamps = pd.to_datetime(df[timestamp_col], errors="coerce", utc=True)
33
- cutoff = timestamps.quantile(1.0 - valid_fraction)
34
- train = np.flatnonzero((timestamps < cutoff).to_numpy())
35
- valid = np.flatnonzero((timestamps >= cutoff).to_numpy())
36
- if len(train) == 0 or len(valid) == 0:
37
- raise ValueError("Temporal holdout produced an empty split")
38
- return Fold(train, valid)