AnmolMogalayi's picture
Upload 35 files
766f33a verified
Raw
History Blame Contribute Delete
7.82 kB
"""
predict.py
Loads model + metadata once at startup.
Handles single prediction and batch scoring with SHAP.
"""
import json
import numpy as np
import pandas as pd
import joblib
import shap
from pathlib import Path
# ── Paths β€” resolve relative to project root regardless of where uvicorn runs ──
_HERE = Path(__file__).resolve().parent # .../backend/
ROOT = _HERE.parent # .../finexcore-loan-default/
MODELS_DIR = ROOT / "models"
PROCESSED_DIR = ROOT / "data" / "processed"
# ── Load everything once at startup ───────────────────────────────────────────
print("Loading model...")
MODEL = joblib.load(MODELS_DIR / "lgbm_best.pkl")
print("Loading metadata...")
with open(MODELS_DIR / "feature_cols.json") as f: FEATURE_COLS = json.load(f)
with open(MODELS_DIR / "threshold.json") as f: THRESHOLD = json.load(f)["threshold"]
with open(MODELS_DIR / "metrics.json") as f: METRICS = json.load(f)
with open(MODELS_DIR / "shap_feature_desc.json") as f: FEAT_DESC = json.load(f)
with open(PROCESSED_DIR / "pipeline_meta.json") as f: PIPELINE_META = json.load(f)
SHAP_TOP = pd.read_csv(MODELS_DIR / "shap_top20.csv").to_dict(orient="records")
print("Loading SHAP explainer...")
EXPLAINER = shap.TreeExplainer(MODEL)
print("All loaded. Ready.")
# ══════════════════════════════════════════════════════════════════════════════
# HELPERS
# ══════════════════════════════════════════════════════════════════════════════
def build_feature_vector(input_dict: dict) -> np.ndarray:
"""
Takes a raw input dict, fills missing with training medians,
clips with training bounds, returns float32 array.
"""
row = {feat: input_dict.get(feat, np.nan) for feat in FEATURE_COLS}
df = pd.DataFrame([row])
# Impute missing
for col, med in PIPELINE_META["medians"].items():
if col in df.columns and df[col].isna().any():
df[col] = df[col].fillna(med)
# Clip outliers
for col, (lo, hi) in PIPELINE_META["clip_bounds"].items():
if col in df.columns:
df[col] = df[col].clip(lower=lo, upper=hi)
# Final fillna safety
df = df.fillna(0)
return df[FEATURE_COLS].values.astype(np.float32)
def risk_tier(prob: float) -> str:
if prob >= THRESHOLD:
return "HIGH"
elif prob >= THRESHOLD * 0.6:
return "MEDIUM"
return "LOW"
def decision(prob: float) -> str:
if prob >= THRESHOLD: return "DECLINE"
elif prob >= THRESHOLD * 0.6: return "REVIEW"
return "APPROVE"
# ══════════════════════════════════════════════════════════════════════════════
# SINGLE PREDICTION
# ══════════════════════════════════════════════════════════════════════════════
def predict_single(input_dict: dict) -> dict:
X = build_feature_vector(input_dict)
prob = float(MODEL.predict_proba(X)[0, 1])
# SHAP
sv = EXPLAINER.shap_values(X)
sv = sv[1][0] if isinstance(sv, list) else sv[0]
shap_factors = []
indices = np.argsort(np.abs(sv))[::-1][:10]
for i in indices:
feat = FEATURE_COLS[i]
shap_factors.append({
"feature": feat,
"description": FEAT_DESC.get(feat, feat.replace("_", " ").title()),
"value": round(float(X[0, i]), 4),
"shap": round(float(sv[i]), 6),
"direction": "increases_risk" if sv[i] > 0 else "reduces_risk",
})
return {
"probability": round(prob, 6),
"probability_pct": round(prob * 100, 2),
"risk_tier": risk_tier(prob),
"decision": decision(prob),
"threshold": round(THRESHOLD, 4),
"credit_score": max(300, min(850, int(850 - prob * 600))),
"shap_factors": shap_factors,
}
# ══════════════════════════════════════════════════════════════════════════════
# BATCH PREDICTION
# ══════════════════════════════════════════════════════════════════════════════
def predict_batch(records: list[dict]) -> dict:
"""
Takes a list of applicant dicts.
Returns scored results + portfolio summary.
"""
if not records:
return {"error": "No records provided"}
# Build feature matrix
rows = []
for rec in records:
row = {feat: rec.get(feat, np.nan) for feat in FEATURE_COLS}
rows.append(row)
df = pd.DataFrame(rows)
# Impute
for col, med in PIPELINE_META["medians"].items():
if col in df.columns:
df[col] = df[col].fillna(med)
# Clip
for col, (lo, hi) in PIPELINE_META["clip_bounds"].items():
if col in df.columns:
df[col] = df[col].clip(lower=lo, upper=hi)
# Drop string cols, fill remaining NaN
obj_cols = df.select_dtypes(include=["object","category"]).columns.tolist()
valid = [c for c in FEATURE_COLS if c in df.columns and c not in obj_cols]
for feat in FEATURE_COLS:
if feat not in df.columns:
df[feat] = PIPELINE_META["medians"].get(feat, 0)
df = df.fillna(0)
X = df[FEATURE_COLS].values.astype(np.float32)
probs = MODEL.predict_proba(X)[:, 1]
# Build results
results = []
for i, (rec, prob) in enumerate(zip(records, probs)):
prob = float(prob)
results.append({
"id": rec.get("SK_ID_CURR", i + 1),
"probability": round(prob, 4),
"risk_tier": risk_tier(prob),
"decision": decision(prob),
})
# Portfolio summary
probs_arr = np.array([r["probability"] for r in results])
approve_n = sum(1 for r in results if r["decision"] == "APPROVE")
review_n = sum(1 for r in results if r["decision"] == "REVIEW")
decline_n = sum(1 for r in results if r["decision"] == "DECLINE")
high_n = sum(1 for r in results if r["risk_tier"] == "HIGH")
medium_n = sum(1 for r in results if r["risk_tier"] == "MEDIUM")
low_n = sum(1 for r in results if r["risk_tier"] == "LOW")
return {
"total": len(results),
"summary": {
"approve_count": approve_n,
"review_count": review_n,
"decline_count": decline_n,
"approve_pct": round(approve_n / len(results) * 100, 1),
"decline_pct": round(decline_n / len(results) * 100, 1),
"mean_probability":round(float(probs_arr.mean()), 4),
"high_risk_count": high_n,
"medium_risk_count": medium_n,
"low_risk_count": low_n,
},
"results": results,
}