feat: the UdyamLinc ML layer — scoring, explanation and quantile forecast
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>
This commit is contained in:
+289
@@ -0,0 +1,289 @@
|
||||
"""
|
||||
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
|
||||
)
|
||||
Reference in New Issue
Block a user