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,55 @@
|
||||
{
|
||||
"rows": 5523,
|
||||
"enterprises": 263,
|
||||
"as_of_dates": 21,
|
||||
"date_min": "2024-08-31",
|
||||
"date_max": "2026-04-30",
|
||||
"positives": 242,
|
||||
"positive_rate": 0.0438,
|
||||
"features": [
|
||||
"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"
|
||||
],
|
||||
"excluded": {
|
||||
"dscr_p10": "reads forecast_point with no as-of filter \u2014 present leaks into past",
|
||||
"dscr_p50": "same"
|
||||
},
|
||||
"horizon_months": 3,
|
||||
"dpd_threshold": 30,
|
||||
"label_source_split": {
|
||||
"SEEDED": 273,
|
||||
"SEEDED_HISTORY": 6039
|
||||
},
|
||||
"synthetic_label_share": 1.0,
|
||||
"feature_coverage": {
|
||||
"feed_cost_change": 84.1,
|
||||
"output_price_change": 97.7,
|
||||
"margin_squeeze": 83.4,
|
||||
"price_z_score": 90.3,
|
||||
"rainfall_anomaly": 100.0,
|
||||
"heat_anomaly": 100.0,
|
||||
"cash_flow_change": 100.0,
|
||||
"emi_delay": 100.0,
|
||||
"inventory_days": 100.0,
|
||||
"upi_inflow_change": 0.0,
|
||||
"feed_staleness_days": 100.0,
|
||||
"days_since_last_entry": 100.0,
|
||||
"emi_to_inflow": 100.0
|
||||
},
|
||||
"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."
|
||||
]
|
||||
}
|
||||
Binary file not shown.
Reference in New Issue
Block a user