Files
udyamlinc-ml/serving/explain.py
T
LincPayandClaude Opus 5 4ed61a2427 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>
2026-08-25 13:43:40 +05:30

112 lines
4.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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