Khanna, Videh Rakesh Rakesh
ML confidence + honest relative-direction display
dffb9e9
Raw
History Blame Contribute Delete
12 kB
#!/usr/bin/env python3
"""ml_predictor/train.py β€” fit the quantile-regression price model.
For each timeframe TF ∈ {INTRADAY, 1D, 3D} we train 5 sklearn HistGradientBoosting
estimators (15 total artifacts), all native to scikit-learn (already in
requirements.txt β€” no lightgbm/xgboost/torch, so HF Spaces builds cleanly):
up_q50 HistGradientBoostingRegressor(loss="quantile", quantile=0.50) β†’ median best-up excursion
up_q90 HistGradientBoostingRegressor(loss="quantile", quantile=0.90) β†’ optimistic high
down_q50 quantile=0.50 on the worst-down excursion β†’ median dip depth
down_q10 quantile=0.10 on the worst-down excursion β†’ downside/stop floor
direction HistGradientBoostingClassifier(class_weight="balanced") β†’ BULLISH/BEARISH/NEUTRAL
Time-based split (no leakage): train on rows on/before `cutoff = max_date - HOLDOUT_MONTHS`,
with a TRADING-DAY embargo dropping rows whose 3-day label window crosses the cutoff.
Writes joblib artifacts + manifest.json to ml_predictor/models/.
Usage (from project root, after dataset.py):
python ml_predictor/train.py
python ml_predictor/train.py --csv ml_predictor/training_data.csv --holdout-months 5
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import warnings
from datetime import timedelta
import numpy as np
import pandas as pd
warnings.filterwarnings("ignore")
_PROJ_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _PROJ_ROOT not in sys.path:
sys.path.insert(0, _PROJ_ROOT)
import joblib # noqa: E402
import sklearn # noqa: E402
from sklearn.ensemble import HistGradientBoostingRegressor, HistGradientBoostingClassifier # noqa: E402
from sklearn.calibration import CalibratedClassifierCV # noqa: E402
from ml_predictor.features import FEATURE_COLUMNS, TIMEFRAMES # noqa: E402
# MODEL_DIR / CSV overridable via env for safe A/B experiments (train a variant to a temp
# dir + point infer/backtest at it via ML_MODEL_DIR without clobbering the production model).
MODEL_DIR = os.environ.get("ML_MODEL_DIR", os.path.join(os.path.dirname(os.path.abspath(__file__)), "models"))
DEFAULT_CSV = os.environ.get("ML_CSV", os.path.join(os.path.dirname(os.path.abspath(__file__)), "training_data.csv"))
HOLDOUT_MONTHS = 5 # last N months held out as the test window
EMBARGO_DAYS = 5 # calendar-day gap between train-end and holdout (label window is ≀3 trading days)
MIN_TRAIN_ROWS = 500
# Label column per (target, TF).
_UP_LABEL = {"INTRADAY": "up_INTRADAY", "1D": "up_1D", "3D": "up_3D"}
_DN_LABEL = {"INTRADAY": "dn_INTRADAY", "1D": "dn_1D", "3D": "dn_3D"}
_DIR_LABEL = {"INTRADAY": "dir_INTRADAY", "1D": "dir_1D", "3D": "dir_3D"}
# Hyperparameters (env-overridable for tuning experiments).
_MAX_ITER = int(os.environ.get("ML_MAX_ITER", "300"))
_MAX_LEAVES = int(os.environ.get("ML_MAX_LEAVES", "31"))
_LR = float(os.environ.get("ML_LR", "0.06"))
_MIN_LEAF = int(os.environ.get("ML_MIN_LEAF", "60"))
_L2 = float(os.environ.get("ML_L2", "1.0"))
_REG_PARAMS = dict(max_iter=_MAX_ITER, max_leaf_nodes=_MAX_LEAVES, learning_rate=_LR,
min_samples_leaf=_MIN_LEAF, l2_regularization=_L2, random_state=42)
_CLF_PARAMS = dict(max_iter=_MAX_ITER, max_leaf_nodes=_MAX_LEAVES, learning_rate=_LR,
min_samples_leaf=_MIN_LEAF, l2_regularization=_L2, random_state=42,
class_weight="balanced")
def _fit_quantile(X, y, q):
m = HistGradientBoostingRegressor(loss="quantile", quantile=q, **_REG_PARAMS)
m.fit(X, y)
return m
def train_all(csv_path: str = DEFAULT_CSV, out_dir: str = MODEL_DIR,
holdout_months: int = HOLDOUT_MONTHS) -> dict:
df = pd.read_csv(csv_path)
df["date"] = pd.to_datetime(df["date"])
max_date = df["date"].max()
cutoff = max_date - pd.DateOffset(months=holdout_months)
train_end = cutoff - timedelta(days=EMBARGO_DAYS)
print(f" Rows: {len(df):,} Β· dates {df['date'].min().date()} β†’ {max_date.date()}")
print(f" Train ≀ {train_end.date()} (embargo {EMBARGO_DAYS}d) Β· holdout {cutoff.date()} β†’ {max_date.date()}")
train_df = df[df["date"] <= train_end]
test_df = df[df["date"] >= cutoff]
print(f" Train rows: {len(train_df):,} Β· Holdout rows: {len(test_df):,}")
if len(train_df) < MIN_TRAIN_ROWS:
raise SystemExit(f"Not enough training rows ({len(train_df)} < {MIN_TRAIN_ROWS}). "
f"Build a denser CSV (dataset.py --step 1) or reduce --holdout-months.")
os.makedirs(out_dir, exist_ok=True)
X_tr_full = train_df[FEATURE_COLUMNS].to_numpy(dtype=float)
manifest = {
"sklearn_version": sklearn.__version__,
"feature_columns": FEATURE_COLUMNS,
"timeframes": TIMEFRAMES,
"train_cutoff": str(train_end.date()),
"holdout_start": str(cutoff.date()),
"max_date": str(max_date.date()),
"n_train_rows": int(len(train_df)),
"quantiles": {"up": [0.10, 0.50, 0.90], "down": [0.10, 0.50, 0.90]},
# Whether 1D/3D direction labels are EXCESS-of-Nifty (alpha). Mirrors dataset.py's
# ML_EXCESS_LABELS default; consumed by infer.py to set each TF's dir_basis so the UI
# can say "outperform/underperform vs Nifty" instead of a misleading absolute call.
"excess_labels": os.environ.get("ML_EXCESS_LABELS", "1") != "0",
"tf": {},
}
for tf in TIMEFRAMES:
print(f"\n ── {tf} ──")
up_y = train_df[_UP_LABEL[tf]].to_numpy(dtype=float)
dn_y = train_df[_DN_LABEL[tf]].to_numpy(dtype=float)
dir_y = train_df[_DIR_LABEL[tf]].astype(str).to_numpy()
# Drop rows with NaN labels (features may contain NaN β€” HistGBM handles them).
up_ok = np.isfinite(up_y)
dn_ok = np.isfinite(dn_y)
# up-excursion quantiles: q10 = easily-reached floor, q50 = expected high, q90 = optimistic.
up_q10 = _fit_quantile(X_tr_full[up_ok], up_y[up_ok], 0.10)
up_q50 = _fit_quantile(X_tr_full[up_ok], up_y[up_ok], 0.50)
up_q90 = _fit_quantile(X_tr_full[up_ok], up_y[up_ok], 0.90)
# down-excursion quantiles: q10 = deep worst-case (stop floor), q50 = median dip (buy level),
# q90 = shallow dip closest to 0 (easily-reached bearish range bound).
down_q10 = _fit_quantile(X_tr_full[dn_ok], dn_y[dn_ok], 0.10)
down_q50 = _fit_quantile(X_tr_full[dn_ok], dn_y[dn_ok], 0.50)
down_q90 = _fit_quantile(X_tr_full[dn_ok], dn_y[dn_ok], 0.90)
# Direction classifier with ISOTONIC PROBABILITY CALIBRATION (improvement "c").
# The raw HistGBM proba was over-confident; CalibratedClassifierCV(cv=3) maps it to
# empirical frequencies so max-proba is a trustworthy P(correct) β€” the basis for the
# HIGH/MEDIUM/LOW label (previously derived from band-width, which anti-correlated
# with returns per the diagnostics).
clf = CalibratedClassifierCV(
HistGradientBoostingClassifier(**_CLF_PARAMS), method="isotonic", cv=3)
clf.fit(X_tr_full, dir_y)
for name, mdl in [("up_q10", up_q10), ("up_q50", up_q50), ("up_q90", up_q90),
("down_q10", down_q10), ("down_q50", down_q50), ("down_q90", down_q90),
("direction", clf)]:
joblib.dump(mdl, os.path.join(out_dir, f"{tf}_{name}.joblib"))
# ── Per-TF metadata: band width + calibrated-confidence thresholds ──
p_up10 = up_q10.predict(X_tr_full)
p_up90 = np.maximum(up_q90.predict(X_tr_full), p_up10) # monotonic
median_band = float(np.median(p_up90 - p_up10))
dir_classes = list(clf.classes_)
# Confidence thresholds = ABSOLUTE, tied to the calibrated max-class probability's
# reliability relative to the random baseline (1/n_classes). Because the classifier is
# isotonic-calibrated, max_proba β‰ˆ P(direction correct), so these thresholds mean the
# same thing across timeframes and stocks. This replaced the old per-TF TERTILES, which
# forced exactly 1/3 of EVERY TF's predictions to LOW regardless of real reliability β€”
# so an easy call could read "LOW" just for sitting in the bottom third. Now a TF whose
# direction is genuinely more separable (INTRADAY) earns more HIGHs, and a noisy TF (3D)
# earns more LOWs β€” honest and comparable.
#
# conf_hi is PER-TF and calibrated so a "HIGH" label means β‰₯~85% direction accuracy
# (research/ml_confidence_sweep.py, OOS 25k rows): INTRADAY max-proba reaches 0.99 and
# conf_hi=0.72 yields ~87% 3-class / ~85% directional-only accuracy at ~46% coverage
# (0.70β†’86%/84% cov 51%, 0.75β†’89%/87% cov 40%). 1D/3D max-proba tops out at ~0.63 and
# even the most-confident calls only hit ~62-66% β€” an 85% (or even 75%) HIGH is
# UNREACHABLE (signal ceiling), so HIGH is disabled for them (conf_hi>1) and they cap
# at MEDIUM. Every threshold is env-overridable per TF (ML_CONF_HI_INTRADAY, …) for A/B.
maxp_tr = clf.predict_proba(X_tr_full).max(axis=1)
baseline = 1.0 / max(1, len(dir_classes))
_DEFAULT_CONF_HI = {"INTRADAY": 0.72, "1D": 1.01, "3D": 1.01}
conf_hi = float(os.environ.get(f"ML_CONF_HI_{tf}",
os.environ.get("ML_CONF_HI_MARGIN_ABS",
_DEFAULT_CONF_HI.get(tf, baseline + 0.20))))
conf_mid = baseline + float(os.environ.get(f"ML_CONF_MID_{tf}",
os.environ.get("ML_CONF_MID_MARGIN", "0.08")))
manifest["tf"][tf] = {
"median_train_width": median_band,
"direction_classes": dir_classes,
"conf_hi": round(conf_hi, 4),
"conf_mid": round(conf_mid, 4),
"conf_scheme": "absolute_vs_baseline_perTF",
"high_disabled": conf_hi > 1.0,
"train_maxproba_p33": round(float(np.quantile(maxp_tr, 0.33)), 4),
"train_maxproba_p66": round(float(np.quantile(maxp_tr, 0.66)), 4),
}
# ── Quick holdout calibration (coverage) sanity print ──
if len(test_df) > 20:
Xte = test_df[FEATURE_COLUMNS].to_numpy(dtype=float)
up_true = test_df[_UP_LABEL[tf]].to_numpy(dtype=float)
dn_true = test_df[_DN_LABEL[tf]].to_numpy(dtype=float)
pred_up90 = np.maximum(up_q90.predict(Xte), up_q50.predict(Xte))
pred_dn10 = np.minimum(down_q10.predict(Xte), down_q50.predict(Xte))
cov_up = float(np.mean(up_true <= pred_up90)) # target β‰ˆ 0.90
cov_dn = float(np.mean(dn_true >= pred_dn10)) # target β‰ˆ 0.90 (10% below)
dir_acc = float(np.mean(clf.predict(Xte) == test_df[_DIR_LABEL[tf]].astype(str).to_numpy()))
print(f" up_q90 coverage={cov_up:.0%} (~90%) Β· dn_q10 coverage={cov_dn:.0%} (~90%) "
f"Β· dir_acc={dir_acc:.0%} Β· median band={median_band:.2f}%")
manifest["tf"][tf].update({
"holdout_up_q90_coverage": round(cov_up, 3),
"holdout_dn_q10_coverage": round(cov_dn, 3),
"holdout_direction_accuracy": round(dir_acc, 3),
})
with open(os.path.join(out_dir, "manifest.json"), "w") as f:
json.dump(manifest, f, indent=2)
n_est = len(TIMEFRAMES) * 7 # 6 quantile regressors + 1 direction classifier per TF
print(f"\n βœ“ Wrote {n_est} estimators + manifest.json β†’ {out_dir}")
print(f" sklearn={sklearn.__version__} Β· train_cutoff={manifest['train_cutoff']}")
return manifest
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--csv", default=DEFAULT_CSV)
ap.add_argument("--out", default=MODEL_DIR)
ap.add_argument("--holdout-months", type=int, default=HOLDOUT_MONTHS)
args = ap.parse_args()
train_all(args.csv, args.out, args.holdout_months)
if __name__ == "__main__":
main()