Spaces:
Running
Running
File size: 12,003 Bytes
b0d9ebf 8af7f2e b0d9ebf 8af7f2e b0d9ebf dffb9e9 b0d9ebf dffb9e9 b0d9ebf dffb9e9 b0d9ebf dffb9e9 b0d9ebf | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 | #!/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()
|