Commit Β·
308a1d2
1
Parent(s): 4287543
feat: S10 /api/predict endpoint + fix auto-cut rules
Browse filesAdded /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>
- app.py +188 -0
- 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 |
-
|
| 308 |
-
|
| 309 |
-
|
|
|
|
|
|
|
|
|
|
| 310 |
actions.append({
|
| 311 |
"type": "emergency_diversify",
|
| 312 |
-
"params": {"
|
| 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
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
|
|
|
| 352 |
actions.append({
|
| 353 |
-
"type": "
|
| 354 |
-
"params": {"mutation_rate": 0.
|
| 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:
|