LBJLincoln Claude Opus 4.6 commited on
Commit
308a1d2
Β·
1 Parent(s): 4287543

feat: S10 /api/predict endpoint + fix auto-cut rules

Browse files

Added /api/predict POST endpoint β€” uses best evolved individual to generate
live NBA game predictions. Trains on full historical data with evolved
feature selection + hyperparams. Accepts team pairs or date (fetches ESPN schedule).

Fixed run_logger.py auto-cut rules:
- BRIER_FLOOR: raised threshold 30β†’50 gens, moderate params instead of full reset,
clears brier_history after trigger to prevent repeated firing
- STAGNATION: moderate mutation boost (2x capped at 0.15) instead of
destructive pop_size 200 + mutation 0.20

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Files changed (2) hide show
  1. app.py +188 -0
  2. evolution/run_logger.py +19 -12
app.py CHANGED
@@ -808,6 +808,13 @@ FULL_EVAL_TOP = 10 # Full eval for top 10 β€” better selection of champion
808
  # ── Feature importance tracking (directed mutation) ──
809
  _feature_importance = None # numpy array: how often each feature appears in top individuals
810
 
 
 
 
 
 
 
 
811
  # ── Remote Config (mutable at runtime via API) ──
812
  remote_config = {
813
  "pending_reset": False,
@@ -820,6 +827,7 @@ remote_config = {
820
  def evolution_loop():
821
  """Main 24/7 genetic evolution loop β€” runs in background thread."""
822
  global TARGET_FEATURES, CROSSOVER_RATE, COOLDOWN, POP_SIZE
 
823
  log("=" * 60)
824
  log("REAL GENETIC EVOLUTION LOOP v3 β€” STARTING")
825
  log(f"Pop: {POP_SIZE} | Target features: {TARGET_FEATURES} | Gens/cycle: {GENS_PER_CYCLE}")
@@ -882,6 +890,12 @@ def evolution_loop():
882
  live["feature_candidates"] = n_feat
883
  log(f"Clean feature matrix: {X.shape} ({n_feat} usable features)")
884
 
 
 
 
 
 
 
885
  # Try restore state
886
  population = []
887
  generation = 0
@@ -1006,6 +1020,14 @@ def evolution_loop():
1006
  best_ever.fitness = dict(best.fitness)
1007
  best_ever.n_features = best.n_features
1008
  best_ever.generation = generation
 
 
 
 
 
 
 
 
1009
 
1010
  # Stagnation detection
1011
  if abs(best.fitness["brier"] - prev_brier) < 0.0005:
@@ -1578,6 +1600,172 @@ async def api_recent_runs():
1578
  return JSONResponse({"error": str(e)}, status_code=500)
1579
 
1580
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1581
  with gr.Blocks(title="NOMOS NBA QUANT β€” Genetic Evolution", theme=gr.themes.Monochrome()) as app:
1582
  gr.Markdown("# NOMOS NBA QUANT AI β€” Real Genetic Evolution 24/7")
1583
  gr.Markdown("*Population of 60 individuals evolving feature selection + hyperparameters. Multi-objective: Brier + ROI + Sharpe + Calibration.*")
 
808
  # ── Feature importance tracking (directed mutation) ──
809
  _feature_importance = None # numpy array: how often each feature appears in top individuals
810
 
811
+ # ── Shared state for /api/predict (set from evolution_loop) ──
812
+ _evo_X = None # Full feature matrix
813
+ _evo_y = None # Labels
814
+ _evo_features = None # Feature names list
815
+ _evo_best = None # Best individual (dict with features, hyperparams, fitness)
816
+ _evo_games = None # Raw games list (for building today's features)
817
+
818
  # ── Remote Config (mutable at runtime via API) ──
819
  remote_config = {
820
  "pending_reset": False,
 
827
  def evolution_loop():
828
  """Main 24/7 genetic evolution loop β€” runs in background thread."""
829
  global TARGET_FEATURES, CROSSOVER_RATE, COOLDOWN, POP_SIZE
830
+ global _evo_X, _evo_y, _evo_features, _evo_best, _evo_games
831
  log("=" * 60)
832
  log("REAL GENETIC EVOLUTION LOOP v3 β€” STARTING")
833
  log(f"Pop: {POP_SIZE} | Target features: {TARGET_FEATURES} | Gens/cycle: {GENS_PER_CYCLE}")
 
890
  live["feature_candidates"] = n_feat
891
  log(f"Clean feature matrix: {X.shape} ({n_feat} usable features)")
892
 
893
+ # ── Share state for /api/predict ──
894
+ _evo_X = X
895
+ _evo_y = y
896
+ _evo_features = feature_names
897
+ _evo_games = games
898
+
899
  # Try restore state
900
  population = []
901
  generation = 0
 
1020
  best_ever.fitness = dict(best.fitness)
1021
  best_ever.n_features = best.n_features
1022
  best_ever.generation = generation
1023
+ # Share for /api/predict
1024
+ _evo_best = {
1025
+ "features": best_ever.features[:],
1026
+ "hyperparams": dict(best_ever.hyperparams),
1027
+ "fitness": dict(best_ever.fitness),
1028
+ "generation": best_ever.generation,
1029
+ "n_features": best_ever.n_features,
1030
+ }
1031
 
1032
  # Stagnation detection
1033
  if abs(best.fitness["brier"] - prev_brier) < 0.0005:
 
1600
  return JSONResponse({"error": str(e)}, status_code=500)
1601
 
1602
 
1603
+ # ═══════════════════════════════════════════════════════
1604
+ # PREDICT API β€” Use evolved model for live predictions
1605
+ # ═══════════════════════════════════════════════════════
1606
+
1607
+ @control_api.post("/api/predict")
1608
+ async def api_predict(request: Request):
1609
+ """Generate predictions using the best evolved individual.
1610
+
1611
+ Body: {"games": [{"home_team": "Boston Celtics", "away_team": "Miami Heat"}, ...]}
1612
+ Or: {"date": "2026-03-18"} to predict all games on a date (fetches from ESPN schedule).
1613
+
1614
+ Returns probabilities from the evolved model trained on ALL historical data.
1615
+ """
1616
+ global _evo_X, _evo_y, _evo_features, _evo_best, _evo_games
1617
+
1618
+ if _evo_best is None or _evo_X is None:
1619
+ return JSONResponse({"error": "evolution not ready β€” model still loading"}, status_code=503)
1620
+
1621
+ body = await request.json()
1622
+ games_to_predict = body.get("games", [])
1623
+ date_str = body.get("date")
1624
+
1625
+ # If date provided, fetch today's schedule from ESPN
1626
+ if date_str and not games_to_predict:
1627
+ try:
1628
+ import urllib.request
1629
+ url = f"https://site.api.espn.com/apis/site/v2/sports/basketball/nba/scoreboard?dates={date_str.replace('-', '')}"
1630
+ req = urllib.request.Request(url, headers={"User-Agent": "NomosQuant/1.0"})
1631
+ with urllib.request.urlopen(req, timeout=15) as resp:
1632
+ data = json.loads(resp.read().decode())
1633
+ for ev in data.get("events", []):
1634
+ comps = ev.get("competitions", [{}])[0]
1635
+ teams = comps.get("competitors", [])
1636
+ if len(teams) == 2:
1637
+ home = next((t for t in teams if t.get("homeAway") == "home"), None)
1638
+ away = next((t for t in teams if t.get("homeAway") == "away"), None)
1639
+ if home and away:
1640
+ games_to_predict.append({
1641
+ "home_team": home["team"]["displayName"],
1642
+ "away_team": away["team"]["displayName"],
1643
+ "game_id": ev.get("id"),
1644
+ "status": comps.get("status", {}).get("type", {}).get("name", ""),
1645
+ })
1646
+ except Exception as e:
1647
+ return JSONResponse({"error": f"ESPN fetch failed: {e}"}, status_code=502)
1648
+
1649
+ if not games_to_predict:
1650
+ return JSONResponse({"error": "no games to predict β€” provide 'games' array or 'date'"}, status_code=400)
1651
+
1652
+ try:
1653
+ # Get best individual's config
1654
+ best = _evo_best
1655
+ selected = [i for i, b in enumerate(best["features"]) if b]
1656
+ hp = best["hyperparams"]
1657
+
1658
+ if len(selected) < 5:
1659
+ return JSONResponse({"error": "best individual has too few features"}, status_code=503)
1660
+
1661
+ # Train model on ALL historical data using best individual's features + hyperparams
1662
+ X_train = np.nan_to_num(_evo_X[:, selected], nan=0.0, posinf=1e6, neginf=-1e6)
1663
+ y_train = _evo_y
1664
+
1665
+ hp_build = dict(hp)
1666
+ hp_build["n_estimators"] = min(hp.get("n_estimators", 150), 200)
1667
+ hp_build["max_depth"] = min(hp.get("max_depth", 6), 8)
1668
+
1669
+ if hp_build.get("model_type") == "stacking":
1670
+ # For stacking, use XGBoost as fallback for prediction
1671
+ hp_build["model_type"] = "xgboost"
1672
+
1673
+ model = _build(hp_build)
1674
+ if model is None:
1675
+ return JSONResponse({"error": "model build failed"}, status_code=500)
1676
+
1677
+ # Apply calibration if specified
1678
+ if hp_build.get("calibration", "none") != "none":
1679
+ model = CalibratedClassifierCV(model, method=hp_build["calibration"], cv=3)
1680
+
1681
+ model.fit(X_train, y_train)
1682
+
1683
+ # Build features for today's games
1684
+ # We append each game to historical data and build features for the last row
1685
+ selected_names = [_evo_features[i] for i in selected if i < len(_evo_features)]
1686
+ predictions = []
1687
+
1688
+ for game in games_to_predict:
1689
+ home = game.get("home_team", "")
1690
+ away = game.get("away_team", "")
1691
+ h_abbr = resolve(home)
1692
+ a_abbr = resolve(away)
1693
+
1694
+ if not h_abbr or not a_abbr:
1695
+ predictions.append({
1696
+ "home_team": home, "away_team": away,
1697
+ "error": "team not recognized"
1698
+ })
1699
+ continue
1700
+
1701
+ # Create a synthetic game entry (no score yet β€” we just need features)
1702
+ today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
1703
+ synthetic_game = {
1704
+ "game_date": today,
1705
+ "home_team": home, "away_team": away,
1706
+ "home": {"team_name": home, "pts": 0},
1707
+ "away": {"team_name": away, "pts": 0},
1708
+ }
1709
+
1710
+ # Build features for this game using historical context
1711
+ try:
1712
+ all_games = list(_evo_games) + [synthetic_game]
1713
+ X_all, y_all, fn_all = build_features(all_games)
1714
+
1715
+ # Apply same variance filter
1716
+ if X_all.shape[1] != _evo_X.shape[1]:
1717
+ # Feature count mismatch β€” use column mapping by name
1718
+ fn_map = {name: i for i, name in enumerate(fn_all)}
1719
+ game_vec = np.zeros(len(selected))
1720
+ for j, si in enumerate(selected):
1721
+ if si < len(_evo_features):
1722
+ fname = _evo_features[si]
1723
+ if fname in fn_map:
1724
+ game_vec[j] = X_all[-1, fn_map[fname]]
1725
+ else:
1726
+ game_vec = np.nan_to_num(X_all[-1, selected], nan=0.0, posinf=1e6, neginf=-1e6)
1727
+
1728
+ prob = float(model.predict_proba(game_vec.reshape(1, -1))[0, 1])
1729
+
1730
+ # Kelly criterion (25% fractional)
1731
+ if prob > 0.55:
1732
+ edge = prob - 0.5
1733
+ kelly = (edge / 0.5) * 0.25 # fractional Kelly
1734
+ else:
1735
+ kelly = 0.0
1736
+
1737
+ predictions.append({
1738
+ "home_team": home, "away_team": away,
1739
+ "home_win_prob": round(prob, 4),
1740
+ "away_win_prob": round(1 - prob, 4),
1741
+ "confidence": round(abs(prob - 0.5) * 2, 4),
1742
+ "kelly_stake": round(kelly, 4),
1743
+ "model_type": best["hyperparams"]["model_type"],
1744
+ "features_used": best["n_features"],
1745
+ "brier_cv": best["fitness"]["brier"],
1746
+ })
1747
+ except Exception as e:
1748
+ predictions.append({
1749
+ "home_team": home, "away_team": away,
1750
+ "error": f"feature build failed: {str(e)[:100]}"
1751
+ })
1752
+
1753
+ return JSONResponse({
1754
+ "predictions": predictions,
1755
+ "model": {
1756
+ "type": best["hyperparams"]["model_type"],
1757
+ "generation": best["generation"],
1758
+ "brier_cv": best["fitness"]["brier"],
1759
+ "roi_cv": best["fitness"]["roi"],
1760
+ "features": best["n_features"],
1761
+ },
1762
+ "timestamp": datetime.now(timezone.utc).isoformat(),
1763
+ })
1764
+
1765
+ except Exception as e:
1766
+ return JSONResponse({"error": f"prediction failed: {str(e)[:200]}"}, status_code=500)
1767
+
1768
+
1769
  with gr.Blocks(title="NOMOS NBA QUANT β€” Genetic Evolution", theme=gr.themes.Monochrome()) as app:
1770
  gr.Markdown("# NOMOS NBA QUANT AI β€” Real Genetic Evolution 24/7")
1771
  gr.Markdown("*Population of 60 individuals evolving feature selection + hyperparameters. Multi-objective: Brier + ROI + Sharpe + Calibration.*")
evolution/run_logger.py CHANGED
@@ -302,14 +302,18 @@ class RunLogger:
302
  self.regression_count = 0
303
 
304
  # ── RULE 2: STAGNATION CUT ──
 
305
  stagnation = engine_state.get("stagnation", 0)
306
  if stagnation >= 20:
307
- self.log_cut("STAGNATION", f"No improvement for {stagnation} generations",
308
- brier, brier, "emergency_diversify",
309
- {"pop_size": 200, "mutation_rate": 0.20, "target_features": 300})
 
 
 
310
  actions.append({
311
  "type": "emergency_diversify",
312
- "params": {"pop_size": 200, "mutation_rate": 0.20, "target_features": 300},
313
  })
314
 
315
  # ── RULE 3: ROI CUT ──
@@ -343,16 +347,19 @@ class RunLogger:
343
  })
344
 
345
  # ── RULE 6: BRIER FLOOR ──
346
- # If Brier is stuck above 0.24 for 30+ gens, force aggressive exploration
347
- if len(self.brier_history) >= 30:
348
- if all(b > 0.24 for b in self.brier_history[-30:]):
349
- self.log_cut("BRIER_FLOOR", "Brier stuck above 0.24 for 30+ gens",
350
- brier, brier, "full_reset",
351
- {"mutation_rate": 0.25, "pop_size": 250, "target_features": 400})
 
352
  actions.append({
353
- "type": "full_reset",
354
- "params": {"mutation_rate": 0.25, "pop_size": 250, "target_features": 400},
355
  })
 
 
356
 
357
  # Update tracking
358
  if brier < self.last_best_brier:
 
302
  self.regression_count = 0
303
 
304
  # ── RULE 2: STAGNATION CUT ──
305
+ # At 20+ gens stagnation, diversify moderately (don't destroy good individuals)
306
  stagnation = engine_state.get("stagnation", 0)
307
  if stagnation >= 20:
308
+ current_mut = engine_state.get("mutation_rate", 0.04)
309
+ # Boost mutation by 2x (capped at 0.15) β€” enough to explore without random noise
310
+ new_mut = min(0.15, current_mut * 2)
311
+ self.log_cut("STAGNATION", f"No improvement for {stagnation} gens (mutation {current_mut:.3f} β†’ {new_mut:.3f})",
312
+ brier, brier, "moderate_diversify",
313
+ {"mutation_rate": new_mut})
314
  actions.append({
315
  "type": "emergency_diversify",
316
+ "params": {"mutation_rate": new_mut},
317
  })
318
 
319
  # ── RULE 3: ROI CUT ──
 
347
  })
348
 
349
  # ── RULE 6: BRIER FLOOR ──
350
+ # If Brier is stuck above 0.24 for 50+ gens, try moderate exploration (NOT full reset)
351
+ # Old rule was destroying progress: mutation 0.25 = random noise, pop 250 > hard cap 80
352
+ if len(self.brier_history) >= 50:
353
+ if all(b > 0.24 for b in self.brier_history[-50:]):
354
+ self.log_cut("BRIER_FLOOR", f"Brier stuck above 0.24 for 50+ gens (best: {min(self.brier_history[-50:]):.4f})",
355
+ brier, brier, "moderate_diversify",
356
+ {"mutation_rate": 0.12, "target_features": 150})
357
  actions.append({
358
+ "type": "emergency_diversify",
359
+ "params": {"mutation_rate": 0.12, "target_features": 150},
360
  })
361
+ # Clear history so rule doesn't fire every single generation
362
+ self.brier_history = self.brier_history[-10:]
363
 
364
  # Update tracking
365
  if brier < self.last_best_brier: