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>
112 lines
4.5 KiB
Python
112 lines
4.5 KiB
Python
"""
|
||
SHAP contributions, folded into the five families the console already speaks.
|
||
|
||
The console, the reason sentences and the Hindi PWA are all written against
|
||
``liquidity | behavioural | trendVolatility | obligationLoad | externalShock``.
|
||
A model that explained itself in thirteen feature names would need every one of
|
||
those surfaces rewritten, and would hand a field officer a vocabulary they have
|
||
never used. So the SHAP values are aggregated to the families instead, and the
|
||
feature that contributed most inside each family is named alongside it.
|
||
|
||
The family map mirrors ``RuleBasedScoringStrategy`` exactly. Where the two
|
||
disagree, the rule engine is right and this file is wrong — an officer must not
|
||
see the same enterprise explained two different ways depending on which engine
|
||
happened to score it.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import numpy as np
|
||
import pandas as pd
|
||
import shap
|
||
|
||
LIQUIDITY = "liquidity"
|
||
BEHAVIOURAL = "behavioural"
|
||
TREND_VOLATILITY = "trendVolatility"
|
||
OBLIGATION_LOAD = "obligationLoad"
|
||
EXTERNAL_SHOCK = "externalShock"
|
||
|
||
FAMILIES = [LIQUIDITY, BEHAVIOURAL, TREND_VOLATILITY, OBLIGATION_LOAD, EXTERNAL_SHOCK]
|
||
|
||
FAMILY_OF: dict[str, str] = {
|
||
# liquidity — is there cash in the business
|
||
"cash_flow_change": LIQUIDITY,
|
||
"inventory_days": LIQUIDITY,
|
||
"upi_inflow_change": LIQUIDITY,
|
||
# behavioural — how the borrower has been acting
|
||
"emi_delay": BEHAVIOURAL,
|
||
"days_since_last_entry": BEHAVIOURAL,
|
||
"dpd_at_t": BEHAVIOURAL,
|
||
# trend / volatility — how unstable the market around them is
|
||
"price_z_score": TREND_VOLATILITY,
|
||
# obligation load — how heavy the instalment is against what comes in
|
||
"emi_to_inflow": OBLIGATION_LOAD,
|
||
"emi_at_t": OBLIGATION_LOAD,
|
||
"outstanding_at_t": OBLIGATION_LOAD,
|
||
# external shock — what the weather and the mandi are doing to them
|
||
"margin_squeeze": EXTERNAL_SHOCK,
|
||
"feed_cost_change": EXTERNAL_SHOCK,
|
||
"output_price_change": EXTERNAL_SHOCK,
|
||
"rainfall_anomaly": EXTERNAL_SHOCK,
|
||
"heat_anomaly": EXTERNAL_SHOCK,
|
||
}
|
||
|
||
|
||
class Explainer:
|
||
"""Wraps a TreeExplainer and does the family arithmetic."""
|
||
|
||
def __init__(self, model, features: list[str]):
|
||
self.features = features
|
||
self._shap = shap.TreeExplainer(model)
|
||
|
||
def explain(self, row: pd.DataFrame) -> tuple[dict[str, int], list[dict]]:
|
||
"""
|
||
:return: (sub-scores 0–100 per family, drivers sorted by contribution)
|
||
|
||
Only contributions that push the score **up** are reported as drivers.
|
||
A negative SHAP value means "this is one of the reasons the score is not
|
||
worse", which is true and useless to an officer deciding whether to visit.
|
||
"""
|
||
values = self._shap.shap_values(row[self.features])
|
||
if isinstance(values, list): # older binary API returns one array per class
|
||
values = values[1]
|
||
contributions = np.asarray(values).reshape(-1)
|
||
|
||
by_family: dict[str, float] = {f: 0.0 for f in FAMILIES}
|
||
best_feature: dict[str, tuple[str, float]] = {}
|
||
|
||
for name, value in zip(self.features, contributions):
|
||
base = name.removesuffix("__missing")
|
||
family = FAMILY_OF.get(base)
|
||
if family is None:
|
||
continue
|
||
by_family[family] += float(value)
|
||
held = best_feature.get(family)
|
||
if held is None or abs(value) > abs(held[1]):
|
||
best_feature[family] = (base, float(value))
|
||
|
||
positive = {f: max(0.0, v) for f, v in by_family.items()}
|
||
total = sum(positive.values())
|
||
|
||
# No family pushed the score up — a genuinely calm enterprise. Spread the
|
||
# weight evenly rather than emitting five zeros, which the console would
|
||
# render as an empty explanation for a score that still exists.
|
||
if total <= 0:
|
||
sub_scores = {f: 20 for f in FAMILIES}
|
||
else:
|
||
sub_scores = {f: int(round(100 * v / total)) for f, v in positive.items()}
|
||
|
||
drivers = []
|
||
for family in FAMILIES:
|
||
feature, raw = best_feature.get(family, (None, 0.0))
|
||
drivers.append({
|
||
"family": family,
|
||
"weight": sub_scores[family],
|
||
"featureCode": feature,
|
||
"value": float(row[feature].iloc[0]) if feature in row and pd.notna(row[feature].iloc[0]) else None,
|
||
"onPrior": bool(feature is None or (feature in row and pd.isna(row[feature].iloc[0]))),
|
||
"shap": round(raw, 5),
|
||
})
|
||
drivers.sort(key=lambda d: d["weight"], reverse=True)
|
||
return sub_scores, drivers
|