"""Train and save the production XGBoost model on ALL ~1112 valid-target rows (incl. out-of-scope GPUs, mirroring what LOGO-CV folds saw), using notebooks/03_model_training.ipynb cell 3's exact feature encoding for GpuPredictor compatibility.""" from __future__ import annotations import json import logging import stat as _stat from pathlib import Path import numpy as np import pandas as pd import xgboost as xgb from src.data.manifest import corpus_sha256, load_manifest, sources_present, verify_manifest from src.models.predictor import FEATURE_COLS # Written by notebooks/03_model_training.ipynb (the only place that fits out-of-fold models); reading it here is optional/best-effort since train_and_save() trains the production model on ALL rows with no hold-out and so has no LOGO-CV metrics of its own. LOGO_CV_METRICS_PATH = Path("data/models/logo_cv_metrics.json") # Same cap predictor.py applies to feature_metadata.json — same trust tier (build artifact, not user input) and comparably small. _MAX_VALIDATION_METRICS_BYTES: int = 1 * 1024 * 1024 # 1 MB logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") log = logging.getLogger(__name__) TARGET = "efficiency_ratio" THROUGHPUT_COL = "throughput_tok_per_sec_per_gpu" # Same hyperparameters as the training notebook's final model (no early stopping); n_estimators=250 is the midpoint of observed best_iteration across folds (77-341), conservative to avoid overfitting without an eval set. PROD_PARAMS: dict = { "n_estimators": 250, "max_depth": 5, "learning_rate": 0.03, "subsample": 0.8, "colsample_bytree": 0.8, "min_child_weight": 5, "tree_method": "hist", "random_state": 42, } FEATURES_PATH = Path("data/processed/mlperf_features.parquet") MODEL_DIR = Path("data/models") def _encode(feat: pd.DataFrame) -> pd.DataFrame: """Apply the same categorical encoding as notebook cell 3.""" df = feat.copy() df["scenario_offline"] = (df["scenario"] == "Offline").astype(int) df["fw_tensorrt"] = (df["framework_family"] == "tensorrt").astype(int) df["fw_vllm"] = (df["framework_family"] == "vllm").astype(int) df["fw_rocm_other"] = (df["framework_family"] == "rocm_other").astype(int) df["is_cdna4"] = (df["amd_arch_gen"] == 2).astype(int) return df def _load_validation_metrics(path: Path = LOGO_CV_METRICS_PATH) -> dict | None: """Best-effort load of the LOGO-CV metrics notebooks/03 writes: returns None (not a raised error) when absent (e.g. fresh clone, notebook never re-run), but once present gets the same symlink+size guard as every other integrity-sensitive file this codebase reads, so a corrupted or maliciously-symlinked file fails loudly rather than silently reading tampered content.""" try: st = path.lstat() except OSError: return None if _stat.S_ISLNK(st.st_mode): raise ValueError(f"Validation metrics path is a symlink (refused): {path}") if st.st_size > _MAX_VALIDATION_METRICS_BYTES: raise ValueError( f"Validation metrics file too large ({st.st_size} bytes > " f"{_MAX_VALIDATION_METRICS_BYTES}): {path}" ) with path.open() as f: return json.load(f) def train_and_save( features_path: Path = FEATURES_PATH, model_dir: Path = MODEL_DIR, ) -> xgb.XGBRegressor: # Verify the corpus's source snapshots still match data_manifest.lock (warn-only, see manifest.py's docstring for why); result is captured so feature_metadata.json can record verification status, with n_sources_present/total disambiguating "clean" from "mostly unchecked" since absent gitignored sources are skipped not flagged; manifest loaded once and passed to both calls to avoid re-parsing the lock file (~1.2ms/call) per call site. locked_manifest = load_manifest() manifest_mismatches = verify_manifest(locked=locked_manifest) n_sources_present, n_sources_total = sources_present(locked=locked_manifest) # Hash the exact corpus file before reading it, so feature_metadata.json records the snapshot that actually produced the model. corpus_hash = corpus_sha256(features_path) feat = pd.read_parquet(features_path) log.info("Loaded %d rows from %s", len(feat), features_path) df = _encode(feat) # Drop rows where target is NaN or infinite. valid = df[TARGET].notna() & np.isfinite(df[TARGET]) df = df[valid].reset_index(drop=True) log.info("Training rows (valid target): %d", len(df)) # Variance gate matching the training notebook's assertion; explicit raise (not assert) since assert is compiled away under python -O. dead = [c for c in FEATURE_COLS if df[c].nunique() <= 1] if dead: raise ValueError(f"Dead features (constant across all training rows): {dead}") X = df[FEATURE_COLS] y = df[TARGET] model = xgb.XGBRegressor(**PROD_PARAMS) model.fit(X, y) log.info("Model trained: %d trees, %d features", PROD_PARAMS["n_estimators"], len(FEATURE_COLS)) validation_metrics = _load_validation_metrics() model_dir.mkdir(parents=True, exist_ok=True) model_path = model_dir / "prophet_v1.json" meta_path = model_dir / "feature_metadata.json" # Write both artifacts atomically via temp-file + os.replace(), so a kill mid-write leaves the reader (GpuPredictor.__init__) seeing the old pair or the new pair, never a mismatched mix. tmp_model = model_path.with_name(model_path.stem + ".tmp.json") model.save_model(str(tmp_model)) tmp_model.replace(model_path) log.info("Saved model → %s", model_path) meta = { "feature_cols": FEATURE_COLS, "target": TARGET, "model_version": "v1", "prod_params": PROD_PARAMS, "n_training_rows": len(df), # Links this artifact to the exact corpus snapshot it trained on (lineage: prediction -> feature_metadata.json -> data_manifest.lock -> pinned MLPerf commits); manifest_verified confirms *this* corpus's pinning was actually checked clean, not just that a hash exists. "corpus_sha256": corpus_hash, "corpus_path": str(features_path), "manifest_verified": len(manifest_mismatches) == 0, # Disambiguates manifest_verified: "5/5" (every locked source present and clean) reads very differently from "1/5" (gitignored MLPerf mirrors simply absent, the normal fresh-clone/CI state), even though both give manifest_verified: true. "manifest_sources_present": n_sources_present, "manifest_sources_total": n_sources_total, # GPUs with zero rows here extrapolate purely from specs; served via GpuPredictor.has_training_data() so callers can disclose this instead of presenting an extrapolated number with false confidence. "trained_gpu_ids": sorted(df["canonical_gpu_id"].unique().tolist()), # Per-GPU row counts behind GpuPredictor.training_data_tier(): a boolean "has any data" can't tell a GPU with 1 row from one with 1,000 — three of eight served GPUs (the AMD SKUs) sit under the 100-row-per-GPU floor despite having nonzero data. "trained_gpu_row_counts": { str(k): int(v) for k, v in df["canonical_gpu_id"].value_counts().items() }, # LOGO-CV out-of-fold metrics from notebooks/03, not this run's own result (train_and_save() fits on ALL rows with no hold-out); None if the notebook wasn't re-run since the last corpus rebuild. "validation_metrics": validation_metrics, } tmp_meta = meta_path.with_name(meta_path.name + ".tmp") with tmp_meta.open("w") as f: json.dump(meta, f, indent=2) tmp_meta.replace(meta_path) log.info("Saved metadata → %s", meta_path) return model if __name__ == "__main__": train_and_save()