| """Quick numerical drift check: LightGBM joblib vs ONNX Runtime. |
| |
| Compares positive-class probabilities on N rows of the reference dataset and |
| reports decision flips at the production threshold (read from model_info.json, |
| falling back to 0.5 for the rule-of-thumb sanity check). |
| |
| Run: |
| uv run python scripts/check_onnx_drift.py --n 5000 |
| """ |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| from pathlib import Path |
|
|
| import joblib |
| import numpy as np |
| import onnxruntime as ort |
| import pandas as pd |
|
|
| ROOT = Path(__file__).resolve().parents[1] |
| DEFAULT_JOBLIB_PATH = ROOT / "models" / "model.joblib" |
| DEFAULT_ONNX_PATH = ROOT / "models" / "model.onnx" |
| DEFAULT_FEATURE_NAMES_PATH = ROOT / "models" / "feature_names.json" |
| DEFAULT_MODEL_INFO_PATH = ROOT / "models" / "model_info.json" |
| DEFAULT_REFERENCE_PATH = ROOT / "data" / "reference_dataset.parquet" |
|
|
|
|
| def main() -> None: |
| parser = argparse.ArgumentParser(description=__doc__) |
| parser.add_argument("--n", type=int, default=5000, help="Number of rows") |
| parser.add_argument("--joblib", type=Path, default=DEFAULT_JOBLIB_PATH) |
| parser.add_argument("--onnx", type=Path, default=DEFAULT_ONNX_PATH) |
| parser.add_argument( |
| "--feature-names", type=Path, default=DEFAULT_FEATURE_NAMES_PATH |
| ) |
| parser.add_argument("--model-info", type=Path, default=DEFAULT_MODEL_INFO_PATH) |
| parser.add_argument("--reference", type=Path, default=DEFAULT_REFERENCE_PATH) |
| parser.add_argument("--seed", type=int, default=42) |
| args = parser.parse_args() |
|
|
| feature_names = json.loads(args.feature_names.read_text()) |
| info = json.loads(args.model_info.read_text()) |
| threshold = float(info.get("metrics", {}).get("best_threshold_mean", 0.5)) |
| print(f"Production threshold (from model_info.json): {threshold}") |
|
|
| |
| reference = pd.read_parquet(args.reference) |
| n = min(args.n, len(reference)) |
| rng = np.random.default_rng(args.seed) |
| idx = rng.choice(len(reference), size=n, replace=False) |
| X_df = reference.iloc[idx][feature_names].reset_index(drop=True) |
| X_np = X_df.to_numpy(dtype=np.float32) |
|
|
| |
| print(f"Running LightGBM on {n} rows (batch)...") |
| model = joblib.load(args.joblib) |
| raw = model.get_raw_model() if hasattr(model, "get_raw_model") else model |
| probas_lgbm = raw.predict_proba(X_df)[:, 1] |
|
|
| |
| print(f"Running ONNX on {n} rows (batch)...") |
| session = ort.InferenceSession(str(args.onnx), providers=["CPUExecutionProvider"]) |
| input_name = session.get_inputs()[0].name |
| proba_output = session.get_outputs()[1].name |
| probas_onnx = session.run([proba_output], {input_name: X_np})[0][:, 1] |
|
|
| |
| abs_delta = np.abs(probas_lgbm - probas_onnx) |
| print() |
| print("=== Numerical drift (probability) ===") |
| print(f"max |delta| = {abs_delta.max():.2e}") |
| print(f"mean |delta| = {abs_delta.mean():.2e}") |
| print(f"p95 |delta| = {np.percentile(abs_delta, 95):.2e}") |
| print(f"p99 |delta| = {np.percentile(abs_delta, 99):.2e}") |
| print(f"# rows > 1e-5 = {(abs_delta > 1e-5).sum()} / {n}") |
| print(f"# rows > 1e-3 = {(abs_delta > 1e-3).sum()} / {n}") |
|
|
| |
| preds_lgbm = (probas_lgbm >= threshold).astype(int) |
| preds_onnx = (probas_onnx >= threshold).astype(int) |
| flips_mask = preds_lgbm != preds_onnx |
| n_flips = int(flips_mask.sum()) |
| print() |
| print(f"=== Decision flips at threshold {threshold} ===") |
| print(f"# flips = {n_flips} / {n} ({100 * n_flips / n:.3f}%)") |
| print(f"# REFUSED→GRANT = {int(((preds_lgbm == 1) & (preds_onnx == 0)).sum())}") |
| print(f"# GRANT→REFUSED = {int(((preds_lgbm == 0) & (preds_onnx == 1)).sum())}") |
|
|
| |
| band = 0.005 |
| near_threshold = (probas_lgbm > threshold - band) & (probas_lgbm < threshold + band) |
| print() |
| print( |
| f"=== Borderline band [{threshold - band:.3f}, {threshold + band:.3f}] ===" |
| ) |
| print(f"# rows in band = {int(near_threshold.sum())} / {n}") |
| if near_threshold.any(): |
| flips_in_band = int((flips_mask & near_threshold).sum()) |
| print(f"# flips in band = {flips_in_band} (= what users actually feel)") |
|
|
| |
| preds_lgbm_05 = (probas_lgbm > 0.5).astype(int) |
| preds_onnx_05 = (probas_onnx > 0.5).astype(int) |
| print() |
| print(f"=== Reference @ 0.5 (textbook sanity) ===") |
| print(f"# flips @ 0.5 = {int((preds_lgbm_05 != preds_onnx_05).sum())} / {n}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|