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,294 @@
|
||||
"""
|
||||
Quantile heads for the cash-flow forecast.
|
||||
|
||||
The problem statement is "AI-Driven **Cash Flow Prediction** & Risk Flagging".
|
||||
The risk head is a model; the forecast, until now, was
|
||||
``DeterministicForecastStrategy`` — a median of monthly ledger position with a
|
||||
capped drift and a band widening as sqrt(h). Honest, explainable, and not AI.
|
||||
This trains the replacement.
|
||||
|
||||
Shape
|
||||
-----
|
||||
One row per (enterprise, as-of month end, horizon 1..6):
|
||||
|
||||
features known at T -> net ledger position in month T + h
|
||||
|
||||
Three models, at alpha = 0.10 / 0.50 / 0.90. **Horizon is a feature, not a
|
||||
separate model per step**: six models per quantile would be eighteen artifacts
|
||||
that cannot share the signal they all depend on, and the h=6 model would train on
|
||||
a sixth of the rows.
|
||||
|
||||
Two rules carried over from the deterministic strategy
|
||||
-----------------------------------------------------
|
||||
1. Quantile regression does not guarantee ordering — P10 can be predicted above
|
||||
P50 on an individual row. The three predictions are sorted per row before
|
||||
they leave here. A crossed band is worse than no band.
|
||||
2. **Missing inputs widen the band; they never lower its centre.** A pessimistic
|
||||
P50 built on ignorance manufactures breaches, which is how an early-warning
|
||||
system loses an officer's trust in one season.
|
||||
|
||||
What the metrics mean
|
||||
---------------------
|
||||
Pinball loss, per quantile, against the deterministic strategy reproduced on the
|
||||
same held-out months. Lower is better. This is the comparison that decides
|
||||
whether the model earns the switch — and, as with the stress head, it is measured
|
||||
against a generated ledger, so it measures the generator too.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import pickle
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import lightgbm as lgb
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import psycopg
|
||||
|
||||
DSN = "postgresql://postgres:root@localhost:5432/UdyamLinc_DEMO"
|
||||
|
||||
QUANTILES = {"p10": 0.10, "p50": 0.50, "p90": 0.90}
|
||||
HORIZONS = 6
|
||||
|
||||
# Features known at T. The point-in-time discipline is the same as the stress
|
||||
# head's: `valid_for_date <= T`. Forecast-derived features are excluded for the
|
||||
# same reason they are there — dscr_* reads the current forecast at every date.
|
||||
FEATURE_CODES = [
|
||||
"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",
|
||||
]
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
# Monthly net position, and the trailing statistics a forecaster would have had.
|
||||
# Everything is computed from months strictly before the as-of month, so a row
|
||||
# never sees the month it is filed under, let alone the one it predicts.
|
||||
SPINE_SQL = """
|
||||
with monthly as (
|
||||
select enterprise_id,
|
||||
date_trunc('month', entry_date)::date as mo,
|
||||
sum(case when kind = 'IN' then amount when kind = 'OUT' then -amount else 0 end) as net,
|
||||
sum(case when kind = 'IN' then amount else 0 end) as inflow
|
||||
from ledger_entry
|
||||
group by 1, 2
|
||||
),
|
||||
stats as (
|
||||
select m.enterprise_id,
|
||||
m.mo as as_of_mo,
|
||||
avg(h.net) as trail_mean,
|
||||
stddev_pop(h.net) as trail_sd,
|
||||
min(h.net) as trail_min,
|
||||
avg(h.inflow) as trail_inflow,
|
||||
count(*) as trail_months
|
||||
from monthly m
|
||||
join monthly h
|
||||
on h.enterprise_id = m.enterprise_id
|
||||
and h.mo < m.mo
|
||||
and h.mo >= m.mo - interval '6 month'
|
||||
group by 1, 2
|
||||
),
|
||||
obligation as (
|
||||
select enterprise_id, as_of_date, sum(instalment_amount) as emi
|
||||
from loan_record group by 1, 2
|
||||
)
|
||||
select s.enterprise_id,
|
||||
s.as_of_mo,
|
||||
s.trail_mean, s.trail_sd, s.trail_min, s.trail_inflow, s.trail_months,
|
||||
e.sector_id, e.district_id,
|
||||
o.emi,
|
||||
t.mo as target_mo,
|
||||
t.net as target_net,
|
||||
(extract(year from t.mo) * 12 + extract(month from t.mo))
|
||||
- (extract(year from s.as_of_mo) * 12 + extract(month from s.as_of_mo)) as horizon
|
||||
from stats s
|
||||
join enterprise e on e.enterprise_id = s.enterprise_id
|
||||
left join obligation o
|
||||
on o.enterprise_id = s.enterprise_id
|
||||
and o.as_of_date = (s.as_of_mo + interval '1 month - 1 day')::date
|
||||
join monthly t
|
||||
on t.enterprise_id = s.enterprise_id
|
||||
and t.mo > s.as_of_mo
|
||||
and t.mo <= s.as_of_mo + interval '6 month'
|
||||
where s.trail_months >= 3
|
||||
"""
|
||||
|
||||
FEATURES_SQL = """
|
||||
select enterprise_id, valid_for_date, feature_code, value
|
||||
from feature_value
|
||||
where feature_code = any(%(codes)s)
|
||||
"""
|
||||
|
||||
|
||||
def build(dsn: str) -> pd.DataFrame:
|
||||
with psycopg.connect(dsn) as conn:
|
||||
spine = pd.read_sql(SPINE_SQL, conn)
|
||||
feats = pd.read_sql(FEATURES_SQL, conn, params={"codes": FEATURE_CODES})
|
||||
|
||||
if spine.empty:
|
||||
raise SystemExit("No rows. Has the ledger backfill run?")
|
||||
|
||||
# Features are filed at month end; the spine is keyed on month start.
|
||||
feats["as_of_mo"] = pd.to_datetime(feats["valid_for_date"]).values.astype("datetime64[M]")
|
||||
wide = feats.pivot_table(
|
||||
index=["enterprise_id", "as_of_mo"], columns="feature_code", values="value", aggfunc="first"
|
||||
).reset_index()
|
||||
|
||||
spine["as_of_mo"] = pd.to_datetime(spine["as_of_mo"]).values.astype("datetime64[M]")
|
||||
df = spine.merge(wide, on=["enterprise_id", "as_of_mo"], how="left")
|
||||
|
||||
for code in FEATURE_CODES:
|
||||
if code not in df.columns:
|
||||
df[code] = np.nan
|
||||
df[f"{code}__missing"] = df[code].isna().astype("int8")
|
||||
|
||||
numeric = FEATURE_CODES + [
|
||||
"trail_mean", "trail_sd", "trail_min", "trail_inflow", "trail_months",
|
||||
"emi", "horizon", "target_net", "sector_id", "district_id",
|
||||
]
|
||||
for col in numeric:
|
||||
df[col] = pd.to_numeric(df[col], errors="coerce")
|
||||
|
||||
df = df.dropna(subset=["target_net", "horizon"])
|
||||
return df.sort_values(["as_of_mo", "enterprise_id", "horizon"]).reset_index(drop=True)
|
||||
|
||||
|
||||
def feature_columns(df: pd.DataFrame) -> list[str]:
|
||||
cols = FEATURE_CODES + [f"{c}__missing" for c in FEATURE_CODES]
|
||||
cols += ["trail_mean", "trail_sd", "trail_min", "trail_inflow", "trail_months",
|
||||
"emi", "horizon", "sector_id", "district_id"]
|
||||
return [c for c in cols if c in df.columns]
|
||||
|
||||
|
||||
def pinball(y: np.ndarray, pred: np.ndarray, alpha: float) -> float:
|
||||
"""The loss quantile regression is actually optimising. Lower is better."""
|
||||
delta = y - pred
|
||||
return float(np.mean(np.maximum(alpha * delta, (alpha - 1) * delta)))
|
||||
|
||||
|
||||
def deterministic_baseline(frame: pd.DataFrame, alpha: float) -> np.ndarray:
|
||||
"""
|
||||
The strategy currently in production, reproduced so the comparison is fair.
|
||||
|
||||
Centre is the trailing mean; the band widens with sqrt(horizon) at the same
|
||||
18% base spread the Java implementation uses. If the model cannot beat this,
|
||||
the switch is not earned and the config value stays on DETERMINISTIC.
|
||||
"""
|
||||
centre = frame["trail_mean"].to_numpy()
|
||||
spread = 0.18 * np.sqrt(frame["horizon"].to_numpy())
|
||||
if alpha < 0.5:
|
||||
return centre * (1 - spread)
|
||||
if alpha > 0.5:
|
||||
return centre * (1 + spread)
|
||||
return centre
|
||||
|
||||
|
||||
def expanding_folds(months: np.ndarray, n_folds: int = 3, min_train: int = 8):
|
||||
uniq = np.array(sorted(pd.unique(months)))
|
||||
step = max(1, (len(uniq) - min_train) // max(1, n_folds))
|
||||
for k in range(n_folds):
|
||||
cut = min_train + k * step
|
||||
if cut >= len(uniq):
|
||||
break
|
||||
test = uniq[cut : cut + step] if k < n_folds - 1 else uniq[cut:]
|
||||
yield uniq[:cut], test
|
||||
|
||||
|
||||
def run(out_dir: Path, dsn: str = DSN) -> dict:
|
||||
df = build(dsn)
|
||||
cols = feature_columns(df)
|
||||
months = df["as_of_mo"].to_numpy()
|
||||
|
||||
results: dict = {
|
||||
"rows": int(len(df)),
|
||||
"enterprises": int(df["enterprise_id"].nunique()),
|
||||
"as_of_months": int(df["as_of_mo"].nunique()),
|
||||
"horizons": int(df["horizon"].max()),
|
||||
"validation": "expanding-window time-series split over as-of months",
|
||||
"feature_order": cols,
|
||||
"quantiles": {},
|
||||
}
|
||||
print(f"rows {len(df):,} · enterprises {results['enterprises']} · months {results['as_of_months']}")
|
||||
|
||||
for name, alpha in QUANTILES.items():
|
||||
model_losses, base_losses = [], []
|
||||
for train_months, test_months in expanding_folds(months):
|
||||
tr = df[df["as_of_mo"].isin(train_months)]
|
||||
te = df[df["as_of_mo"].isin(test_months)]
|
||||
if te.empty or tr.empty:
|
||||
continue
|
||||
model = lgb.LGBMRegressor(
|
||||
objective="quantile", alpha=alpha,
|
||||
n_estimators=400, learning_rate=0.05, num_leaves=15,
|
||||
min_child_samples=30, subsample=0.85, subsample_freq=1,
|
||||
colsample_bytree=0.85, verbose=-1, random_state=42,
|
||||
)
|
||||
model.fit(tr[cols], tr["target_net"])
|
||||
pred = model.predict(te[cols])
|
||||
y = te["target_net"].to_numpy()
|
||||
model_losses.append(pinball(y, pred, alpha))
|
||||
base_losses.append(pinball(y, deterministic_baseline(te, alpha), alpha))
|
||||
|
||||
results["quantiles"][name] = {
|
||||
"alpha": alpha,
|
||||
"pinball_model": round(float(np.mean(model_losses)), 2),
|
||||
"pinball_deterministic": round(float(np.mean(base_losses)), 2),
|
||||
}
|
||||
q = results["quantiles"][name]
|
||||
better = q["pinball_model"] < q["pinball_deterministic"]
|
||||
print(f"{name} pinball model {q['pinball_model']:>10,.0f} "
|
||||
f"deterministic {q['pinball_deterministic']:>10,.0f} "
|
||||
f"{'model wins' if better else 'DETERMINISTIC WINS'}")
|
||||
|
||||
# Refit each quantile on everything for serving.
|
||||
final = {}
|
||||
for name, alpha in QUANTILES.items():
|
||||
model = lgb.LGBMRegressor(
|
||||
objective="quantile", alpha=alpha,
|
||||
n_estimators=400, learning_rate=0.05, num_leaves=15,
|
||||
min_child_samples=30, subsample=0.85, subsample_freq=1,
|
||||
colsample_bytree=0.85, verbose=-1, random_state=42,
|
||||
)
|
||||
model.fit(df[cols], df["target_net"])
|
||||
final[name] = model
|
||||
|
||||
wins = sum(1 for q in results["quantiles"].values()
|
||||
if q["pinball_model"] < q["pinball_deterministic"])
|
||||
results["verdict"] = (
|
||||
f"{wins}/3 quantiles beat the deterministic strategy on held-out months"
|
||||
)
|
||||
print(f"\n{results['verdict']}")
|
||||
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(out_dir / "forecast_model.pkl", "wb") as fh:
|
||||
pickle.dump({"models": final, "features": cols, "horizons": HORIZONS,
|
||||
"version": "lightgbm-forecast-v1"}, fh)
|
||||
(out_dir / "forecast_metrics.json").write_text(
|
||||
json.dumps(results | {"trainedAt": datetime.now().isoformat()}, indent=2), encoding="utf-8"
|
||||
)
|
||||
print(f"written: {out_dir / 'forecast_model.pkl'}")
|
||||
return results
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description="Train the UdyamLinc quantile forecast heads")
|
||||
ap.add_argument("--out", default="models")
|
||||
ap.add_argument("--dsn", default=DSN)
|
||||
args = ap.parse_args()
|
||||
run(Path(args.out), args.dsn)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user