sentinel-fraud-detection / src /model_bundle.py
HarryDataScience's picture
Publish SENTINEL
0655115 verified
Raw History Blame Contribute Delete
6.82 kB
"""Versioned, pickle-free model bundle.
Artifacts in model/:
xgb_model.json XGBoost native JSON
preprocess.json fitted scaler parameters
calibrator.json probability calibrator (isotonic / sigmoid / none)
metadata.json version, feature order, threshold, costs, library versions,
SHA-256 of every artifact above
metrics.json evaluation results (written by training)
reference_stats.json feature stats + PSI reference bins (written by training)
"""
import json
import logging
import platform
from dataclasses import dataclass
from pathlib import Path
import numpy as np
import pandas as pd
import xgboost as xgb
from .config import sha256_file
from .features import SCALED_FEATURES, make_features
logger = logging.getLogger(__name__)
ARTIFACT_FILES = ("xgb_model.json", "preprocess.json", "calibrator.json")
SCHEMA_VERSION = 2
class ArtifactError(RuntimeError):
"""Model artifacts are missing, corrupted, or incompatible."""
def library_versions() -> dict:
import numpy
import pandas
return {
"python": platform.python_version(),
"xgboost": xgb.__version__,
"numpy": numpy.__version__,
"pandas": pandas.__version__,
}
class Calibrator:
"""Pure-numpy probability calibrator, serialisable to JSON."""
def __init__(self, spec: dict):
self.spec = spec
self.method = spec["method"]
@classmethod
def identity(cls):
return cls({"method": "none"})
@classmethod
def isotonic(cls, x_thresholds, y_thresholds):
return cls({"method": "isotonic", "x": [float(v) for v in x_thresholds],
"y": [float(v) for v in y_thresholds]})
@classmethod
def sigmoid(cls, a: float, b: float):
return cls({"method": "sigmoid", "a": float(a), "b": float(b)})
def __call__(self, p):
p = np.asarray(p, dtype=float)
if self.method == "isotonic":
return np.interp(p, self.spec["x"], self.spec["y"])
if self.method == "sigmoid":
clipped = np.clip(p, 1e-7, 1 - 1e-7)
z = np.log(clipped / (1 - clipped))
return 1.0 / (1.0 + np.exp(-(self.spec["a"] * z + self.spec["b"])))
return p
@dataclass
class ModelBundle:
metadata: dict
clf: xgb.XGBClassifier
scaler_mean: np.ndarray
scaler_scale: np.ndarray
calibrator: Calibrator
# ---- properties -------------------------------------------------------
@property
def feature_names(self) -> list:
return self.metadata["feature_names"]
@property
def threshold(self) -> float:
return float(self.metadata["threshold"])
@property
def version(self) -> str:
return self.metadata["model_version"]
# ---- inference --------------------------------------------------------
def prepare(self, raw: pd.DataFrame) -> pd.DataFrame:
"""Raw transactions -> scaled feature frame in the exact training order."""
x = make_features(raw)
for i, col in enumerate(SCALED_FEATURES):
x[col] = (x[col] - self.scaler_mean[i]) / self.scaler_scale[i]
return x[self.feature_names]
def predict_proba(self, raw: pd.DataFrame) -> np.ndarray:
"""Calibrated fraud probabilities."""
return self.calibrator(self.predict_raw(self.prepare(raw)))
def predict_raw(self, prepared: pd.DataFrame) -> np.ndarray:
return self.clf.predict_proba(prepared)[:, 1]
def contributions(self, raw: pd.DataFrame):
"""TreeSHAP contributions (log-odds). Returns (prepared_features, contribs[n, n_features])."""
prepared = self.prepare(raw)
booster = self.clf.get_booster()
dm = xgb.DMatrix(prepared, feature_names=self.feature_names)
contribs = booster.predict(dm, pred_contribs=True)[:, :-1] # last column = bias
return prepared, contribs
# ---- persistence ------------------------------------------------------
@classmethod
def load(cls, model_dir, strict_versions: bool = False) -> "ModelBundle":
d = Path(model_dir)
meta_path = d / "metadata.json"
if not meta_path.exists():
raise ArtifactError(f"metadata.json not found in {d}")
metadata = json.loads(meta_path.read_text(encoding="utf-8"))
if metadata.get("schema_version") != SCHEMA_VERSION:
raise ArtifactError(
f"Unsupported artifact schema {metadata.get('schema_version')} (expected {SCHEMA_VERSION})")
for name in ARTIFACT_FILES:
path = d / name
if not path.exists():
raise ArtifactError(f"Missing artifact: {path}")
expected = metadata.get("artifact_sha256", {}).get(name)
if expected != sha256_file(path):
raise ArtifactError(f"Checksum mismatch for {name}: artifact is corrupted or was modified")
trained = metadata.get("library_versions", {})
current = library_versions()
for lib in ("xgboost",):
t, c = trained.get(lib, ""), current[lib]
if t.split(".")[:2] != c.split(".")[:2]:
message = f"{lib} version mismatch: trained with {t}, running {c}"
if strict_versions:
raise ArtifactError(message)
logger.warning(message)
clf = xgb.XGBClassifier()
clf.load_model(str(d / "xgb_model.json"))
pre = json.loads((d / "preprocess.json").read_text(encoding="utf-8"))
cal = Calibrator(json.loads((d / "calibrator.json").read_text(encoding="utf-8")))
bundle = cls(metadata, clf, np.array(pre["mean"], float), np.array(pre["scale"], float), cal)
if list(clf.feature_names_in_) != bundle.feature_names:
raise ArtifactError("Feature order in xgb_model.json does not match metadata.json")
return bundle
def save_bundle(model_dir, clf, scaler_mean, scaler_scale, calibrator: Calibrator, metadata: dict) -> dict:
d = Path(model_dir)
d.mkdir(parents=True, exist_ok=True)
clf.save_model(str(d / "xgb_model.json"))
(d / "preprocess.json").write_text(json.dumps({
"scaled_features": SCALED_FEATURES,
"mean": [float(v) for v in scaler_mean],
"scale": [float(v) for v in scaler_scale],
}, indent=2), encoding="utf-8")
(d / "calibrator.json").write_text(json.dumps(calibrator.spec), encoding="utf-8")
metadata = dict(metadata)
metadata["schema_version"] = SCHEMA_VERSION
metadata["feature_names"] = [str(c) for c in clf.feature_names_in_]
metadata["library_versions"] = library_versions()
metadata["artifact_sha256"] = {n: sha256_file(d / n) for n in ARTIFACT_FILES}
(d / "metadata.json").write_text(json.dumps(metadata, indent=2), encoding="utf-8")
return metadata