113 lines
5.7 KiB
Python
113 lines
5.7 KiB
Python
"""
|
|
Train Favorite-Policy Model (v1) — leak-free MS model for the validated strategy.
|
|
================================================================================
|
|
Trains a LEAK-FREE 1X2 model (drops the result-encoding columns) and saves it
|
|
plus the feature list and policy metadata. This is the brain of the new system;
|
|
the favourite-band value policy (odds ~1.5-2.2, model_prob>implied, flat stake)
|
|
is applied on top of its probabilities at serving time.
|
|
|
|
Honest holdout: trains on the first --holdout-frac of history, evaluates the
|
|
EXACT policy on the most recent slice (never seen in training), then retrains
|
|
on ALL history for the saved production artifact.
|
|
|
|
Saves to models/favorite_v1/: model.json, feature_cols.json, metadata.json
|
|
|
|
Usage: python scripts/train_favorite_model.py
|
|
"""
|
|
from __future__ import annotations
|
|
import argparse, json, os, sys, datetime
|
|
import numpy as np, pandas as pd, xgboost as xgb
|
|
|
|
if sys.stdout and hasattr(sys.stdout, "reconfigure"):
|
|
try: sys.stdout.reconfigure(encoding="utf-8")
|
|
except Exception: pass
|
|
|
|
AI_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
CSV = os.path.join(AI_DIR, "data", "training_data_v27.csv")
|
|
OUT = os.path.join(AI_DIR, "models", "favorite_v1")
|
|
|
|
META = {"match_id","home_team_id","away_team_id","league_id","mst_utc",
|
|
"score_home","score_away","ht_score_home","ht_score_away"}
|
|
# Result-encoding leakage — never feed these to the model (train OR serve).
|
|
LEAKY = {"home_goals_form","away_goals_form","total_goals","ht_total_goals",
|
|
"squad_diff","home_squad_quality","away_squad_quality",
|
|
"referee_home_bias","referee_avg_goals"}
|
|
|
|
PARAMS = {"objective":"multi:softprob","num_class":3,"max_depth":5,"eta":0.05,
|
|
"subsample":0.8,"colsample_bytree":0.8,"tree_method":"hist","verbosity":0}
|
|
|
|
|
|
def policy_eval(P, y, O, lo, hi, margin):
|
|
implied = np.where(O > 1.0, 1.0/O, np.nan)
|
|
edge = np.where(np.isnan(implied), -9.0, P - implied)
|
|
pick = edge.argmax(1); pe = edge[np.arange(len(y)), pick]; po = O[np.arange(len(y)), pick]
|
|
bet = (pe > margin) & (po >= lo) & (po < hi)
|
|
win = (pick == y) & bet
|
|
pnl = np.where(win, po-1.0, -1.0)[bet]
|
|
n = int(bet.sum())
|
|
return {"bets": n, "hit_pct": round(100*win.sum()/max(n,1),1),
|
|
"roi_pct": round(100*pnl.sum()/max(n,1),2), "net_u": round(float(pnl.sum()),1)}
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__)
|
|
ap.add_argument("--lo", type=float, default=1.5)
|
|
ap.add_argument("--hi", type=float, default=2.2)
|
|
ap.add_argument("--margin", type=float, default=0.0)
|
|
ap.add_argument("--holdout-frac", type=float, default=0.15)
|
|
ap.add_argument("--estimators", type=int, default=300)
|
|
args = ap.parse_args()
|
|
|
|
print(f"Loading {CSV} ...")
|
|
df = pd.read_csv(CSV, low_memory=False).sort_values("mst_utc").reset_index(drop=True)
|
|
sh = pd.to_numeric(df["score_home"], errors="coerce")
|
|
sa = pd.to_numeric(df["score_away"], errors="coerce")
|
|
ok = sh.notna() & sa.notna()
|
|
df, sh, sa = df[ok].reset_index(drop=True), sh[ok.values].values, sa[ok.values].values
|
|
y = np.where(sh > sa, 0, np.where(sh == sa, 1, 2))
|
|
O = df[["odds_ms_h","odds_ms_d","odds_ms_a"]].apply(pd.to_numeric, errors="coerce").fillna(0.0).values
|
|
feats = [c for c in df.columns if c not in META and not c.startswith("label_") and c not in LEAKY]
|
|
X = df[feats].apply(pd.to_numeric, errors="coerce").fillna(0.0).values
|
|
print(f" {len(df):,} rows, {len(feats)} leak-free features")
|
|
|
|
# ── Honest holdout (last slice, never trained on) ──
|
|
cut = int(len(df) * (1 - args.holdout_frac))
|
|
bst = xgb.train(PARAMS, xgb.DMatrix(X[:cut], label=y[:cut]), num_boost_round=args.estimators)
|
|
Ph = bst.predict(xgb.DMatrix(X[cut:]))
|
|
acc = float((Ph.argmax(1) == y[cut:]).mean())
|
|
hold = policy_eval(Ph, y[cut:], O[cut:], args.lo, args.hi, args.margin)
|
|
print(f"\nHOLDOUT (last {args.holdout_frac:.0%}, {len(df)-cut:,} matches, never seen):")
|
|
print(f" MS accuracy: {acc*100:.1f}%")
|
|
print(f" POLICY band[{args.lo},{args.hi}] margin {args.margin}: {hold}")
|
|
|
|
# ── Production model: retrain on ALL history ──
|
|
print("\nTraining production model on ALL history ...")
|
|
final = xgb.train(PARAMS, xgb.DMatrix(X, label=y), num_boost_round=args.estimators)
|
|
os.makedirs(OUT, exist_ok=True)
|
|
final.save_model(os.path.join(OUT, "model.json"))
|
|
with open(os.path.join(OUT, "feature_cols.json"), "w", encoding="utf-8") as f:
|
|
json.dump(feats, f, ensure_ascii=False, indent=2)
|
|
meta = {
|
|
"version": "favorite_v1",
|
|
"trained_at": datetime.datetime.now().isoformat(timespec="seconds"),
|
|
"market": "MS",
|
|
"classes": {"0": "home(1)", "1": "draw(X)", "2": "away(2)"},
|
|
"policy": {"odds_lo": args.lo, "odds_hi": args.hi, "margin": args.margin,
|
|
"stake": "flat 1u", "rule": "bet model's max value edge if picked odds in band",
|
|
"never": ["longshots odds>=hi", "parlays/combos"]},
|
|
"n_train": len(df), "n_features": len(feats),
|
|
"leaky_excluded": sorted(LEAKY),
|
|
"holdout_eval": {"accuracy_pct": round(acc*100,1), **hold},
|
|
"caveat": "CSV odds are a static capture, not verified closing. Forward paper-trade with real CLV before staking.",
|
|
}
|
|
with open(os.path.join(OUT, "metadata.json"), "w", encoding="utf-8") as f:
|
|
json.dump(meta, f, ensure_ascii=False, indent=2)
|
|
print(f"\n✅ Saved production model to {OUT}/")
|
|
print(f" model.json, feature_cols.json ({len(feats)} feats), metadata.json")
|
|
print("\nNEXT: serving wrapper that loads this + applies the policy to upcoming")
|
|
print("matches, logs paper-trade picks, and we measure real forward CLV/ROI.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|