Brings the Python service under version control for the first time. It has
been running and serving the demo since 23 August; until now it existed only
on one developer's disk.
Contents:
training/build_dataset.py point-in-time join over the feature store, with
leakage assertions. dscr_p10 and dscr_p50 are
excluded deliberately — both are derived from
the forecast, which is derived from the
instalment, so the model would read its own
answer.
training/train_stress.py LightGBM vs XGBoost vs two single-feature
baselines, expanding-window validation over
as-of dates, publishes to the model_metric table
training/train_forecast.py three quantile heads (P10/P50/P90), horizon as a
feature rather than one model per horizon
serving/explain.py SHAP TreeExplainer folded into the five risk
families the console already speaks. The family
map mirrors RuleBasedScoringStrategy in the API
repo; where they disagree the rule engine wins.
serving/app.py /score, /score/batch, /forecast, /health, /model
The trained models, their metrics and the training dataset are committed
alongside the code. Without them the published AUC of 0.760 is a claim nobody
else can check; at ~2.2 MB together they are worth the space.
Measured, on 5,523 rows / 263 enterprises / 242 positives / 100% synthetic
labels: LightGBM AUC-ROC 0.7599, and the emi_to_inflow baseline 0.7685 on the
same folds. The baseline wins. That is recorded rather than tuned away — on
generated labels, beating it would mostly prove the model had learned the
generator.
The quantile forecast beats the deterministic strategy on all three quantiles
(pinball loss, 12-33% better) but flags every borrower as breaching, so the
deployment runs udyam.forecast.engine=DETERMINISTIC and this model is held
back pending calibration against real repayment outcomes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
290 lines
9.7 KiB
Python
290 lines
9.7 KiB
Python
"""
|
|
The inference service Spring Boot calls.
|
|
|
|
One network boundary, and it is deliberately narrow: the request carries the
|
|
feature map and nothing else about the borrower. No name, no phone, no account
|
|
reference — those do not exist in the platform's schema, and this service must
|
|
never become the reason someone adds them.
|
|
|
|
The response mirrors ``ScoreOutcome`` field for field, so the Java adapter is a
|
|
mapping rather than a translation.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pickle
|
|
from pathlib import Path
|
|
|
|
import pandas as pd
|
|
from fastapi import FastAPI, HTTPException
|
|
from pydantic import BaseModel, Field
|
|
|
|
from explain import Explainer, FAMILIES
|
|
|
|
MODEL_DIR = Path(__file__).resolve().parent.parent / "models"
|
|
MODEL_PATH = MODEL_DIR / "stress_model.pkl"
|
|
|
|
app = FastAPI(title="UdyamLinc ML", version="1.0.0")
|
|
|
|
_bundle: dict | None = None
|
|
_explainer: Explainer | None = None
|
|
|
|
|
|
def bundle() -> dict:
|
|
global _bundle, _explainer
|
|
if _bundle is None:
|
|
with open(MODEL_PATH, "rb") as fh:
|
|
_bundle = pickle.load(fh)
|
|
_explainer = Explainer(_bundle["model"], _bundle["features"])
|
|
return _bundle
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
class ScoreRequest(BaseModel):
|
|
enterpriseId: str
|
|
sectorId: int | None = None
|
|
districtId: int | None = None
|
|
# Nulls are meaningful and must survive the wire: the engine has to be able to
|
|
# see what it did not get, exactly as ScoringStrategy requires of a Java engine.
|
|
features: dict[str, float | None] = Field(default_factory=dict)
|
|
dpdAtT: float | None = None
|
|
outstandingAtT: float | None = None
|
|
emiAtT: float | None = None
|
|
|
|
|
|
class Driver(BaseModel):
|
|
family: str
|
|
weight: int
|
|
featureCode: str | None
|
|
value: float | None
|
|
onPrior: bool
|
|
|
|
|
|
class ScoreResponse(BaseModel):
|
|
enterpriseId: str
|
|
score: int
|
|
band: str
|
|
confidence: int
|
|
probability: float
|
|
subScores: dict[str, int]
|
|
drivers: list[Driver]
|
|
familiesOnPrior: int
|
|
engine: str = "MODEL"
|
|
modelVersion: str
|
|
|
|
|
|
class BatchRequest(BaseModel):
|
|
items: list[ScoreRequest]
|
|
|
|
|
|
def _frame(req: ScoreRequest, features: list[str]) -> pd.DataFrame:
|
|
row: dict[str, float | None] = {}
|
|
for name in features:
|
|
if name.endswith("__missing"):
|
|
base = name.removesuffix("__missing")
|
|
row[name] = 1 if req.features.get(base) is None else 0
|
|
elif name == "dpd_at_t":
|
|
row[name] = req.dpdAtT
|
|
elif name == "outstanding_at_t":
|
|
row[name] = req.outstandingAtT
|
|
elif name == "emi_at_t":
|
|
row[name] = req.emiAtT
|
|
elif name == "sector_id":
|
|
row[name] = req.sectorId
|
|
elif name == "district_id":
|
|
row[name] = req.districtId
|
|
else:
|
|
row[name] = req.features.get(name)
|
|
# Everything float64: a dict of mixed None and float builds an object column,
|
|
# and LightGBM rejects object dtypes rather than coercing. None becomes NaN,
|
|
# which is exactly how the model was trained to read a missing feature.
|
|
return pd.DataFrame([row], columns=features).astype("float64")
|
|
|
|
|
|
def _score_one(req: ScoreRequest) -> ScoreResponse:
|
|
b = bundle()
|
|
frame = _frame(req, b["features"])
|
|
probability = float(b["model"].predict_proba(frame)[:, 1][0])
|
|
score = int(round(probability * 100))
|
|
|
|
band = ("RED" if score >= b["red_threshold"]
|
|
else "WATCH" if score >= b["watch_threshold"]
|
|
else "STANDARD")
|
|
|
|
sub_scores, drivers = _explainer.explain(frame) # type: ignore[union-attr]
|
|
on_prior = sum(1 for d in drivers if d["onPrior"])
|
|
|
|
# Confidence falls with the share of the catalogue that came back null. A
|
|
# prediction built on holes must report as one — the rule engine already does
|
|
# this through familiesOnPrior, and the two must not disagree.
|
|
supplied = sum(1 for v in req.features.values() if v is not None)
|
|
coverage = supplied / max(1, len(req.features))
|
|
confidence = int(round(max(20.0, min(95.0, 95 * coverage))))
|
|
|
|
return ScoreResponse(
|
|
enterpriseId=req.enterpriseId,
|
|
score=score,
|
|
band=band,
|
|
confidence=confidence,
|
|
probability=round(probability, 5),
|
|
subScores=sub_scores,
|
|
drivers=[Driver(**{k: d[k] for k in ("family", "weight", "featureCode", "value", "onPrior")})
|
|
for d in drivers],
|
|
familiesOnPrior=on_prior,
|
|
modelVersion=f"{b['algorithm']}-stress-v1",
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
@app.get("/health")
|
|
def health() -> dict:
|
|
return {"status": "UP", "model": MODEL_PATH.exists()}
|
|
|
|
|
|
@app.get("/model")
|
|
def model_info() -> dict:
|
|
b = bundle()
|
|
return {
|
|
"algorithm": b["algorithm"],
|
|
"modelVersion": f"{b['algorithm']}-stress-v1",
|
|
"features": b["features"],
|
|
"families": FAMILIES,
|
|
"redThreshold": b["red_threshold"],
|
|
"watchThreshold": b["watch_threshold"],
|
|
"trainedOn": "synthetic DPD history — metrics are provisional",
|
|
}
|
|
|
|
|
|
@app.post("/score", response_model=ScoreResponse)
|
|
def score(req: ScoreRequest) -> ScoreResponse:
|
|
return _score_one(req)
|
|
|
|
|
|
@app.post("/score/batch", response_model=list[ScoreResponse])
|
|
def score_batch(req: BatchRequest) -> list[ScoreResponse]:
|
|
"""One call for a nightly run over the whole book, rather than 263."""
|
|
return [_score_one(item) for item in req.items]
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Forecast — quantile heads #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
FORECAST_PATH = MODEL_DIR / "forecast_model.pkl"
|
|
|
|
_forecast: dict | None = None
|
|
|
|
|
|
def forecast_bundle() -> dict | None:
|
|
"""None when no forecast model has been trained — the caller falls back."""
|
|
global _forecast
|
|
if _forecast is None and FORECAST_PATH.exists():
|
|
with open(FORECAST_PATH, "rb") as fh:
|
|
_forecast = pickle.load(fh)
|
|
return _forecast
|
|
|
|
|
|
class ForecastRequest(BaseModel):
|
|
enterpriseId: str
|
|
sectorId: int | None = None
|
|
districtId: int | None = None
|
|
features: dict[str, float | None] = Field(default_factory=dict)
|
|
# Trailing ledger statistics, computed by the caller. The service stays
|
|
# stateless: it holds no database credentials and cannot read a borrower's
|
|
# ledger even if asked to. Java already reads the ledger for the
|
|
# deterministic path, so this is the same query it was already making.
|
|
trailMean: float | None = None
|
|
trailSd: float | None = None
|
|
trailMin: float | None = None
|
|
trailInflow: float | None = None
|
|
trailMonths: float | None = None
|
|
emi: float | None = None
|
|
horizons: int = 6
|
|
|
|
|
|
class ForecastPoint(BaseModel):
|
|
horizon: int
|
|
p10: float
|
|
p50: float
|
|
p90: float
|
|
|
|
|
|
class ForecastResponse(BaseModel):
|
|
enterpriseId: str
|
|
engine: str = "MODEL"
|
|
modelVersion: str
|
|
points: list[ForecastPoint]
|
|
|
|
|
|
@app.post("/forecast", response_model=ForecastResponse)
|
|
def forecast(req: ForecastRequest) -> ForecastResponse:
|
|
b = forecast_bundle()
|
|
if b is None:
|
|
raise HTTPException(status_code=503, detail="No forecast model trained")
|
|
|
|
# Three months of ledger is the floor the deterministic strategy uses too. An
|
|
# enterprise with less gets no forecast rather than a fabricated one.
|
|
if (req.trailMonths or 0) < 3:
|
|
return ForecastResponse(
|
|
enterpriseId=req.enterpriseId, modelVersion=b["version"], points=[]
|
|
)
|
|
|
|
rows = []
|
|
for h in range(1, min(req.horizons, b["horizons"]) + 1):
|
|
row: dict[str, float | None] = {}
|
|
for name in b["features"]:
|
|
if name.endswith("__missing"):
|
|
row[name] = 1 if req.features.get(name.removesuffix("__missing")) is None else 0
|
|
elif name == "horizon":
|
|
row[name] = h
|
|
elif name == "trail_mean":
|
|
row[name] = req.trailMean
|
|
elif name == "trail_sd":
|
|
row[name] = req.trailSd
|
|
elif name == "trail_min":
|
|
row[name] = req.trailMin
|
|
elif name == "trail_inflow":
|
|
row[name] = req.trailInflow
|
|
elif name == "trail_months":
|
|
row[name] = req.trailMonths
|
|
elif name == "emi":
|
|
row[name] = req.emi
|
|
elif name == "sector_id":
|
|
row[name] = req.sectorId
|
|
elif name == "district_id":
|
|
row[name] = req.districtId
|
|
else:
|
|
row[name] = req.features.get(name)
|
|
rows.append(row)
|
|
|
|
frame = pd.DataFrame(rows, columns=b["features"]).astype("float64")
|
|
|
|
preds = {q: b["models"][q].predict(frame) for q in ("p10", "p50", "p90")}
|
|
|
|
points = []
|
|
for i, h in enumerate(range(1, len(rows) + 1)):
|
|
# Quantile regression does not guarantee ordering — sort per row. A
|
|
# crossed band is worse than no band, and it is the kind of thing that
|
|
# renders as a chart nobody can read rather than as an error.
|
|
lo, mid, hi = sorted(float(preds[q][i]) for q in ("p10", "p50", "p90"))
|
|
|
|
# Missing inputs widen the band and never lower its centre. The rule the
|
|
# deterministic strategy already honoured: a pessimistic P50 built on
|
|
# ignorance manufactures breaches out of not knowing.
|
|
absent = sum(1 for v in req.features.values() if v is None)
|
|
if absent:
|
|
widen = 1 + 0.06 * absent
|
|
half_lo = (mid - lo) * widen
|
|
half_hi = (hi - mid) * widen
|
|
lo, hi = mid - half_lo, mid + half_hi
|
|
|
|
points.append(ForecastPoint(horizon=h, p10=round(lo, 2), p50=round(mid, 2), p90=round(hi, 2)))
|
|
|
|
return ForecastResponse(
|
|
enterpriseId=req.enterpriseId, modelVersion=b["version"], points=points
|
|
)
|