Download src/batteryswapai/competition_model.py from CarlAlbertCode/BatterySwapAI2026-MnesisLab: direct link, hf CLI and curl.
- Browser
- Download file 32.3 kB
-
https://huggingface.co/CarlAlbertCode/BatterySwapAI2026-MnesisLab/resolve/main/src/batteryswapai/competition_model.py
- Command line
-
hf download hf://CarlAlbertCode/BatterySwapAI2026-MnesisLab/src/batteryswapai/competition_model.py
-
curl -L -o competition_model.py https://huggingface.co/CarlAlbertCode/BatterySwapAI2026-MnesisLab/resolve/main/src/batteryswapai/competition_model.py
32.3 kB
| from __future__ import annotations | |
| from dataclasses import dataclass | |
| import numpy as np | |
| import pandas as pd | |
| from sklearn.ensemble import ( | |
| HistGradientBoostingClassifier, | |
| HistGradientBoostingRegressor, | |
| RandomForestClassifier, | |
| ) | |
| from sklearn.impute import SimpleImputer | |
| from sklearn.linear_model import LogisticRegression | |
| from sklearn.model_selection import GroupKFold | |
| from scipy.stats import rankdata | |
| from .competition_features import ( | |
| CONTEXT_COLUMNS, | |
| MIN_TRAJECTORY_COVERAGE, | |
| TRAJECTORY_COLUMNS, | |
| TRAJECTORY_REFERENCE_OFFSET, | |
| TRAJECTORY_COVERAGE_COLUMN, | |
| TRAJECTORY_LAGS, | |
| add_context_columns, | |
| numeric_feature_columns, | |
| ) | |
| def _logit(values: np.ndarray) -> np.ndarray: | |
| clipped = np.clip(values, 1e-5, 1.0 - 1e-5) | |
| return np.log(clipped / (1.0 - clipped)) | |
| class FrozenLogisticCalibrator: | |
| """Minimal binary logistic inference independent of sklearn pickle internals.""" | |
| coefficients: np.ndarray | |
| intercept: float | |
| def from_estimator(cls, estimator: LogisticRegression) -> "FrozenLogisticCalibrator": | |
| return cls( | |
| coefficients=np.asarray(estimator.coef_[0], dtype=float).copy(), | |
| intercept=float(estimator.intercept_[0]), | |
| ) | |
| def predict_proba(self, values: np.ndarray) -> np.ndarray: | |
| scores = np.asarray(values, dtype=float) @ self.coefficients + self.intercept | |
| positive = np.empty_like(scores, dtype=float) | |
| nonnegative = scores >= 0.0 | |
| positive[nonnegative] = 1.0 / (1.0 + np.exp(-scores[nonnegative])) | |
| exp_scores = np.exp(scores[~nonnegative]) | |
| positive[~nonnegative] = exp_scores / (1.0 + exp_scores) | |
| return np.column_stack([1.0 - positive, positive]) | |
| def _clean(frame: pd.DataFrame, columns: list[str]) -> pd.DataFrame: | |
| return frame.reindex(columns=columns).replace([np.inf, -np.inf], np.nan).astype(float) | |
| def _derived_features(base: pd.DataFrame, kind: str) -> pd.DataFrame: | |
| derived: dict[str, pd.Series] = {} | |
| if kind in {"physics", "all"}: | |
| for level in ("voltage_mean_3d", "voltage_mean_7d", "voltage_max_3d"): | |
| for window in (30, 60, 90, 180): | |
| rate = np.maximum(-base[f"voltage_slope_{window}d"], 1e-5) | |
| derived[f"knee_{level}_{window}"] = np.clip( | |
| (base[level] - 2.40) / rate, | |
| -100.0, | |
| 1000.0, | |
| ) | |
| if kind == "all": | |
| for short, long in ((3, 14), (7, 30), (14, 60), (30, 90), (30, 180), (60, 365)): | |
| derived[f"slope_accel_{short}_{long}"] = ( | |
| base[f"voltage_slope_{short}d"] - base[f"voltage_slope_{long}d"] | |
| ) | |
| derived[f"mean_drop_{short}_{long}"] = ( | |
| base[f"voltage_mean_{short}d"] - base[f"voltage_mean_{long}d"] | |
| ) | |
| for window in (3, 7, 14, 30, 60, 90): | |
| derived[f"volt_temp_{window}"] = base[f"voltage_mean_{window}d"] / ( | |
| base[f"temperature_mean_{window}d"] + 273.15 | |
| ) | |
| return pd.concat([base, pd.DataFrame(derived, index=base.index)], axis=1) | |
| HAZARD_EDGES = (0, 14, 28, 42, 70, 120, 200, 365) | |
| FORECAST_HORIZONS = (30, 60, 90) | |
| FORECAST_THRESHOLDS = (2.50, 2.45) | |
| TRAJECTORY_BLEND_WEIGHT = 0.25 | |
| def _trajectory_features(bins: np.ndarray) -> pd.DataFrame: | |
| """Shape of the degradation trajectory: levels, drops, steps and summaries.""" | |
| current = bins[:, 0] | |
| highest = np.nanmax(bins, axis=1) | |
| lowest = np.nanmin(bins, axis=1) | |
| spread = np.nanstd(bins, axis=1) | |
| columns: dict[str, np.ndarray] = {} | |
| for position, lag in enumerate(TRAJECTORY_LAGS): | |
| columns[f"v_lag{lag}"] = bins[:, position] | |
| for position, lag in enumerate(TRAJECTORY_LAGS[1:], start=1): | |
| columns[f"drop{lag}"] = current - bins[:, position] | |
| columns[f"ndrop{lag}"] = (current - bins[:, position]) / np.where( | |
| spread > 1e-6, spread, np.nan | |
| ) | |
| steps = np.diff(bins, axis=1) | |
| for position in range(steps.shape[1]): | |
| columns[f"step{position}"] = -steps[:, position] | |
| columns.update( | |
| own_max=highest, | |
| own_min=lowest, | |
| own_std=spread, | |
| cur_minus_max=current - highest, | |
| cur_over_max=current / highest, | |
| pos=(current - lowest) / np.where(highest - lowest > 1e-6, highest - lowest, np.nan), | |
| fb50=np.nanmean(bins < 2.50, axis=1), | |
| fb45=np.nanmean(bins < 2.45, axis=1), | |
| mono=np.nanmean(steps < 0, axis=1), | |
| ) | |
| return pd.DataFrame(columns) | |
| def _snapshot_bins(snapshot: pd.DataFrame) -> np.ndarray | None: | |
| if not set(TRAJECTORY_COLUMNS).issubset(snapshot.columns): | |
| return None | |
| return snapshot.reindex(columns=list(TRAJECTORY_COLUMNS)).to_numpy(dtype=float) | |
| def _dense_transitions(matrix: dict, buildings: dict[str, str]): | |
| """Every observed degradation transition, not just the terminal failures.""" | |
| grid = matrix["grid"] | |
| first = matrix["first"] | |
| devices = list(matrix["index"]) | |
| rows, cuts = [], [] | |
| for name, position in matrix["index"].items(): | |
| begin = int(first[position]) | |
| if begin >= grid.shape[1]: | |
| continue | |
| end = int(np.flatnonzero(np.isfinite(grid[position]))[-1]) | |
| start = begin + 180 | |
| for cut in range(start, end + 1, 14): | |
| rows.append(position) | |
| cuts.append(cut) | |
| rows = np.asarray(rows, dtype=np.int64) | |
| cuts = np.asarray(cuts, dtype=np.int64) | |
| lags = np.asarray(TRAJECTORY_LAGS, dtype=np.int64) + TRAJECTORY_REFERENCE_OFFSET | |
| columns = cuts[:, None] - lags[None, :] | |
| safe = np.clip(columns, 0, grid.shape[1] - 1) | |
| values = grid[rows[:, None], safe] | |
| usable = (columns >= 0) & (columns >= first[rows][:, None]) | |
| bins = np.where(usable, values, np.nan) | |
| names = np.array([devices[position] for position in rows]) | |
| where = np.array([buildings.get(name, "") for name in names]) | |
| return rows, cuts, bins, names, where | |
| def _forecast_targets(matrix: dict, rows, cuts, eol_day: dict[str, float], names): | |
| """Threshold crossings and future voltage, read only from observed history.""" | |
| grid = matrix["grid"] | |
| last = matrix["observed_last"] | |
| ends_at = np.array([eol_day.get(name, np.nan) for name in names], dtype=float) | |
| targets: dict[tuple, np.ndarray] = {} | |
| for horizon in FORECAST_HORIZONS: | |
| finish = cuts + horizon | |
| reachable = finish <= last[rows] | |
| died = np.isfinite(ends_at) & (ends_at <= finish) | |
| lowest = np.full(len(rows), np.nan) | |
| for position in range(len(rows)): | |
| begin = cuts[position] + 1 | |
| stop = min(int(finish[position]), int(last[rows[position]])) | |
| if stop >= begin: | |
| lowest[position] = np.nanmin(grid[rows[position], begin : stop + 1]) | |
| for threshold in FORECAST_THRESHOLDS: | |
| label = np.where( | |
| died, 1.0, np.where(reachable, (lowest <= threshold).astype(float), np.nan) | |
| ) | |
| targets[(horizon, threshold)] = np.where(~reachable & ~died, np.nan, label) | |
| future = np.where(reachable, grid[rows, np.clip(finish, 0, grid.shape[1] - 1)], np.nan) | |
| targets[(horizon, "voltage")] = np.where(died, 2.30, future) | |
| return targets | |
| def _forecaster(random_state: int, regression: bool): | |
| common = dict(learning_rate=0.06, max_iter=200, max_leaf_nodes=15, min_samples_leaf=40, | |
| l2_regularization=5.0, early_stopping=True, validation_fraction=0.1, | |
| n_iter_no_change=15, random_state=random_state) | |
| if regression: | |
| return HistGradientBoostingRegressor(**common) | |
| return HistGradientBoostingClassifier(loss="log_loss", **common) | |
| def _fit_forecasters(matrix, buildings, eol_day, random_state, | |
| snapshot_features, snapshot_buildings): | |
| """Learn degradation dynamics from dense transitions; score snapshots by held-out building.""" | |
| rows, cuts, bins, names, where = _dense_transitions(matrix, buildings) | |
| if len(rows) < 500: | |
| return None, None | |
| features = _trajectory_features(bins) | |
| targets = _forecast_targets(matrix, rows, cuts, eol_day, names) | |
| unique = np.array(sorted(set(where) - {""})) | |
| if unique.size < 3: | |
| return None, None | |
| assignment = {name: position % 3 for position, name in enumerate(unique)} | |
| dense_fold = np.array([assignment.get(name, -1) for name in where]) | |
| snap_fold = np.array([assignment.get(str(name), -1) for name in snapshot_buildings]) | |
| fitted: dict[str, object] = {} | |
| holdout: dict[str, np.ndarray] = {} | |
| for (horizon, kind), values in targets.items(): | |
| regression = kind == "voltage" | |
| name = f"v{horizon}" if regression else f"p{horizon}_{kind}" | |
| usable = np.isfinite(values) | |
| if usable.sum() < 200: | |
| continue | |
| estimator = _forecaster(random_state, regression) | |
| estimator.fit(features[usable], values[usable]) | |
| fitted[name] = estimator | |
| out = np.full(len(snapshot_features), np.nan) | |
| for group in range(3): | |
| train = usable & (dense_fold != group) | |
| target_rows = np.flatnonzero(snap_fold == group) | |
| if train.sum() < 100 or target_rows.size == 0: | |
| continue | |
| fold_model = _forecaster(random_state + group + 1, regression) | |
| fold_model.fit(features[train], values[train]) | |
| block = snapshot_features.iloc[target_rows] | |
| out[target_rows] = ( | |
| fold_model.predict(block) if regression | |
| else fold_model.predict_proba(block)[:, 1] | |
| ) | |
| holdout[name] = out | |
| if not fitted: | |
| return None, None | |
| return fitted, pd.DataFrame(holdout) | |
| def _forecast_features(forecasters, features: pd.DataFrame) -> pd.DataFrame: | |
| out: dict[str, np.ndarray] = {} | |
| for name, estimator in forecasters.items(): | |
| if name.startswith("v"): | |
| out[name] = estimator.predict(features) | |
| else: | |
| out[name] = estimator.predict_proba(features)[:, 1] | |
| return pd.DataFrame(out, index=features.index) | |
| def _context_features(snapshot: pd.DataFrame) -> pd.DataFrame: | |
| """Scenario-level context, already attached by scenario_snapshot.""" | |
| missing = [name for name in CONTEXT_COLUMNS if name not in snapshot.columns] | |
| if missing: | |
| snapshot = add_context_columns(snapshot.copy()) | |
| return snapshot.reindex(columns=list(CONTEXT_COLUMNS)).astype(float) | |
| def _context_estimators(random_state: int) -> dict[str, object]: | |
| estimators: dict[str, object] = {} | |
| for seed in range(3): | |
| estimators[f"hist_{seed}"] = HistGradientBoostingClassifier( | |
| loss="log_loss", learning_rate=0.05, max_iter=300, max_leaf_nodes=15, | |
| min_samples_leaf=30, l2_regularization=5.0, early_stopping=True, | |
| validation_fraction=0.12, n_iter_no_change=25, | |
| class_weight={0: 1.0, 1: 5.0}, random_state=random_state + seed, | |
| ) | |
| estimators["forest"] = RandomForestClassifier( | |
| n_estimators=350, min_samples_leaf=5, max_features=0.7, | |
| class_weight="balanced_subsample", n_jobs=-1, random_state=random_state, | |
| ) | |
| return estimators | |
| def _fit_context(features: pd.DataFrame, target: np.ndarray, random_state: int): | |
| estimators = _context_estimators(random_state) | |
| imputer = SimpleImputer(strategy="median") | |
| imputed = imputer.fit_transform(features) | |
| for name, estimator in estimators.items(): | |
| estimator.fit(imputed if name == "forest" else features, target) | |
| return estimators, imputer | |
| def _context_risk(estimators, imputer, features: pd.DataFrame) -> np.ndarray: | |
| imputed = imputer.transform(features) | |
| parts = [ | |
| estimator.predict_proba(imputed if name == "forest" else features)[:, 1] | |
| for name, estimator in estimators.items() | |
| ] | |
| return np.mean(parts, axis=0) | |
| def _fit_hazard(base: pd.DataFrame, target_rul: pd.Series, observed: pd.Series, random_state: int): | |
| """Person-period model: one row per battery per interval it is still alive.""" | |
| rul = target_rul.to_numpy(dtype=float) | |
| events = observed.to_numpy(dtype=bool) | |
| rows, bins, labels = [], [], [] | |
| for index in range(len(HAZARD_EDGES) - 1): | |
| low, high = HAZARD_EDGES[index], HAZARD_EDGES[index + 1] | |
| alive = np.flatnonzero(rul >= low) | |
| if alive.size == 0: | |
| continue | |
| rows.append(alive) | |
| bins.append(np.full(alive.shape, index)) | |
| labels.append((events[alive] & (rul[alive] < high)).astype(int)) | |
| if not rows: | |
| return None | |
| positions = np.concatenate(rows) | |
| features = base.iloc[positions].copy() | |
| features["hazard_interval"] = np.concatenate(bins) | |
| target = np.concatenate(labels) | |
| if target.sum() < 20: | |
| return None | |
| estimator = HistGradientBoostingClassifier( | |
| loss="log_loss", learning_rate=0.05, max_iter=300, max_leaf_nodes=15, | |
| min_samples_leaf=40, l2_regularization=5.0, early_stopping=True, | |
| validation_fraction=0.12, n_iter_no_change=25, random_state=random_state, | |
| ) | |
| estimator.fit(features, target) | |
| return estimator | |
| def _hazard_risk(estimator, base: pd.DataFrame, horizon_days: int) -> np.ndarray: | |
| survival = np.ones(len(base), dtype=float) | |
| for index in range(len(HAZARD_EDGES) - 1): | |
| if HAZARD_EDGES[index + 1] > horizon_days: | |
| break | |
| features = base.copy() | |
| features["hazard_interval"] = index | |
| survival *= 1.0 - estimator.predict_proba(features)[:, 1] | |
| return 1.0 - survival | |
| def _scenario_ranks(values: np.ndarray, groups: np.ndarray | None) -> np.ndarray: | |
| if groups is None: | |
| return rankdata(values) / max(len(values), 1) | |
| ranks = np.empty(len(values), dtype=float) | |
| for key in pd.unique(groups): | |
| mask = groups == key | |
| ranks[mask] = rankdata(values[mask]) / max(int(mask.sum()), 1) | |
| return ranks | |
| def _event_estimators(random_state: int) -> dict[str, object]: | |
| common = dict( | |
| loss="log_loss", | |
| learning_rate=0.05, | |
| max_iter=240, | |
| early_stopping=True, | |
| validation_fraction=0.12, | |
| n_iter_no_change=25, | |
| random_state=random_state, | |
| ) | |
| return { | |
| "weighted_hist": HistGradientBoostingClassifier( | |
| max_leaf_nodes=15, | |
| min_samples_leaf=35, | |
| l2_regularization=4.0, | |
| class_weight={0: 1.0, 1: 5.0}, | |
| **common, | |
| ), | |
| "forest": RandomForestClassifier( | |
| n_estimators=350, | |
| min_samples_leaf=5, | |
| max_features=0.7, | |
| class_weight="balanced_subsample", | |
| n_jobs=-1, | |
| random_state=random_state, | |
| ), | |
| "all_hist": HistGradientBoostingClassifier( | |
| max_leaf_nodes=31, | |
| min_samples_leaf=25, | |
| l2_regularization=5.0, | |
| **common, | |
| ), | |
| "physics_hist": HistGradientBoostingClassifier( | |
| max_leaf_nodes=31, | |
| min_samples_leaf=25, | |
| l2_regularization=5.0, | |
| **common, | |
| ), | |
| } | |
| def _fit_event_estimators( | |
| estimators: dict[str, object], | |
| base: pd.DataFrame, | |
| all_features: pd.DataFrame, | |
| physics_features: pd.DataFrame, | |
| target: np.ndarray, | |
| sample_weight: np.ndarray | None = None, | |
| ) -> tuple[dict[str, object], SimpleImputer]: | |
| imputer = SimpleImputer(strategy="median") | |
| forest_features = imputer.fit_transform(base) | |
| estimators["weighted_hist"].fit(base, target, sample_weight=sample_weight) | |
| estimators["forest"].fit(forest_features, target, sample_weight=sample_weight) | |
| estimators["all_hist"].fit(all_features, target, sample_weight=sample_weight) | |
| estimators["physics_hist"].fit(physics_features, target, sample_weight=sample_weight) | |
| return estimators, imputer | |
| def _device_weights(devices: pd.Series) -> np.ndarray: | |
| """One vote per battery, so devices seen in many scenarios do not dominate.""" | |
| counts = devices.groupby(devices).transform("size").to_numpy(dtype=float) | |
| weights = 1.0 / np.maximum(counts, 1.0) | |
| return weights * (len(weights) / weights.sum()) | |
| def _event_components( | |
| estimators: dict[str, object], | |
| imputer: SimpleImputer, | |
| base: pd.DataFrame, | |
| all_features: pd.DataFrame, | |
| physics_features: pd.DataFrame, | |
| ) -> np.ndarray: | |
| return np.column_stack( | |
| [ | |
| estimators["weighted_hist"].predict_proba(base)[:, 1], | |
| estimators["forest"].predict_proba(imputer.transform(base))[:, 1], | |
| estimators["all_hist"].predict_proba(all_features)[:, 1], | |
| estimators["physics_hist"].predict_proba(physics_features)[:, 1], | |
| ] | |
| ) | |
| class EventTimeModel: | |
| event_estimators: dict[str, object] | |
| event_imputer: SimpleImputer | |
| event_calibrator: FrozenLogisticCalibrator | |
| rul_estimator: HistGradientBoostingRegressor | |
| survivor_rul_estimator: HistGradientBoostingRegressor | None | |
| feature_columns: list[str] | |
| all_feature_columns: list[str] | |
| physics_feature_columns: list[str] | |
| quantile: float | |
| horizon_days: int | |
| dataset_revision: str | |
| balanced: "EventTimeModel | None" = None | |
| context_estimators: dict | None = None | |
| context_imputer: SimpleImputer | None = None | |
| context_columns: list[str] | None = None | |
| hazard_estimator: object | None = None | |
| blend_weights: tuple[float, float, float, float] = (1.0, 0.0, 0.0, 0.0) | |
| forecasters: dict | None = None | |
| residual_model: object | None = None | |
| residual_columns: list[str] | None = None | |
| trajectory_weight: float = 0.0 | |
| def _feature_sets( | |
| self, snapshot: pd.DataFrame | |
| ) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]: | |
| base = _clean(snapshot, self.feature_columns) | |
| all_features = _clean(_derived_features(base, "all"), self.all_feature_columns) | |
| physics_features = _clean( | |
| _derived_features(base, "physics"), self.physics_feature_columns | |
| ) | |
| return base, all_features, physics_features | |
| def _calibrated_risk(self, snapshot: pd.DataFrame) -> np.ndarray: | |
| base, all_features, physics_features = self._feature_sets(snapshot) | |
| components = _event_components( | |
| self.event_estimators, | |
| self.event_imputer, | |
| base, | |
| all_features, | |
| physics_features, | |
| ) | |
| stacked = _logit(components) | |
| return np.asarray(self.event_calibrator.predict_proba(stacked)[:, 1]) | |
| def predict_risk_components(self, snapshot: pd.DataFrame) -> dict[str, np.ndarray]: | |
| components = {"primary": self._calibrated_risk(snapshot)} | |
| if self.balanced is not None: | |
| components["balanced"] = self.balanced._calibrated_risk(snapshot) | |
| if self.context_estimators is not None: | |
| features = _clean( | |
| pd.concat( | |
| [_clean(snapshot, self.feature_columns), _context_features(snapshot)], | |
| axis=1, | |
| ), | |
| self.context_columns, | |
| ) | |
| components["context"] = _context_risk( | |
| self.context_estimators, self.context_imputer, features | |
| ) | |
| if self.hazard_estimator is not None: | |
| components["hazard"] = _hazard_risk( | |
| self.hazard_estimator, _clean(snapshot, self.feature_columns), self.horizon_days | |
| ) | |
| return components | |
| def _residual_risk(self, snapshot: pd.DataFrame): | |
| """Degradation-dynamics view; None when the trajectory history is unusable.""" | |
| if self.residual_model is None or self.forecasters is None: | |
| return None, None | |
| bins = _snapshot_bins(snapshot) | |
| if bins is None: | |
| return None, None | |
| trajectory = _trajectory_features(bins) | |
| forecast = _forecast_features(self.forecasters, trajectory) | |
| frame = pd.concat([forecast, trajectory], axis=1) | |
| frame = frame.reindex(columns=self.residual_columns).astype(float) | |
| risk = self.residual_model.predict_proba(frame)[:, 1] | |
| if TRAJECTORY_COVERAGE_COLUMN in snapshot.columns: | |
| coverage = pd.to_numeric( | |
| snapshot[TRAJECTORY_COVERAGE_COLUMN], errors="coerce" | |
| ).to_numpy(dtype=float) | |
| else: | |
| coverage = np.isfinite(bins).mean(axis=1) | |
| coverage = np.where(np.isfinite(coverage), coverage, 0.0) | |
| # A battery without enough history keeps its blend position rather than moving on noise. | |
| weight = np.where(coverage >= MIN_TRAJECTORY_COVERAGE, self.trajectory_weight, 0.0) | |
| return risk, weight | |
| def predict_event_risk(self, snapshot: pd.DataFrame) -> np.ndarray: | |
| groups = ( | |
| snapshot["scenario"].astype(str).to_numpy() | |
| if "scenario" in snapshot.columns | |
| else None | |
| ) | |
| primary = blend_risk_components( | |
| self.predict_risk_components(snapshot), self.blend_weights, groups | |
| ) | |
| risk, weight = self._residual_risk(snapshot) | |
| if risk is None or not np.any(weight > 0.0): | |
| return primary | |
| return blend_trajectory(primary, risk, weight, groups) | |
| def predict_rul(self, snapshot: pd.DataFrame) -> np.ndarray: | |
| base = _clean(snapshot, self.feature_columns) | |
| return np.asarray(self.rul_estimator.predict(base), dtype=float) | |
| def predict_survivor_rul(self, snapshot: pd.DataFrame) -> np.ndarray | None: | |
| """Expected remaining life of batteries that outlive the planning window.""" | |
| if self.survivor_rul_estimator is None: | |
| return None | |
| base = _clean(snapshot, self.feature_columns) | |
| return np.asarray(self.survivor_rul_estimator.predict(base), dtype=float) | |
| def blend_risk_components( | |
| components: dict[str, np.ndarray], | |
| weights: tuple[float, float, float, float], | |
| groups: np.ndarray | None, | |
| ) -> np.ndarray: | |
| """Rank each view inside a scenario, then map back onto the calibrated scale.""" | |
| order = ("primary", "balanced", "context", "hazard") | |
| primary = np.asarray(components["primary"], dtype=float) | |
| active = [ | |
| (np.asarray(components[name], dtype=float), float(weights[index])) | |
| for index, name in enumerate(order) | |
| if name in components and float(weights[index]) > 0.0 | |
| ] | |
| if len(active) <= 1: | |
| return primary | |
| blended = np.zeros(len(primary), dtype=float) | |
| total = 0.0 | |
| for values, weight in active: | |
| blended += weight * _scenario_ranks(values, groups) | |
| total += weight | |
| blended /= max(total, 1e-9) | |
| return _map_to_scale(blended, primary, groups) | |
| def blend_trajectory(primary, residual, weight, groups): | |
| """Nudge the ranking by the degradation view, keeping the calibrated scale.""" | |
| primary = np.asarray(primary, dtype=float) | |
| residual = np.asarray(residual, dtype=float) | |
| weight = np.asarray(weight, dtype=float) | |
| residual = np.where(np.isfinite(residual), residual, np.nanmin(residual[np.isfinite(residual)]) | |
| if np.isfinite(residual).any() else 0.0) | |
| blended = np.empty(len(primary), dtype=float) | |
| keys = [None] if groups is None else list(pd.unique(groups)) | |
| for key in keys: | |
| mask = slice(None) if key is None else (groups == key) | |
| size = len(primary[mask]) | |
| first = rankdata(primary[mask]) / max(size, 1) | |
| second = rankdata(residual[mask]) / max(size, 1) | |
| local = weight[mask] | |
| blended[mask] = (1.0 - local) * first + local * second | |
| return _map_to_scale(blended, primary, groups) | |
| def _map_to_scale(blended: np.ndarray, reference: np.ndarray, groups) -> np.ndarray: | |
| """Keep the calibrated marginal distribution and take only the new ordering.""" | |
| mapped = np.empty(len(blended), dtype=float) | |
| keys = [None] if groups is None else list(pd.unique(groups)) | |
| for key in keys: | |
| mask = slice(None) if key is None else (groups == key) | |
| scores = blended[mask] | |
| scale = np.sort(np.asarray(reference)[mask]) | |
| order = rankdata(scores, method="ordinal").astype(int) - 1 | |
| mapped[mask] = scale[order] | |
| return mapped | |
| def _fit_trajectory_stack(work, trajectory, eol_times, event_target, random_state): | |
| """Dense degradation forecasting plus the temporal-bin view, as one residual ranker.""" | |
| bins = _snapshot_bins(work) | |
| if bins is None: | |
| return None, None, None, 0.0 | |
| snapshot_features = _trajectory_features(bins) | |
| buildings = dict(zip(work["battery"].astype(str), work["building"].astype(str))) | |
| eol_day: dict[str, float] = {} | |
| if eol_times is not None: | |
| origin = trajectory["origin"] | |
| for name, when in eol_times.items(): | |
| if pd.notna(when): | |
| eol_day[str(name)] = float((pd.Timestamp(when).normalize() - origin).days) | |
| forecasters, holdout = _fit_forecasters( | |
| trajectory, buildings, eol_day, random_state, | |
| snapshot_features, work["building"].astype(str).to_numpy(), | |
| ) | |
| if forecasters is None: | |
| return None, None, None, 0.0 | |
| frame = pd.concat([holdout.reset_index(drop=True), snapshot_features.reset_index(drop=True)], axis=1) | |
| columns = list(frame.columns) | |
| residual = HistGradientBoostingClassifier( | |
| loss="log_loss", learning_rate=0.05, max_iter=300, max_leaf_nodes=15, | |
| min_samples_leaf=30, l2_regularization=5.0, early_stopping=True, | |
| validation_fraction=0.12, n_iter_no_change=25, class_weight={0: 1.0, 1: 5.0}, | |
| random_state=random_state, | |
| ) | |
| residual.fit(frame.astype(float), event_target) | |
| return forecasters, residual, columns, TRAJECTORY_BLEND_WEIGHT | |
| def _fit_survivor_estimator( | |
| base: pd.DataFrame, | |
| target_rul: pd.Series, | |
| observed: pd.Series, | |
| horizon_days: int, | |
| maximum_days: float, | |
| random_state: int, | |
| ) -> HistGradientBoostingRegressor | None: | |
| """Median remaining life among batteries observed to outlive the window.""" | |
| mask = observed & target_rul.gt(float(horizon_days)) & target_rul.le(float(maximum_days)) | |
| if int(mask.sum()) < 50: | |
| return None | |
| estimator = HistGradientBoostingRegressor( | |
| loss="quantile", | |
| quantile=0.5, | |
| learning_rate=0.05, | |
| max_iter=200, | |
| max_leaf_nodes=15, | |
| min_samples_leaf=40, | |
| l2_regularization=4.0, | |
| early_stopping=True, | |
| validation_fraction=0.15, | |
| n_iter_no_change=20, | |
| random_state=random_state, | |
| ) | |
| estimator.fit(base.loc[mask], target_rul.loc[mask]) | |
| return estimator | |
| def fit_event_time_model( | |
| training_snapshots: pd.DataFrame, | |
| *, | |
| quantile: float = 0.05, | |
| horizon_days: int = 42, | |
| rul_training_max_days: float = 90.0, | |
| survivor_training_max_days: float = 400.0, | |
| dataset_revision: str = "unknown", | |
| random_state: int = 2026, | |
| calibration_folds: int = 5, | |
| device_balanced: bool = False, | |
| with_auxiliary: bool = True, | |
| trajectory: dict | None = None, | |
| eol_times: pd.Series | None = None, | |
| blend_weights: tuple[float, float, float, float] = (0.55, 0.15, 0.15, 0.15), | |
| max_iter: int | None = None, | |
| ) -> EventTimeModel: | |
| del max_iter # retained for compatibility with earlier validation commands | |
| work = training_snapshots.dropna(subset=["target_rul_days"]).copy() | |
| columns = numeric_feature_columns(work) | |
| base = _clean(work, columns) | |
| all_features = _derived_features(base, "all") | |
| physics_features = _derived_features(base, "physics") | |
| all_columns = list(all_features.columns) | |
| physics_columns = list(physics_features.columns) | |
| target_rul = pd.to_numeric(work["target_rul_days"], errors="coerce").astype(float) | |
| event_target = ( | |
| work["event_observed"].astype(bool) | |
| & target_rul.between(0.0, float(horizon_days)) | |
| ).astype("int8").to_numpy() | |
| devices = work["battery"].astype(str) | |
| groups = devices.to_numpy() | |
| weights = _device_weights(devices) if device_balanced else None | |
| folds = min(int(calibration_folds), len(np.unique(groups))) | |
| if folds < 2: | |
| raise ValueError("At least two device groups are required for event calibration") | |
| oof_components = np.full((len(work), 4), np.nan) | |
| splitter = GroupKFold(n_splits=folds) | |
| for fold_number, (train_index, valid_index) in enumerate( | |
| splitter.split(base, groups=groups), start=1 | |
| ): | |
| fold_estimators = _event_estimators(random_state + fold_number) | |
| fold_estimators, fold_imputer = _fit_event_estimators( | |
| fold_estimators, | |
| base.iloc[train_index], | |
| all_features.iloc[train_index], | |
| physics_features.iloc[train_index], | |
| event_target[train_index], | |
| None if weights is None else weights[train_index], | |
| ) | |
| oof_components[valid_index] = _event_components( | |
| fold_estimators, | |
| fold_imputer, | |
| base.iloc[valid_index], | |
| all_features.iloc[valid_index], | |
| physics_features.iloc[valid_index], | |
| ) | |
| fitted_calibrator = LogisticRegression(C=0.03, max_iter=1000) | |
| fitted_calibrator.fit(_logit(oof_components), event_target, sample_weight=weights) | |
| calibrator = FrozenLogisticCalibrator.from_estimator(fitted_calibrator) | |
| estimators = _event_estimators(random_state) | |
| estimators, imputer = _fit_event_estimators( | |
| estimators, | |
| base, | |
| all_features, | |
| physics_features, | |
| event_target, | |
| weights, | |
| ) | |
| observed_near_horizon = ( | |
| work["event_observed"].astype(bool) | |
| & target_rul.gt(0.0) | |
| & target_rul.le(float(rul_training_max_days)) | |
| ) | |
| rul_estimator = HistGradientBoostingRegressor( | |
| loss="quantile", | |
| quantile=quantile, | |
| learning_rate=0.055, | |
| max_iter=260, | |
| max_leaf_nodes=19, | |
| min_samples_leaf=30, | |
| l2_regularization=3.0, | |
| early_stopping=True, | |
| validation_fraction=0.12, | |
| n_iter_no_change=25, | |
| random_state=random_state, | |
| ) | |
| rul_estimator.fit(base.loc[observed_near_horizon], target_rul.loc[observed_near_horizon]) | |
| survivor_rul_estimator = _fit_survivor_estimator( | |
| base, | |
| target_rul, | |
| work["event_observed"].astype(bool), | |
| horizon_days, | |
| survivor_training_max_days, | |
| random_state, | |
| ) | |
| balanced = None | |
| forecasters = None | |
| residual_model = None | |
| residual_columns = None | |
| trajectory_weight = 0.0 | |
| context_estimators = None | |
| context_imputer = None | |
| context_columns = None | |
| hazard_estimator = None | |
| weights_used = (1.0, 0.0, 0.0, 0.0) | |
| if with_auxiliary: | |
| # Diverse views of the same 82 failures: reweighted, context-aware, and time-to-event. | |
| balanced = fit_event_time_model( | |
| training_snapshots, | |
| quantile=quantile, | |
| horizon_days=horizon_days, | |
| rul_training_max_days=rul_training_max_days, | |
| survivor_training_max_days=survivor_training_max_days, | |
| dataset_revision=dataset_revision, | |
| random_state=random_state, | |
| calibration_folds=calibration_folds, | |
| device_balanced=True, | |
| with_auxiliary=False, | |
| ) | |
| context_frame = pd.concat([base, _context_features(work)], axis=1) | |
| context_columns = list(context_frame.columns) | |
| context_estimators, context_imputer = _fit_context( | |
| context_frame, event_target, random_state | |
| ) | |
| hazard_estimator = _fit_hazard( | |
| base, target_rul, work["event_observed"].astype(bool), random_state | |
| ) | |
| weights_used = tuple(float(value) for value in blend_weights) | |
| if trajectory is not None: | |
| forecasters, residual_model, residual_columns, trajectory_weight = _fit_trajectory_stack( | |
| work, trajectory, eol_times, event_target, random_state | |
| ) | |
| return EventTimeModel( | |
| estimators, | |
| imputer, | |
| calibrator, | |
| rul_estimator, | |
| survivor_rul_estimator, | |
| columns, | |
| all_columns, | |
| physics_columns, | |
| quantile, | |
| horizon_days, | |
| dataset_revision, | |
| balanced, | |
| context_estimators, | |
| context_imputer, | |
| context_columns, | |
| hazard_estimator, | |
| weights_used, | |
| forecasters, | |
| residual_model, | |
| residual_columns, | |
| trajectory_weight, | |
| ) | |