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:
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"rows": 30711,
|
||||
"enterprises": 263,
|
||||
"as_of_months": 22,
|
||||
"horizons": 6,
|
||||
"validation": "expanding-window time-series split over as-of months",
|
||||
"feature_order": [
|
||||
"feed_cost_change",
|
||||
"output_price_change",
|
||||
"margin_squeeze",
|
||||
"price_z_score",
|
||||
"rainfall_anomaly",
|
||||
"heat_anomaly",
|
||||
"cash_flow_change",
|
||||
"emi_delay",
|
||||
"inventory_days",
|
||||
"feed_staleness_days",
|
||||
"days_since_last_entry",
|
||||
"emi_to_inflow",
|
||||
"feed_cost_change__missing",
|
||||
"output_price_change__missing",
|
||||
"margin_squeeze__missing",
|
||||
"price_z_score__missing",
|
||||
"rainfall_anomaly__missing",
|
||||
"heat_anomaly__missing",
|
||||
"cash_flow_change__missing",
|
||||
"emi_delay__missing",
|
||||
"inventory_days__missing",
|
||||
"feed_staleness_days__missing",
|
||||
"days_since_last_entry__missing",
|
||||
"emi_to_inflow__missing",
|
||||
"trail_mean",
|
||||
"trail_sd",
|
||||
"trail_min",
|
||||
"trail_inflow",
|
||||
"trail_months",
|
||||
"emi",
|
||||
"horizon",
|
||||
"sector_id",
|
||||
"district_id"
|
||||
],
|
||||
"quantiles": {
|
||||
"p10": {
|
||||
"alpha": 0.1,
|
||||
"pinball_model": 3150.78,
|
||||
"pinball_deterministic": 4182.0
|
||||
},
|
||||
"p50": {
|
||||
"alpha": 0.5,
|
||||
"pinball_model": 6194.72,
|
||||
"pinball_deterministic": 7061.24
|
||||
},
|
||||
"p90": {
|
||||
"alpha": 0.9,
|
||||
"pinball_model": 2770.14,
|
||||
"pinball_deterministic": 4112.53
|
||||
}
|
||||
},
|
||||
"verdict": "3/3 quantiles beat the deterministic strategy on held-out months",
|
||||
"trainedAt": "2026-08-24T11:07:50.265843"
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,192 @@
|
||||
{
|
||||
"dataset": {
|
||||
"rows": 5523,
|
||||
"positives": 242,
|
||||
"positive_rate": 0.0438,
|
||||
"as_of_dates": 21,
|
||||
"synthetic_label_share": 1.0
|
||||
},
|
||||
"validation": "expanding-window time-series split over as-of dates",
|
||||
"feature_order": [
|
||||
"feed_cost_change",
|
||||
"output_price_change",
|
||||
"margin_squeeze",
|
||||
"price_z_score",
|
||||
"rainfall_anomaly",
|
||||
"heat_anomaly",
|
||||
"cash_flow_change",
|
||||
"emi_delay",
|
||||
"inventory_days",
|
||||
"upi_inflow_change",
|
||||
"feed_staleness_days",
|
||||
"days_since_last_entry",
|
||||
"emi_to_inflow",
|
||||
"feed_cost_change__missing",
|
||||
"output_price_change__missing",
|
||||
"margin_squeeze__missing",
|
||||
"price_z_score__missing",
|
||||
"rainfall_anomaly__missing",
|
||||
"heat_anomaly__missing",
|
||||
"cash_flow_change__missing",
|
||||
"emi_delay__missing",
|
||||
"inventory_days__missing",
|
||||
"upi_inflow_change__missing",
|
||||
"feed_staleness_days__missing",
|
||||
"days_since_last_entry__missing",
|
||||
"emi_to_inflow__missing",
|
||||
"dpd_at_t",
|
||||
"outstanding_at_t",
|
||||
"emi_at_t",
|
||||
"sector_id",
|
||||
"district_id"
|
||||
],
|
||||
"algorithms": {
|
||||
"lightgbm": {
|
||||
"folds": [
|
||||
{
|
||||
"n": 789,
|
||||
"positives": 38,
|
||||
"auc_roc": 0.6814422874763473,
|
||||
"pr_auc": 0.12090605567917119,
|
||||
"brier": 0.0690301962610968,
|
||||
"red_flagged": 23,
|
||||
"precision_red": 0.17391304347826086,
|
||||
"recall_red": 0.10526315789473684,
|
||||
"recall_flagged": 0.3684210526315789,
|
||||
"train_to": "2025-03-31",
|
||||
"test_from": "2025-04-30"
|
||||
},
|
||||
{
|
||||
"n": 789,
|
||||
"positives": 31,
|
||||
"auc_roc": 0.7962805345135756,
|
||||
"pr_auc": 0.17203736204143655,
|
||||
"brier": 0.08691410549130126,
|
||||
"red_flagged": 54,
|
||||
"precision_red": 0.14814814814814814,
|
||||
"recall_red": 0.25806451612903225,
|
||||
"recall_flagged": 0.6129032258064516,
|
||||
"train_to": "2025-06-30",
|
||||
"test_from": "2025-07-31"
|
||||
},
|
||||
{
|
||||
"n": 789,
|
||||
"positives": 39,
|
||||
"auc_roc": 0.8423931623931624,
|
||||
"pr_auc": 0.22591607572996394,
|
||||
"brier": 0.11095460084002903,
|
||||
"red_flagged": 90,
|
||||
"precision_red": 0.2222222222222222,
|
||||
"recall_red": 0.5128205128205128,
|
||||
"recall_flagged": 0.8205128205128205,
|
||||
"train_to": "2025-09-30",
|
||||
"test_from": "2025-10-31"
|
||||
},
|
||||
{
|
||||
"n": 1052,
|
||||
"positives": 51,
|
||||
"auc_roc": 0.7193982488100136,
|
||||
"pr_auc": 0.20698077930278122,
|
||||
"brier": 0.08188506640426521,
|
||||
"red_flagged": 59,
|
||||
"precision_red": 0.22033898305084745,
|
||||
"recall_red": 0.2549019607843137,
|
||||
"recall_flagged": 0.43137254901960786,
|
||||
"train_to": "2025-12-31",
|
||||
"test_from": "2026-01-31"
|
||||
}
|
||||
],
|
||||
"pooled": {
|
||||
"auc_roc": 0.7599,
|
||||
"pr_auc": 0.1815,
|
||||
"brier": 0.0872,
|
||||
"precision_red": 0.1912,
|
||||
"recall_red": 0.2828,
|
||||
"recall_flagged": 0.5583
|
||||
}
|
||||
},
|
||||
"xgboost": {
|
||||
"folds": [
|
||||
{
|
||||
"n": 789,
|
||||
"positives": 38,
|
||||
"auc_roc": 0.6397084588969094,
|
||||
"pr_auc": 0.10510222243195182,
|
||||
"brier": 0.055753882974386215,
|
||||
"red_flagged": 13,
|
||||
"precision_red": 0.15384615384615385,
|
||||
"recall_red": 0.05263157894736842,
|
||||
"recall_flagged": 0.15789473684210525,
|
||||
"train_to": "2025-03-31",
|
||||
"test_from": "2025-04-30"
|
||||
},
|
||||
{
|
||||
"n": 789,
|
||||
"positives": 31,
|
||||
"auc_roc": 0.7543195165546004,
|
||||
"pr_auc": 0.14310881976856776,
|
||||
"brier": 0.05676882714033127,
|
||||
"red_flagged": 24,
|
||||
"precision_red": 0.20833333333333334,
|
||||
"recall_red": 0.16129032258064516,
|
||||
"recall_flagged": 0.3870967741935484,
|
||||
"train_to": "2025-06-30",
|
||||
"test_from": "2025-07-31"
|
||||
},
|
||||
{
|
||||
"n": 789,
|
||||
"positives": 39,
|
||||
"auc_roc": 0.8195897435897437,
|
||||
"pr_auc": 0.17177662573810526,
|
||||
"brier": 0.0742882490158081,
|
||||
"red_flagged": 41,
|
||||
"precision_red": 0.1951219512195122,
|
||||
"recall_red": 0.20512820512820512,
|
||||
"recall_flagged": 0.5641025641025641,
|
||||
"train_to": "2025-09-30",
|
||||
"test_from": "2025-10-31"
|
||||
},
|
||||
{
|
||||
"n": 1052,
|
||||
"positives": 51,
|
||||
"auc_roc": 0.7052947052947053,
|
||||
"pr_auc": 0.1285152676436765,
|
||||
"brier": 0.06149039790034294,
|
||||
"red_flagged": 32,
|
||||
"precision_red": 0.125,
|
||||
"recall_red": 0.0784313725490196,
|
||||
"recall_flagged": 0.27450980392156865,
|
||||
"train_to": "2025-12-31",
|
||||
"test_from": "2026-01-31"
|
||||
}
|
||||
],
|
||||
"pooled": {
|
||||
"auc_roc": 0.7297,
|
||||
"pr_auc": 0.1371,
|
||||
"brier": 0.0621,
|
||||
"precision_red": 0.1706,
|
||||
"recall_red": 0.1244,
|
||||
"recall_flagged": 0.3459
|
||||
}
|
||||
}
|
||||
},
|
||||
"caveats": [
|
||||
"Feature rows are reconstructed by fact date, not knowledge date \u2014 the archive was ingested in one pass, so a past row uses a value a live run of that date would not yet have had.",
|
||||
"Labels are generated DPD history (SEEDED_HISTORY). Metrics computed on this dataset measure the generator as much as the borrowers, and must be reported as computed on synthetic history.",
|
||||
"dscr_p10 and dscr_p50 are excluded: both read the current forecast at every as-of date."
|
||||
],
|
||||
"baselines": {
|
||||
"repayment_history_only": {
|
||||
"auc_roc": 0.6181,
|
||||
"pr_auc": 0.0869,
|
||||
"precision_red": 0.11
|
||||
},
|
||||
"obligation_ratio_only": {
|
||||
"auc_roc": 0.7685,
|
||||
"pr_auc": 0.1828,
|
||||
"precision_red": 0.1001
|
||||
}
|
||||
},
|
||||
"winner": "lightgbm",
|
||||
"base_rate": 0.0438
|
||||
}
|
||||
Binary file not shown.
Reference in New Issue
Block a user