| """End-to-end training pipeline (CLASS-05, D-REPRO-04, Pattern 6). |
| |
| Run: `make train` (delegates to `uv run python -m model.train`). |
| Produces: artifacts/{classifier.joblib, anomaly.joblib, eval_metrics.json} |
| artifacts/plots/{confusion_matrix, reliability_raw, |
| reliability_calibrated, lead_time_cdf}.png |
| |
| NaN policy (canonical, deferred from 02-04 deviation #3): |
| - Classifier path: LightGBM handles NaN natively — no preprocessing. |
| - Anomaly TRAINING: filter rows with any NaN feature (no window-layout |
| constraint at training time; preserves IForest fit semantics). |
| - Anomaly EVAL / score_lead_times: MUST preserve window-major layout |
| (Pitfall 11); impute NaN to 0.0 in-place (zero-fill). With ~20% NaN |
| concentrated in the disconnect-event windows where operational nulls |
| are emitted, zero-fill keeps the IForest score signal directionally |
| correct (zero values are typically "less anomalous" against a healthy |
| baseline so missed-window detections are conservative — surfaced via |
| per_class_miss_rate). |
| """ |
| from __future__ import annotations |
|
|
| import json |
| from pathlib import Path |
|
|
| import joblib |
| import numpy as np |
|
|
| from model.eval import build_eval_metrics |
| from model.features import ( |
| CLASSES, |
| load_anomaly_features, |
| load_split, |
| ) |
| from model.inference import apply_mask_and_renormalize |
| from model.normal_split import generate_normal_split |
| from model.plots import ( |
| plot_confusion_matrix, |
| plot_lead_time_cdfs, |
| plot_reliability_grid, |
| ) |
| from model.seeds import phase2_seeds |
| from model.train_anomaly import ( |
| calibrate_threshold, |
| per_class_miss_rate, |
| score_lead_times, |
| train_anomaly, |
| ) |
| from model.train_classifier import ( |
| train_calibrated_classifier, |
| train_raw_classifier, |
| ) |
|
|
| SCHEMA_VERSION: str = "1.0.0" |
|
|
|
|
| def _impute_nan_zero(X: np.ndarray) -> np.ndarray: |
| """Zero-fill NaN entries in-place-safe; preserves shape (Pitfall 11 layout).""" |
| X = X.copy() |
| X[~np.isfinite(X)] = 0.0 |
| return X |
|
|
|
|
| def main() -> None: |
| artifacts = Path("artifacts") |
| plots_dir = artifacts / "plots" |
| plots_dir.mkdir(parents=True, exist_ok=True) |
|
|
| seeds = phase2_seeds() |
|
|
| |
| |
| X_train, y_train, _ = load_split(Path("data/train.parquet")) |
| print(f"[train] classifier: {len(X_train)} rows × {X_train.shape[1]} features") |
| classifier = train_calibrated_classifier( |
| X_train, y_train, |
| classifier_seed=seeds["classifier_train"], |
| cv_seed=seeds["classifier_cv"], |
| ) |
| joblib.dump(classifier, artifacts / "classifier.joblib", compress=3) |
|
|
| |
| |
| |
| raw_clf = train_raw_classifier( |
| X_train, y_train, classifier_seed=seeds["classifier_train"] |
| ) |
|
|
| |
| X_anom_train, _, _ = load_anomaly_features(Path("data/train.parquet")) |
| |
| finite_mask = np.isfinite(X_anom_train).all(axis=1) |
| X_anom_train_clean = X_anom_train[finite_mask] |
| print( |
| f"[train] anomaly: {len(X_anom_train_clean)} of {len(X_anom_train)} " |
| f"finite rows ({100.0 * finite_mask.mean():.1f}%)" |
| ) |
| iforest = train_anomaly(X_anom_train_clean, random_state=seeds["anomaly_train"]) |
|
|
| normal_path = Path("data/normal.parquet") |
| if not normal_path.exists(): |
| generate_normal_split(seeds["normal_split_synth"], normal_path) |
| X_normal, _, _ = load_anomaly_features(normal_path) |
| |
| threshold = calibrate_threshold(iforest, X_normal, percentile=95.0) |
|
|
| joblib.dump( |
| {"detector": iforest, "threshold": threshold}, |
| artifacts / "anomaly.joblib", compress=3, |
| ) |
|
|
| |
| X_eval, y_eval, _ = load_split(Path("data/eval.parquet")) |
| calibrated_proba = classifier.predict_proba(X_eval) |
| raw_proba = raw_clf.predict_proba(X_eval) |
|
|
| |
| |
| |
| import pyarrow.parquet as pq |
|
|
| eval_tbl = pq.read_table(Path("data/eval.parquet"), columns=["network_mode"]) |
| network_mode_per_row = np.array(eval_tbl["network_mode"].to_pylist()) |
|
|
| n_eval = len(X_eval) |
| masked_proba = np.zeros_like(calibrated_proba) |
| for i in range(n_eval): |
| masked_proba[i] = apply_mask_and_renormalize( |
| calibrated_proba[i], network_mode_per_row[i] |
| ) |
| y_pred_after_mask = np.argmax(masked_proba, axis=1) |
|
|
| |
| |
| X_anom_eval, y_anom_eval, ts_eval = load_anomaly_features(Path("data/eval.parquet")) |
| X_anom_eval = _impute_nan_zero(X_anom_eval) |
| lead_times = score_lead_times( |
| iforest, X_anom_eval, y_anom_eval, ts_eval, threshold |
| ) |
| miss_rates = per_class_miss_rate(iforest, X_anom_eval, y_anom_eval, threshold) |
|
|
| |
| plot_confusion_matrix( |
| y_eval, y_pred_after_mask, str(plots_dir / "confusion_matrix.png") |
| ) |
| plot_reliability_grid( |
| y_eval, raw_proba, str(plots_dir / "reliability_raw.png"), |
| "Reliability — raw LightGBM softmax", |
| ) |
| plot_reliability_grid( |
| y_eval, calibrated_proba, str(plots_dir / "reliability_calibrated.png"), |
| "Reliability — isotonic-calibrated", |
| ) |
| plot_lead_time_cdfs(lead_times, str(plots_dir / "lead_time_cdf.png")) |
|
|
| |
| metrics = build_eval_metrics( |
| y_eval=y_eval, |
| calibrated_proba=calibrated_proba, |
| y_pred_after_mask=y_pred_after_mask, |
| network_mode_per_row=network_mode_per_row, |
| anomaly_threshold=threshold, |
| per_class_lead_times=lead_times, |
| per_class_miss_rates=miss_rates, |
| schema_version=SCHEMA_VERSION, |
| ) |
| (artifacts / "eval_metrics.json").write_text( |
| json.dumps(metrics, indent=2, sort_keys=True) |
| ) |
| print(f"wrote {artifacts / 'eval_metrics.json'}") |
| print(f" CLASSES = {CLASSES}") |
| print(f" macro_f1 = {metrics['macro_f1']:.4f}") |
| print(f" ece_mean = {metrics['ece_mean']:.4f}") |
| print( |
| f" lead_time_aggregate_median_s = " |
| f"{metrics['anomaly']['lead_time_aggregate_median_s']:.2f}" |
| ) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|