gpu-perf-prophet / src /models /train_final.py
vxa8502's picture
Code cleanup
0623ff0
Raw
History Blame Contribute Delete
7.75 kB
"""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()