razorshield-api / src /models /calibration.py
Vedant Sanjay Jadhav
feat: complete RazorShield AI risk platform
10ec54c
Raw
History Blame Contribute Delete
6.7 kB
"""
calibration.py
--------------
Probability calibration for Dataset A transaction model.
Compares:
1. Raw XGBoost probabilities
2. Sigmoid calibration (Platt scaling)
3. Isotonic calibration
Uses Validation set ONLY for fitting calibration.
Evaluates Brier Score, Log Loss, and Expected Calibration Error (ECE).
Selects and freezes the best calibration method for Test evaluation.
Outputs:
- models/transaction_model/calibrated_model.joblib
- data/processed/calibration_report.json
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Any
import joblib
import numpy as np
import pandas as pd
from sklearn.calibration import CalibratedClassifierCV, calibration_curve
from sklearn.metrics import brier_score_loss, log_loss
ROOT = Path(__file__).resolve().parents[2]
DATA_DIR = ROOT / "data"
PROCESSED_DIR = DATA_DIR / "processed"
MODELS_DIR = ROOT / "models"
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(message)s",
)
LOGGER = logging.getLogger("probability-calibration")
NUMERIC_FEATURES = [
"amount", "amount_log1p", "hour", "day_of_week", "is_weekend",
"customer_txn_count_past", "customer_amount_mean_past", "customer_amount_std_past",
"device_txn_count_past", "customer_amount_dev", "identity_available",
"missing_p_email", "missing_r_email", "missing_addr1", "missing_device_info"
]
CATEGORICAL_FEATURES = [
"ProductCD", "card1", "card2", "card3", "card4", "card5", "card6",
"addr1", "addr2", "P_emaildomain", "R_emaildomain", "DeviceType", "DeviceInfo"
]
def compute_ece(y_true: np.ndarray, y_prob: np.ndarray, n_bins: int = 10) -> float:
"""Computes Expected Calibration Error (ECE)."""
prob_true, prob_pred = calibration_curve(y_true, y_prob, n_bins=n_bins, strategy="uniform")
bin_edges = np.linspace(0, 1, n_bins + 1)
ece = 0.0
total_samples = len(y_true)
for i in range(n_bins):
mask = (y_prob >= bin_edges[i]) & (y_prob < bin_edges[i + 1])
bin_size = np.sum(mask)
if bin_size > 0:
bin_acc = np.mean(y_true[mask])
bin_conf = np.mean(y_prob[mask])
ece += (bin_size / total_samples) * abs(bin_acc - bin_conf)
return float(ece)
def evaluate_calibration_metrics(y_true: np.ndarray, y_prob: np.ndarray) -> dict[str, float]:
"""Calculates Brier Score, Log Loss, and ECE."""
brier = float(brier_score_loss(y_true, y_prob))
loss = float(log_loss(y_true, y_prob))
ece = compute_ece(y_true, y_prob)
return {
"brier_score": round(brier, 6),
"log_loss": round(loss, 6),
"ece": round(ece, 6),
}
def fit_and_evaluate_calibration(
dataset_a_path: Path | None = None,
) -> dict[str, Any]:
if dataset_a_path is None:
dataset_a_path = PROCESSED_DIR / "dataset_a_features.parquet"
df_a = pd.read_parquet(dataset_a_path)
val_df = df_a[df_a["split"] == "validation"].copy()
test_df = df_a[df_a["split"] == "test"].copy()
tx_model_path = MODELS_DIR / "transaction_model" / "xgboost_model.joblib"
encoder_path = MODELS_DIR / "transaction_model" / "encoder.joblib"
if not tx_model_path.exists():
raise FileNotFoundError(f"Base transaction model not found at {tx_model_path}")
xgb_tx = joblib.load(tx_model_path)
encoder = joblib.load(encoder_path)
val_cat = encoder.transform(val_df[CATEGORICAL_FEATURES].astype(str))
X_val = np.hstack([val_df[NUMERIC_FEATURES].values.astype(np.float32), val_cat.astype(np.float32)])
y_val = val_df["isFraud"].values.astype(int)
test_cat = encoder.transform(test_df[CATEGORICAL_FEATURES].astype(str))
X_test = np.hstack([test_df[NUMERIC_FEATURES].values.astype(np.float32), test_cat.astype(np.float32)])
y_test = test_df["isFraud"].values.astype(int)
# 1. Raw XGBoost probabilities
val_prob_raw = xgb_tx.predict_proba(X_val)[:, 1]
test_prob_raw = xgb_tx.predict_proba(X_test)[:, 1]
raw_val_m = evaluate_calibration_metrics(y_val, val_prob_raw)
raw_test_m = evaluate_calibration_metrics(y_test, test_prob_raw)
# 2. Sigmoid Calibration (Platt scaling fitted strictly on Validation probabilities)
LOGGER.info("Fitting Sigmoid probability calibration on Validation set ...")
from sklearn.linear_model import LogisticRegression
from sklearn.isotonic import IsotonicRegression
cal_sigmoid = LogisticRegression(C=1e5, solver="lbfgs")
cal_sigmoid.fit(val_prob_raw.reshape(-1, 1), y_val)
val_prob_sig = cal_sigmoid.predict_proba(val_prob_raw.reshape(-1, 1))[:, 1]
test_prob_sig = cal_sigmoid.predict_proba(test_prob_raw.reshape(-1, 1))[:, 1]
sig_val_m = evaluate_calibration_metrics(y_val, val_prob_sig)
sig_test_m = evaluate_calibration_metrics(y_test, test_prob_sig)
# 3. Isotonic Calibration (fitted strictly on Validation probabilities)
LOGGER.info("Fitting Isotonic probability calibration on Validation set ...")
cal_isotonic = IsotonicRegression(out_of_bounds="clip")
cal_isotonic.fit(val_prob_raw, y_val)
val_prob_iso = cal_isotonic.transform(val_prob_raw)
test_prob_iso = cal_isotonic.transform(test_prob_raw)
iso_val_m = evaluate_calibration_metrics(y_val, val_prob_iso)
iso_test_m = evaluate_calibration_metrics(y_test, test_prob_iso)
methods = {
"raw": {"val": raw_val_m, "test": raw_test_m, "model": xgb_tx},
"sigmoid": {"val": sig_val_m, "test": sig_test_m, "model": cal_sigmoid},
"isotonic": {"val": iso_val_m, "test": iso_test_m, "model": cal_isotonic},
}
# Select best calibration method based on Validation Brier Score
best_method = min(methods.keys(), key=lambda m: methods[m]["val"]["brier_score"])
LOGGER.info("Selected best calibration method: %s (Val Brier=%.6f, Val LogLoss=%.6f)",
best_method, methods[best_method]["val"]["brier_score"], methods[best_method]["val"]["log_loss"])
# Save calibrated model artifact
cal_model_path = MODELS_DIR / "transaction_model" / "calibrated_model.joblib"
joblib.dump(methods[best_method]["model"], cal_model_path)
report = {
"dataset": "Dataset A Transaction Model Calibration",
"selected_calibration_method": best_method,
"methods": {
k: {"validation": v["val"], "test": v["test"]}
for k, v in methods.items()
},
}
json_path = PROCESSED_DIR / "calibration_report.json"
with json_path.open("w", encoding="utf-8") as f:
json.dump(report, f, indent=2)
LOGGER.info("Calibration report saved to %s", json_path)
return report
if __name__ == "__main__":
fit_and_evaluate_calibration()