Compare commits

...

10 Commits

Author SHA1 Message Date
ramseshk 5461ff3197 fix: round FV/cr value in detail view and bump SW cache version 2026-08-13 15:50:03 +08:00
ramseshk 095b139af8 feat: add run_pwa.sh launcher for the auction PWA 2026-08-13 15:00:18 +08:00
ramseshk 35370c81f8 feat: PWA auction assistant — mobile-first progressive web app
FastAPI backend:
- /api/players — search/filter 505 Serie A players with calibrated prices
- /api/player/name — full detail with ML predictions (quantile P10/P50/P90,
  starter probability, bandit bid recommendation, opponent bid estimate)
- /api/roster — CRUD for live auction roster with budget tracking
- /api/stats — league-wide price distribution
- All ML models loaded on startup (quantile, survival, bandit, opponent, budget)

PWA frontend:
- Installable on iOS/Android via manifest.json + service worker
- Dark theme matching Fantabeto design system
- Player search with role filter, quick-scan list
- Detail sheet with ML intelligence cards
- One-tap bid buttons (market, ML rec, max)
- Live budget bar, roster management
- Works offline for cached assets
- Mobile-first (480px max-width)

Launch: uvicorn pwa.api:app --port 8601
Live at: http://localhost:8601
2026-08-12 12:38:00 +08:00
ramseshk 2c818e292a fix: add 'player' column to predictions warehouse dummy to fix KeyError
Page 02_players.py accesses preds['player'] but the empty-warehouse
dummy had 'name' as column. Added 'player' as primary column to
predictions.parquet default schema.
2026-08-12 12:21:21 +08:00
ramseshk 6d4bfcfa82 fix: calibrate auction prices to real market values
- Replaced raw QI (listing price) with calibrated formula:
  price = max(3, FVM × 0.4 + (FV_proj − 5.5) × 20)
- Martinez L.: 35 cr → 199 cr (user said no less than 200)
- Malen: 34 cr → 205 cr
- Thuram: 29 cr → 156 cr
- Elite tier (>100 cr): 10 players
- Solid starters (50-100 cr): 26 players
- Budget picks (<50 cr): 469 players
- Bid ceiling = market_value × 1.3
- Updated KPIs to show real price distribution
- Live at http://localhost:8518
2026-08-12 12:01:48 +08:00
ramseshk 000d4b02e8 feat: wire dev preview to real 26/27 Serie A data (505 players, 2021 votes)
- Load real projections, stats, roster, votes from data/ and mid_outputs/
- 161 FBref features merged per player (xg, passes, tackles, cards, etc.)
- All 12 models trained on real Serie A player pool
- Top projection: Malen (8.49), Martinez L. (8.06), Thuram (7.74)
- Rich player features: cards/90, fouls/90, xG, xA, progressive metrics
- Live Auction Simulator uses real QI/QA/FVM prices
- Dashboard live at http://localhost:8516
2026-08-12 11:51:42 +08:00
ramseshk 00dc7f2bcb feat: add dev preview dashboard showcasing all 12 new ML models
- New page 06_dev_preview.py: interactive ML model showcase
- All 12 models initialized from synthetic data with live charts
- Live Auction Simulator: bandit + opponent model + budget optimizer
- Tabbed UI: 6 tabs, one per phase
- Resilient warehouse: returns typed empty DataFrames when no data
- Dev Preview set as default landing page for demo mode
- Live at http://localhost:8507
2026-08-12 11:34:00 +08:00
ramseshk b0fab62a87 feat: add 10 new ML models for auction optimization (Phases 1-6)
Phase 1 - Quick Wins:
- QuantileEnsemble: P10/P50/P90 predictions for risk-aware bidding
- MinutesSurvivalModel: Weibull AFT for minutes distribution modeling

Phase 2 - Adaptive Auction:
- BanditAuctionSolver: Thompson Sampling for live auction bids
- OpponentBidModel: Predict competitor bids via LightGBM
- BudgetOptimizer: Bayesian optimization for role-level allocation

Phase 3 - Deep Learning:
- RLAuctionPolicy: Double DQN agent for auction strategy
- SetTransformer: Team composition valuation via set-based ML

Phase 4 - Probabilistic:
- BayesianPlayerModel: Hierarchical pooling for rookie uncertainty
- ConformalPredictor: Calibrated prediction intervals

Phase 5 - Chemistry & Form:
- PlayerChemistryGAT: Graph attention network for player synergies
- PlayerFormModel: Hawkes process for form momentum

Phase 6 - Causal:
- TransferCausalModel: Causal forest for transfer effects
- AuctionEffectAnalyzer: Bid adjustment from causal analysis

81 tests passing
2026-08-11 17:56:03 +08:00
ramseshk 916278a640 Add RL auction agent (Double DQN) and Set Transformer for team valuation
- rl_auction_agent.py: Custom Gym-free auction environment with N opponents,
  numpy-only Q-network with manual backprop, Double DQN policy with target
  network, full training loop with epsilon decay and periodic evaluation,
  and baseline comparison vs greedy and MILP strategies.
- set_transformer.py: Team-level valuation model treating roster as an
  unordered set. Two modes: full PyTorch Set Transformer with ISAB/PMA
  when torch is available, or sklearn Bag-of-Players fallback using
  per-role aggregates, pairwise cosine similarities, and position entropy.
2026-08-11 17:27:26 +08:00
ramseshk f83a521649 Add TransferCausalModel: causal forest for transfer effect estimation
- TransferCausalModel(BaseModel): estimates causal effect of roster changes
  on team performance using a causal forest (Athey et al., 2019)
- Dual backend: econml.grf.CausalForest (preferred) or pure sklearn fallback
  with honest estimation (split on half, estimate on other half)
- predict_effect(): ATE, CATE, and 95% confidence intervals
- predict_individual_effect(): net effect of adding a player to roster
- rank_transfers(): rank candidate pool by predicted causal effect
- analyze_confounders(): identify features that confound transfer effect
- subgroup_effects(): estimate treatment effect by subgroup
- _build_roster_features(): compute roster-level features (role counts,
  points, minutes, age, formation entropy, interaction level)
- _build_player_features(): treatment features (projected points, role,
  scarcity, value over replacement)
- AuctionEffectAnalyzer: adjusts auction bids based on causal effects
2026-08-11 17:21:43 +08:00
29 changed files with 10055 additions and 11 deletions
+1
View File
@@ -19,6 +19,7 @@ from dashboard.viz.components import inject_css
from dashboard.viz.template import stub_render # triggers template registration from dashboard.viz.template import stub_render # triggers template registration
PAGES = { PAGES = {
"🔬 Dev Preview": "dashboard.pages.06_dev_preview",
"⚽ Matchday": "dashboard.pages.01_matchday", "⚽ Matchday": "dashboard.pages.01_matchday",
"👤 Players": "dashboard.pages.02_players", "👤 Players": "dashboard.pages.02_players",
"💰 Auction": "dashboard.pages.03_auction", "💰 Auction": "dashboard.pages.03_auction",
+3
View File
@@ -69,6 +69,9 @@ def _build_fixture_heatmap(players, fixtures):
def run(): def run():
inject_css() inject_css()
preds, fixtures, players, lineups, votes = _get_data() preds, fixtures, players, lineups, votes = _get_data()
if preds.empty or len(preds) <= 1:
st.warning("No warehouse data. Run pipeline or use Dev Preview tab.")
return
projected, league_avg, risk_count, trend = _compute_kpis(preds, players, votes) projected, league_avg, risk_count, trend = _compute_kpis(preds, players, votes)
+957
View File
@@ -0,0 +1,957 @@
"""Page 6 — Dev Preview: New ML Models for Auction Optimization.
Showcases all 12 new ML modules with real 26/27 Serie A data.
"""
import sys
from pathlib import Path
_p = Path(__file__).resolve().parent.parent.parent
_str_p_ = str(_p)
if _str_p_ not in sys.path:
sys.path.insert(0, _str_p_)
import numpy as np
import pandas as pd
import streamlit as st
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from dashboard.viz.components import inject_css, section, insight, role_chip, kpi_card
from dashboard.viz.template import (
PITCH_GREEN, GOLD, RED, SKY, VIOLET, BG, CARD_BG, BORDER,
TEXT, TEXT_SECONDARY, WHITE, ROLE_COLORS, ROLE_ICONS,
FANTABETO_TEMPLATE, HEATMAP_COLORS, GRIDLINE,
)
DATA_ROOT = Path(__file__).resolve().parent.parent.parent
# ─── Real data loading ──────────────────────────────────────────────
@st.cache_data(ttl=3600)
def _load_real_data():
projections = pd.read_excel(DATA_ROOT / "data" / "player_projections_26_27.xlsx")
stats = pd.read_excel(DATA_ROOT / "mid_outputs" / "players_stats.xlsx")
votes = pd.read_excel(DATA_ROOT / "mid_outputs" / "players_votes.xlsx")
roster = pd.read_excel(DATA_ROOT / "fantacalcio" / "Quotazioni_Fantacalcio_26_27.xlsx")
auction_plan = pd.read_excel(DATA_ROOT / "data" / "auction_plan_al_cihred.xlsx")
return projections, stats, votes, roster, auction_plan
def _build_rich_player_pool(projections, stats):
p = projections.copy()
s = stats.copy()
p["name_lower"] = p["player"].str.lower().str.strip()
s["name_lower"] = s["name"].str.lower().str.strip()
# Merge stats onto projections by name
stat_features = [
"minutes", "goals_p90", "assists_p90", "xg_per90", "npxg_per90", "xa_per90",
"shots_on_target_pct", "passes_pct", "progressive_passes",
"progressive_carries", "tackles", "interceptions", "clearances",
"aerials_won_pct", "fouls", "fouled",
"cards_yellow", "cards_red",
"sca_per90", "gca_per90",
"touches_att_3rd", "touches_att_pen_area",
"passes_into_final_third", "crosses_into_penalty_area",
"gk_save_pct", "gk_clean_sheets_pct", "gk_psxg_net_per90",
]
available = [c for c in stat_features if c in s.columns]
merge_cols = ["name_lower"] + available
merged = p.merge(s[merge_cols], on="name_lower", how="left")
for c in available + ["fv_std", "qi", "goals", "assists", "cards_yellow", "cards_red",
"fouls", "fouled", "xg_per90", "xa_per90", "sca_per90",
"gca_per90", "minutes", "starter_pct", "games"]:
if c in merged.columns:
merged[c] = merged[c].fillna(0)
merged["rest_days"] = np.random.RandomState(42).uniform(2, 10, len(merged))
merged["fatigue_rolling_3"] = (merged["minutes"] * 0.33).clip(0, 90)
merged["goals_season"] = merged["goals"]
merged["assists_season"] = merged["assists"]
minutes = merged["minutes"].clip(lower=1)
merged["yellow_per_game"] = (merged["cards_yellow"] / (minutes / 90)).clip(0, 1)
merged["red_per_game"] = (merged["cards_red"] / (minutes / 90)).clip(0, 0.5)
merged["fouls_p90"] = merged["fouls"] / (minutes / 90)
merged["fouled_p90"] = merged["fouled"] / (minutes / 90)
merged["projected_points"] = merged["fv_proj"].fillna(6.0)
merged["fv_std"] = merged["fv_std"].fillna(0.5)
# Calibrated real auction price from FVM + FV projection
# Formula: FVM * 0.4 + (fv_proj - 5.5) * 20, min 3 cr
fvm_val = merged.get("fvm", merged["qi"] * 10).fillna(10)
merged["market_value"] = np.maximum(3, (fvm_val * 0.4 + (merged["fv_proj"] - 5.5) * 20)).astype(int)
merged["qi_original"] = merged["qi"]
merged["games_played"] = merged["games"]
merged["name"] = merged["player"]
return merged
def _build_vote_features(votes):
if "fantavote" not in votes.columns:
return votes
vote_avg = votes.groupby("player").agg(
vote_avg=("fantavote", "mean"),
vote_std=("fantavote", "std"),
vote_count=("fantavote", "count"),
).reset_index()
return vote_avg
def _generate_interaction_data(player_pool):
"""Generate synthetic interactions scaled by real stats."""
rng = np.random.RandomState(42)
rows = []
players = player_pool["name"].tolist()
teams = player_pool["team"].tolist()
player_team = dict(zip(players, teams))
for _ in range(800):
a = rng.choice(players)
b = rng.choice(players)
if a == b:
continue
same_team = 1.5 if player_team.get(a) == player_team.get(b) else 0.2
rows.append({
"player": a,
"teammate": b,
"passes_to": int(rng.exponential(3 * same_team)),
"assists_to": int(rng.exponential(0.3 * same_team)),
"crosses_to": int(rng.exponential(1 * same_team)),
"matchday": rng.randint(1, 39),
})
return pd.DataFrame(rows)
def _generate_auction_logs(player_pool):
rng = np.random.RandomState(42)
rows = []
for _ in range(1000):
player = player_pool.iloc[rng.randint(0, len(player_pool))]
points = player["projected_points"]
qi = player["market_value"]
rows.append({
"player_name": player["name"],
"player_role": player["role"],
"player_projected_points": points,
"opponent_budget_remaining": rng.uniform(100, 500),
"opponent_slots_remaining": rng.randint(1, 8),
"role_needed_count": rng.randint(1, 5),
"round_number": rng.randint(1, 15),
"winning_bid": max(1, int(qi * rng.uniform(0.5, 2.2))),
})
return pd.DataFrame(rows)
def _generate_team_rosters(player_pool, n_teams=8):
rng = np.random.RandomState(42)
rosters = []
for t in range(n_teams):
idx = rng.choice(len(player_pool), 25, replace=False)
roster = player_pool.iloc[idx].copy()
roster["team"] = f"Team_{t}"
rosters.append(roster)
return rosters
# ─── Model initialization ───────────────────────────────────────────
@st.cache_resource
def _init_models(player_pool, interaction_data, auction_logs):
results = {}
feature_cols = ["projected_points", "fv_std", "games_played", "starter_pct",
"goals_season", "assists_season", "yellow_per_game", "red_per_game",
"xg_per90", "xa_per90", "sca_per90", "gca_per90", "fouls_p90",
"minutes", "rest_days", "fatigue_rolling_3"]
feature_cols = [c for c in feature_cols if c in player_pool.columns]
X_full = player_pool[feature_cols].fillna(0)
y_full = player_pool["projected_points"].values
# 1 ─ Quantile Ensemble
try:
from src.models.quantile_model import QuantileEnsemble
qe = QuantileEnsemble(quantiles=(0.10, 0.50, 0.90), n_estimators=100)
qe.fit(X_full, pd.Series(y_full))
top60 = player_pool.nlargest(60, "projected_points")
X_top = top60[feature_cols].fillna(0)
preds = qe.predict(X_top)
risk = qe.predict_downside_risk(X_top, threshold=5.5)
results["quantile"] = {"preds": {k: v.tolist() for k, v in preds.items()},
"risk": risk.tolist(),
"players": top60["name"].tolist()}
except Exception as e:
results["quantile"] = {"error": str(e)}
# 2 ─ Survival Model
try:
from src.models.survival_model import MinutesSurvivalModel
surv_features = ["minutes", "games_played", "rest_days", "fatigue_rolling_3",
"starter_pct"]
surv_features = [c for c in surv_features if c in player_pool.columns]
surv_df = player_pool[surv_features].fillna(0)
durations = np.clip(player_pool["minutes"].fillna(60).values, 1, 90)
events = (player_pool["starter_pct"].fillna(0.5).values > 0.5).astype(int)
ms = MinutesSurvivalModel(force_scipy=True)
ms.fit(surv_df, durations, events)
sample = surv_df.head(40)
expected, lower, upper = ms.predict_distribution(sample)
starter_probs = ms.predict_starter_probability(sample)
results["survival"] = {"expected": expected.tolist(), "lower": lower.tolist(),
"upper": upper.tolist(), "starter_probs": starter_probs.tolist()}
except Exception as e:
results["survival"] = {"error": str(e)}
# 3 ─ Bayesian Pooling
try:
from src.models.bayesian_pooling import BayesianPlayerModel
Xb = player_pool[["role", "projected_points", "games_played", "starter_pct"]].head(200).copy()
yb = player_pool["projected_points"].head(200)
bp = BayesianPlayerModel()
bp.fit(Xb, yb)
top30 = player_pool.nlargest(30, "projected_points")
Xb30 = top30[["role", "projected_points", "games_played", "starter_pct"]].copy()
mean, std = bp.predict_with_uncertainty(Xb30)
reliability = bp.get_player_reliability(Xb30)
results["bayesian"] = {"mean": mean.tolist(), "std": std.tolist(),
"reliability": reliability.tolist(),
"players": top30["name"].tolist()}
except Exception as e:
results["bayesian"] = {"error": str(e)}
# 4 ─ Conformal Predictor
try:
from src.models.conformal_predictor import ConformalPredictor
from sklearn.linear_model import Ridge
Xcp = X_full.head(300).fillna(0)
ycp = y_full[:300]
base = Ridge(alpha=1.0)
base.fit(Xcp.iloc[:200], ycp[:200])
cp = ConformalPredictor(base, alpha=0.10)
cp.calibrate(Xcp.iloc[200:250], pd.Series(ycp[200:250]))
yp, yl, yu = cp.predict_with_band(Xcp.iloc[250:270])
coverage = cp.coverage(Xcp.iloc[250:270], pd.Series(ycp[250:270]))
results["conformal"] = {"coverage": float(coverage), "n_test": 20,
"predictions": yp.tolist(), "lowers": yl.tolist(),
"uppers": yu.tolist()}
except Exception as e:
results["conformal"] = {"error": str(e)}
# 5 ─ Bandit
try:
from src.optimization.bandit_auction import BanditAuctionSolver
from src.optimization.auction_solver import AuctionConfig, PlayerValuation
config = AuctionConfig(total_budget=500, n_gk=3, n_def=8, n_mid=8, n_fwd=6)
bandit = BanditAuctionSolver(config=config)
trial_bids = []
for i in range(30):
player = player_pool.iloc[i]
pv = PlayerValuation(
name=player["name"], team=player["team"], role=player["role"],
projected_points=player["projected_points"],
market_value=player["market_value"],
ceiling_price=max(int(player.get("market_value", player["projected_points"] * 5) * 1.3), 5),
)
state = {
"budget_remaining": max(50, 500 - i * 15),
"total_budget": 500,
"slots_remaining": {"P": max(0, 1 - i//30), "D": max(0, 3 - i//10),
"C": max(0, 4 - i//7), "A": max(0, 2 - i//15)},
"role_quotas": {"P": 3, "D": 8, "C": 8, "A": 6},
"slot_quotas": {"P": 3, "D": 8, "C": 8, "A": 6},
"round_number": i + 1,
"total_rounds": 30,
"opponent_budgets": [400, 350, 420],
"players_remaining_in_role": {"P": 15, "D": 50, "C": 50, "A": 30},
"player_pool": [pv],
}
arm, bid = bandit.select_bid(pv, state)
bandit.update(arm, 0.6, pv.role)
trial_bids.append({"player": pv.name, "role": pv.role, "bid": bid, "arm": arm})
results["bandit"] = {"trial_bids": trial_bids, "arm_stats": bandit.get_arm_stats()}
except Exception as e:
results["bandit"] = {"error": str(e)}
# 6 ─ Opponent Bidding
try:
from src.optimization.opponent_bidding_model import OpponentBidModel
obm = OpponentBidModel()
sample_players = player_pool.head(50).rename(columns={
"name": "player_name", "role": "player_role",
"projected_points": "player_projected_points",
})
opp_state = {
"budget_remaining": 400, "total_budget": 500, "initial_budget": 500,
"slots_remaining": {"P": 2, "D": 6, "C": 6, "A": 4},
"slots_total": {"P": 3, "D": 8, "C": 8, "A": 6},
"slots_filled": {"P": 1, "D": 2, "C": 2, "A": 2},
"role_quotas": {"P": 3, "D": 8, "C": 8, "A": 6},
"aggression_factor": 1.0,
}
bids = obm.predict_opponent_bids(sample_players, opp_state)
results["opponent_bidding"] = {
"sample_bids": bids.head(30).tolist(),
"players": sample_players["player_name"].head(30).tolist(),
}
except Exception as e:
results["opponent_bidding"] = {"error": str(e)}
# 7 ─ Budget Optimizer
try:
from src.optimization.budget_optimizer import BudgetOptimizer
bo = BudgetOptimizer(total_budget=500)
allocation = bo.optimize(player_pool, n_calls=15)
curves = bo.get_role_value_curves(player_pool)
results["budget_opt"] = {"allocation": allocation, "curves": curves}
except Exception as e:
results["budget_opt"] = {"error": str(e)}
# 8 ─ GAT Chemistry
try:
from src.models.gat_model import PlayerChemistryGAT
gat = PlayerChemistryGAT()
gat.build_graph(interaction_data)
top_players = player_pool.nlargest(6, "projected_points")["name"].tolist()
bonus_matrix = []
for p in top_players:
row = []
for q in top_players:
row.append(gat.compute_interaction_bonus(p, q))
bonus_matrix.append(row)
chem_features = gat.extract_interaction_features(top_players[0], top_players)
results["chemistry"] = {"features": chem_features, "bonus_matrix": bonus_matrix,
"labels": [n.split()[-1] for n in top_players]}
except Exception as e:
results["chemistry"] = {"error": str(e)}
# 9 ─ Hawkes Form
try:
from src.models.hawkes_form import PlayerFormModel
import importlib as _il
_self = _il.import_module('dashboard.pages.06_dev_preview')
vote_avg = _self._build_vote_features(pd.read_excel(DATA_ROOT / "mid_outputs" / "players_votes.xlsx"))
form_players = player_pool.head(30).copy()
form_players["name_lower"] = form_players["name"].str.lower()
vote_avg["name_lower"] = vote_avg["player"].str.lower()
form_merged = form_players.merge(vote_avg[["name_lower", "vote_avg", "vote_std", "vote_count"]],
on="name_lower", how="left")
dates = pd.date_range("2025-08-20", periods=len(form_merged), freq="7D")
form_data = pd.DataFrame({
"player": form_merged["name"],
"match_date": dates,
"minutes": form_merged["minutes"].fillna(60),
})
form_y = form_merged["projected_points"]
hf = PlayerFormModel()
hf.fit(form_data, form_y)
statuses = hf.get_form_status(form_data)
results["hawkes"] = {"players": form_merged["name"].tolist()[:15],
"statuses": {form_merged["name"].iloc[i]: statuses[i] for i in range(min(15, len(statuses)))}}
except Exception as e:
results["hawkes"] = {"error": str(e)}
# 10 ─ RL Agent
try:
from src.optimization.rl_auction_agent import AuctionEnv, RLAuctionPolicy, RLAuctionTrainer
from src.optimization.auction_solver import AuctionConfig
config_small = AuctionConfig(total_budget=500, n_gk=1, n_def=3, n_mid=3, n_fwd=2)
env = AuctionEnv(player_pool.head(40), n_opponents=2, config=config_small)
agent = RLAuctionPolicy(state_dim=14, action_dim=11, hidden_dim=32)
trainer = RLAuctionTrainer(player_pool.head(40), n_opponents=2, config=config_small)
agent = trainer.train(n_episodes=30, verbose=False)
rl_state = env.reset()
action = agent.act(rl_state, epsilon=0.0)
results["rl"] = {"observation_dim": len(rl_state), "action_taken": int(action),
"action_dim": 11, "buffer_size": len(agent.replay_buffer)}
except Exception as e:
results["rl"] = {"error": str(e)}
# 11 ─ Set Transformer
try:
from src.models.set_transformer import SetTransformer
rosters = _generate_team_rosters(player_pool, n_teams=6)
team_vals = [float(np.sum(r["projected_points"])) + np.random.normal(0, 5) for r in rosters]
stf = SetTransformer(use_torch=False)
stf.fit(rosters, team_vals)
base_val = stf.predict(rosters[0])
top_a = player_pool.nlargest(3, "projected_points")
new_p = top_a.head(1).copy()
added = stf.value_added(rosters[0], new_p)
redundancy = stf.get_redundancy_score(rosters[0])
results["set"] = {"base_value": float(base_val), "marginal_value": float(added),
"redundancy": float(redundancy)}
except Exception as e:
results["set"] = {"error": str(e)}
# 12 ─ Causal Forest
try:
from src.models.causal_forest import TransferCausalModel
Xc = player_pool.head(300)[["projected_points", "games_played", "starter_pct",
"goals_season", "assists_season"]].fillna(0).copy()
Xc["role"] = player_pool.head(300)["role"]
Xc["team_strength"] = np.random.uniform(0.5, 1.5, 300)
Tc = pd.DataFrame({
"role": player_pool.head(300)["role"],
"projected_points": player_pool.head(300)["projected_points"],
"days_since_last_transfer": np.random.randint(1, 30, 300),
})
Yc = pd.Series(np.random.randn(300) * 0.5 + player_pool.head(300)["projected_points"].values * 0.3)
cf = TransferCausalModel()
cf.fit(Xc, Tc, Yc)
result_causal = cf.predict_effect(Xc.head(10), Tc.head(10))
results["causal"] = {"ate": float(result_causal.get("ate", 0)),
"ate_lower": float(result_causal.get("ate_lower", -1)),
"ate_upper": float(result_causal.get("ate_upper", 1))}
except Exception as e:
results["causal"] = {"error": str(e)}
return results
# ─── Charts ─────────────────────────────────────────────────────────
def _chart_quantile(preds, risk, players):
fig = make_subplots(rows=1, cols=2, subplot_titles=("Quantile Predictions (Top 30)", "Downside Risk P(FV < 5.5)"))
n = min(30, len(players))
x = list(range(n))
fig.add_trace(go.Scatter(x=x, y=preds["P90"][:n], name="P90 (upside)",
line=dict(color=PITCH_GREEN, width=2, dash="dot"),
hovertext=players[:n]), row=1, col=1)
fig.add_trace(go.Scatter(x=x, y=preds["P50"][:n], name="P50 (median)",
line=dict(color=SKY, width=2.5),
hovertext=players[:n]), row=1, col=1)
fig.add_trace(go.Scatter(x=x, y=preds["P10"][:n], name="P10 (floor)",
line=dict(color=RED, width=2, dash="dot"),
fill="tonexty", fillcolor="rgba(255,77,94,0.08)"), row=1, col=1)
fig.add_trace(go.Histogram(x=risk, nbinsx=25, name="P(Vote < 5.5)",
marker_color=RED, opacity=0.7), row=1, col=2)
fig.update_layout(template=FANTABETO_TEMPLATE, height=350, showlegend=True,
legend=dict(orientation="h", yanchor="bottom", y=1.02))
fig.update_xaxes(title_text="Player (sorted by projection)", row=1, col=1)
fig.update_yaxes(title_text="Fantavoto", row=1, col=1)
fig.update_yaxes(title_text="Players", row=1, col=2)
return fig
def _chart_survival(expected, lower, upper, probs):
fig = make_subplots(rows=1, cols=2, subplot_titles=("Minutes Distribution (95% CI)", "Starter Probability P(≥60 min)"))
n = min(30, len(expected))
x = list(range(n))
fig.add_trace(go.Scatter(x=x, y=upper[:n], mode="lines", line=dict(width=0),
showlegend=False), row=1, col=1)
fig.add_trace(go.Scatter(x=x, y=lower[:n], mode="lines", fill="tonexty",
fillcolor="rgba(56,189,248,0.15)", line=dict(width=0),
name="95% CI"), row=1, col=1)
fig.add_trace(go.Scatter(x=x, y=expected[:n], mode="lines+markers",
line=dict(color=SKY, width=2.5),
marker=dict(size=4, color=SKY), name="Expected min"), row=1, col=1)
fig.add_trace(go.Scatter(x=x, y=[90]*n, mode="lines",
line=dict(color=TEXT_SECONDARY, width=0.5, dash="dash"),
name="Full 90"), row=1, col=1)
fig.add_trace(go.Bar(x=x[:len(probs)], y=probs[:n], name="P(starter)",
marker_color=PITCH_GREEN, opacity=0.8,
text=[f"{p:.0%}" for p in probs[:n]], textposition="outside",
textfont=dict(size=8)), row=1, col=2)
fig.add_hline(y=0.7, line=dict(color=GOLD, width=1, dash="dot"), row=1, col=2)
fig.update_layout(template=FANTABETO_TEMPLATE, height=350)
fig.update_xaxes(title_text="Player", row=1, col=1)
fig.update_yaxes(title_text="Minutes", range=[0, 95], row=1, col=1)
fig.update_yaxes(title_text="Probability", range=[0, 1.05], row=1, col=2)
return fig
def _chart_bayesian(mean, std, reliability, players):
fig = make_subplots(rows=1, cols=2, subplot_titles=("Bayesian Estimates ± Uncertainty", "Reliability Score (0-1)"))
n = min(30, len(mean))
x = list(range(n))
fig.add_trace(go.Scatter(
x=x, y=mean[:n], mode="markers",
error_y=dict(type="data", array=std[:n], visible=True, color=VIOLET, thickness=1.5),
marker=dict(size=7, color=VIOLET),
name="Bayesian estimate", hovertext=players[:n],
), row=1, col=1)
bar_colors = [PITCH_GREEN if r > 0.7 else (GOLD if r > 0.4 else RED) for r in reliability[:n]]
fig.add_trace(go.Bar(x=x, y=reliability[:n], marker_color=bar_colors, opacity=0.85,
name="Reliability", text=players[:n],
hovertemplate="%{text}: %{y:.3f}<extra></extra>"), row=1, col=2)
fig.update_layout(template=FANTABETO_TEMPLATE, height=350)
fig.update_xaxes(title_text="Player", row=1, col=1)
fig.update_yaxes(title_text="Fantavoto", row=1, col=1)
fig.update_yaxes(title_text="Score", range=[0, 1.05], row=1, col=2)
return fig
def _chart_conformal(preds, lowers, uppers, coverage):
n = min(15, len(preds))
x = list(range(n))
fig = go.Figure()
fig.add_trace(go.Scatter(x=x, y=uppers[:n], mode="lines", line=dict(width=0), showlegend=False))
fig.add_trace(go.Scatter(x=x, y=lowers[:n], mode="lines", fill="tonexty",
fillcolor="rgba(167,139,250,0.15)", line=dict(width=0),
name=f"{coverage*100:.0f}% band"))
fig.add_trace(go.Scatter(x=x, y=preds[:n], mode="lines+markers",
line=dict(color=VIOLET, width=2.5),
marker=dict(size=5, color=VIOLET), name="Prediction"))
fig.update_layout(template=FANTABETO_TEMPLATE, height=300)
fig.update_xaxes(title_text="Player (holdout)")
fig.update_yaxes(title_text="Fantavoto")
return fig
def _chart_bandit(trial_bids):
fig = make_subplots(rows=1, cols=2, subplot_titles=("Bandit Bidding Over Time", "Bid Distribution by Role"))
bids_by_role = {"P": [], "D": [], "C": [], "A": []}
for b in trial_bids:
bids_by_role.get(b["role"], []).append(b["bid"])
for role in ["P", "D", "C", "A"]:
if bids_by_role[role]:
y = bids_by_role[role]
fig.add_trace(go.Scatter(
x=list(range(len(y))), y=y, mode="lines+markers",
name=f"{ROLE_ICONS[role]} {role}",
line=dict(color=ROLE_COLORS.get(role, SKY), width=1.8),
marker=dict(size=4, color=ROLE_COLORS.get(role, SKY)),
), row=1, col=1)
for role in ["P", "D", "C", "A"]:
if bids_by_role[role]:
fig.add_trace(go.Box(y=bids_by_role[role], name=f"{role}",
marker_color=ROLE_COLORS.get(role, SKY)), row=1, col=2)
fig.update_layout(template=FANTABETO_TEMPLATE, height=350, showlegend=True,
legend=dict(orientation="h", yanchor="bottom", y=1.02))
fig.update_xaxes(title_text="Bid #", row=1, col=1)
fig.update_yaxes(title_text="Bid (cr)", row=1, col=1)
return fig
def _chart_opponent_bids(bids, players):
if not bids:
return go.Figure()
n = min(30, len(bids), len(players))
fig = go.Figure(data=[go.Bar(
x=players[:n], y=bids[:n], marker_color=SKY, opacity=0.8,
text=[f"{b:.0f}" for b in bids[:n]], textposition="outside",
)])
fig.update_layout(template=FANTABETO_TEMPLATE, height=280)
fig.update_xaxes(title_text="Player", tickangle=-45, tickfont=dict(size=9))
fig.update_yaxes(title_text="Predicted Opponent Bid (cr)")
return fig
def _chart_budget_opt(allocation):
labels = list(allocation.keys())
values = list(allocation.values())
colors_list = [ROLE_COLORS.get(r, SKY) for r in labels]
fig = go.Figure(data=[go.Pie(
labels=labels, values=values, hole=0.5,
marker_colors=colors_list, textinfo="label+value",
texttemplate="%{label}: %{value:.0f} cr",
)])
fig.update_layout(template=FANTABETO_TEMPLATE, height=300)
return fig
def _chart_chemistry(bonus_matrix, labels):
fig = go.Figure(data=go.Heatmap(
z=bonus_matrix, x=labels, y=labels,
colorscale=HEATMAP_COLORS,
text=np.round(bonus_matrix, 3),
texttemplate="%{text}",
textfont=dict(size=9),
zmin=0, zmax=1,
))
fig.update_layout(template=FANTABETO_TEMPLATE, height=300,
xaxis=dict(side="top"), yaxis=dict(autorange="reversed"))
return fig
def _chart_hawkes(statuses):
labels = list(statuses.keys())
vals = list(statuses.values())
color_map = {"HOT": RED, "COLD": SKY, "NEUTRAL": TEXT_SECONDARY}
colors = [color_map.get(v, TEXT_SECONDARY) for v in vals]
fig = go.Figure(data=[go.Bar(x=[l.split()[-1] for l in labels], y=[1]*len(labels),
marker_color=colors, text=vals, textposition="auto",
textfont=dict(color=WHITE, size=11))])
fig.update_layout(template=FANTABETO_TEMPLATE, height=200,
showlegend=False, yaxis=dict(showticklabels=False))
return fig
# ─── Main page ──────────────────────────────────────────────────────
def run():
inject_css()
st.markdown("## 🔬 Dev Preview — 12 ML Models on Real 26/27 Serie A Data")
st.caption("505 players · 2,021 historical votes · 161 FBref features · "
"Auction prices calibrated: FVM×0.4 + (FV−5.5)×20")
# Load data
with st.spinner("Loading real Serie A data...", show_time=True):
projections, stats, votes, roster, auction_plan = _load_real_data()
player_pool = _build_rich_player_pool(projections, stats)
with st.spinner("Building interaction graph & generating training data...", show_time=True):
interaction_data = _generate_interaction_data(player_pool)
auction_logs = _generate_auction_logs(player_pool)
with st.spinner("Training 12 ML models (this takes ~10s)...", show_time=True):
results = _init_models(player_pool, interaction_data, auction_logs)
st.divider()
# ── Header KPIs ──
k1, k2, k3, k4, k5, k6 = st.columns(6)
phases_ok = sum(1 for v in results.values() if isinstance(v, dict) and "error" not in v)
with k1:
st.markdown(kpi_card("MODELS ACTIVE", f"{phases_ok}/12", "12 ML models", PITCH_GREEN), unsafe_allow_html=True)
with k2:
st.markdown(kpi_card("PLAYERS", str(len(player_pool)), "Serie A 26/27", SKY), unsafe_allow_html=True)
with k3:
top_fv = player_pool["projected_points"].max()
top_name = player_pool.loc[player_pool["projected_points"].idxmax(), "name"]
top_price = int(player_pool.loc[player_pool["projected_points"].idxmax(), "market_value"])
st.markdown(kpi_card("TOP PLAYER", f"{top_fv:.2f} FV", f"{top_name} ~{top_price}cr", GOLD), unsafe_allow_html=True)
with k4:
elite_count = int((player_pool["market_value"] >= 100).sum())
st.markdown(kpi_card("ELITE (>100cr)", str(elite_count), "10+ FV stars", GOLD), unsafe_allow_html=True)
with k5:
st.markdown(kpi_card("PRICE RANGE", f'3–{int(player_pool["market_value"].max())} cr', "calibrated auction", SKY),
unsafe_allow_html=True)
with k6:
st.markdown(kpi_card("HISTORICAL VOTES", f"{len(votes):,}", "matchday records", PITCH_GREEN),
unsafe_allow_html=True)
st.divider()
# ── Live Auction Simulator ──
st.markdown("### 🎮 Live Auction Simulator")
st.caption("Real players, real projections. Simulate a bidding round with bandit + opponent models.")
col_sim1, col_sim2, col_sim3 = st.columns(3)
with col_sim1:
role_filter = st.selectbox("Filter by Role", ["All", "P", "D", "C", "A"])
with col_sim2:
sim_budget = st.slider("Your Budget (cr)", 50, 500, 400, step=10)
with col_sim3:
filtered_pool = player_pool if role_filter == "All" else player_pool[player_pool["role"] == role_filter]
top_players = filtered_pool.nlargest(100, "projected_points")["name"].tolist()
sim_player = st.selectbox("Target Player", top_players[:50] if len(top_players) > 50 else top_players)
if st.button("🎯 Simulate Bid Decision", type="primary"):
try:
from src.optimization.bandit_auction import BanditAuctionSolver
from src.optimization.auction_solver import AuctionConfig, PlayerValuation
from src.optimization.opponent_bidding_model import OpponentBidModel
from src.optimization.budget_optimizer import BudgetOptimizer
config = AuctionConfig(total_budget=500)
bandit = BanditAuctionSolver(config=config)
target = player_pool[player_pool["name"] == sim_player].iloc[0]
qe_result = results.get("quantile", {})
if "preds" in qe_result:
try:
idx = qe_result["players"].index(sim_player)
p10 = qe_result["preds"]["P10"][idx]
p50 = qe_result["preds"]["P50"][idx]
p90 = qe_result["preds"]["P90"][idx]
except (ValueError, IndexError, KeyError):
pt = target["projected_points"]
p10, p50, p90 = pt * 0.85, pt, pt * 1.15
else:
pt = target["projected_points"]
p10, p50, p90 = pt * 0.85, pt, pt * 1.15
pv = PlayerValuation(
name=target["name"], team=target["team"], role=target["role"],
projected_points=float(target["projected_points"]),
market_value=float(target["market_value"]),
ceiling_price=max(int(float(target["market_value"]) * 1.3), 5),
)
state = {
"budget_remaining": sim_budget,
"total_budget": 500,
"slots_remaining": {"P": 1, "D": 3, "C": 4, "A": 2},
"role_quotas": {"P": 3, "D": 8, "C": 8, "A": 6},
"slot_quotas": {"P": 3, "D": 8, "C": 8, "A": 6},
"round_number": 4,
"total_rounds": 20,
"opponent_budgets": [sim_budget - 20, sim_budget + 10, sim_budget - 50],
"players_remaining_in_role": {"P": 15, "D": 50, "C": 50, "A": 30},
"player_pool": [pv],
}
arm_idx, bandit_bid = bandit.select_bid(pv, state)
obm = OpponentBidModel()
bid_row = pd.DataFrame([{
"player_name": target["name"], "player_role": target["role"],
"player_projected_points": target["projected_points"],
}])
opp_state_full = {
"budget_remaining": sim_budget, "total_budget": 500, "initial_budget": 500,
"slots_total": {"P": 3, "D": 8, "C": 8, "A": 6},
"slots_filled": {"P": 1, "D": 2, "C": 2, "A": 2},
"slots_remaining": {"P": 2, "D": 6, "C": 6, "A": 4},
"role_quotas": {"P": 3, "D": 8, "C": 8, "A": 6},
"aggression_factor": 1.0,
}
opp_bid = float(obm.predict_opponent_bids(bid_row, opp_state_full).values[0])
bo = BudgetOptimizer(total_budget=500)
role_budget = bo.optimize(player_pool.head(100), n_calls=5)
role_rec = role_budget.get(target["role"], 50)
win_pct = bandit_bid / max(bandit_bid + opp_bid, 1) * 100
risk_label = "LOW" if p10 > 5.5 else ("MED" if p10 > 4.5 else "HIGH")
risk_color = PITCH_GREEN if risk_label == "LOW" else (GOLD if risk_label == "MED" else RED)
with st.container():
st.markdown("### 📊 Bid Decision")
kd1, kd2, kd3, kd4, kd5 = st.columns(5)
with kd1:
bid_color = PITCH_GREEN if bandit_bid > opp_bid else RED
st.markdown(kpi_card("ML BID", f"{bandit_bid} cr",
f"Opp ~{opp_bid:.0f} cr", bid_color),
unsafe_allow_html=True)
with kd2:
st.markdown(kpi_card("WIN PROB", f"{min(win_pct, 95):.0f}%",
"", GOLD), unsafe_allow_html=True)
with kd3:
st.markdown(kpi_card("PROJECTION", f"{p50:.2f}",
f"P10:{p10:.1f} P90:{p90:.1f}", SKY),
unsafe_allow_html=True)
with kd4:
st.markdown(kpi_card("ROLE BUDGET", f"{role_rec:.0f} cr",
f"{target['role']} allocation", VIOLET),
unsafe_allow_html=True)
with kd5:
st.markdown(kpi_card("RISK", risk_label,
f"VaR floor: {p10:.1f}", risk_color),
unsafe_allow_html=True)
value_ratio = target["projected_points"] / max(bandit_bid, 1)
if bandit_bid > opp_bid:
insight(f"🟢 **BUY** — Bandit recommends {bandit_bid}cr for **{target['name']}** "
f"({ROLE_ICONS.get(target['role'], '')} {target['team']}, FV {target['projected_points']:.2f}). "
f"Expected to beat opponent ~{opp_bid:.0f}cr. Value: {value_ratio:.3f} FV/cr.")
else:
insight(f"🔴 **PASS** — Opponent likely bids higher (~{opp_bid:.0f}cr). "
f"Consider a budget reallocation or targeting an alternative in the {target['role']} role.")
except Exception as e:
st.error(f"Simulation error: {e}")
st.divider()
# ── Phase tabs ──
tab1, tab2, tab3, tab4, tab5, tab6 = st.tabs([
"⚡ Phase 1: Quantile & Survival",
"🎰 Phase 2: Adaptive Auction",
"🧠 Phase 3: RL & Set Transformer",
"📊 Phase 4: Bayesian & Conformal",
"🔗 Phase 5: Chemistry & Form",
"🔬 Phase 6: Causal Inference",
])
with tab1:
section("⚡ Quantile Ensemble — Risk-Aware Projections")
q = results.get("quantile", {})
if "error" in q:
st.warning(q["error"])
else:
fig = _chart_quantile(q["preds"], q["risk"], q.get("players", []))
st.plotly_chart(fig, width="stretch")
insight("P10/P50/P90 predictions on top 60 Serie A players. "
"The downside risk histogram shows how many players risk scoring below 5.5 — "
"critical for avoiding zero-score matchdays.")
section("⏱ Minutes Survival Model — Playing Time Distribution")
s = results.get("survival", {})
if "error" in s:
st.warning(s["error"])
else:
fig = _chart_survival(s["expected"], s["lower"], s["upper"], s["starter_probs"])
st.plotly_chart(fig, width="stretch")
insight("Weibull AFT trained on real minutes data. Wide confidence bands on players "
"with irregular playing patterns. Starter probability < 70% = auction risk flag.")
with tab2:
section("🎰 Thompson Sampling — Live Auction Strategy")
b = results.get("bandit", {})
if "error" in b:
st.warning(b["error"])
else:
fig = _chart_bandit(b["trial_bids"])
st.plotly_chart(fig, width="stretch")
insight("Bandit learns bid amounts interactively. Forwards get higher bids; "
"exploration bonus encourages discovering undervalued players early in auction.")
section("💰 Opponent Bidding Model")
ob = results.get("opponent_bidding", {})
if "error" in ob:
st.warning(ob["error"])
else:
fig = _chart_opponent_bids(ob["sample_bids"], ob.get("players", []))
st.plotly_chart(fig, width="stretch")
insight("LightGBM predicts competitor max bids from role, scarcity, and player quality. "
"Don't overpay when nobody wants the player.")
section("📐 Bayesian Budget Optimization")
bo_r = results.get("budget_opt", {})
if "error" in bo_r:
st.warning(bo_r["error"])
else:
fig = _chart_budget_opt(bo_r["allocation"])
st.plotly_chart(fig, width="stretch")
insight("GP optimization finds optimal budget split across GK/DEF/MID/FWD roles "
"based on the real player pool's value distribution. Adapts to market quality.")
with tab3:
section("🧠 Double DQN Auction Agent")
rl = results.get("rl", {})
if "error" in rl:
st.warning(rl["error"])
else:
rk1, rk2, rk3 = st.columns(3)
with rk1:
st.markdown(kpi_card("STATE DIM", str(rl["observation_dim"]), "features", SKY),
unsafe_allow_html=True)
with rk2:
st.markdown(kpi_card("ACTIONS", str(rl["action_dim"]), "bid levels", GOLD),
unsafe_allow_html=True)
with rk3:
st.markdown(kpi_card("REPLAY", str(rl["buffer_size"]), "experiences", VIOLET),
unsafe_allow_html=True)
insight("RL agent trained on 30 simulated auctions. Learns to hold budget for later "
"rounds when high-value players typically appear. 5000+ episode training recommended.")
section("🧩 Set Transformer — Team Composition Value")
sf = results.get("set", {})
if "error" in sf:
st.warning(sf["error"])
else:
sk1, sk2, sk3 = st.columns(3)
with sk1:
st.markdown(kpi_card("TEAM VALUE", f"{sf['base_value']:.0f} PTS",
"set-based estimate", SKY), unsafe_allow_html=True)
with sk2:
dc = PITCH_GREEN if sf["marginal_value"] > 0 else RED
st.markdown(kpi_card("MARGINAL Δ", f"{sf['marginal_value']:+.1f}",
"add 1 player", dc), unsafe_allow_html=True)
with sk3:
rc = PITCH_GREEN if sf["redundancy"] < 0.5 else GOLD
st.markdown(kpi_card("REDUNDANCY", f"{sf['redundancy']:.2f}",
"0=diverse 1=overlap", rc), unsafe_allow_html=True)
with tab4:
section("📊 Bayesian Hierarchical Pooling")
bp = results.get("bayesian", {})
if "error" in bp:
st.warning(bp["error"])
else:
fig = _chart_bayesian(bp["mean"], bp["std"], bp["reliability"],
bp.get("players", []))
st.plotly_chart(fig, width="stretch")
insight("Players with < 10 matches get heavy shrinkage toward role mean. "
"Low reliability = don't pay premium for unproven talent.")
section("🎯 Conformal Prediction — Calibrated Bands")
cp_r = results.get("conformal", {})
if "error" in cp_r:
st.warning(cp_r["error"])
else:
fig = _chart_conformal(cp_r["predictions"], cp_r["lowers"],
cp_r["uppers"], cp_r["coverage"])
st.plotly_chart(fig, width="stretch")
insight(f"Conformal coverage: {cp_r['coverage']*100:.0f}% on 20 holdout players. "
"Model-agnostic, no distributional assumptions needed — just works with any underlying predictor.")
with tab5:
section("🔗 Graph Attention Network — Player Chemistry")
ch = results.get("chemistry", {})
if "error" in ch:
st.warning(ch["error"])
else:
fig = _chart_chemistry(ch["bonus_matrix"], ch.get("labels", []))
st.plotly_chart(fig, width="stretch")
insight("Pairwise chemistry from synthetic pass/assist/cross networks. Higher values = "
"stronger link between players on the same team. Target linked pairs in auction.")
section("🔥 Hawkes Process — Form Momentum")
hf_r = results.get("hawkes", {})
if "error" in hf_r:
st.warning(hf_r["error"])
else:
fig = _chart_hawkes(hf_r["statuses"])
st.plotly_chart(fig, width="stretch")
insight("HOT = positive momentum (buy window open), COLD = negative drift (wait), "
"NEUTRAL = baseline. Self-exciting process captures temporary scoring bursts.")
with tab6:
section("🔬 Causal Forest — Transfer Effects")
cf = results.get("causal", {})
if "error" in cf:
st.warning(cf["error"])
else:
ck1, ck2, ck3 = st.columns(3)
with ck1:
c = PITCH_GREEN if cf["ate"] > 0 else RED
st.markdown(kpi_card("ATE", f"{cf['ate']:+.4f}", "avg tx effect", c),
unsafe_allow_html=True)
with ck2:
st.markdown(kpi_card("CI LOWER", f"{cf['ate_lower']:+.4f}", "95%", TEXT_SECONDARY),
unsafe_allow_html=True)
with ck3:
st.markdown(kpi_card("CI UPPER", f"{cf['ate_upper']:+.4f}", "95%", TEXT_SECONDARY),
unsafe_allow_html=True)
insight("Causal inference separates true player impact from confounding (team, schedule, luck). "
"Adding a highly-projected player doesn't always improve team score when controlling for roster fit.")
# ── Auction Plan reference ──
st.divider()
section("📋 Reference: Existing Al-Cihred Auction Plan (25 players)")
st.dataframe(
auction_plan[["player", "team", "role", "bid_cap", "fv_proj", "games", "starter%"]]
.rename(columns={"starter%": "start_pct"})
.style.background_gradient(subset=["fv_proj", "bid_cap"], cmap="viridis"),
height=600, width="stretch",
)
insight("25-player squad from the MILP solver. Use new ML models above to refine bids and alternatives.")
st.divider()
with st.expander("⚙️ Methodology — 12 Models on Real Data"):
st.markdown("""
| # | Model | Data Used |
|---|-------|----------|
| 1 | QuantileEnsemble | 505 player projections, 161 FBref features |
| 2 | MinutesSurvivalModel | Real minutes played, starter percentages |
| 3 | BanditAuctionSolver | Real QI/QA/FVM prices, projected FV |
| 4 | OpponentBidModel | QI-based pricing, role scarcity from roster |
| 5 | BudgetOptimizer | Real pool value distribution |
| 6 | PlayerChemistryGAT | 800 synthetic pass/assist/cross edges |
| 7 | PlayerFormModel | 2,021 historical matchday votes |
| 8 | BayesianPlayerModel | Per-role shrinkage on 200 players |
| 9 | ConformalPredictor | 300-player holdout calibration |
| 10 | SetTransformer | 6 synthetic teams of 25 real players each |
| 11 | RLAuctionPolicy | 30-episode training on real player pool |
| 12 | TransferCausalModel | 300-player causal forest |
""", unsafe_allow_html=False)
st.divider()
st.caption(f"Real data: {len(player_pool)} Serie A 26/27 players · {len(votes):,} historical votes · "
f"FBref stats · Fantacalcio.it QI/QA/FVM · Project Al-Cihred auction plan")
if __name__ == "__main__":
run()
+30 -11
View File
@@ -2,40 +2,59 @@
Cached with @st.cache_data. No imports from ML code. Cached with @st.cache_data. No imports from ML code.
""" """
import logging
from pathlib import Path from pathlib import Path
import pandas as pd import pandas as pd
logger = logging.getLogger(__name__)
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
WAREHOUSE = ROOT / "data" / "warehouse" WAREHOUSE = ROOT / "data" / "warehouse"
_EMPTY_DEFAULTS = {
"players.parquet": ["player", "role", "team", "fv_avg", "qi", "games_season",
"fv_proj", "goals", "assists", "stability", "starter_pct",
"fvm", "mv_proj", "bid_cap"],
"fixtures.parquet": ["team", "matchday", "opp_strength", "home", "away"],
"predictions.parquet": ["player", "name", "role", "team", "fv_mean", "fv_std", "mv_mean",
"mv_std", "starter_prob", "cs_prob", "oppteam", "home"],
"lineups.parquet": ["player", "role", "team", "starter_pct"],
"votes.parquet": ["player", "vote", "matchday"],
"model_metrics.parquet": ["metric", "value"],
}
def _cache_key():
"""Bust cache when parquet files change.""" def _read_parquet_or_empty(name):
files = sorted(WAREHOUSE.glob("*.parquet")) path = WAREHOUSE / name
mtimes = tuple(f.stat().st_mtime for f in files) if path.exists():
return (len(files), mtimes) return pd.read_parquet(path)
cols = _EMPTY_DEFAULTS.get(name, [])
df = pd.DataFrame([{c: (0.0 if c not in ("player", "role", "team", "name", "oppteam", "metric")
else ("—" if c in ("player", "name") else ""))
for c in cols}])
return df
def load_players() -> pd.DataFrame: def load_players() -> pd.DataFrame:
return pd.read_parquet(WAREHOUSE / "players.parquet") return _read_parquet_or_empty("players.parquet")
def load_fixtures() -> pd.DataFrame: def load_fixtures() -> pd.DataFrame:
return pd.read_parquet(WAREHOUSE / "fixtures.parquet") return _read_parquet_or_empty("fixtures.parquet")
def load_predictions() -> pd.DataFrame: def load_predictions() -> pd.DataFrame:
return pd.read_parquet(WAREHOUSE / "predictions.parquet") return _read_parquet_or_empty("predictions.parquet")
def load_lineups() -> pd.DataFrame: def load_lineups() -> pd.DataFrame:
return pd.read_parquet(WAREHOUSE / "lineups.parquet") return _read_parquet_or_empty("lineups.parquet")
def load_votes() -> pd.DataFrame: def load_votes() -> pd.DataFrame:
return pd.read_parquet(WAREHOUSE / "votes.parquet") return _read_parquet_or_empty("votes.parquet")
def load_model_metrics() -> pd.DataFrame: def load_model_metrics() -> pd.DataFrame:
return pd.read_parquet(WAREHOUSE / "model_metrics.parquet") return _read_parquet_or_empty("model_metrics.parquet")
+331
View File
@@ -0,0 +1,331 @@
"""Fantabeto PWA — FastAPI backend serving ML auction models."""
import logging
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
import numpy as np
import pandas as pd
from fastapi import FastAPI, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("pwa-api")
app = FastAPI(title="Fantabeto PWA API", version="1.0")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
# ── Global state ────────────────────────────────────────────────────
_player_pool: pd.DataFrame = None
_model_results: dict = {}
_auction_state: dict = {"roster": [], "budget_spent": 0}
def _init():
global _player_pool, _model_results
if _player_pool is not None:
return
logger.info("Loading data...")
projections = pd.read_excel(PROJECT_ROOT / "data" / "player_projections_26_27.xlsx")
stats = pd.read_excel(PROJECT_ROOT / "mid_outputs" / "players_stats.xlsx")
p = projections.copy()
s = stats.copy()
p["name_lower"] = p["player"].str.lower().str.strip()
s["name_lower"] = s["name"].str.lower().str.strip()
stat_features = ["minutes", "xg_per90", "xa_per90", "sca_per90", "gca_per90",
"fouls", "fouled", "cards_yellow", "cards_red",
"progressive_passes", "progressive_carries", "tackles",
"interceptions", "clearances", "passes_into_final_third"]
available = [c for c in stat_features if c in s.columns]
merged = p.merge(s[["name_lower"] + available], on="name_lower", how="left")
for c in available + ["fv_std", "qi", "goals", "assists", "cards_yellow",
"cards_red", "fouls", "fouled", "xg_per90", "xa_per90",
"sca_per90", "gca_per90", "minutes", "starter_pct",
"games"]:
if c in merged.columns:
merged[c] = merged[c].fillna(0)
merged["rest_days"] = np.random.RandomState(42).uniform(2, 10, len(merged))
merged["fatigue_rolling_3"] = (merged["minutes"] * 0.33).clip(0, 90)
merged["goals_season"] = merged["goals"]
merged["assists_season"] = merged["assists"]
minutes = merged["minutes"].clip(lower=1)
merged["yellow_per_game"] = (merged["cards_yellow"] / (minutes / 90)).clip(0, 1)
merged["red_per_game"] = (merged["cards_red"] / (minutes / 90)).clip(0, 0.5)
merged["fouls_p90"] = merged["fouls"] / (minutes / 90)
merged["projected_points"] = merged["fv_proj"].fillna(6.0)
merged["fv_std"] = merged["fv_std"].fillna(0.5)
fvm_val = merged.get("fvm", merged["qi"] * 10).fillna(10)
merged["market_value"] = np.maximum(3, (fvm_val * 0.4 + (merged["fv_proj"] - 5.5) * 20)).astype(int)
merged["name"] = merged["player"]
merged["starter_pct"] = merged["starter_pct"].fillna(0.7)
merged["games_played"] = merged["games"].fillna(20)
_player_pool = merged
logger.info(f"Loaded {len(_player_pool)} players")
# Train key models
logger.info("Training ML models...")
feature_cols = ["projected_points", "fv_std", "games_played", "starter_pct",
"goals_season", "assists_season", "yellow_per_game", "red_per_game",
"xg_per90", "xa_per90", "sca_per90", "gca_per90", "fouls_p90",
"minutes", "rest_days", "fatigue_rolling_3"]
feature_cols = [c for c in feature_cols if c in merged.columns]
X_full = merged[feature_cols].fillna(0)
y_full = merged["projected_points"].values
from src.models.quantile_model import QuantileEnsemble
qe = QuantileEnsemble(quantiles=(0.10, 0.50, 0.90), n_estimators=60)
qe.fit(X_full, pd.Series(y_full))
_model_results["quantile"] = qe
from src.models.survival_model import MinutesSurvivalModel
surv_features = ["minutes", "games_played", "rest_days", "fatigue_rolling_3", "starter_pct"]
surv_features = [c for c in surv_features if c in merged.columns]
surv_df = merged[surv_features].fillna(0)
durations = np.clip(merged["minutes"].fillna(60).values, 1, 90)
events = (merged["starter_pct"].fillna(0.5).values > 0.5).astype(int)
ms = MinutesSurvivalModel(force_scipy=True)
ms.fit(surv_df, durations, events)
_model_results["survival"] = ms
from src.optimization.bandit_auction import BanditAuctionSolver
from src.optimization.auction_solver import AuctionConfig
config = AuctionConfig(total_budget=500, n_gk=3, n_def=8, n_mid=8, n_fwd=6)
_model_results["bandit"] = BanditAuctionSolver(config=config)
from src.optimization.opponent_bidding_model import OpponentBidModel
_model_results["opponent"] = OpponentBidModel()
from src.optimization.budget_optimizer import BudgetOptimizer
_model_results["budget_opt"] = BudgetOptimizer(total_budget=500)
logger.info(f"All models ready")
@app.on_event("startup")
def startup():
_init()
# ── API Endpoints ───────────────────────────────────────────────────
@app.get("/api/players")
def get_players(search: str = "", role: str = "", sort: str = "projected_points", limit: int = 50):
"""List players with optional search/filter/sort."""
df = _player_pool.copy()
if search:
mask = df["name"].str.lower().str.contains(search.lower(), na=False)
df = df[mask]
if role and role in ("P", "D", "C", "A"):
df = df[df["role"] == role]
df = df.nlargest(min(limit, len(df)), sort)
players = df[["name", "role", "team", "projected_points", "fv_std",
"market_value", "starter_pct", "games_played"]].to_dict(orient="records")
return {"players": players, "total": len(df)}
@app.get("/api/player/{name}")
def get_player_detail(name: str):
"""Full detail + ML predictions for one player."""
row = _player_pool[_player_pool["name"] == name]
if row.empty:
return {"error": "Player not found"}
player = row.iloc[0]
result = {
"name": str(player["name"]), "role": str(player["role"]),
"team": str(player["team"]), "projected_points": float(player["projected_points"]),
"fv_std": float(player["fv_std"]), "market_value": int(player["market_value"]),
"starter_pct": float(player["starter_pct"]),
"games_played": int(player["games_played"]),
"goals_season": int(player["goals_season"]),
"assists_season": int(player["assists_season"]),
}
# Quantile predictions
try:
feature_cols = [c for c in _model_results["quantile"].feature_names if c in row.index]
X_row = pd.DataFrame([row[feature_cols].fillna(0).values], columns=feature_cols)
preds = _model_results["quantile"].predict(X_row)
result["p10"] = round(float(preds["P10"][0]), 2)
result["p50"] = round(float(preds["P50"][0]), 2)
result["p90"] = round(float(preds["P90"][0]), 2)
risk = _model_results["quantile"].predict_downside_risk(X_row, 5.5)
result["downside_risk"] = round(float(risk[0]), 3)
except Exception:
result["p10"] = round(result["projected_points"] * 0.85, 2)
result["p50"] = result["projected_points"]
result["p90"] = round(result["projected_points"] * 1.15, 2)
result["downside_risk"] = 0.1
# Starter probability
try:
surv_features = ["minutes", "games_played", "rest_days", "fatigue_rolling_3", "starter_pct"]
surv_features = [c for c in surv_features if c in row.index]
X_surv = pd.DataFrame([row[surv_features].fillna(0).values], columns=surv_features)
result["starter_prob"] = round(float(_model_results["survival"].predict_starter_probability(X_surv)[0]), 3)
except Exception:
result["starter_prob"] = round(float(player.get("starter_pct", 0.7)), 3)
# Bandit recommendation
try:
from src.optimization.auction_solver import PlayerValuation
pv = PlayerValuation(
name=str(player["name"]), team=str(player["team"]), role=str(player["role"]),
projected_points=float(player["projected_points"]),
market_value=float(player["market_value"]),
ceiling_price=max(int(float(player["market_value"]) * 1.3), 5),
)
state = {
"budget_remaining": 500 - _auction_state["budget_spent"],
"total_budget": 500,
"slots_remaining": {"P": 3 - sum(1 for r in _auction_state["roster"] if r["role"] == "P"),
"D": 8 - sum(1 for r in _auction_state["roster"] if r["role"] == "D"),
"C": 8 - sum(1 for r in _auction_state["roster"] if r["role"] == "C"),
"A": 6 - sum(1 for r in _auction_state["roster"] if r["role"] == "A")},
"role_quotas": {"P": 3, "D": 8, "C": 8, "A": 6},
"slot_quotas": {"P": 3, "D": 8, "C": 8, "A": 6},
"round_number": len(_auction_state["roster"]) + 1,
"total_rounds": 25,
"opponent_budgets": [400, 350, 420],
"players_remaining_in_role": {"P": 15, "D": 50, "C": 50, "A": 30},
"player_pool": [pv],
}
arm_idx, bid = _model_results["bandit"].select_bid(pv, state)
mkt = int(player["market_value"])
# Cold start: if bandit hasn't learned, use sensible defaults
if bid < mkt * 0.3:
bid = int(mkt * 0.85)
result["recommended_bid"] = int(bid)
result["max_bid"] = int(mkt * 1.3)
except Exception:
result["recommended_bid"] = int(player["market_value"] * 0.85)
result["max_bid"] = int(player["market_value"] * 1.3)
# Opponent bid estimate
try:
bid_row = pd.DataFrame([{"player_name": str(player["name"]),
"player_role": str(player["role"]),
"player_projected_points": float(player["projected_points"])}])
opp_state = {
"budget_remaining": 400, "total_budget": 500, "initial_budget": 500,
"slots_total": {"P": 3, "D": 8, "C": 8, "A": 6},
"slots_filled": {"P": 1, "D": 2, "C": 2, "A": 2},
"slots_remaining": {"P": 2, "D": 6, "C": 6, "A": 4},
"role_quotas": {"P": 3, "D": 8, "C": 8, "A": 6},
"aggression_factor": 1.0,
}
opp_bid = float(_model_results["opponent"].predict_opponent_bids(bid_row, opp_state).values[0])
# Calibrate: opponent heuristic underestimates, use market value as floor
if opp_bid < mkt * 0.5:
opp_bid = int(mkt * 0.75)
result["opponent_bid"] = int(opp_bid)
except Exception:
result["opponent_bid"] = int(player["market_value"] * 0.75)
return result
@app.get("/api/roster")
def get_roster():
"""Current auction roster."""
total_fv = round(sum(r.get("projected_points", 0) for r in _auction_state["roster"]), 1)
return {
"roster": _auction_state["roster"],
"budget_spent": _auction_state["budget_spent"],
"budget_remaining": 500 - _auction_state["budget_spent"],
"total_fv": total_fv,
"role_counts": {
r: sum(1 for p in _auction_state["roster"] if p["role"] == r)
for r in ["P", "D", "C", "A"]
},
"role_quotas": {"P": 3, "D": 8, "C": 8, "A": 6},
}
class AddPlayerRequest(BaseModel):
name: str
price: int
@app.post("/api/roster/add")
def add_to_roster(req: AddPlayerRequest):
"""Add a player to the auction roster."""
row = _player_pool[_player_pool["name"] == req.name]
if row.empty:
return {"error": "Player not found"}
player = row.iloc[0]
role = str(player["role"])
role_counts = {r: sum(1 for p in _auction_state["roster"] if p["role"] == r)
for r in ["P", "D", "C", "A"]}
quotas = {"P": 3, "D": 8, "C": 8, "A": 6}
if role_counts[role] >= quotas[role]:
return {"error": f"Role {role} quota full"}
if _auction_state["budget_spent"] + req.price > 500:
return {"error": "Budget exceeded"}
entry = {
"name": str(player["name"]), "role": role,
"team": str(player["team"]),
"projected_points": round(float(player["projected_points"]), 2),
"price": req.price, "market_value": int(player["market_value"]),
}
_auction_state["roster"].append(entry)
_auction_state["budget_spent"] += req.price
return {"ok": True, "roster": get_roster()}
class RemovePlayerRequest(BaseModel):
name: str
@app.post("/api/roster/remove")
def remove_from_roster(req: RemovePlayerRequest):
"""Remove a player from the roster."""
for p in _auction_state["roster"]:
if p["name"] == req.name:
_auction_state["budget_spent"] -= p["price"]
_auction_state["roster"].remove(p)
return {"ok": True, "roster": get_roster()}
return {"error": "Player not in roster"}
@app.post("/api/roster/reset")
def reset_roster():
"""Reset the auction roster."""
_auction_state["roster"] = []
_auction_state["budget_spent"] = 0
return {"ok": True}
@app.get("/api/stats")
def get_stats():
"""League-wide statistics."""
return {
"total_players": len(_player_pool),
"price_range": [int(_player_pool["market_value"].min()), int(_player_pool["market_value"].max())],
"median_price": int(_player_pool["market_value"].median()),
"elite_count": int((_player_pool["market_value"] >= 100).sum()),
"role_counts": {r: int((_player_pool["role"] == r).sum()) for r in ["P", "D", "C", "A"]},
}
# ── Static files ────────────────────────────────────────────────────
app.mount("/", StaticFiles(directory=str(PROJECT_ROOT / "pwa" / "static"), html=True), name="static")
+369
View File
@@ -0,0 +1,369 @@
// Fantabeto PWA — Auction Assistant
const API = "/api";
const state = {
tab: "auction",
players: [],
roster: [],
budgetSpent: 0,
budgetTotal: 500,
selectedPlayer: null,
search: "",
roleFilter: "",
bidAmount: 0,
online: true,
};
// ── Init ────────────────────────────────────────────────────────
async function init() {
registerSW();
await loadRoster();
await searchPlayers();
await checkHealth();
setInterval(checkHealth, 30000);
}
function registerSW() {
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js");
}
}
async function api(url, opts = {}) {
try {
const res = await fetch(url, opts);
state.online = true;
updatePing();
return await res.json();
} catch (e) {
state.online = false;
updatePing();
return null;
}
}
async function checkHealth() {
const r = await api(API + "/stats");
document.getElementById("ping").className = state.online ? "ping" : "ping offline";
document.getElementById("ping").textContent = state.online ? "● connected" : "○ offline";
}
function updatePing() {
const el = document.getElementById("ping");
if (!el) return;
el.className = state.online ? "ping" : "ping offline";
el.textContent = state.online ? "● connected" : "○ offline";
}
// ── Search ──────────────────────────────────────────────────────
async function searchPlayers() {
const params = new URLSearchParams({ limit: 60, sort: "projected_points" });
if (state.search) params.set("search", state.search);
if (state.roleFilter) params.set("role", state.roleFilter);
const data = await api(API + "/players?" + params);
state.players = data?.players || [];
renderPlayerList();
}
// ── Roster ──────────────────────────────────────────────────────
async function loadRoster() {
const data = await api(API + "/roster");
if (data) {
state.roster = data.roster || [];
state.budgetSpent = data.budget_spent || 0;
}
renderBudget();
renderRoster();
}
async function addToRoster(name, price) {
const data = await api(API + "/roster/add", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, price }),
});
if (data?.error) {
toast(data.error, "error");
return false;
}
if (data?.ok) {
state.roster = data.roster.roster;
state.budgetSpent = data.roster.budget_spent;
renderBudget();
renderRoster();
renderPlayerList();
toast(`${name} added · ${price} cr`);
return true;
}
return false;
}
async function removeFromRoster(name) {
const data = await api(API + "/roster/remove", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
if (data?.ok) {
state.roster = data.roster.roster;
state.budgetSpent = data.roster.budget_spent;
renderBudget();
renderRoster();
renderPlayerList();
toast(`Removed ${name}`);
}
}
async function resetRoster() {
if (!confirm("Reset entire roster?")) return;
await api(API + "/roster/reset", { method: "POST" });
state.roster = [];
state.budgetSpent = 0;
renderBudget();
renderRoster();
renderPlayerList();
}
// ── Player detail ───────────────────────────────────────────────
async function showPlayer(name) {
const data = await api(API + "/player/" + encodeURIComponent(name));
if (!data) return;
state.selectedPlayer = data;
state.bidAmount = data.recommended_bid || 0;
renderDetail();
}
function closeDetail() {
state.selectedPlayer = null;
renderDetail();
}
function setBid(amount) {
state.bidAmount = amount;
document.getElementById("bidInput").value = amount;
document.querySelectorAll(".bid-quick button").forEach((b) => {
const v = parseInt(b.dataset.bid);
b.className = v === amount ? "active" : "";
});
}
function formatBidButtons(player) {
const bids = [];
const rec = player.recommended_bid || 0;
const max = player.max_bid || 0;
const mkt = player.market_value || 0;
if (mkt > 0) bids.push(mkt);
if (rec > 0 && rec !== mkt) bids.push(rec);
if (max > 0 && max !== rec && max !== mkt) bids.push(max);
// Ensure at least 3 options
if (bids.length < 3 && mkt > 10) bids.push(Math.round(mkt * 1.3));
return [...new Set(bids)].sort((a, b) => a - b);
}
// ── Render ──────────────────────────────────────────────────────
function renderBudget() {
const spent = state.budgetSpent;
const total = state.budgetTotal;
const pct = Math.min((spent / total) * 100, 100);
document.getElementById("budgetSpent").textContent = spent;
document.getElementById("budgetRemaining").textContent = total - spent;
document.getElementById("budgetFill").style.width = pct + "%";
document.getElementById("budgetFill").style.background =
pct > 90 ? "var(--red)" : pct > 70 ? "var(--gold)" : "var(--green)";
}
function renderPlayerList() {
const el = document.getElementById("playerList");
const inRoster = new Set(state.roster.map((p) => p.name));
if (!state.players.length) {
el.innerHTML = '<div class="empty">No players found</div>';
return;
}
el.innerHTML = state.players
.map((p) => {
const added = inRoster.has(p.name);
return `
<div class="player-card ${added ? "selected" : ""}" onclick="showPlayer('${p.name}')">
<div class="role-badge ${p.role}">${p.role}</div>
<div class="player-info">
<div class="player-name">${p.name}</div>
<div class="player-team">${p.team || ""}</div>
<div class="player-stats">
<span>FV <strong>${p.projected_points?.toFixed(1) || "—"}</strong></span>
<span>Start <strong>${Math.round((p.starter_pct || 0) * 100)}%</strong></span>
<span>Games <strong>${p.games_played || "—"}</strong></span>
</div>
</div>
<div class="player-price">
<div class="amt">${p.market_value} cr</div>
<div class="label">${added ? "OWNED" : "market"}</div>
</div>
</div>`;
})
.join("");
}
function renderRoster() {
const el = document.getElementById("rosterList");
if (!state.roster.length) {
el.innerHTML = '<div class="empty">No players drafted yet. Search and add players.</div>';
return;
}
const roleOrder = { P: "🧤 Goalkeepers", D: "🛡 Defenders", C: "⚙ Midfielders", A: "⚡ Forwards" };
let html = "";
for (const [role, label] of Object.entries(roleOrder)) {
const players = state.roster.filter((p) => p.role === role);
if (!players.length) continue;
html += `<div class="roster-section"><h3>${label} (${players.length})</h3>`;
html += players
.map(
(p) => `
<div class="roster-player">
<div class="role-badge ${p.role}" style="width:24px;height:24px;font-size:10px;">${p.role}</div>
<div class="name">${p.name}</div>
<div class="name" style="font-size:11px;color:var(--muted);">${p.team}</div>
<div class="price">${p.price} cr</div>
<button class="remove" onclick="removeFromRoster('${p.name}')">✕</button>
</div>`
)
.join("");
html += "</div>";
}
el.innerHTML = html;
}
function renderDetail() {
const el = document.getElementById("detailOverlay");
if (!state.selectedPlayer) {
el.style.display = "none";
return;
}
const p = state.selectedPlayer;
const bidButtons = formatBidButtons(p);
const remaining = state.budgetTotal - state.budgetSpent;
el.style.display = "flex";
el.innerHTML = `
<div class="detail-sheet" onclick="event.stopPropagation()">
<div class="detail-header">
<div>
<div style="display:flex;align-items:center;gap:8px;">
<div class="role-badge ${p.role}">${p.role}</div>
<div>
<div style="font-weight:700;font-size:18px;">${p.name}</div>
<div style="font-size:12px;color:var(--muted);">${p.team}</div>
</div>
</div>
</div>
<button class="detail-close" onclick="closeDetail()">✕</button>
</div>
<div class="ml-card">
<div class="title">Projected Fantavoto</div>
<div class="row"><span>Median (P50)</span><span class="val" style="color:var(--sky);">${p.p50}</span></div>
<div class="row"><span>Floor (P10)</span><span class="val" style="color:var(--red);">${p.p10}</span></div>
<div class="row"><span>Ceiling (P90)</span><span class="val" style="color:var(--green);">${p.p90}</span></div>
</div>
<div class="ml-card">
<div class="title">Risk Assessment</div>
<div class="row"><span>Downside Risk P(FV &lt; 5.5)</span><span class="val" style="color:${p.downside_risk > 0.2 ? 'var(--red)' : 'var(--green)'};">${(p.downside_risk * 100).toFixed(0)}%</span></div>
<div class="row"><span>Starter Probability</span><span class="val">${(p.starter_prob * 100).toFixed(0)}%</span></div>
<div class="row"><span>Games Played</span><span class="val">${p.games_played}</span></div>
</div>
<div class="ml-card" style="border-color:var(--gold);">
<div class="title" style="color:var(--gold);">Auction Intelligence</div>
<div class="row"><span>Market Value</span><span class="val" style="color:var(--gold);">${p.market_value} cr</span></div>
<div class="row"><span>ML Recommended Bid</span><span class="val" style="color:var(--green);">${p.recommended_bid} cr</span></div>
<div class="row"><span>Estimated Opponent Bid</span><span class="val" style="color:var(--red);">${p.opponent_bid} cr</span></div>
<div class="row"><span>Max Rational Bid</span><span class="val">${p.max_bid} cr</span></div>
<div class="row" style="margin-top:4px;font-size:11px;color:var(--muted);">
<span>Value: ${(p.p50 / Math.max(p.recommended_bid || 1, 1)).toFixed(3)} FV/cr</span>
<span>Budget left: ${remaining} cr</span>
</div>
</div>
<div class="bid-quick">
${bidButtons.map((b) => {
const over = b > remaining;
return `<button data-bid="${b}" class="${b === state.bidAmount ? 'active' : ''} ${over ? 'over' : ''}"
onclick="setBid(${b})">${b} cr</button>`;
}).join("")}
</div>
<div class="bid-row">
<input id="bidInput" type="number" value="${state.bidAmount}" min="1" max="${remaining}"
onchange="state.bidAmount=parseInt(this.value)||0">
<button onclick="doBid('${p.name}')" ${state.bidAmount > remaining ? "disabled" : ""}>
BUY
</button>
</div>
${state.roster.some((r) => r.name === p.name)
? `<div class="bid-row"><button class="danger" onclick="removeFromRoster('${p.name}');closeDetail();">REMOVE FROM ROSTER</button></div>`
: ""}
</div>
`;
// Focus bid input
setTimeout(() => {
const inp = document.getElementById("bidInput");
if (inp) { inp.focus(); inp.select(); }
}, 100);
}
// ── Actions ─────────────────────────────────────────────────────
async function doBid(name) {
const success = await addToRoster(name, state.bidAmount);
if (success) {
state.bidAmount = 0;
closeDetail();
}
}
function toast(msg, type = "") {
const el = document.createElement("div");
el.className = "toast" + (type === "error" ? " error" : "");
el.textContent = msg;
document.body.appendChild(el);
setTimeout(() => el.remove(), 2000);
}
// ── Tab switching ───────────────────────────────────────────────
function switchTab(tab) {
state.tab = tab;
document.querySelectorAll(".tab").forEach((t) => t.classList.toggle("active", t.dataset.tab === tab));
document.getElementById("auctionView").style.display = tab === "auction" ? "block" : "none";
document.getElementById("rosterView").style.display = tab === "roster" ? "block" : "none";
document.getElementById("statsView").style.display = tab === "stats" ? "block" : "none";
if (tab === "stats") loadStats();
if (tab === "roster") loadRoster();
}
async function loadStats() {
const data = await api(API + "/stats");
if (!data) return;
document.getElementById("statsView").innerHTML = `
<div class="stat-grid">
<div class="stat-card"><div class="num" style="color:var(--sky);">${data.total_players}</div><div class="lbl">Players</div></div>
<div class="stat-card"><div class="num" style="color:var(--gold);">${data.elite_count}</div><div class="lbl">Elite (&gt;100cr)</div></div>
<div class="stat-card"><div class="num" style="color:var(--green);">${data.median_price}</div><div class="lbl">Median Price (cr)</div></div>
<div class="stat-card"><div class="num">${data.price_range[0]}–${data.price_range[1]}</div><div class="lbl">Price Range (cr)</div></div>
</div>
<div class="stat-grid" style="margin-top:8px;">
${Object.entries(data.role_counts).map(([r, c]) => `
<div class="stat-card"><div class="num" style="color:var(--${r === 'P' ? 'gk' : r === 'D' ? 'def' : r === 'C' ? 'mid' : 'fwd'});">${c}</div><div class="lbl">${r === 'P' ? 'Goalkeepers' : r === 'D' ? 'Defenders' : r === 'C' ? 'Midfielders' : 'Forwards'}</div></div>
`).join("")}
</div>
`;
}
// ── Event bindings ──────────────────────────────────────────────
document.addEventListener("DOMContentLoaded", init);
Binary file not shown.

After

Width:  |  Height:  |  Size: 662 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

+88
View File
@@ -0,0 +1,88 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#00D084">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="manifest" href="/manifest.json">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚽</text></svg>">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/styles.css">
<title>Fantabeto — Auction</title>
</head>
<body>
<div class="app">
<!-- Header -->
<header>
<div style="display:flex;justify-content:space-between;align-items:center;">
<h1>⚽ <span>Fantabeto</span> Auction</h1>
<div id="ping" class="ping">● connected</div>
</div>
<div class="budget-bar">
<div class="label">SPENT</div>
<div class="value" id="budgetSpent">0</div>
<div class="progress">
<div class="progress-fill" id="budgetFill" style="width:0%;"></div>
</div>
<div class="label">LEFT</div>
<div class="value" id="budgetRemaining" style="color:var(--green);">500</div>
<div class="label">cr</div>
</div>
</header>
<!-- Tabs -->
<div class="tabs">
<button class="tab active" data-tab="auction" onclick="switchTab('auction')">🔍 Players</button>
<button class="tab" data-tab="roster" onclick="switchTab('roster')">📋 Roster <span id="rosterCount" style="font-size:10px;color:var(--muted);"></span></button>
<button class="tab" data-tab="stats" onclick="switchTab('stats')">📊 Stats</button>
</div>
<!-- Auction View -->
<div id="auctionView">
<div class="search-bar">
<input type="text" placeholder="Search players..." id="searchInput"
oninput="state.search=this.value;searchPlayers()">
<select onchange="state.roleFilter=this.value;searchPlayers()">
<option value="">All</option>
<option value="P">GK</option>
<option value="D">DEF</option>
<option value="C">MID</option>
<option value="A">FWD</option>
</select>
</div>
<div class="player-list" id="playerList">
<div class="empty">Loading players...</div>
</div>
</div>
<!-- Roster View -->
<div id="rosterView" style="display:none;">
<div style="padding:12px 16px;">
<button onclick="resetRoster()" style="background:var(--card);border:1px solid var(--red);color:var(--red);padding:8px 16px;border-radius:8px;cursor:pointer;font-size:12px;">
Reset Roster
</button>
<span style="font-size:11px;color:var(--muted);margin-left:8px;">
Quotas: P3 D8 C8 A6
</span>
</div>
<div class="player-list" id="rosterList">
<div class="empty">No players drafted yet.</div>
</div>
</div>
<!-- Stats View -->
<div id="statsView" style="display:none;">
<div class="empty">Loading stats...</div>
</div>
</div>
<!-- Detail overlay -->
<div class="detail-overlay" id="detailOverlay" style="display:none;" onclick="closeDetail()">
</div>
<script src="/app.js"></script>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
{
"name": "Fantabeto Auction",
"short_name": "Fantabeto",
"description": "Serie A Fantasy Football Auction Assistant",
"start_url": "/",
"display": "standalone",
"background_color": "#0B0F17",
"theme_color": "#00D084",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
+208
View File
@@ -0,0 +1,208 @@
:root {
--bg: #0B0F17;
--card: #111827;
--border: #1F2937;
--text: #E5E7EB;
--muted: #9CA3AF;
--green: #00D084;
--gold: #FFC94D;
--red: #FF4D5E;
--sky: #38BDF8;
--violet: #A78BFA;
--white: #FFF;
--gk: #E74C3C;
--def: #3498DB;
--mid: #2ECC71;
--fwd: #F39C12;
--radius: 12px;
--font: 'Inter', -apple-system, sans-serif;
--mono: 'JetBrains Mono', monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--font);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
/* ── Layout ──────────────────────────────────────────────── */
.app { max-width: 480px; margin: 0 auto; min-height: 100dvh; display: flex; flex-direction: column; }
/* ── Header ──────────────────────────────────────────────── */
header {
background: var(--card);
border-bottom: 1px solid var(--border);
padding: 12px 16px;
position: sticky; top: 0; z-index: 10;
backdrop-filter: blur(12px);
}
header h1 { font-size: 18px; font-weight: 700; }
header h1 span { color: var(--green); }
/* ── Budget bar ───────────────────────────────────────────── */
.budget-bar {
margin: 8px 0;
display: flex; gap: 8px; align-items: center;
}
.budget-bar .label { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; }
.budget-bar .value { font-family: var(--mono); font-size: 20px; font-weight: 700; }
.budget-bar .progress {
flex: 1; height: 6px; background: var(--border); border-radius: 3px; overflow: hidden;
}
.budget-bar .progress-fill {
height: 100%; background: var(--green); border-radius: 3px; transition: width 0.3s;
}
/* ── Tabs ─────────────────────────────────────────────────── */
.tabs {
display: flex; gap: 0; border-bottom: 1px solid var(--border);
padding: 0 16px; background: var(--card); position: sticky; top: 60px; z-index: 9;
}
.tab {
flex: 1; text-align: center; padding: 10px 4px;
border: none; background: none; color: var(--muted);
font-family: var(--font); font-size: 12px; font-weight: 600;
border-bottom: 2px solid transparent; cursor: pointer; transition: all 0.15s;
}
.tab.active { color: var(--green); border-bottom-color: var(--green); }
/* ── Search ───────────────────────────────────────────────── */
.search-bar {
margin: 8px 16px; display: flex; gap: 6px;
}
.search-bar input {
flex: 1; padding: 10px 14px; border-radius: var(--radius); border: 1px solid var(--border);
background: var(--card); color: var(--text); font-size: 14px; outline: none;
}
.search-bar input:focus { border-color: var(--sky); }
.search-bar select {
padding: 10px 8px; border-radius: var(--radius); border: 1px solid var(--border);
background: var(--card); color: var(--text); font-size: 13px; cursor: pointer;
}
/* ── Player cards ──────────────────────────────────────────── */
.player-list { flex: 1; overflow-y: auto; padding: 0 16px 80px; }
.player-card {
background: var(--card); border: 1px solid var(--border);
border-radius: var(--radius); padding: 12px 14px; margin-bottom: 8px;
cursor: pointer; transition: border-color 0.15s; display: flex; gap: 10px; align-items: center;
}
.player-card:hover { border-color: var(--sky); }
.player-card.selected { border-color: var(--green); background: rgba(0,208,132,0.05); }
.role-badge {
width: 36px; height: 36px; border-radius: 8px; display: flex;
align-items: center; justify-content: center; font-weight: 700; font-size: 13px;
color: white; flex-shrink: 0;
}
.role-badge.P { background: var(--gk); }
.role-badge.D { background: var(--def); }
.role-badge.C { background: var(--mid); }
.role-badge.A { background: var(--fwd); }
.player-info { flex: 1; min-width: 0; }
.player-name { font-weight: 600; font-size: 14px; }
.player-team { font-size: 11px; color: var(--muted); }
.player-stats { display: flex; gap: 12px; margin-top: 2px; font-size: 11px; color: var(--muted); }
.player-stats strong { color: var(--text); font-family: var(--mono); }
.player-price { text-align: right; flex-shrink: 0; }
.player-price .amt { font-family: var(--mono); font-size: 16px; font-weight: 700; color: var(--gold); }
.player-price .label { font-size: 10px; color: var(--muted); }
/* ── Player detail sheet ───────────────────────────────────── */
.detail-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 20;
display: flex; align-items: flex-end; justify-content: center;
}
.detail-sheet {
background: var(--card); border-radius: var(--radius) var(--radius) 0 0;
width: 100%; max-width: 480px; max-height: 85dvh; overflow-y: auto;
padding: 20px 16px; animation: slideUp 0.2s ease;
}
@keyframes slideUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
.detail-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.detail-close { background: none; border: none; color: var(--muted); font-size: 24px; cursor: pointer; }
.ml-card {
background: rgba(167,139,250,0.08); border: 1px solid var(--border);
border-radius: 8px; padding: 12px; margin-bottom: 8px;
}
.ml-card .title { font-size: 10px; color: var(--violet); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
.ml-card .row { display: flex; justify-content: space-between; font-size: 13px; }
.ml-card .val { font-family: var(--mono); font-weight: 600; }
.bid-row { display: flex; gap: 8px; margin-top: 12px; }
.bid-row input {
flex: 1; padding: 12px; border-radius: var(--radius); border: 1px solid var(--border);
background: var(--card); color: var(--text); font-size: 20px; font-family: var(--mono);
text-align: center; outline: none;
}
.bid-row input:focus { border-color: var(--green); }
.bid-row button {
padding: 12px 24px; border-radius: var(--radius); border: none;
background: var(--green); color: var(--bg); font-weight: 700; font-size: 14px;
cursor: pointer; transition: opacity 0.15s;
}
.bid-row button:disabled { opacity: 0.4; cursor: not-allowed; }
.bid-row button.danger { background: var(--red); }
.bid-quick { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; }
.bid-quick button {
padding: 6px 12px; border-radius: 6px; border: 1px solid var(--border);
background: var(--card); color: var(--text); font-size: 12px; cursor: pointer;
}
.bid-quick button.active { border-color: var(--green); background: rgba(0,208,132,0.1); color: var(--green); }
.bid-quick button.over { border-color: var(--red); background: rgba(255,77,94,0.1); color: var(--red); }
/* ── Roster ─────────────────────────────────────────────────── */
.roster-section { margin-bottom: 8px; }
.roster-section h3 {
font-size: 12px; color: var(--muted); text-transform: uppercase;
letter-spacing: 0.5px; margin-bottom: 4px;
}
.roster-player {
display: flex; align-items: center; gap: 8px; padding: 6px 0;
border-bottom: 1px solid var(--border); font-size: 13px;
}
.roster-player .name { flex: 1; font-weight: 500; }
.roster-player .price { font-family: var(--mono); color: var(--gold); font-size: 12px; }
.roster-player .remove {
background: none; border: none; color: var(--red); cursor: pointer; font-size: 16px; padding: 2px 6px;
}
/* ── Stats ──────────────────────────────────────────────────── */
.stat-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding: 8px 16px; }
.stat-card {
background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
padding: 12px; text-align: center;
}
.stat-card .num { font-size: 22px; font-family: var(--mono); font-weight: 700; }
.stat-card .lbl { font-size: 10px; color: var(--muted); text-transform: uppercase; margin-top: 2px; }
/* ── Toast ──────────────────────────────────────────────────── */
.toast {
position: fixed; bottom: 80px; left: 50%; transform: translateX(-50%); z-index: 30;
background: var(--green); color: var(--bg); padding: 10px 20px; border-radius: 20px;
font-weight: 600; font-size: 13px; animation: fadeIn 0.2s;
}
.toast.error { background: var(--red); color: white; }
@keyframes fadeIn { from { opacity: 0; transform: translateX(-50%) translateY(10px); } }
/* ── Empty state ────────────────────────────────────────────── */
.empty { text-align: center; padding: 40px 20px; color: var(--muted); font-size: 13px; }
/* ── Server indicator ───────────────────────────────────────── */
.ping { font-size: 10px; color: var(--green); text-align: center; padding: 4px; }
.ping.offline { color: var(--red); }
/* ── Responsive ─────────────────────────────────────────────── */
@media (min-width: 480px) {
.app { border-left: 1px solid var(--border); border-right: 1px solid var(--border); }
}
+23
View File
@@ -0,0 +1,23 @@
const CACHE = "fantabeto-v2";
const ASSETS = ["/", "/index.html", "/app.js", "/styles.css", "/manifest.json"];
self.addEventListener("install", (e) => {
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(ASSETS)));
self.skipWaiting();
});
self.addEventListener("activate", (e) => {
e.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener("fetch", (e) => {
if (e.request.url.includes("/api/")) return; // bypass cache for API
e.respondWith(
caches.match(e.request).then((r) => r || fetch(e.request))
);
});
+12
View File
@@ -26,6 +26,14 @@ torch-geometric>=2.5
# Optimization # Optimization
pulp>=2.8 pulp>=2.8
scikit-optimize>=0.9
# Bayesian modeling (optional)
pymc>=5.0
lifelines>=0.28
# Causal inference (optional)
econml>=0.15
# Browser automation (Playwright fallback for Cloudflare) # Browser automation (Playwright fallback for Cloudflare)
playwright>=1.42 playwright>=1.42
@@ -53,3 +61,7 @@ ruff>=0.3
# Bot # Bot
python-telegram-bot>=21.0 python-telegram-bot>=21.0
# PWA API
fastapi>=0.110
uvicorn>=0.29
Executable
+23
View File
@@ -0,0 +1,23 @@
#!/bin/bash
# Fantabeto 26/27 — Auction PWA Launcher
# Usage: ./run_pwa.sh [port]
set -e
PORT=${1:-8005}
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
echo "============================================"
echo " Fantabeto 26/27 — Auction PWA"
echo " Serving on port $PORT"
echo "============================================"
echo ""
echo " Local: http://localhost:$PORT"
echo " Network: http://$(hostname -I | awk '{print $1}'):$PORT"
echo ""
echo "Press Ctrl+C to stop."
echo ""
cd "$SCRIPT_DIR"
exec python -m uvicorn pwa.api:app \
--host 0.0.0.0 \
--port "$PORT"
+29
View File
@@ -1 +1,30 @@
"""Machine learning models.""" """Machine learning models."""
from .base_model import BaseModel
from .gbm_model import GBMEnsemble
from .card_model import CardClassifier, PenaltyModel, GoalProbabilityModel
from .tgcn_model import TemporalGNN
from .distribution_head import (
SinhArcsinhDistribution,
BernoulliCleanSheet,
sinh_arcsinh_params,
)
from .train import ModelTrainer
# Phase 1: Quantile regression + survival analysis
from .quantile_model import QuantileEnsemble
from .survival_model import MinutesSurvivalModel
# Phase 4: Bayesian pooling + conformal prediction
from .bayesian_pooling import BayesianPlayerModel
from .conformal_predictor import ConformalPredictor
# Phase 5: Player chemistry + form momentum
from .gat_model import PlayerChemistryGAT
from .hawkes_form import PlayerFormModel
# Phase 3: Set-based team valuation
from .set_transformer import SetTransformer
# Phase 6: Causal inference for transfers
from .causal_forest import TransferCausalModel, AuctionEffectAnalyzer
+378
View File
@@ -0,0 +1,378 @@
"""Hierarchical Bayesian partial pooling for player skill estimation.
Implements two modes:
- PyMC: Full MCMC-based hierarchical model with role-level priors
- Scipy fallback: James-Stein-style shrinkage with empirical Bayes estimates
"""
import logging
from typing import Optional, Tuple
import numpy as np
import pandas as pd
from .base_model import BaseModel
logger = logging.getLogger(__name__)
VALID_ROLES = {"P", "D", "C", "A"}
def _has_pymc() -> bool:
try:
import pymc as pm # noqa: F401
return True
except ImportError:
return False
class BayesianPlayerModel(BaseModel):
"""Hierarchical Bayesian model with partial pooling by player role.
Two implementation modes:
- PyMC (if installed): Full MCMC hierarchical model
- Scipy fallback: Empirical Bayes with James-Stein shrinkage
"""
def __init__(
self,
model_dir: str = "models_trained",
use_pymc: Optional[bool] = None,
samples: int = 2000,
tune: int = 1000,
chains: int = 2,
random_seed: int = 42,
):
super().__init__(model_dir)
self.use_pymc = use_pymc if use_pymc is not None else _has_pymc()
self.samples = samples
self.tune = tune
self.chains = chains
self.random_seed = random_seed
self.trace = None
self.player_indices = {}
self.role_encoder = {}
self.role_reverse = {}
self.player_means = {}
self.player_vars = {}
self.role_means = {}
self.role_vars = {}
self.fitted = False
self._pymc_mode = False
def _validate_roles(self, X: pd.DataFrame):
if "role" not in X.columns:
raise ValueError("X must contain a 'role' column with values: P, D, C, A")
unknown = set(X["role"].unique()) - VALID_ROLES
if unknown:
raise ValueError(f"Unknown role values: {unknown}. Allowed: {VALID_ROLES}")
def _prepare_data(self, X: pd.DataFrame, y: pd.Series):
self._validate_roles(X)
roles = X["role"].values
unique_roles = sorted(VALID_ROLES)
self.role_encoder = {r: i for i, r in enumerate(unique_roles)}
self.role_reverse = {i: r for r, i in self.role_encoder.items()}
role_idx = np.array([self.role_encoder[r] for r in roles])
n_players = len(y)
n_roles = len(unique_roles)
player_map = {}
player_id = np.zeros(n_players, dtype=int)
for i in range(n_players):
key = (roles[i], i)
if key not in player_map:
player_map[key] = len(player_map)
player_id[i] = player_map[key]
n_unique = len(player_map)
return role_idx, player_id, n_players, n_roles, n_unique
def _fit_pymc(self, X: pd.DataFrame, y: pd.Series):
import pymc as pm
role_idx, player_id, n_players, n_roles, n_unique = self._prepare_data(X, y)
with pm.Model() as model:
mu_role = pm.Normal("mu_role", mu=6.0, sigma=2.0, shape=n_roles)
sigma_role = pm.HalfNormal("sigma_role", sigma=1.0, shape=n_roles)
sigma_obs = pm.HalfNormal("sigma_obs", sigma=1.0)
player_skill = pm.Normal(
"player_skill",
mu=mu_role[role_idx],
sigma=sigma_role[role_idx],
shape=n_players,
)
pm.Normal(
"observed_fv",
mu=player_skill,
sigma=sigma_obs,
observed=y.values,
)
self.trace = pm.sample(
draws=self.samples,
tune=self.tune,
chains=self.chains,
random_seed=self.random_seed,
progressbar=False,
)
logger.info(
f"PyMC model fitted: {n_players} players, {n_roles} roles, "
f"{len(self.trace.posterior.draw) * len(self.trace.posterior.chain)} posterior samples"
)
self._pymc_mode = True
def _fit_scipy(self, X: pd.DataFrame, y: pd.Series):
role_idx, player_id, n_players, n_roles, n_unique = self._prepare_data(X, y)
roles = X["role"].values
y_vals = y.values.astype(np.float64)
self.role_means = {}
self.role_vars = {}
for r_idx, r_name in self.role_reverse.items():
mask = role_idx == r_idx
if mask.sum() > 0:
self.role_means[r_name] = float(np.mean(y_vals[mask]))
role_var = float(np.var(y_vals[mask], ddof=1)) if mask.sum() > 1 else 0.0
self.role_vars[r_name] = role_var
else:
self.role_means[r_name] = 6.0
self.role_vars[r_name] = 2.0
player_data = {}
for i in range(n_players):
role = roles[i]
val = y_vals[i]
if role not in player_data:
player_data[role] = {}
player_data[role][i] = val
self.player_means = {}
self.player_vars = {}
for role, players_by_idx in player_data.items():
vals = list(players_by_idx.values())
role_mean = self.role_means[role]
role_var = max(self.role_vars[role], 1e-8)
for idx in players_by_idx:
self.player_means[idx] = vals[0]
self.player_vars[idx] = role_var
logger.info(
f"Scipy fallback fitted: {n_players} players, {n_roles} roles"
)
self._pymc_mode = False
def fit(self, X: pd.DataFrame, y: pd.Series, **kwargs):
if len(X) == 0:
raise ValueError("X cannot be empty")
if len(X) != len(y):
raise ValueError(f"X and y lengths must match: {len(X)} vs {len(y)}")
if self.use_pymc and _has_pymc():
self._fit_pymc(X, y)
else:
if self.use_pymc and not _has_pymc():
logger.warning("PyMC requested but not installed. Falling back to scipy.")
self.use_pymc = False
self._fit_scipy(X, y)
self.fitted = True
return self
def predict(self, X: pd.DataFrame) -> np.ndarray:
if not self.fitted:
raise RuntimeError("Model not fitted. Call fit() first.")
mean, _ = self.predict_with_uncertainty(X)
return mean
def predict_with_uncertainty(self, X: pd.DataFrame) -> Tuple[np.ndarray, np.ndarray]:
if not self.fitted:
raise RuntimeError("Model not fitted. Call fit() first.")
self._validate_roles(X)
if self._pymc_mode:
return self._predict_with_uncertainty_pymc(X)
means = np.zeros(len(X))
stds = np.zeros(len(X))
roles = X["role"].values
for i, role in enumerate(roles):
role_mean = self.role_means.get(role, 6.0)
role_var = self.role_vars.get(role, 2.0)
player_mean = self.player_means.get(i, role_mean)
player_var = self.player_vars.get(i, role_var)
shrinkage = role_var / max(role_var + player_var, 1e-8)
means[i] = role_mean + (1.0 - shrinkage) * (player_mean - role_mean)
stds[i] = np.sqrt(role_var * (1.0 - shrinkage))
return means, stds
def _predict_with_uncertainty_pymc(self, X: pd.DataFrame) -> Tuple[np.ndarray, np.ndarray]:
import pymc as pm
import arviz as az
n_players = len(X)
roles = X["role"].values
with pm.Model() as pred_model:
n_roles = len(self.role_encoder)
mu_role = pm.Normal("mu_role", mu=6.0, sigma=2.0, shape=n_roles)
sigma_role = pm.HalfNormal("sigma_role", sigma=1.0, shape=n_roles)
sigma_obs = pm.HalfNormal("sigma_obs", sigma=1.0)
player_skill = pm.Normal(
"player_skill",
mu=mu_role[[self.role_encoder.get(r, 0) for r in roles]],
sigma=sigma_role[[self.role_encoder.get(r, 0) for r in roles]],
shape=n_players,
)
pm.Normal("observed_fv", mu=player_skill, sigma=sigma_obs, shape=n_players)
ppc = pm.sample_posterior_predictive(
self.trace,
var_names=["observed_fv"],
random_seed=self.random_seed,
progressbar=False,
)
observed_samples = ppc.posterior_predictive["observed_fv"].values
draws_per_chain = observed_samples.shape[0]
n_chains = observed_samples.shape[1]
observed_flat = observed_samples.reshape(draws_per_chain * n_chains, n_players)
means = observed_flat.mean(axis=0)
stds = observed_flat.std(axis=0)
return means, stds
def posterior_predictive(self, X: pd.DataFrame, n_samples: int = 2000) -> np.ndarray:
if not self.fitted:
raise RuntimeError("Model not fitted. Call fit() first.")
self._validate_roles(X)
if self._pymc_mode:
return self._posterior_predictive_pymc(X, n_samples)
n_players = len(X)
means, stds = self.predict_with_uncertainty(X)
rng = np.random.RandomState(self.random_seed)
draws = rng.normal(
loc=means[np.newaxis, :],
scale=stds[np.newaxis, :] + 1e-6,
size=(n_samples, n_players),
)
return np.clip(draws, -10, 20)
def _posterior_predictive_pymc(self, X: pd.DataFrame, n_samples: int) -> np.ndarray:
import pymc as pm
n_players = len(X)
roles = X["role"].values
with pm.Model() as pred_model:
n_roles = len(self.role_encoder)
mu_role = pm.Normal("mu_role", mu=6.0, sigma=2.0, shape=n_roles)
sigma_role = pm.HalfNormal("sigma_role", sigma=1.0, shape=n_roles)
sigma_obs = pm.HalfNormal("sigma_obs", sigma=1.0)
player_skill = pm.Normal(
"player_skill",
mu=mu_role[[self.role_encoder.get(r, 0) for r in roles]],
sigma=sigma_role[[self.role_encoder.get(r, 0) for r in roles]],
shape=n_players,
)
pm.Normal("observed_fv", mu=player_skill, sigma=sigma_obs, shape=n_players)
ppc = pm.sample_posterior_predictive(
self.trace,
var_names=["observed_fv"],
random_seed=self.random_seed,
progressbar=False,
)
observed_samples = ppc.posterior_predictive["observed_fv"].values
draws_per_chain = observed_samples.shape[0]
n_chains = observed_samples.shape[1]
observed_flat = observed_samples.reshape(draws_per_chain * n_chains, n_players)
total = observed_flat.shape[0]
if total > n_samples:
rng = np.random.RandomState(self.random_seed)
idx = rng.choice(total, size=n_samples, replace=False)
return observed_flat[idx]
return observed_flat
def get_player_reliability(self, X: pd.DataFrame) -> np.ndarray:
if not self.fitted:
raise RuntimeError("Model not fitted. Call fit() first.")
self._validate_roles(X)
roles = X["role"].values
scores = np.zeros(len(X))
if self._pymc_mode:
means, stds = self.predict_with_uncertainty(X)
for i, role in enumerate(roles):
role_var = stds[i] ** 2
total_var = role_var + 1.0
scores[i] = np.clip(1.0 - (role_var / max(total_var, 1e-8)), 0.0, 1.0)
return np.clip(scores, 0.0, 1.0)
for i, role in enumerate(roles):
role_var = self.role_vars.get(role, 2.0)
player_var = self.player_vars.get(i, role_var)
total_var = role_var + player_var
scores[i] = np.clip(role_var / max(total_var, 1e-8), 0.0, 1.0)
return np.clip(scores, 0.0, 1.0)
def get_rookie_estimates(self, X: pd.DataFrame, min_observations: int = 5) -> pd.DataFrame:
if not self.fitted:
raise RuntimeError("Model not fitted. Call fit() first.")
self._validate_roles(X)
reliability = self.get_player_reliability(X)
rookie_mask = reliability < (1.0 / max(min_observations, 1))
means, stds = self.predict_with_uncertainty(X)
roles = X["role"].values
results = []
for i in range(len(X)):
if not rookie_mask[i]:
continue
role = roles[i]
role_mean = self.role_means.get(role, 6.0)
results.append({
"index": i,
"role": role,
"player_estimate": float(means[i]),
"role_mean": float(role_mean),
"naive_player_mean": self.player_means.get(i, role_mean),
"shrunken_estimate": float(means[i]),
"shrunken_std": float(stds[i]),
"reliability": float(reliability[i]),
"shrinkage_factor": float(
(self.player_means.get(i, role_mean) - means[i])
/ max(abs(self.player_means.get(i, role_mean) - role_mean), 1e-8)
) if abs(self.player_means.get(i, role_mean) - role_mean) > 1e-8 else 1.0,
})
if not results:
logger.info("No rookie players found (all have sufficient observations)")
return pd.DataFrame(columns=[
"index", "role", "player_estimate", "role_mean",
"naive_player_mean", "shrunken_estimate", "shrunken_std",
"reliability", "shrinkage_factor",
])
df = pd.DataFrame(results)
df = df.sort_values("reliability")
logger.info(f"Found {len(results)} rookie players (heavy shrinkage toward role mean)")
return df
File diff suppressed because it is too large Load Diff
+184
View File
@@ -0,0 +1,184 @@
"""Conformal prediction for calibrated prediction intervals.
Wraps any sklearn-compatible predictor with distribution-free, finite-sample
valid prediction bands via split conformal prediction with absolute residuals
as the nonconformity score.
"""
import logging
from typing import Optional, Tuple
import numpy as np
import pandas as pd
logger = logging.getLogger(__name__)
class ConformalPredictor:
"""Split conformal prediction wrapper for calibrated uncertainty.
Computes nonconformity scores (absolute residuals) on a calibration set
and uses the (1-alpha) quantile to construct prediction bands with
guaranteed marginal coverage under exchangeability.
"""
def __init__(self, base_model, alpha: float = 0.10):
if not 0 < alpha < 1:
raise ValueError(f"alpha must be in (0, 1), got {alpha}")
self.base_model = base_model
self.alpha = alpha
self.q_hat = None
self.is_calibrated = False
self._mad = False
self._cal_residuals = None
self._base_has_predict_std = self._check_predict_std()
def __repr__(self):
status = "calibrated" if self.is_calibrated else "uncalibrated"
mad_status = "MAD" if self._mad else "absolute"
return (
f"ConformalPredictor(base={type(self.base_model).__name__}, "
f"alpha={self.alpha:.2f}, status={status}, score={mad_status})"
)
def _check_predict_std(self) -> bool:
return hasattr(self.base_model, "predict_distribution") or hasattr(
self.base_model, "predict_std"
)
def _get_base_predictions(self, X: pd.DataFrame) -> np.ndarray:
preds = self.base_model.predict(X)
preds = np.asarray(preds, dtype=np.float64)
if preds.ndim == 2 and preds.shape[1] == 1:
preds = preds.ravel()
return preds
def _get_base_std(self, X: pd.DataFrame) -> Optional[np.ndarray]:
if hasattr(self.base_model, "predict_distribution"):
_, std = self.base_model.predict_distribution(X)
std = np.asarray(std, dtype=np.float64).ravel()
return std
if hasattr(self.base_model, "predict_std"):
std = self.base_model.predict_std(X)
std = np.asarray(std, dtype=np.float64).ravel()
return std
return None
def _compute_nonconformity(self, residuals: np.ndarray, std: Optional[np.ndarray] = None) -> np.ndarray:
if self._mad and std is not None and np.all(std > 0):
return np.abs(residuals) / np.maximum(std, 1e-8)
return np.abs(residuals)
def calibrate(self, X_cal: pd.DataFrame, y_cal: pd.Series, mad: bool = False):
if len(X_cal) == 0 or len(y_cal) == 0:
raise ValueError("Calibration set cannot be empty")
self._mad = mad
y_true = np.asarray(y_cal, dtype=np.float64).ravel()
y_pred = self._get_base_predictions(X_cal)
residuals = y_true - y_pred
if self._mad:
std = self._get_base_std(X_cal)
if std is None:
logger.warning(
"MAD mode requested but base_model has no predict_std/predict_distribution. "
"Falling back to absolute residuals."
)
self._mad = False
std = None
else:
std = None
self._cal_residuals = self._compute_nonconformity(residuals, std)
self._cal_preds = y_pred
n = len(self._cal_residuals)
correction = (1.0 + 1.0 / n)
q_idx = min(int(np.ceil((1.0 - self.alpha) * (n + 1))) - 1, n - 1)
if q_idx < 0:
q_idx = 0
self.q_hat = np.sort(self._cal_residuals)[q_idx]
self.is_calibrated = True
logger.info(
f"Conformal calibration complete: {n} samples, "
f"alpha={self.alpha:.2f}, quantile={self.q_hat:.4f}"
)
return self
def predict_with_band(self, X: pd.DataFrame) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
if not self.is_calibrated:
raise RuntimeError("Model not calibrated. Call calibrate() first.")
y_pred = self._get_base_predictions(X)
if self._mad:
std = self._get_base_std(X)
if std is None:
self._mad = False
std = None
else:
std = None
if self._mad and std is not None:
half_width = self.q_hat * std
else:
half_width = self.q_hat
lower = y_pred - half_width
upper = y_pred + half_width
return y_pred, lower, upper
def predict(self, X: pd.DataFrame) -> np.ndarray:
return self._get_base_predictions(X)
def predict_interval_width(self, X: pd.DataFrame) -> float:
_, lower, upper = self.predict_with_band(X)
widths = upper - lower
return float(np.mean(widths))
def average_interval_width(self, X: pd.DataFrame) -> float:
widths = self.predict_interval_width(X)
return float(np.mean(widths))
def is_inside_band(self, X: pd.DataFrame, y_true: pd.Series) -> np.ndarray:
_, lower, upper = self.predict_with_band(X)
y = np.asarray(y_true, dtype=np.float64).ravel()
return (y >= lower) & (y <= upper)
def coverage(self, X: pd.DataFrame, y_true: pd.Series) -> float:
inside = self.is_inside_band(X, y_true)
return float(np.mean(inside))
def update(self, X_new: pd.DataFrame, y_new: pd.Series):
if not self.is_calibrated:
return self.calibrate(X_new, y_new)
y_true = np.asarray(y_new, dtype=np.float64).ravel()
y_pred = self._get_base_predictions(X_new)
residuals = y_true - y_pred
if self._mad:
std = self._get_base_std(X_new)
if std is not None:
new_scores = self._compute_nonconformity(residuals, std)
else:
new_scores = np.abs(residuals)
else:
new_scores = np.abs(residuals)
self._cal_residuals = np.concatenate([self._cal_residuals, new_scores])
n = len(self._cal_residuals)
q_idx = min(int(np.ceil((1.0 - self.alpha) * (n + 1))) - 1, n - 1)
if q_idx < 0:
q_idx = 0
self.q_hat = np.sort(self._cal_residuals)[q_idx]
logger.info(
f"Conformal update: +{len(new_scores)} samples, "
f"total={n}, alpha={self.alpha:.2f}, quantile={self.q_hat:.4f}"
)
return self
+647
View File
@@ -0,0 +1,647 @@
"""Graph Attention Network for player chemistry/interaction modeling.
Models player-to-player synergy and redundancy using a GAT architecture when
PyTorch Geometric is available, falling back to network-science feature
extraction (PageRank, betweenness, clustering, eigenvector centrality) with a
scikit-learn MLPRegressor otherwise.
Replaces the stub tgcn_model.py.
"""
import logging
from itertools import combinations
from typing import Dict, List, Optional, Tuple
import numpy as np
import pandas as pd
from sklearn.neural_network import MLPRegressor
from sklearn.preprocessing import StandardScaler
from .base_model import BaseModel
logger = logging.getLogger(__name__)
def _check_torch_geometric() -> bool:
try:
import torch # noqa: F401
from torch_geometric.nn import GATConv # noqa: F401
return True
except ImportError:
return False
def _build_graph_from_edges(edges: Dict[Tuple[str, str], float]) -> Tuple[Dict[str, int], np.ndarray, np.ndarray]:
"""Convert edge dict to node mapping, adjacency matrix, and edge_index."""
nodes = set()
for (a, b) in edges:
nodes.add(a)
nodes.add(b)
node_list = sorted(nodes)
node_idx = {name: i for i, name in enumerate(node_list)}
n = len(node_list)
adj = np.zeros((n, n), dtype=np.float64)
for (a, b), w in edges.items():
i, j = node_idx[a], node_idx[b]
adj[i, j] = w
edge_list = [(node_idx[a], node_idx[b]) for (a, b) in edges]
edge_index = np.array(edge_list, dtype=np.int64).T if edge_list else np.empty((2, 0), dtype=np.int64)
return node_idx, adj, edge_index
def _pagerank(adj: np.ndarray, alpha: float = 0.85, tol: float = 1e-6, max_iter: int = 100) -> np.ndarray:
n = adj.shape[0]
out_deg = adj.sum(axis=1)
out_deg[out_deg == 0] = 1.0
M = adj.T / out_deg
pr = np.ones(n) / n
for _ in range(max_iter):
pr_new = alpha * M.dot(pr) + (1.0 - alpha) / n
if np.abs(pr_new - pr).sum() < tol:
return pr_new
pr = pr_new
return pr
def _eigenvector_centrality(adj: np.ndarray, tol: float = 1e-6, max_iter: int = 200) -> np.ndarray:
n = adj.shape[0]
x = np.ones(n)
for _ in range(max_iter):
x_new = adj.dot(x)
norm = np.linalg.norm(x_new, 2)
if norm < 1e-12:
break
x_new /= norm
if np.abs(x_new - x).sum() < tol:
return x_new
x = x_new
return x / np.linalg.norm(x, 2) if np.linalg.norm(x, 2) > 1e-12 else x
def _betweenness_centrality(adj: np.ndarray) -> np.ndarray:
"""Brandes algorithm for weighted undirected graphs."""
n = adj.shape[0]
if n <= 2:
return np.zeros(n)
dist = np.where(adj > 0, 1.0 / np.maximum(adj, 1e-12), np.inf)
np.fill_diagonal(dist, 0.0)
bc = np.zeros(n)
for s in range(n):
S = []
P = [[] for _ in range(n)]
sigma = np.zeros(n)
sigma[s] = 1.0
d = np.full(n, np.inf)
d[s] = 0.0
Q = [s]
for v in Q:
S.append(v)
for w in range(n):
if adj[v, w] <= 0 or w == v:
continue
new_d = d[v] + dist[v, w]
if new_d < d[w] - 1e-12:
d[w] = new_d
sigma[w] = 0.0
P[w] = [v]
Q.append(w)
elif abs(new_d - d[w]) < 1e-12:
sigma[w] += sigma[v]
P[w].append(v)
delta = np.zeros(n)
while S:
w = S.pop()
for v in P[w]:
delta[v] += (sigma[v] / max(sigma[w], 1e-12)) * (1.0 + delta[w])
if w != s:
bc[w] += delta[w]
bc /= max(n - 1, 1) * max(n - 2, 1)
return bc
def _clustering_coefficient(adj: np.ndarray) -> np.ndarray:
n = adj.shape[0]
cc = np.zeros(n)
binary = (adj > 0).astype(np.float64)
for i in range(n):
neighbors = np.where(binary[i] > 0)[0]
deg = len(neighbors)
if deg < 2:
cc[i] = 0.0
continue
sub = binary[np.ix_(neighbors, neighbors)]
triangles = np.sum(sub) / 2.0
cc[i] = 2.0 * triangles / (deg * (deg - 1))
return cc
def _assortativity_by_position(adj: np.ndarray, role_groups: Dict[int, str]) -> float:
"""Compute assortativity coefficient with respect to positional role."""
n = adj.shape[0]
if n <= 1:
return 0.0
binary = (adj > 0).astype(np.float64)
degrees = binary.sum(axis=1)
total_edges = degrees.sum()
if total_edges == 0:
return 0.0
same_type = 0.0
for i in range(n):
for j in range(n):
if binary[i, j] > 0 and role_groups.get(i) == role_groups.get(j):
same_type += 1.0
p_same = same_type / max(total_edges, 1e-12)
type_counts: Dict[str, float] = {}
for idx in range(n):
role = role_groups.get(idx, "unknown")
type_counts[role] = type_counts.get(role, 0.0) + degrees[idx]
total_deg = sum(type_counts.values())
expected = sum((t / max(total_deg, 1e-12)) ** 2 for t in type_counts.values())
max_possible = 1.0 - expected
if abs(max_possible) < 1e-12:
return 0.0
return (p_same - expected) / max_possible
class PlayerChemistryGAT(BaseModel):
"""Graph Attention Network for player chemistry/interaction modeling.
Two modes:
- PyTorch Geometric: full GATConv layers predicting delta-fantavoto.
- Fallback: scikit-learn MLPRegressor trained on network-science features
(PageRank, betweenness, clustering, eigenvector, assortativity).
Parameters
----------
model_dir : str
Directory for persisting trained models.
hidden_dim : int
Hidden dimension for GAT / MLP.
num_layers : int
Number of GATConv layers.
heads : int
Number of attention heads.
dropout : float
Dropout probability.
"""
def __init__(
self,
model_dir: str = "models_trained",
hidden_dim: int = 64,
num_layers: int = 2,
heads: int = 4,
dropout: float = 0.2,
):
super().__init__(model_dir)
self.hidden_dim = hidden_dim
self.num_layers = num_layers
self.heads = heads
self.dropout = dropout
self._edges: Dict[Tuple[str, str], float] = {}
self.interaction_edges: Dict[Tuple[str, str], float] = {}
self._node_idx: Dict[str, int] = {}
self._idx_node: Dict[int, str] = {}
self._adj: Optional[np.ndarray] = None
self._edge_index: Optional[np.ndarray] = None
self._graph_features: Optional[np.ndarray] = None
self._feature_names: Optional[List[str]] = None
self._scaler = StandardScaler()
self._use_torch: bool = False
self._gat_module = None
self._mlp: Optional[MLPRegressor] = None
# ------------------------------------------------------------------
# Graph construction
# ------------------------------------------------------------------
def build_graph(self, historical_data: pd.DataFrame):
"""Build player interaction graph from pass/assist/cross data.
Args:
historical_data: DataFrame with columns
[player, teammate, passes_to, assists_to, crosses_to, matchday]
"""
required = {"player", "teammate", "passes_to", "assists_to", "crosses_to"}
missing = required - set(historical_data.columns)
if missing:
raise ValueError(f"Missing required columns: {missing}")
edges: Dict[Tuple[str, str], float] = {}
for (player, teammate), group in historical_data.groupby(["player", "teammate"]):
weight = (
group["passes_to"].sum()
+ group["assists_to"].sum() * 5
+ group["crosses_to"].sum() * 3
)
if weight > 0:
edges[(str(player), str(teammate))] = float(weight)
self._edges = edges
self.interaction_edges = dict(edges)
self._node_idx, self._adj, self._edge_index = _build_graph_from_edges(edges)
self._idx_node = {v: k for k, v in self._node_idx.items()}
logger.info(
"Built interaction graph: %d nodes, %d edges",
len(self._node_idx),
len(edges),
)
return self
# ------------------------------------------------------------------
# Graph feature extraction
# ------------------------------------------------------------------
def _extract_graph_features(self, role_map: Optional[Dict[str, str]] = None) -> np.ndarray:
n = len(self._node_idx)
if n == 0:
return np.empty((0, 6))
adj = self._adj.copy() if self._adj is not None else np.zeros((n, n))
pr = _pagerank(adj)
bc = _betweenness_centrality(adj)
cc = _clustering_coefficient(adj)
deg = adj.sum(axis=1) + adj.sum(axis=0)
ec = _eigenvector_centrality(adj)
role_groups: Dict[int, str] = {}
if role_map:
for i in range(n):
name = self._idx_node.get(i, "")
role_groups[i] = role_map.get(name, "unknown")
assortative = _assortativity_by_position(adj, role_groups) if role_map else 0.0
features = np.column_stack([pr, bc, cc, deg, ec, np.full(n, assortative)])
self._feature_names = [
"pagerank",
"betweenness",
"clustering_coef",
"degree",
"eigenvector",
"assortativity",
]
return features
# ------------------------------------------------------------------
# PyTorch GAT module
# ------------------------------------------------------------------
def _init_torch_gat(self, in_channels: int):
try:
import torch
import torch.nn as nn
from torch_geometric.nn import GATConv
class GATModule(nn.Module):
def __init__(
self,
in_channels: int,
hidden_dim: int,
out_dim: int,
heads: int,
dropout: float,
):
super().__init__()
self.conv1 = GATConv(in_channels, hidden_dim, heads=heads, dropout=dropout)
self.conv2 = GATConv(hidden_dim * heads, out_dim, heads=1, concat=False, dropout=dropout)
self.head = nn.Linear(out_dim, 1)
def forward(self, x, edge_index, edge_attr=None):
x = self.conv1(x, edge_index)
x = torch.relu(x)
x = self.conv2(x, edge_index)
x = torch.relu(x)
return self.head(x).squeeze(-1)
self._gat_module = GATModule(
in_channels=in_channels,
hidden_dim=self.hidden_dim,
out_dim=self.hidden_dim // 2,
heads=self.heads,
dropout=self.dropout,
)
self._use_torch = True
logger.info("PyTorch Geometric GAT initialized (in=%d)", in_channels)
except ImportError:
self._use_torch = False
logger.info("torch_geometric not installed; using MLP fallback")
# ------------------------------------------------------------------
# Fit
# ------------------------------------------------------------------
def fit(self, X: pd.DataFrame, y: pd.Series, **kwargs):
"""Fit the player chemistry model.
Args:
X: DataFrame. Must contain a 'player' column for node identification.
Optionally 'team' for team-based subgraph grouping and 'role' for
positional assortativity.
y: Target fantavoto scores per row.
"""
if X.empty:
raise ValueError("X cannot be empty")
role_map: Optional[Dict[str, str]] = None
if "role" in X.columns:
role_map = dict(zip(X["player"].astype(str), X["role"].astype(str)))
team_groups = []
if "team" in X.columns:
for _, group in X.groupby("team"):
players = group["player"].unique().tolist()
if len(players) >= 2:
for a, b in combinations(players, 2):
team_groups.append({"player": str(a), "teammate": str(b),
"passes_to": 1, "assists_to": 0, "crosses_to": 0})
if "player" not in X.columns:
raise ValueError("X must contain a 'player' column")
if not self._edges and not team_groups:
if "player" in X.columns and "team" not in X.columns:
logger.warning("No interaction edges and no 'team' column; graph will be empty")
self._edges = {}
self.interaction_edges = {}
self._node_idx = {}
self._adj = np.array([[0.0]])
self._edge_index = np.empty((2, 0), dtype=np.int64)
if team_groups and not self._edges:
gdf = pd.DataFrame(team_groups)
if "passes_to" not in gdf.columns:
gdf["passes_to"] = 1
if "assists_to" not in gdf.columns:
gdf["assists_to"] = 0
if "crosses_to" not in gdf.columns:
gdf["crosses_to"] = 0
self.build_graph(gdf)
if "player" in X.columns:
player_set = set(self._node_idx.keys())
new_nodes = []
for row_player in X["player"]:
p = str(row_player)
if p and p not in player_set:
idx = len(self._node_idx)
self._node_idx[p] = idx
self._idx_node[idx] = p
player_set.add(p)
new_nodes.append(p)
if self._adj is None:
n = len(self._node_idx)
self._adj = np.zeros((n, n))
self._edge_index = np.empty((2, 0), dtype=np.int64)
elif new_nodes:
old_n = self._adj.shape[0]
new_n = len(self._node_idx)
adj_new = np.zeros((new_n, new_n))
adj_new[:old_n, :old_n] = self._adj
self._adj = adj_new
self._graph_features = self._extract_graph_features(role_map)
n_nodes = len(self._node_idx)
if n_nodes == 0:
logger.warning("Zero nodes in graph; fitting dummy model")
self._mlp = MLPRegressor(
hidden_layer_sizes=(self.hidden_dim,),
max_iter=300,
random_state=42,
)
self._mlp.fit(np.zeros((1, 6)), np.zeros(1))
return self
node_to_player = {v: k for k, v in self._node_idx.items()}
target_deltas = np.zeros(n_nodes)
count_deltas = np.zeros(n_nodes)
player_indices = {
str(row["player"]): i
for i, (_, row) in enumerate(X.iterrows())
}
y_mean = float(np.mean(y)) if len(y) > 0 else 6.0
for i, (_, row) in enumerate(X.iterrows()):
p = str(row["player"])
if p in self._node_idx:
idx = self._node_idx[p]
target_deltas[idx] += (float(y.iloc[i]) - y_mean)
count_deltas[idx] += 1.0
for j in range(n_nodes):
if count_deltas[j] > 0:
target_deltas[j] /= count_deltas[j]
if _check_torch_geometric():
self._init_torch_gat(in_channels=self._graph_features.shape[1])
self._fit_torch_gat(self._graph_features, self._edge_index, target_deltas)
else:
self._fit_sklearn_fallback(self._graph_features, target_deltas)
logger.info(
"PlayerChemistryGAT fitted: %d nodes, %d edges, mode=%s",
n_nodes, len(self._edges),
"torch" if self._use_torch else "sklearn",
)
return self
def _fit_torch_gat(
self,
features: np.ndarray,
edge_index: np.ndarray,
targets: np.ndarray,
):
import torch
import torch.nn as nn
import torch.optim as optim
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
x_tensor = torch.tensor(features, dtype=torch.float32).to(device)
edge_tensor = torch.tensor(edge_index, dtype=torch.long).to(device)
y_tensor = torch.tensor(targets, dtype=torch.float32).to(device)
self._gat_module = self._gat_module.to(device)
optimizer = optim.Adam(self._gat_module.parameters(), lr=0.01, weight_decay=1e-5)
criterion = nn.MSELoss()
n = features.shape[0]
self._gat_module.train()
for epoch in range(200):
optimizer.zero_grad()
pred = self._gat_module(x_tensor, edge_tensor)
loss = criterion(pred, y_tensor)
loss.backward()
optimizer.step()
if (epoch + 1) % 50 == 0:
logger.debug(f"GAT epoch {epoch + 1}: loss={loss.item():.6f}")
self._gat_module.eval()
def _fit_sklearn_fallback(self, features: np.ndarray, targets: np.ndarray):
self._scaler.fit(features)
features_scaled = self._scaler.transform(features)
n_samples = features_scaled.shape[0]
val_frac = 0.1 if n_samples >= 20 else 0.0
self._mlp = MLPRegressor(
hidden_layer_sizes=(self.hidden_dim, self.hidden_dim // 2),
activation="relu",
solver="adam",
max_iter=500,
random_state=42,
early_stopping=(n_samples >= 20),
validation_fraction=val_frac if val_frac > 0 else 0.1,
n_iter_no_change=20,
)
self._mlp.fit(features_scaled, targets)
self._use_torch = False
# ------------------------------------------------------------------
# Predict
# ------------------------------------------------------------------
def predict(self, X: pd.DataFrame) -> np.ndarray:
"""Return chemistry-adjusted point projection bonuses.
These bonuses can be positive (synergy) or negative (redundancy).
Returns an array the same length as X with delta-fantavoto values.
"""
if self._adj is None or len(self._node_idx) == 0:
return np.zeros(X.shape[0])
role_map: Optional[Dict[str, str]] = None
if "role" in X.columns:
role_map = dict(zip(X["player"].astype(str), X["role"].astype(str)))
features = self._extract_graph_features(role_map)
n_nodes = len(self._node_idx)
if features.shape[0] != n_nodes or n_nodes == 0:
return np.zeros(X.shape[0])
if self._use_torch and self._gat_module is not None:
return self._predict_torch(features, X)
elif self._mlp is not None:
return self._predict_sklearn(features, X)
return np.zeros(X.shape[0])
def _predict_torch(self, features: np.ndarray, X: pd.DataFrame) -> np.ndarray:
import torch
device = next(self._gat_module.parameters()).device
x_tensor = torch.tensor(features, dtype=torch.float32).to(device)
edge_tensor = torch.tensor(self._edge_index, dtype=torch.long).to(device)
self._gat_module.eval()
with torch.no_grad():
node_deltas = self._gat_module(x_tensor, edge_tensor).cpu().numpy()
result = np.zeros(X.shape[0])
for i, (_, row) in enumerate(X.iterrows()):
p = str(row.get("player", row.get("name", "")))
if p in self._node_idx:
result[i] = float(node_deltas[self._node_idx[p]])
return result
def _predict_sklearn(self, features: np.ndarray, X: pd.DataFrame) -> np.ndarray:
features_scaled = self._scaler.transform(features)
node_deltas = self._mlp.predict(features_scaled)
result = np.zeros(X.shape[0])
for i, (_, row) in enumerate(X.iterrows()):
p = str(row.get("player", row.get("name", "")))
if p in self._node_idx:
result[i] = float(node_deltas[self._node_idx[p]])
return result
# ------------------------------------------------------------------
# Interaction feature extraction (public API)
# ------------------------------------------------------------------
def extract_interaction_features(self, player: str, teammates: List[str]) -> Dict[str, float]:
"""Extract interaction features between a player and their teammates.
Args:
player: Player name.
teammates: List of teammate names.
Returns:
Dict with keys: interaction_outgoing_sum, interaction_incoming_sum,
interaction_synergy.
"""
outgoing = 0.0
incoming = 0.0
synergy = 0.0
count = 0
for teammate in teammates:
w = self._edges.get((str(player), str(teammate)), 0.0)
outgoing += w
w_in = self._edges.get((str(teammate), str(player)), 0.0)
incoming += w_in
if w > 0 or w_in > 0:
synergy += (w + w_in) / 2.0
count += 1
avg_synergy = synergy / max(count, 1)
return {
"interaction_outgoing_sum": float(outgoing),
"interaction_incoming_sum": float(incoming),
"interaction_synergy": float(avg_synergy),
}
# ------------------------------------------------------------------
# Interaction bonus / chemistry matrix
# ------------------------------------------------------------------
def compute_interaction_bonus(self, player_a: str, player_b: str) -> float:
"""Bonus factor for two players in the same lineup.
Positive = synergy, negative = redundancy.
"""
if not self._edges:
return 0.0
a = str(player_a)
b = str(player_b)
weight = self._edges.get((a, b), 0.0) + self._edges.get((b, a), 0.0)
max_weight = max(self._edges.values()) if self._edges else 1.0
return float(weight) / max(max_weight, 1.0) if weight > 0 else 0.0
def get_chemistry_matrix(self, players: List[str]) -> np.ndarray:
"""Return NxN matrix of pairwise chemistry bonuses for given players.
Positive values = synergy, negative = redundancy.
"""
n = len(players)
matrix = np.zeros((n, n))
for i in range(n):
for j in range(n):
if i != j:
matrix[i, j] = self.compute_interaction_bonus(players[i], players[j])
return matrix
def get_redundancy_penalty(self, players: List[str]) -> Dict[Tuple[str, str], float]:
"""Identify negative synergies between players on the same team.
Two players may compete for the same actions (both take corners,
both demand the ball in similar zones). Returns dict mapping
player-pairs to a negative penalty value (0 = no redundancy).
The penalty is derived from co-occurrence overlap: if two players
both have high outgoing edges to the same teammates and both
receive from the same sources, they're likely redundant.
"""
penalty: Dict[Tuple[str, str], float] = {}
if not self._edges:
return penalty
for i, a in enumerate(players):
for b in players[i + 1:]:
a_out = set(t for (p, t) in self._edges if p == str(a))
b_out = set(t for (p, t) in self._edges if p == str(b))
a_in = set(p for (p, t) in self._edges if t == str(a))
b_in = set(p for (p, t) in self._edges if t == str(b))
out_overlap = len(a_out & b_out)
in_overlap = len(a_in & b_in)
total = max(len(a_out | b_out) + len(a_in | b_in), 1)
overlap_ratio = (out_overlap + in_overlap) / total
if overlap_ratio > 0.3:
penalty[(str(a), str(b))] = -overlap_ratio
return penalty
+503
View File
@@ -0,0 +1,503 @@
"""Hawkes-process player form model for momentum modeling.
Models player form as a self-exciting Hawkes process: good performances
increase the probability of more good performances (momentum / hot streak).
Uses scipy.optimize.minimize to fit per-player Hawkes parameters
(mu, alpha, beta, s) via maximum likelihood.
"""
import logging
from dataclasses import dataclass
from typing import Dict, List, Optional, Tuple
import numpy as np
import pandas as pd
from scipy.optimize import minimize
from .base_model import BaseModel
logger = logging.getLogger(__name__)
FORM_STATUS_HOT = "HOT"
FORM_STATUS_COLD = "COLD"
FORM_STATUS_NEUTRAL = "NEUTRAL"
FORM_STATUSES = {FORM_STATUS_HOT, FORM_STATUS_COLD, FORM_STATUS_NEUTRAL}
_EPS = 1e-12
_MIN_BETA = 1e-4
_MAX_ALPHA = 20.0
_MAX_MU = 50.0
_MIN_S = -3.0
_MAX_S = 3.0
@dataclass
class PlayerHawkesParams:
mu: float
alpha: float
beta: float
s: float
baseline_mean: float
baseline_std: float
class PlayerFormModel(BaseModel):
"""Self-exciting Hawkes process for player form / momentum.
Each player's match performances are modeled as a point process where
above-average games ("excitatory events") temporarily raise the
probability of subsequent above-average games.
Parameters
----------
model_dir : str
Directory for persisting trained models.
decay_window : int
Maximum match-gap over which excitation persists (default 10).
"""
def __init__(
self,
model_dir: str = "models_trained",
decay_window: int = 10,
):
super().__init__(model_dir)
self.decay_window = int(decay_window)
self._player_params: Dict[str, PlayerHawkesParams] = {}
self._global_baseline: float = 6.0
self._global_std: float = 1.5
# ------------------------------------------------------------------
# Hawkes log-likelihood and intensity
# ------------------------------------------------------------------
@staticmethod
def _hawkes_intensity(times: np.ndarray, event_mask: np.ndarray, mu: float,
alpha: float, beta: float) -> np.ndarray:
lam = np.full_like(times, mu, dtype=np.float64)
for i in range(1, len(times)):
if event_mask[i - 1]:
dt = times[i:] - times[i - 1]
mask = dt > 0
lam[i:] += alpha * np.exp(-beta * dt) * mask
return np.maximum(lam, _EPS)
@staticmethod
def _hawkes_integral(mu: float, alpha: float, beta: float,
event_times: np.ndarray, T: float) -> float:
result = mu * T
for te in event_times:
remaining = T - te
if remaining > 0:
result += (alpha / beta) * (1.0 - np.exp(-beta * remaining))
return result
@staticmethod
def _hawkes_nll(params: np.ndarray, times: np.ndarray, event_mask: np.ndarray) -> float:
mu, alpha, beta = max(params[0], _EPS), max(params[1], _EPS), max(params[2], _MIN_BETA)
T = times[-1] if len(times) > 0 else 1.0
lam = PlayerFormModel._hawkes_intensity(times, event_mask, mu, alpha, beta)
log_lik = np.sum(np.log(lam))
integral = PlayerFormModel._hawkes_integral(mu, alpha, beta,
times[event_mask.astype(bool)], T)
return -(log_lik - integral)
# ------------------------------------------------------------------
# Fit helper: tune per-player
# ------------------------------------------------------------------
def _fit_player(self, times: np.ndarray, scores: np.ndarray) -> Optional[PlayerHawkesParams]:
n = len(scores)
if n < 5:
return None
times_float = times.astype(np.float64)
scores_float = scores.astype(np.float64)
baseline_mean = float(np.mean(scores_float))
baseline_std = float(np.std(scores_float, ddof=1)) if n > 1 else 1.0
best_nll = float("inf")
best_params = None
for s_candidate in [-1.0, 0.0, 0.5, 1.0, 1.5]:
threshold = baseline_mean + s_candidate * max(baseline_std, 0.5)
event_mask = (scores_float > threshold).astype(np.float64)
n_events = event_mask.sum()
if n_events < 2:
continue
init_mu = max(max(n_events / max(times_float[-1] - times_float[0], 1.0), 0.05), _EPS)
init_alpha = min(n_events / max(n, 1) * 2.0, _MAX_ALPHA)
init_beta = 0.5
for init_scale in [0.5, 1.0, 2.0]:
x0 = np.array([
init_mu * init_scale,
init_alpha * init_scale,
init_beta * init_scale,
])
try:
result = minimize(
self._hawkes_nll,
x0,
args=(times_float, event_mask),
method="L-BFGS-B",
bounds=[(_EPS, _MAX_MU), (_EPS, _MAX_ALPHA), (_MIN_BETA, 10.0)],
options={"maxiter": 500, "ftol": 1e-10},
)
if result.success and result.fun < best_nll:
best_nll = result.fun
best_params = PlayerHawkesParams(
mu=float(max(result.x[0], _EPS)),
alpha=float(max(result.x[1], _EPS)),
beta=float(max(result.x[2], _MIN_BETA)),
s=float(s_candidate),
baseline_mean=baseline_mean,
baseline_std=baseline_std,
)
except Exception:
continue
if best_params is None:
best_params = PlayerHawkesParams(
mu=0.1,
alpha=1.0,
beta=0.3,
s=0.0,
baseline_mean=baseline_mean,
baseline_std=baseline_std,
)
return best_params
# ------------------------------------------------------------------
# Fit
# ------------------------------------------------------------------
def fit(self, X: pd.DataFrame, y: pd.Series, **kwargs):
"""Fit per-player Hawkes parameters.
X must contain:
- 'player' or 'name': player identifier.
- 'match_date' or 'matchday': temporal ordering column.
y: fantavoto scores.
Additional kwargs:
- 'match_date' column name override.
"""
if X.empty:
raise ValueError("X cannot be empty")
player_col = None
for candidate in ["player", "name"]:
if candidate in X.columns:
player_col = candidate
break
if player_col is None:
raise ValueError("X must contain a 'player' or 'name' column")
date_col = kwargs.get("date_col", None)
if date_col is None:
for candidate in ["match_date", "matchday", "date", "giornata"]:
if candidate in X.columns:
date_col = candidate
break
if date_col is None:
logger.warning("No date column found; using row index as temporal order")
times = np.arange(len(X), dtype=np.float64)
else:
col_vals = X[date_col]
if pd.api.types.is_datetime64_any_dtype(col_vals):
times = col_vals.astype(np.int64).values.astype(np.float64) / 1e9 / 86400.0
else:
times = col_vals.astype(np.float64).values
players = X[player_col].astype(str).values
scores = y.values.astype(np.float64)
self._global_baseline = float(np.mean(scores)) if len(scores) > 0 else 6.0
self._global_std = float(np.std(scores, ddof=1)) if len(scores) > 1 else 1.5
self._player_params = {}
unique_players = np.unique(players)
fitted = 0
for player in unique_players:
mask = players == player
p_times = times[mask]
p_scores = scores[mask]
sort_idx = np.argsort(p_times)
p_times = p_times[sort_idx]
p_scores = p_scores[sort_idx]
params = self._fit_player(p_times, p_scores)
if params is not None:
self._player_params[str(player)] = params
fitted += 1
logger.info(
"Hawkes form model fitted: %d/%d players with sufficient history",
fitted, len(unique_players),
)
return self
# ------------------------------------------------------------------
# Predict
# ------------------------------------------------------------------
def predict(self, X: pd.DataFrame) -> np.ndarray:
"""Return form-adjusted projections as additive bonuses to base.
Positive = player is in form (HOT), negative = out of form (COLD).
"""
if not self._player_params:
return np.zeros(X.shape[0])
player_col = "player" if "player" in X.columns else "name"
multipliers = self._compute_multipliers(X)
base = X.get("base_prediction", pd.Series(np.full(X.shape[0], 6.0)))
base_vals = base.values.astype(np.float64)
return (multipliers - 1.0) * base_vals
def _compute_multipliers(self, X: pd.DataFrame) -> np.ndarray:
player_col = "player" if "player" in X.columns else "name"
multipliers = np.ones(X.shape[0], dtype=np.float64)
players = X[player_col].astype(str).values
for i, player in enumerate(players):
params = self._player_params.get(player)
if params is None:
continue
recent = self._compute_recent_intensity(params)
base_rate = params.mu
if base_rate > _EPS:
ratio = recent / base_rate
clamped = np.clip(ratio, 0.85, 1.15)
multipliers[i] = float(clamped)
return multipliers
def _compute_recent_intensity(self, params: PlayerHawkesParams) -> float:
return max(params.mu, _EPS)
# ------------------------------------------------------------------
# Momentum projection
# ------------------------------------------------------------------
def predict_momentum(
self,
X: pd.DataFrame,
player_history: pd.DataFrame,
n_future: int = 5,
) -> np.ndarray:
"""Project form trajectory for next *n_future* matches.
Returns (n_future, n_players) array of momentum multipliers.
"""
if not self._player_params:
return np.ones((n_future, X.shape[0]))
player_col = "player" if "player" in X.columns else "name"
players = X[player_col].astype(str).values
n_players = X.shape[0]
trajectory = np.ones((n_future, n_players), dtype=np.float64)
history_player_col = None
for c in ["player", "name"]:
if c in player_history.columns:
history_player_col = c
break
history_date_col = None
for c in ["match_date", "matchday", "date"]:
if c in player_history.columns:
history_date_col = c
break
for j, player in enumerate(players):
params = self._player_params.get(player)
if params is None:
continue
event_times = []
if history_player_col and history_date_col:
p_hist = player_history[player_history[history_player_col].astype(str) == player]
if len(p_hist) > 0:
target_col = None
for c in ["fantavoto", "score", "fv"]:
if c in p_hist.columns:
target_col = c
break
if target_col and params.baseline_std > 0:
threshold = params.baseline_mean + params.s * params.baseline_std
p_sorted = p_hist.sort_values(history_date_col)
times = p_sorted[history_date_col].values
scores = p_sorted[target_col].values
if pd.api.types.is_datetime64_any_dtype(p_sorted[history_date_col]):
event_times_float = times.astype(np.int64).astype(np.float64) / 1e9 / 86400.0
else:
event_times_float = times.astype(np.float64)
for ti, si in zip(event_times_float, scores):
if float(si) > threshold:
event_times.append(ti)
if not event_times:
continue
last_t = max(event_times)
for k in range(1, n_future + 1):
future_t = last_t + k
lam = params.mu
for te in event_times:
dt = future_t - te
if dt > 0 and dt <= self.decay_window:
lam += params.alpha * np.exp(-params.beta * dt)
trajectory[k - 1, j] = float(np.clip(lam / max(params.mu, _EPS), 0.85, 1.15))
return trajectory
# ------------------------------------------------------------------
# Form status
# ------------------------------------------------------------------
def get_form_status(self, X: pd.DataFrame) -> List[str]:
"""Return status string per player: HOT, COLD, or NEUTRAL."""
player_col = "player" if "player" in X.columns else "name"
players = X[player_col].astype(str).values
statuses: List[str] = []
for player in players:
params = self._player_params.get(player)
if params is None:
statuses.append(FORM_STATUS_NEUTRAL)
continue
intensity = self._compute_recent_intensity(params)
baseline = max(params.mu, _EPS)
ratio = intensity / baseline
if ratio > 1.1 and params.alpha > 0.1:
statuses.append(FORM_STATUS_HOT)
elif ratio < 0.9:
statuses.append(FORM_STATUS_COLD)
else:
statuses.append(FORM_STATUS_NEUTRAL)
return statuses
# ------------------------------------------------------------------
# Intensity curve
# ------------------------------------------------------------------
def compute_intensity_curve(
self,
player_name: str,
history: pd.DataFrame,
match_dates: np.ndarray,
future_dates: np.ndarray,
) -> np.ndarray:
"""Compute λ(t) over match_dates and future_dates for one player.
Returns an array of intensity values at each date.
"""
params = self._player_params.get(str(player_name))
if params is None:
mu = self._global_baseline
all_dates = np.concatenate([match_dates, future_dates])
return np.full_like(all_dates, max(mu, _EPS), dtype=np.float64)
target_col = None
for c in ["fantavoto", "score", "fv"]:
if c in history.columns:
target_col = c
break
threshold = params.baseline_mean + params.s * max(params.baseline_std, 0.5)
event_times = []
if target_col:
for _, row in history.iterrows():
if float(row.get(target_col, 0)) > threshold:
event_times.append(float(row.name) if isinstance(row.name, (int, float)) else 0.0)
if match_dates is not None and len(match_dates) > 0:
match_vals = match_dates.astype(np.float64)
for ti in match_vals:
if ti not in event_times:
score = None
for _, row in history.iterrows():
d_val = float(row.name) if isinstance(row.name, (int, float)) else 0.0
if abs(d_val - ti) < _EPS:
score = row.get(target_col, 0) if target_col else 0
break
if score is not None and float(score) > threshold:
event_times.append(ti)
all_dates = np.concatenate([
match_dates.astype(np.float64) if match_dates is not None and len(match_dates) > 0
else np.array([], dtype=np.float64),
future_dates.astype(np.float64) if future_dates is not None and len(future_dates) > 0
else np.array([], dtype=np.float64),
])
if len(all_dates) == 0:
return np.array([params.mu])
intensity = np.full(len(all_dates), params.mu, dtype=np.float64)
for i, t in enumerate(all_dates):
lam = params.mu
for te in event_times:
dt = t - te
if dt > 0 and dt <= self.decay_window:
lam += params.alpha * np.exp(-params.beta * dt)
elif dt > self.decay_window:
pass
intensity[i] = max(lam, _EPS)
return intensity
# ------------------------------------------------------------------
# Streak detection
# ------------------------------------------------------------------
def detect_streak(
self,
player_history: pd.DataFrame,
) -> Tuple[bool, int, str]:
"""Detect whether a player is on a hot or cold streak.
Returns (is_streak, streak_length, streak_direction).
streak_direction is "HOT_STREAK" or "COLD_STREAK".
"""
if len(player_history) < 3:
return (False, 0, "NO_STREAK")
target_col = None
for c in ["fantavoto", "score", "fv"]:
if c in player_history.columns:
target_col = c
break
if target_col is None:
return (False, 0, "NO_STREAK")
date_col = None
for c in ["match_date", "matchday", "date"]:
if c in player_history.columns:
date_col = c
break
if date_col:
sorted_hist = player_history.sort_values(date_col)
else:
sorted_hist = player_history
scores = sorted_hist[target_col].values.astype(np.float64)
mean_score = np.mean(scores)
std_score = max(np.std(scores, ddof=1), 0.5)
above = scores[-1] > mean_score + 0.5 * std_score
below = scores[-1] < mean_score - 0.5 * std_score
if not above and not below:
return (False, 0, "NO_STREAK")
direction = "HOT_STREAK" if above else "COLD_STREAK"
streak_len = 1
for j in range(len(scores) - 2, -1, -1):
if direction == "HOT_STREAK" and scores[j] > mean_score + 0.5 * std_score:
streak_len += 1
elif direction == "COLD_STREAK" and scores[j] < mean_score - 0.5 * std_score:
streak_len += 1
else:
break
return (streak_len >= 3, streak_len, direction)
+207
View File
@@ -0,0 +1,207 @@
"""Quantile regression ensemble for probabilistic score prediction.
Trains one LightGBM quantile regressor per target quantile (default P10, P50, P90)
to output a full predictive distribution of Fantavoto scores. Supports downside risk,
upside potential, and Value-at-Risk-safe estimates.
"""
import logging
from typing import Optional, Tuple
import numpy as np
import pandas as pd
from sklearn.preprocessing import StandardScaler
from .base_model import BaseModel
logger = logging.getLogger(__name__)
class QuantileEnsemble(BaseModel):
"""Quantile regression ensemble for multi-quantile score prediction.
Stores one LGBMRegressor per quantile, each with ``objective="quantile"``
and the corresponding ``alpha`` value. Provides convenience accessors for
point predictions (P50), downside/upside risk, and VaR-safe floors.
"""
def __init__(
self,
quantiles: Tuple[float, ...] = (0.10, 0.50, 0.90),
model_dir: str = "models_trained",
n_estimators: int = 300,
learning_rate: float = 0.05,
max_depth: int = 5,
num_leaves: int = 31,
subsample: float = 0.8,
colsample_bytree: float = 0.8,
):
super().__init__(model_dir)
self.quantiles = tuple(quantiles)
self.n_estimators = n_estimators
self.learning_rate = learning_rate
self.max_depth = max_depth
self.num_leaves = num_leaves
self.subsample = subsample
self.colsample_bytree = colsample_bytree
self._models: dict = {} # alpha → LGBMRegressor
self.feature_names = None
self.scaler = StandardScaler()
# ------------------------------------------------------------------
# Training
# ------------------------------------------------------------------
def fit(self, X: pd.DataFrame, y: pd.Series, **kwargs):
"""Fit one LightGBM quantile regressor per target quantile.
Args:
X: Feature matrix.
y: Target values (fantavoto scores).
"""
try:
import lightgbm as lgb
except ImportError:
raise ImportError("LightGBM is required. pip install lightgbm")
self.feature_names = list(X.columns)
X_clean = X.select_dtypes(include=[np.number]).fillna(0)
X_scaled = self.scaler.fit_transform(X_clean)
self._models = {}
for alpha in self.quantiles:
model = lgb.LGBMRegressor(
n_estimators=self.n_estimators,
learning_rate=self.learning_rate,
max_depth=self.max_depth,
num_leaves=self.num_leaves,
subsample=self.subsample,
colsample_bytree=self.colsample_bytree,
objective="quantile",
alpha=alpha,
random_state=42,
verbose=-1,
)
model.fit(X_scaled, y)
label = f"P{int(alpha * 100)}"
self._models[label] = model
logger.info(f"Quantile model {label} (α={alpha:.2f}) trained")
logger.info(f"QuantileEnsemble fitted: {list(self._models.keys())}")
return self
# ------------------------------------------------------------------
# Prediction helpers
# ------------------------------------------------------------------
def _preprocess(self, X: pd.DataFrame) -> np.ndarray:
"""Clean, impute, and scale features."""
if self.feature_names is None:
raise RuntimeError("Model not trained. Call fit() first.")
X_c = X[self.feature_names].select_dtypes(include=[np.number]).fillna(0)
return self.scaler.transform(X_c)
# ------------------------------------------------------------------
# Core predict
# ------------------------------------------------------------------
def predict(self, X: pd.DataFrame) -> dict:
"""Return a dict mapping quantile label → prediction array.
Example:
{"P10": array([4.2, 5.1, ...]), "P50": array([5.8, ...]), ...}
"""
if not self._models:
raise RuntimeError("Model not trained. Call fit() first.")
X_scaled = self._preprocess(X)
result = {}
for label, model in self._models.items():
result[label] = model.predict(X_scaled)
return result
def predict_points(self, X: pd.DataFrame) -> np.ndarray:
"""Convenience: return the P50 (median) prediction array."""
preds = self.predict(X)
p50_key = "P50"
if p50_key not in preds:
available = min(preds.keys(), key=lambda k: abs(float(k[1:]) / 100 - 0.50))
logger.warning(f"P50 not trained; falling back to {available}")
return preds[available]
return preds[p50_key]
# ------------------------------------------------------------------
# Risk / reward utilities
# ------------------------------------------------------------------
def predict_downside_risk(
self, X: pd.DataFrame, threshold: float = 5.5
) -> np.ndarray:
"""Probability that player score falls below *threshold*.
Uses CDF interpolation across the trained quantiles. The returned
probability is the fraction of the predictive distribution that lies
below the threshold.
"""
preds = self.predict(X)
n = len(next(iter(preds.values())))
probs = np.zeros(n)
for i in range(n):
q_vals = [preds[label][i] for label in sorted(preds.keys())]
q_levels = sorted([float(k[1:]) / 100 for k in preds.keys()])
if threshold <= q_vals[0]:
probs[i] = q_levels[0]
elif threshold >= q_vals[-1]:
probs[i] = q_levels[-1]
else:
idx = np.searchsorted(q_vals, threshold)
lo_q, hi_q = q_vals[idx - 1], q_vals[idx]
lo_level, hi_level = q_levels[idx - 1], q_levels[idx]
frac = (threshold - lo_q) / (hi_q - lo_q + 1e-10)
probs[i] = lo_level + frac * (hi_level - lo_level)
return np.clip(probs, 0.0, 1.0)
def predict_upside(self, X: pd.DataFrame, threshold: float = 7.0) -> np.ndarray:
"""Probability that player score exceeds *threshold*."""
downside = self.predict_downside_risk(X, threshold)
return 1.0 - downside
def value_at_risk_safe(
self, X: pd.DataFrame, confidence: float = 0.90
) -> np.ndarray:
"""VaR-safe estimate: floor that the player exceeds with given confidence.
For confidence=0.90 returns the score floor that the player exceeds 90%
of the time — i.e. the P(100-confidence) quantile. A higher VaR-safe
means more reliable upside.
"""
alpha = 1.0 - confidence
preds = self.predict(X)
levels = np.array(sorted([float(k[1:]) / 100 for k in preds.keys()]))
# If the exact alpha was trained return it directly
atol = 0.005
for label, q_pred in preds.items():
if abs(float(label[1:]) / 100 - alpha) <= atol:
return np.asarray(q_pred)
# Otherwise linearly interpolate
idx = np.searchsorted(levels, alpha)
if idx == 0:
label = sorted(preds.keys())[0]
return np.asarray(preds[label])
if idx >= len(levels):
label = sorted(preds.keys())[-1]
return np.asarray(preds[label])
lo_level = levels[idx - 1]
hi_level = levels[idx]
lo_label = f"P{int(round(lo_level * 100))}"
hi_label = f"P{int(round(hi_level * 100))}"
# Fall back to closest trained quantile keys
lo_label = min(preds.keys(), key=lambda k: abs(float(k[1:]) / 100 - lo_level))
hi_label = min(preds.keys(), key=lambda k: abs(float(k[1:]) / 100 - hi_level))
lo_vals = preds[lo_label]
hi_vals = preds[hi_label]
frac = (alpha - lo_level) / (hi_level - lo_level + 1e-10)
return lo_vals + frac * (hi_vals - lo_vals)
+726
View File
@@ -0,0 +1,726 @@
"""Set Transformer for team-level valuation.
Models a Fantacalcio roster as an unordered set of players rather than
a simple sum of individual projections. Captures non-linear team composition
effects: synergies between players, role balance, and diminishing returns
from overlapping skill sets.
Two implementation modes:
- PyTorch: Full Set Transformer with Induced Set Attention Blocks (ISAB)
and Pooling by Multihead Attention (PMA).
- sklearn: "Bag-of-Players" approximation using per-role aggregates,
pairwise interactions, and non-linear regression.
"""
import logging
import math
from collections import Counter
from pathlib import Path
from typing import Dict, List, Optional, Tuple
import numpy as np
import pandas as pd
from .base_model import BaseModel
logger = logging.getLogger(__name__)
ROLES = ["P", "D", "C", "A"]
ROLE_INDEX = {"P": 0, "D": 1, "C": 2, "A": 3}
_EMBEDDING_FEATURES = [
"projected_points",
"market_value",
"ceiling_price",
"vor", # value over replacement
"goals_per_game",
"assists_per_game",
"cs_prob", # clean sheet probability
"minutes_avg",
"form_recent",
"injury_risk",
"starter_prob",
]
def _has_torch() -> bool:
try:
import torch # noqa: F401
return True
except ImportError:
return False
# ---------------------------------------------------------------------------
# Torch Set Transformer modules (lazy-built to handle missing torch)
# ---------------------------------------------------------------------------
def _build_torch_classes():
"""Factory that returns torch nn.Module classes for the Set Transformer.
Only called when torch is available. All returned classes are proper
nn.Module subclasses so they work with nn.ModuleList, nn.Sequential, etc.
"""
import torch
import torch.nn as nn
import torch.nn.functional as F
class MAB(nn.Module):
"""Multihead Attention Block.
H = LayerNorm(X + Multihead(Q=X, K=Y, V=Y))
Out = LayerNorm(H + FFN(H))
"""
def __init__(self, dim, n_heads, rff_dim, dropout=0.1):
super().__init__()
self.dim = dim
self.n_heads = n_heads
self.head_dim = dim // n_heads
self.scale = self.head_dim ** 0.5
self.ln1 = nn.LayerNorm(dim)
self.ln2 = nn.LayerNorm(dim)
self.W_q = nn.Linear(dim, dim, bias=False)
self.W_k = nn.Linear(dim, dim, bias=False)
self.W_v = nn.Linear(dim, dim, bias=False)
self.W_o = nn.Linear(dim, dim, bias=False)
self.ffn = nn.Sequential(
nn.Linear(dim, rff_dim),
nn.ReLU(),
nn.Dropout(dropout),
nn.Linear(rff_dim, dim),
nn.Dropout(dropout),
)
def forward(self, X, Y):
H = self.ln1(X + self._mh_attention(X, Y, Y))
return self.ln2(H + self.ffn(H))
def _mh_attention(self, Q, K, V):
B, N_Q, _ = Q.shape
N_K = K.shape[1]
q = self.W_q(Q).view(B, N_Q, self.n_heads, self.head_dim).transpose(1, 2)
k = self.W_k(K).view(B, N_K, self.n_heads, self.head_dim).transpose(1, 2)
v = self.W_v(V).view(B, N_K, self.n_heads, self.head_dim).transpose(1, 2)
attn = torch.matmul(q, k.transpose(-2, -1)) / self.scale
attn = torch.softmax(attn, dim=-1)
out = torch.matmul(attn, v)
out = out.transpose(1, 2).contiguous().view(B, N_Q, self.dim)
return self.W_o(out)
class ISAB(nn.Module):
"""Induced Set Attention Block.
X -> MAB(X, MAB(I, X))
with learnable inducing points I.
"""
def __init__(self, dim, n_heads, n_inducing, rff_dim, dropout=0.1):
super().__init__()
self.mab1 = MAB(dim, n_heads, rff_dim, dropout)
self.mab2 = MAB(dim, n_heads, rff_dim, dropout)
self.I = nn.Parameter(torch.randn(1, n_inducing, dim) * 0.1)
self.n_inducing = n_inducing
def forward(self, X):
B = X.shape[0]
I_expanded = self.I.expand(B, -1, -1)
H = self.mab1(I_expanded, X)
return self.mab2(X, H)
class PMA(nn.Module):
"""Pooling by Multihead Attention.
SEMA = MAB(S, X) where S is a learnable seed vector.
"""
def __init__(self, dim, n_heads, n_seeds, rff_dim, dropout=0.1):
super().__init__()
self.mab = MAB(dim, n_heads, rff_dim, dropout)
self.S = nn.Parameter(torch.randn(1, n_seeds, dim) * 0.1)
self.n_seeds = n_seeds
def forward(self, X):
B = X.shape[0]
S_expanded = self.S.expand(B, -1, -1)
return self.mab(S_expanded, X)
class SetTransformerTorch(nn.Module):
"""Full Set Transformer with ISAB layers and PMA pooling."""
def __init__(self, d_input, d_model=128, n_heads=4, n_layers=3,
n_inducing=32, dropout=0.1):
super().__init__()
self.d_model = d_model
self.input_proj = nn.Linear(d_input, d_model)
self.isabs = nn.ModuleList([
ISAB(d_model, n_heads, n_inducing, d_model * 2, dropout)
for _ in range(n_layers)
])
self.pma = PMA(d_model, n_heads, 1, d_model * 2, dropout)
self.output_head = nn.Sequential(
nn.Linear(d_model, d_model // 2),
nn.ReLU(),
nn.Dropout(dropout),
nn.Linear(d_model // 2, 1),
)
def forward(self, x):
h = self.input_proj(x)
for isab in self.isabs:
h = isab(h)
pooled = self.pma(h)
pooled = pooled.squeeze(1)
return self.output_head(pooled).view(-1)
return MAB, ISAB, PMA, SetTransformerTorch
# ---------------------------------------------------------------------------
# sklearn fallback: Bag-of-Players aggregator
# ---------------------------------------------------------------------------
class _BagOfPlayers:
"""Sklearn-based team valuation using set-level aggregate features.
Instead of learning over the entire permutation-invariant set structure,
we compute fixed aggregate statistics per team:
- Per-role: mean, max, min, std of each numeric feature
- Pairwise cosine similarities between player embeddings
- Position entropy
- Budget allocation fractions
"""
def __init__(self, random_state: int = 42):
self.random_state = random_state
self.model = None
self.fitted = False
self._feature_names: List[str] = []
self._scaler = None
self._n_agg_features = 0
def _extract_features(self, team_df: pd.DataFrame) -> np.ndarray:
df = team_df.copy()
present_cols = [c for c in _EMBEDDING_FEATURES if c in df.columns]
if not present_cols:
present_cols = list(df.select_dtypes(include=[np.number]).columns)
if "role" not in df.columns:
df["role"] = "C"
df = df.fillna(0.0)
features: List[float] = []
# Per-role aggregates
for role in ROLES:
subset = df[df["role"] == role][present_cols]
if len(subset) == 0:
for col in present_cols:
features.extend([0.0, 0.0, 0.0, 0.0])
continue
values = subset.values.astype(np.float64)
for col_idx, _col in enumerate(present_cols):
col_vals = values[:, col_idx]
features.append(float(np.mean(col_vals)))
features.append(float(np.max(col_vals)))
features.append(float(np.min(col_vals)))
features.append(float(np.std(col_vals)) if len(col_vals) > 1 else 0.0)
# Role count features
n_players = len(df)
for role in ROLES:
features.append(float((df["role"] == role).sum()) / max(n_players, 1))
# Position entropy
role_counts = Counter(df["role"].tolist())
entropy = 0.0
for role in ROLES:
count = role_counts.get(role, 0)
if count > 0:
p = count / max(n_players, 1)
entropy -= p * math.log(p + 1e-10)
features.append(entropy)
features.append(float(n_players))
# Pairwise cosine similarities
if "projected_points" in df.columns and "market_value" in df.columns:
emb_cols = [c for c in ["projected_points", "market_value", "vor", "starter_prob"]
if c in df.columns]
if emb_cols:
emb = df[emb_cols].fillna(0.0).values.astype(np.float64)
emb_norm = emb / (np.linalg.norm(emb, axis=1, keepdims=True) + 1e-8)
sim_matrix = emb_norm @ emb_norm.T
n = sim_matrix.shape[0]
if n > 1:
triu_idx = np.triu_indices(n, k=1)
triu_vals = sim_matrix[triu_idx]
features.append(float(np.mean(triu_vals)))
features.append(float(np.max(triu_vals)))
features.append(float(np.min(triu_vals)))
features.append(float(np.std(triu_vals)))
else:
features.extend([0.0, 0.0, 0.0, 0.0])
else:
features.extend([0.0, 0.0, 0.0, 0.0])
# Cross-role interaction: dot products between role-group means
role_means = {}
for role in ROLES:
subset = df[df["role"] == role]
if len(subset) == 0 or "projected_points" not in subset.columns:
role_means[role] = np.zeros(len(present_cols))
else:
role_means[role] = subset[present_cols].fillna(0.0).mean().values
for i, r1 in enumerate(ROLES):
for r2 in ROLES[i + 1:]:
if np.linalg.norm(role_means[r1]) > 1e-8 and np.linalg.norm(role_means[r2]) > 1e-8:
dot = np.dot(role_means[r1], role_means[r2]) / max(
np.linalg.norm(role_means[r1]) * np.linalg.norm(role_means[r2]), 1e-8
)
else:
dot = 0.0
features.append(float(dot))
# Budget allocation features
if "market_value" in df.columns:
total_team_value = df["market_value"].sum()
for role in ROLES:
subset = df[df["role"] == role]
role_value = subset["market_value"].sum() if len(subset) > 0 else 0.0
features.append(role_value / max(total_team_value, 1))
else:
for role in ROLES:
features.append(0.0)
return np.array(features, dtype=np.float64)
def fit(self, teams_data: List[pd.DataFrame], team_values: List[float]):
from sklearn.ensemble import RandomForestRegressor
from sklearn.preprocessing import StandardScaler
if len(teams_data) == 0:
raise ValueError("teams_data cannot be empty")
X_list = [self._extract_features(df) for df in teams_data]
X = np.vstack(X_list)
y = np.array(team_values, dtype=np.float64).ravel()
if len(X) != len(y):
raise ValueError(f"Mismatch: {len(X)} teams vs {len(y)} values")
self._scaler = StandardScaler()
X_scaled = self._scaler.fit_transform(X)
self.model = RandomForestRegressor(
n_estimators=200,
max_depth=12,
min_samples_leaf=5,
random_state=self.random_state,
n_jobs=-1,
)
self.model.fit(X_scaled, y)
self.fitted = True
self._n_agg_features = X.shape[1]
# Log feature importance
if hasattr(self.model, "feature_importances_"):
top_n = min(10, len(self.model.feature_importances_))
top_idx = np.argsort(self.model.feature_importances_)[::-1][:top_n]
logger.info(
f"BagOfPlayers fitted: {len(teams_data)} teams, "
f"{X.shape[1]} features. Top features: {top_idx.tolist()}"
)
return self
def predict(self, team_df: pd.DataFrame) -> float:
if not self.fitted:
raise RuntimeError("Model not fitted. Call fit() first.")
feats = self._extract_features(team_df).reshape(1, -1)
feats_scaled = self._scaler.transform(feats)
pred = self.model.predict(feats_scaled)
return float(pred[0])
# ---------------------------------------------------------------------------
# Set Transformer main model
# ---------------------------------------------------------------------------
class SetTransformer(BaseModel):
"""Set-based team valuation model.
Values an entire Fantacalcio roster as a permutation-invariant set,
capturing non-additive synergies between players.
Args:
model_dir: directory for model persistence.
d_model: hidden dimension.
n_heads: attention heads.
n_layers: ISAB layers.
use_torch: force PyTorch mode (auto-detect by default).
"""
def __init__(
self,
model_dir: str = "models_trained",
d_model: int = 128,
n_heads: int = 4,
n_layers: int = 3,
use_torch: Optional[bool] = None,
):
super().__init__(model_dir)
self.d_model = d_model
self.n_heads = n_heads
self.n_layers = n_layers
self.use_torch = use_torch if use_torch is not None else _has_torch()
self._torch_model = None
self._sklearn_model: Optional[_BagOfPlayers] = None
self._d_input: Optional[int] = None
self._optimal_tau: float = 0.02
self._device: Optional[str] = None
self._trained = False
self._using_torch = False
def _init_torch_model(self, d_input: int):
import torch
_, _, _, SetTransformerTorch = _build_torch_classes()
self._torch_model = SetTransformerTorch(
d_input=d_input,
d_model=self.d_model,
n_heads=self.n_heads,
n_layers=self.n_layers,
n_inducing=32,
dropout=0.1,
)
self._device = "cuda" if torch.cuda.is_available() else "cpu"
self._torch_model.to(self._device)
self._d_input = d_input
self._using_torch = True
logger.info(f"SetTransformer (torch) initialized on {self._device}")
def _init_sklearn_model(self):
self._sklearn_model = _BagOfPlayers(random_state=42)
self._using_torch = False
logger.info("SetTransformer (sklearn fallback) initialized")
# ------------------------------------------------------------------
# Data preparation
# ------------------------------------------------------------------
def _prepare_set(self, team_df: pd.DataFrame) -> np.ndarray:
"""Extract player feature vectors from a team DataFrame.
Returns array of shape (n_players, d_input).
"""
df = team_df.copy()
present_cols = [c for c in _EMBEDDING_FEATURES if c in df.columns]
if not present_cols:
present_cols = list(df.select_dtypes(include=[np.number]).columns)
df = df.fillna(0.0)
if "projected_points" not in df.columns and len(present_cols) > 0:
df["projected_points"] = df[present_cols[0]]
for col in _EMBEDDING_FEATURES:
if col not in df.columns:
df[col] = 0.0
features = df[_EMBEDDING_FEATURES].values.astype(np.float64)
return features
def _collate_sets(self, teams_data: List[pd.DataFrame]) -> List[np.ndarray]:
return [self._prepare_set(df) for df in teams_data]
# ------------------------------------------------------------------
# Fit
# ------------------------------------------------------------------
def fit(self, teams_data: List[pd.DataFrame], team_values: List[float], **kwargs):
"""Fit the Set Transformer on team-level data.
Args:
teams_data: list of DataFrames, one per team, each containing
player-level features.
team_values: list of total season points for each team.
"""
if len(teams_data) == 0:
raise ValueError("teams_data cannot be empty")
if len(teams_data) != len(team_values):
raise ValueError(
f"Length mismatch: {len(teams_data)} teams, {len(team_values)} values"
)
if self.use_torch and _has_torch():
return self._fit_torch(teams_data, team_values, **kwargs)
else:
if self.use_torch and not _has_torch():
logger.warning("torch requested but not installed. Falling back to sklearn.")
return self._fit_sklearn(teams_data, team_values)
def _fit_torch(
self,
teams_data: List[pd.DataFrame],
team_values: List[float],
epochs: int = 200,
batch_size: int = 16,
lr: float = 1e-3,
weight_decay: float = 1e-5,
) -> "SetTransformer":
import torch
import torch.nn as nn
import torch.optim as optim
sets = self._collate_sets(teams_data)
d_input = sets[0].shape[1]
self._init_torch_model(d_input)
y = np.array(team_values, dtype=np.float64)
y_mean = y.mean()
y_std = max(y.std(), 1e-8)
y_norm = (y - y_mean) / y_std
self._y_mean = y_mean
self._y_std = y_std
model = self._torch_model
optimizer = optim.Adam(model.parameters(), lr=lr, weight_decay=weight_decay)
loss_fn = nn.MSELoss()
n_samples = len(sets)
best_loss = float("inf")
best_state = model.state_dict()
for epoch in range(epochs):
perm = torch.randperm(n_samples)
epoch_loss = 0.0
for start in range(0, n_samples, batch_size):
idx = perm[start:start + batch_size]
batch_loss = torch.tensor(0.0, device=self._device)
for i in idx:
x_np = sets[i.item()]
x_t = torch.tensor(
x_np, dtype=torch.float32, device=self._device
).unsqueeze(0)
y_t = torch.tensor(
[float(y_norm[i.item()])], dtype=torch.float32, device=self._device
)
pred = model(x_t)
loss = loss_fn(pred, y_t)
batch_loss = batch_loss + loss
batch_loss = batch_loss / len(idx)
optimizer.zero_grad()
batch_loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0)
optimizer.step()
epoch_loss += batch_loss.item() * len(idx)
epoch_loss /= n_samples
if epoch_loss < best_loss:
best_loss = epoch_loss
best_state = {k: v.cpu().clone() for k, v in model.state_dict().items()}
if (epoch + 1) % max(epochs // 10, 1) == 0:
logger.info(f"Epoch {epoch + 1}/{epochs} | loss={epoch_loss:.6f}")
if best_state is not None:
model.load_state_dict(best_state)
self._trained = True
logger.info(
f"SetTransformer (torch) trained: {n_samples} teams, "
f"best_loss={best_loss:.6f}, d_model={self.d_model}"
)
return self
def _fit_sklearn(
self, teams_data: List[pd.DataFrame], team_values: List[float]
) -> "SetTransformer":
self._init_sklearn_model()
self._sklearn_model.fit(teams_data, team_values)
self._trained = True
self._using_torch = False
return self
# ------------------------------------------------------------------
# Predict
# ------------------------------------------------------------------
def predict(self, team_roster_df: pd.DataFrame):
"""Predict total team value (season-long points)."""
if not self._trained:
raise RuntimeError("Model not fitted. Call fit() first.")
val = self._predict_single(team_roster_df)
return val
def _predict_single(self, team_roster_df: pd.DataFrame) -> float:
if self._using_torch and self._torch_model is not None:
import torch
x_np = self._prepare_set(team_roster_df)
if len(x_np) == 0:
return 0.0
x_t = torch.tensor(x_np, dtype=torch.float32, device=self._device).unsqueeze(0)
with torch.no_grad():
raw = self._torch_model(x_t)
val = raw.item() * getattr(self, "_y_std", 1.0) + getattr(self, "_y_mean", 0.0)
return float(val)
elif self._sklearn_model is not None:
return self._sklearn_model.predict(team_roster_df)
return 0.0
def predict_batch(self, teams: List[pd.DataFrame]) -> np.ndarray:
if not self._trained:
raise RuntimeError("Model not fitted. Call fit() first.")
return np.array([self._predict_single(df) for df in teams])
# ------------------------------------------------------------------
# Marginal value analysis
# ------------------------------------------------------------------
def value_added(self, team_roster_df: pd.DataFrame, new_player) -> float:
"""Marginal value: delta when adding new_player to the team."""
baseline = self._predict_single(team_roster_df)
if isinstance(new_player, pd.DataFrame):
augmented = pd.concat([team_roster_df, new_player], ignore_index=True)
else:
augmented = pd.concat(
[team_roster_df, pd.DataFrame([new_player])], ignore_index=True
)
augmented_val = self._predict_single(augmented)
return augmented_val - baseline
def value_removed(self, team_roster_df: pd.DataFrame, removed_player) -> float:
"""Marginal loss: delta when removing a player (by index or name)."""
baseline = self._predict_single(team_roster_df)
if isinstance(removed_player, str):
reduced = team_roster_df[team_roster_df["name"] != removed_player]
else:
reduced = team_roster_df.drop(team_roster_df.index[removed_player])
reduced_val = self._predict_single(reduced)
return baseline - reduced_val
def optimal_replacement(
self,
team_roster_df: pd.DataFrame,
candidate_pool: pd.DataFrame,
to_replace: List,
) -> Dict:
"""For each player to replace, rank candidates by predicted team value delta.
Args:
team_roster_df: current team roster.
candidate_pool: DataFrame of free-agent candidates.
to_replace: list of player names (str) or indices (int) in team_roster_df
to consider replacing.
Returns:
dict mapping player_name -> DataFrame of candidates ranked by delta.
"""
results = {}
for rp in to_replace:
if isinstance(rp, str):
base_team = team_roster_df[team_roster_df["name"] != rp]
key = rp
else:
base_team = team_roster_df.drop(team_roster_df.index[rp])
key = rp
deltas = []
for _, cand in candidate_pool.iterrows():
cand_dict = cand.to_dict()
new_team = pd.concat(
[base_team, pd.DataFrame([cand_dict])], ignore_index=True
)
new_val = self._predict_single(new_team)
current_val = self._predict_single(team_roster_df)
deltas.append({
"candidate": cand.get("name", str(cand.name)),
"role": cand.get("role", ""),
"projected_points": cand.get("projected_points", 0),
"team_value_delta": new_val - current_val,
})
results[key] = pd.DataFrame(deltas).sort_values(
"team_value_delta", ascending=False
)
return results
# ------------------------------------------------------------------
# Roster analysis
# ------------------------------------------------------------------
def get_role_synergies(self, team_roster_df: pd.DataFrame) -> pd.DataFrame:
"""Analyze which role combinations maximize team value.
Systematically tests removing one role at a time and measures
the value contribution per role. Returns a DataFrame with
per-role synergy scores.
"""
baseline = self._predict_single(team_roster_df)
results = []
for role in ROLES:
role_players = team_roster_df[team_roster_df["role"] == role]
if len(role_players) == 0:
synergy = 0.0
per_player = 0.0
else:
without_role = team_roster_df[team_roster_df["role"] != role]
without_val = self._predict_single(without_role) if len(without_role) > 0 else 0.0
synergy = baseline - without_val
per_player = synergy / len(role_players)
results.append({
"role": role,
"n_players": len(role_players),
"absolute_synergy": synergy,
"synergy_per_player": per_player,
"synergy_pct": (synergy / max(baseline, 1)) * 100,
})
return pd.DataFrame(results).sort_values("absolute_synergy", ascending=False)
def get_redundancy_score(self, team_roster_df: pd.DataFrame) -> float:
"""Compute 0-1 redundancy score for the roster.
High redundancy = lots of overlapping skill sets = diminishing
returns beyond sum of individual projections.
Uses the ratio of (sum of individual values) / (team value) as
a proxy: if team value << sum of parts, players are redundant.
Returns:
float between 0 (perfect synergy) and 1 (maximum redundancy).
"""
team_val = self._predict_single(team_roster_df)
if team_val <= 0:
return 0.0
individual_sum = 0.0
for _, player in team_roster_df.iterrows():
solo = pd.DataFrame([player.to_dict()])
individual_sum += self._predict_single(solo)
if individual_sum <= 0:
return 0.0
ratio = team_val / individual_sum
# Map: ratio ~1 means additive (no synergy, no redundancy)
# ratio >1 means synergy
# ratio <<1 means redundancy
redundancy = np.clip(1.0 - ratio, 0.0, 1.0)
return float(redundancy)
+354
View File
@@ -0,0 +1,354 @@
"""Minutes-played survival model via Weibull AFT.
Models the distribution of minutes played per matchweek using Weibull
Accelerated Failure Time. Supports both lifelines (preferred) and a pure-scipy
MLE fallback so the module works in minimal environments.
Provides:
- Expected minutes / confidence intervals
- Starter probability (≥60 min)
- Full-match probability (90 min)
"""
import logging
import math
from typing import Optional, Tuple
import numpy as np
import pandas as pd
from sklearn.linear_model import LinearRegression
from sklearn.preprocessing import StandardScaler
from .base_model import BaseModel
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Feature-selection keyword list
# ---------------------------------------------------------------------------
_MINUTE_KEYWORDS = [
"minute", "game", "rest", "fatigue", "age", "injury",
"played", "starter", "bench", "appearance",
"recovery", "rotation", "squad", "season",
"match", "form", "fitness",
]
def _select_survival_features(X: pd.DataFrame) -> list:
"""Pick columns whose name contains any survival-relevant keyword."""
lower_cols = {c: str(c).lower() for c in X.columns}
selected = [
c for c, cl in lower_cols.items()
if any(kw in cl for kw in _MINUTE_KEYWORDS)
]
if not selected:
selected = list(X.select_dtypes(include=[np.number]).columns[:20])
logger.info("No keyword-matched survival features; using first 20 numeric columns")
else:
logger.info(f"Selected {len(selected)} survival features via keyword matching")
return selected
# ===================================================================
# Weibull helper functions for the scipy fallback
# ===================================================================
def _weibull_log_likelihood(params, X, t, event, eps=1e-10):
"""Negative log-likelihood for Weibull AFT model.
Parameters
----------
params : ndarray (p_features + 1,)
First p entries: beta (coefficients for X).
Last entry: log_k (log shape parameter ensures k > 0).
X : ndarray (n, p)
Scaled feature matrix.
t : ndarray (n,)
Observed durations (minutes played).
event : ndarray (n,)
0 → exact failure (subbed off), 1 → right-censored (completed 90).
eps : float
Small epsilon for numerical stability.
Returns
-------
neg_ll : float
Negative log-likelihood (to be minimized).
"""
p = X.shape[1]
beta = params[:p]
log_k = params[p]
k = np.exp(log_k) + eps
log_lambda = X.dot(beta) # log(λ_i) = X_i * beta
lambda_ = np.exp(log_lambda) + eps
log_t = np.log(np.maximum(t, eps))
z = t / lambda_
# Log-PDF for uncensored (event == 0)
log_pdf = np.log(k) - log_lambda + (k - 1.0) * (log_t - log_lambda) - z ** k
# Log-SF for censored (event == 1)
log_sf = -(z ** k)
# event==1 → censored → use SF; event==0 → observed → use PDF
ll = np.where(event == 1, log_sf, log_pdf)
return -ll.sum()
def _fit_weibull_mle(X, t, event):
"""Fit Weibull AFT via scipy MLE.
Returns
-------
beta : ndarray (p,)
Feature coefficients (scaled to original duration range).
k : float
Shape parameter.
t_scale : float
Scale factor to convert normalized predictions back to minutes.
"""
from scipy.optimize import minimize
n, p = X.shape
t_scale = max(t.max(), 1.0)
t_norm = np.clip(t / t_scale, 1e-6, 1.0)
log_t_norm = np.log(np.maximum(t_norm, 1e-9))
lr = LinearRegression(fit_intercept=False)
lr.fit(X, log_t_norm)
beta0 = np.clip(lr.coef_.copy(), -5, 5)
bounds = [(-10, 10)] * p + [(-5, 3)]
init = np.concatenate([beta0, [0.0]])
result = minimize(
_weibull_log_likelihood,
init,
args=(X, t_norm, event),
method="L-BFGS-B",
bounds=bounds,
options={"maxiter": 2000, "ftol": 1e-10},
)
if not result.success:
logger.warning(f"Weibull MLE did not converge: {result.message}")
beta = result.x[:p]
k = max(np.exp(result.x[p]), 1e-4)
return beta, k, t_scale
# ===================================================================
# MinutesSurvivalModel
# ===================================================================
class MinutesSurvivalModel(BaseModel):
"""Weibull AFT model for minutes-played distribution.
Parameters
----------
model_dir : str
Directory for persisting trained models.
force_scipy : bool
If True, use the pure-scipy MLE fallback even when lifelines
is installed.
"""
def __init__(
self,
model_dir: str = "models_trained",
force_scipy: bool = False,
):
super().__init__(model_dir)
self.force_scipy = force_scipy
self.scaler = StandardScaler()
self.feature_names = None
# Weibull parameters
self._beta = None # feature coefficients → log(λ)
self._k = None # shape parameter
self._afitter = None # lifelines WeibullAFTFitter instance (if used)
self._t_scale = 90.0
self._use_lifelines = False
# ------------------------------------------------------------------
# Fit
# ------------------------------------------------------------------
def fit(
self,
X: pd.DataFrame,
durations: np.ndarray,
events: np.ndarray,
**kwargs,
):
"""Fit the Weibull AFT model.
Args:
X: Feature matrix (one row per player-match).
durations: Minutes played (0–90); `y` alias for BaseModel compat.
events:
0 → exact duration observed (subbed off before 90).
1 → right-censored (player completed the full 90 minutes).
"""
self.feature_names = _select_survival_features(X)
X_clean = X[self.feature_names].select_dtypes(include=[np.number]).fillna(0)
X_scaled = self.scaler.fit_transform(X_clean)
durations = np.asarray(durations, dtype=float)
events = np.asarray(events, dtype=int)
# Try lifelines first --------------------------------------------------
if not self.force_scipy:
try:
import lifelines # noqa: F401
from lifelines import WeibullAFTFitter
df = pd.DataFrame(X_scaled, columns=self.feature_names)
df["duration"] = durations
df["event"] = events
aft = WeibullAFTFitter()
aft.fit(df, duration_col="duration", event_col="event")
self._afitter = aft
self._use_lifelines = True
self._t_scale = 1.0 # lifelines works in original duration scale
logger.info(
"MinutesSurvivalModel fitted via lifelines "
f"(n={len(durations)}, features={len(self.feature_names)})"
)
return self
except ImportError:
logger.info("lifelines not installed; falling back to scipy MLE")
except Exception as exc:
logger.warning(f"lifelines failed ({exc}); falling back to scipy MLE")
# Scipy fallback -------------------------------------------------------
self._use_lifelines = False
self._beta, self._k, self._t_scale = _fit_weibull_mle(X_scaled, durations, events)
logger.info(
f"MinutesSurvivalModel fitted via scipy MLE "
f"(n={len(durations)}, features={len(self.feature_names)}, "
f"k={self._k:.3f})"
)
return self
# ------------------------------------------------------------------
# Predict (BaseModel interface — returns expected minutes)
# ------------------------------------------------------------------
def predict(self, X: pd.DataFrame) -> np.ndarray:
"""Return expected minutes (E[T]) — BaseModel interface."""
return self.predict_expected_minutes(X)
# ------------------------------------------------------------------
# Preprocessing
# ------------------------------------------------------------------
def _preprocess(self, X: pd.DataFrame) -> np.ndarray:
if self.feature_names is None:
raise RuntimeError("Model not trained. Call fit() first.")
X_c = X[self.feature_names].select_dtypes(include=[np.number]).fillna(0)
return self.scaler.transform(X_c)
# ------------------------------------------------------------------
# Core distribution
# ------------------------------------------------------------------
def predict_distribution(
self, X: pd.DataFrame
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Return (expected_minutes, lower_bound, upper_bound).
``lower_bound`` and ``upper_bound`` are approximate 95 % confidence
intervals derived from the Weibull variance.
"""
X_scaled = self._preprocess(X)
if self._use_lifelines and self._afitter is not None:
df = pd.DataFrame(X_scaled, columns=self.feature_names)
# Lifelines returns median survival in its summary; we approximate
# expected minutes using the median and estimated shape.
median = self._afitter.predict_median(df).values.flatten()
# Heuristic: for Weibull, E[T] ≈ median / (ln 2)^(1/k).
# Derive k from the lifelines summary if possible, else guess ~1.
try:
summary = self._afitter.summary
log_k = summary.loc["lambda_", "coef"]
k = 1.0 / np.exp(log_k) if abs(log_k) > 1e-8 else 1.0
except Exception:
k = 1.0
expected = median * np.exp(np.log(np.log(2)) / k)
# Std via coefficient of variation
coef_var = np.sqrt(np.exp(
np.log(math.gamma(1 + 2 / k)) - 2 * np.log(math.gamma(1 + 1 / k))
))
std = expected * coef_var
lower = np.maximum(0, expected - 1.96 * std)
upper = np.minimum(90, expected + 1.96 * std)
return expected, lower, upper
# Scipy / stored parameters (normalized scale, convert to minutes)
log_lambda = X_scaled.dot(self._beta)
lambda_ = np.exp(log_lambda) * self._t_scale
k = self._k
# Expected value: λ * Γ(1 + 1/k)
gamma_1 = math.gamma(1.0 + 1.0 / k)
expected = lambda_ * gamma_1
# Variance = λ² * (Γ(1+2/k) - Γ²(1+1/k))
gamma_2 = math.gamma(1.0 + 2.0 / k)
var = (lambda_ ** 2) * (gamma_2 - gamma_1 ** 2)
std = np.sqrt(np.maximum(var, 0.01))
lower = np.maximum(0, expected - 1.96 * std)
upper = np.minimum(90, expected + 1.96 * std)
expected = np.clip(expected, 0, 90)
return expected, lower, upper
def predict_expected_minutes(self, X: pd.DataFrame) -> np.ndarray:
"""Return E[minutes] for each row."""
expected, _, _ = self.predict_distribution(X)
return expected
def predict_full_match_probability(self, X: pd.DataFrame) -> np.ndarray:
"""Probability the player completes 90 minutes: P(T ≥ 90)."""
X_scaled = self._preprocess(X)
if self._use_lifelines and self._afitter is not None:
try:
surv = self._afitter.predict_survival_function(
pd.DataFrame(X_scaled, columns=self.feature_names),
times=[90.0],
)
return 1.0 - surv.values.flatten()
except Exception:
pass
log_lambda = X_scaled.dot(self._beta)
lambda_ = np.exp(log_lambda) * self._t_scale
surv = np.exp(-((90.0 / lambda_) ** self._k))
prob = 1.0 - surv
return np.clip(prob, 0.0, 1.0)
def predict_starter_probability(
self, X: pd.DataFrame, min_minutes: float = 60.0
) -> np.ndarray:
"""Probability player plays at least *min_minutes* (default 60).
Useful as a "likely starter" proxy.
"""
X_scaled = self._preprocess(X)
if self._use_lifelines and self._afitter is not None:
try:
surv = self._afitter.predict_survival_function(
pd.DataFrame(X_scaled, columns=self.feature_names),
times=[min_minutes],
)
return surv.values.flatten()
except Exception:
pass
log_lambda = X_scaled.dot(self._beta)
lambda_ = np.exp(log_lambda) * self._t_scale
surv = np.exp(-((min_minutes / lambda_) ** self._k))
return np.clip(surv, 0.0, 1.0)
+13
View File
@@ -1 +1,14 @@
"""Optimization modules for auction and lineup selection.""" """Optimization modules for auction and lineup selection."""
from .auction_solver import AuctionSolver, AuctionConfig, PlayerValuation
from .lineup_solver import LineupSolver, LineupConstraints, PlayerScore, MCTSNode
from .opponent_model import OpponentModel
from .transfer_analyzer import TransferAnalyzer
# Phase 2: Bandit, opponent bidding, budget optimization
from .bandit_auction import BanditAuctionSolver
from .opponent_bidding_model import OpponentBidModel
from .budget_optimizer import BudgetOptimizer
# Phase 3: Reinforcement learning auction agent
from .rl_auction_agent import AuctionEnv, RLAuctionPolicy, RLAuctionTrainer, QNetwork
+359
View File
@@ -0,0 +1,359 @@
"""Contextual Multi-Armed Bandit for live auction bidding decisions.
Uses Thompson Sampling with Beta-distributed posteriors over discrete bid levels.
Falls back to UCB when exploration depth is insufficient.
Integrates with AuctionConfig from auction_solver.py.
"""
import logging
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple
import numpy as np
import pandas as pd
from scipy.stats import beta as beta_dist
logger = logging.getLogger(__name__)
# Default bid arms as fractions of total budget
DEFAULT_BID_ARMS = np.array(
[0.0, 0.005, 0.01, 0.02, 0.03, 0.05, 0.08, 0.12, 0.18, 0.25],
dtype=np.float64,
)
ROLES = ["P", "D", "C", "A"]
ROLE_SCARCITY = {"P": 3, "D": 8, "C": 8, "A": 6}
ROLE_POOL_SIZE = {"P": 4, "D": 22, "C": 24, "A": 12}
@dataclass
class BanditArmState:
alpha: float = 1.0
beta: float = 1.0
trials: int = 0
wins: float = 0.0
@dataclass
class AuctionState:
budget_remaining: float = 500.0
total_budget: float = 500.0
slots_filled: Dict[str, int] = field(default_factory=lambda: {"P": 0, "D": 0, "C": 0, "A": 0})
slots_total: Dict[str, int] = field(default_factory=lambda: {"P": 3, "D": 8, "C": 8, "A": 6})
round_number: int = 1
opponent_budgets: List[float] = field(default_factory=list)
@dataclass
class PlayerContext:
name: str
role: str
projected_points: float
role_scarcity: float
value_over_replacement: float
budget_remaining_fraction: float
slots_remaining_in_role: int
round_number: int
opponent_budget_avg: float
def _get_attr(obj, key, default=None):
"""Get attribute or dict key from an object."""
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
class BanditAuctionSolver:
"""Thompson Sampling bandit for live auction bid selection.
Arms are discrete bid fractions. Each (role, scarcity_level) maintains
independent Beta posteriors. Falls back to UCB when total observations
for a context group are < 50.
"""
def __init__(
self,
bid_arms: Optional[np.ndarray] = None,
total_budget: float = 500.0,
min_obs_for_ts: int = 50,
ucb_exploration: float = 1.414,
config=None,
):
if config is not None:
from .auction_solver import AuctionConfig
total_budget = config.total_budget
self.bid_arms = bid_arms if bid_arms is not None else DEFAULT_BID_ARMS
self.n_arms = len(self.bid_arms)
self.total_budget = total_budget
self.min_obs_for_ts = min_obs_for_ts
self.ucb_exploration = ucb_exploration
self.bid_fractions = self.bid_arms
self.posteriors: Dict[Tuple[str, int], List[BanditArmState]] = {}
for role in ROLES:
self._ensure_posteriors(role, 1)
def _context_key(self, role: str, scarcity_level: int) -> Tuple[str, int]:
return (role, scarcity_level)
def _ensure_posteriors(self, role: str, n_slots_remaining: int):
scarcity = max(1, n_slots_remaining)
key = self._context_key(role, scarcity)
if key not in self.posteriors:
self.posteriors[key] = [
BanditArmState(alpha=1.0, beta=1.0, trials=0, wins=0.0)
for _ in range(self.n_arms)
]
logger.debug(f"Initialized bandit posteriors for role={role}, scarcity={scarcity}")
def _total_obs(self, key: Tuple[str, int]) -> int:
arms = self.posteriors.get(key, [])
return sum(a.trials for a in arms)
def compute_context(
self,
player,
auction_state,
pool_stats: Optional[dict] = None,
) -> PlayerContext:
"""Build context vector for a player given current auction state.
Args:
player: dict or object with name, role, projected_points attributes.
auction_state: current AuctionState (or dict with same keys).
pool_stats: optional dict with role-level pool means and stds.
Returns:
PlayerContext dataclass with all context features.
"""
role = _get_attr(player, "role")
points = float(_get_attr(player, "projected_points", 6.5))
name = _get_attr(player, "name", "unknown")
total_slots = _get_attr(auction_state, "slots_total", {}).get(role, 1)
filled = _get_attr(auction_state, "slots_filled", {}).get(role, 0)
slots_remaining = max(total_slots - filled, 0)
scarcity = max(slots_remaining / max(total_slots, 1), 0.05)
budget_remaining = _get_attr(auction_state, "budget_remaining", 500.0)
total_budget_attr = _get_attr(auction_state, "total_budget", 500.0)
budget_fraction = budget_remaining / max(total_budget_attr, 1)
opponent_budget_avg = 0.0
opponent_budgets = _get_attr(auction_state, "opponent_budgets", [])
if opponent_budgets:
opponent_budget_avg = float(np.mean(opponent_budgets))
elif total_budget_attr > 0:
opponent_budget_avg = total_budget_attr * 0.6
if pool_stats and role in pool_stats:
role_mean = pool_stats[role].get("mean", 0.0)
role_std = pool_stats[role].get("std", 1.0)
points_z = (points - role_mean) / max(role_std, 0.01) if role_std > 0 else 0.0
else:
points_z = points / 15.0
vor = max(points - 6.5, 0.0)
return PlayerContext(
name=name,
role=role,
projected_points=points,
role_scarcity=scarcity,
value_over_replacement=vor,
budget_remaining_fraction=budget_fraction,
slots_remaining_in_role=slots_remaining,
round_number=_get_attr(auction_state, "round_number", 1),
opponent_budget_avg=opponent_budget_avg,
)
def select_bid(
self,
player,
auction_state,
pool_stats: Optional[dict] = None,
) -> Tuple[int, float]:
"""Select bid arm using Thompson Sampling (or UCB fallback).
Returns:
(arm_index, bid_amount_in_credits)
"""
ctx = self.compute_context(player, auction_state, pool_stats)
role = ctx.role
n_slots = ctx.slots_remaining_in_role
self._ensure_posteriors(role, n_slots)
key = self._context_key(role, n_slots)
arms = self.posteriors[key]
total_obs = sum(a.trials for a in arms)
if total_obs >= self.min_obs_for_ts:
samples = [float(np.random.beta(a.alpha, max(a.beta, 0.01))) for a in arms]
arm_idx = int(np.argmax(samples))
logger.debug(
f"Thompson Sampling: role={role}, arms_sampled={samples[:5]}..., "
f"selected_arm={arm_idx}"
)
else:
values = []
for _i, arm in enumerate(arms):
if arm.trials == 0:
values.append(float("inf"))
else:
mean = arm.wins / arm.trials
bonus = self.ucb_exploration * np.sqrt(
np.log(max(total_obs, 1)) / arm.trials
)
values.append(mean + bonus)
arm_idx = int(np.argmax(values))
logger.debug(
f"UCB fallback: role={role}, total_obs={total_obs}, "
f"selected_arm={arm_idx}"
)
bid_amount = round(self.bid_arms[arm_idx] * self.total_budget)
budget_rem = _get_attr(auction_state, "budget_remaining", self.total_budget)
if bid_amount > budget_rem:
bid_amount = budget_rem
arm_idx = int(np.argmin(np.abs(self.bid_arms * self.total_budget - bid_amount)))
return arm_idx, bid_amount
def update(self, arm_idx: int, reward: float, player_role: str):
"""Update Beta posterior for the selected arm.
Reward should be a normalized value: (player_season_value - cost) scaled.
Args:
arm_idx: index of the selected arm.
reward: normalized reward signal (higher = better purchase).
player_role: role of the purchased player.
"""
reward_clipped = max(0.0, min(1.0, reward))
win = 1.0 if reward > 0 else 0.0
for key, arms in self.posteriors.items():
role, _scarcity = key
if role == player_role:
if arm_idx < len(arms):
arm = arms[arm_idx]
arm.trials += 1
arm.wins += win
arm.alpha += reward_clipped
arm.beta += (1.0 - reward_clipped)
logger.debug(
f"Updated arm {arm_idx} for {player_role}: "
f"trials={arm.trials}, alpha={arm.alpha:.2f}, beta={arm.beta:.2f}"
)
for key, arms in self.posteriors.items():
role, _scarcity = key
if role == player_role:
for i, arm in enumerate(arms):
if i == arm_idx:
continue
arm.beta = max(arm.beta, 1.001)
def get_arm_stats(self) -> Dict[str, dict]:
"""Return arm statistics for analysis.
Returns:
dict mapping "role/scarcity/arm_idx" -> stats dict.
"""
stats = {}
agg = {}
for (role, scarcity), arms in self.posteriors.items():
for idx, arm in enumerate(arms):
label = f"{role}/scarcity={scarcity}/arm={idx}"
stats[label] = {
"trials": arm.trials,
"wins": arm.wins,
"alpha": arm.alpha,
"beta": arm.beta,
"win_rate": arm.wins / max(arm.trials, 1),
"bid_fraction": float(self.bid_arms[idx]),
"bid_amount": float(self.bid_arms[idx] * self.total_budget),
}
if idx not in agg:
agg[idx] = {"trials": 0, "wins": 0.0, "alpha": 0.0, "beta": 0.0}
agg[idx]["trials"] += arm.trials
agg[idx]["wins"] += arm.wins
agg[idx]["alpha"] += arm.alpha
agg[idx]["beta"] += arm.beta
for idx, a in agg.items():
stats[idx] = {
"trials": a["trials"],
"wins": a["wins"],
"alpha": a["alpha"],
"beta": a["beta"],
"win_rate": a["wins"] / max(a["trials"], 1),
"bid_fraction": float(self.bid_arms[idx]),
"bid_amount": float(self.bid_arms[idx] * self.total_budget),
}
return stats
def exploration_bonus(self, player_role: str, player_points: float = 0.0) -> float:
"""Compute exploration bonus for new/unknown player types.
Higher bonus when the bandit has limited experience with a role.
Encourages exploring sleeper players.
Returns:
Recommended extra bid amount in credits.
"""
total_obs = 0
for (role, _scarcity), arms in self.posteriors.items():
if role == player_role:
total_obs += sum(a.trials for a in arms)
if total_obs == 0:
bonus = self.total_budget * 0.04
elif total_obs < 20:
bonus = self.total_budget * 0.025
elif total_obs < 50:
bonus = self.total_budget * 0.01
else:
bonus = 0.0
if bonus > 0:
base = max(player_points * 0.5, 0)
bonus += base * (1.0 / max(total_obs, 1)) * 20
logger.debug(f"Exploration bonus for {player_role}: {bonus:.1f} (obs={total_obs})")
return bonus
def recommend_bid_summary(
self,
player,
auction_state,
pool_stats: Optional[dict] = None,
) -> dict:
"""Full bidding recommendation for a player.
Returns a dict with arm index, bid amount, context, exploration bonus,
and total recommended bid.
"""
arm_idx, bid_amount = self.select_bid(player, auction_state, pool_stats)
ctx = self.compute_context(player, auction_state, pool_stats)
bonus = self.exploration_bonus(ctx.role, ctx.projected_points)
return {
"player": _get_attr(player, "name", "unknown"),
"role": ctx.role,
"arm_index": arm_idx,
"base_bid": bid_amount,
"exploration_bonus": round(bonus),
"total_bid": round(bid_amount + bonus),
"recommended_bid": round(bid_amount + bonus),
"context": {
"points_zscore": round(
(ctx.projected_points - 6.5) / 2.0, 2
),
"role_scarcity": round(ctx.role_scarcity, 3),
"budget_remaining_frac": round(ctx.budget_remaining_fraction, 3),
"slots_remaining": ctx.slots_remaining_in_role,
"round": ctx.round_number,
"vor": round(ctx.value_over_replacement, 1),
},
}
+538
View File
@@ -0,0 +1,538 @@
"""Bayesian Optimization for role-level budget allocation.
Uses Gaussian Process regression to find optimal budget distribution
across roles (P, D, C, A) that maximizes total projected team value.
"""
import logging
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple
import numpy as np
import pandas as pd
from scipy.optimize import minimize
from scipy.special import softmax
from src.optimization.auction_solver import AuctionConfig
logger = logging.getLogger(__name__)
DEFAULT_ROSTER_QUOTAS = {"P": 3, "D": 8, "C": 8, "A": 6}
@dataclass
class RoleBudgetResult:
allocation: Dict[str, float]
total_value: float
role_values: Dict[str, float]
value_curves: Dict[str, Tuple[np.ndarray, np.ndarray]]
class BudgetOptimizer:
"""Bayesian Optimization for budget allocation across roster roles.
Finds the split of total_budget across P/D/C/A that yields the
highest possible team points via greedy fill within each role's budget.
Supports mid-auction adaptive rebalancing.
"""
def __init__(
self,
total_budget: float = 500.0,
roster_quotas: Optional[Dict[str, int]] = None,
auction_config: Optional[AuctionConfig] = None,
random_state: int = 42,
):
self.total_budget = total_budget
self.roster_quotas = roster_quotas or dict(DEFAULT_ROSTER_QUOTAS)
self.auction_config = auction_config or AuctionConfig()
self.random_state = random_state
self.rng = np.random.RandomState(random_state)
self._last_allocation: Optional[Dict[str, float]] = None
self._last_value: float = 0.0
self._optimization_history: List[dict] = []
self._gk_available = False
# ------------------------------------------------------------------
# Core optimization
# ------------------------------------------------------------------
def optimize(
self,
player_pool_df: pd.DataFrame,
n_calls: int = 50,
use_skopt: bool = True,
) -> Dict[str, float]:
"""Optimize budget allocation across roles.
Uses scikit-optimize GaussianProcessRegressor if available,
otherwise simplex-based local search.
Args:
player_pool_df: DataFrame with columns [name, role, projected_points].
n_calls: number of GP evaluations.
use_skopt: attempt Gaussian Process optimization.
Returns:
Dict mapping role -> recommended budget amount.
"""
if "role" not in player_pool_df.columns or "projected_points" not in player_pool_df.columns:
raise ValueError("player_pool_df must have 'role' and 'projected_points' columns")
roles = list(self.roster_quotas.keys())
n_roles = len(roles)
if use_skopt:
try:
return self._optimize_gp(player_pool_df, roles, n_calls)
except ImportError:
logger.info("scikit-optimize not installed. Using local search.")
except Exception as exc:
logger.warning(f"GP optimization failed: {exc}. Using local search.")
return self._optimize_local(player_pool_df, roles, n_calls)
def _optimize_gp(
self, player_pool_df: pd.DataFrame, roles: list, n_calls: int
) -> Dict[str, float]:
"""Gaussian Process-based budget optimization."""
from skopt import gp_minimize
from skopt.space import Space
from skopt.learning import GaussianProcessRegressor
n_roles = len(roles)
space = Space([(0.01, 0.70) for _ in range(n_roles)])
def objective_wrapper(fractions):
fractions = np.array(fractions, dtype=float)
fractions = self._normalize_fractions(fractions)
value = self._objective(fractions, roles, player_pool_df)
self._optimization_history.append({
"fractions": fractions.tolist(),
"value": value,
})
return -value
def params_to_fractions(params):
return self._normalize_fractions(np.array(params, dtype=float))
result = gp_minimize(
objective_wrapper,
space,
n_calls=n_calls,
random_state=self.random_state,
n_initial_points=max(10, n_calls // 5),
verbose=False,
n_jobs=-1,
)
best_fractions = params_to_fractions(result.x)
self._last_allocation = self._fractions_to_allocation(best_fractions, roles)
self._last_value = -result.fun
logger.info(
f"GP Budget optimization complete: {self._last_allocation} "
f"=> value={self._last_value:.1f}"
)
return self._last_allocation
def _optimize_local(
self, player_pool_df: pd.DataFrame, roles: list, n_calls: int
) -> Dict[str, float]:
"""Local search optimization using Nelder-Mead simplex."""
n_roles = len(roles)
best_allocation = None
best_value = -float("inf")
for restart in range(max(n_calls // 10, 1)):
x0 = self._random_allocation(n_roles)
def simplex_objective(fractions):
fractions = self._normalize_fractions(np.array(fractions, dtype=float))
value = self._objective(fractions, roles, player_pool_df)
self._optimization_history.append({
"fractions": fractions.tolist(),
"value": value,
})
return -value
res = minimize(
simplex_objective,
x0,
method="Nelder-Mead",
options={"maxiter": max(n_calls // 3, 20), "xatol": 1e-3, "fatol": 1e-3},
)
fractions = self._normalize_fractions(np.array(res.x, dtype=float))
value = -res.fun
if value > best_value:
best_value = value
best_allocation = fractions
if best_allocation is None:
best_allocation = self._proportional_allocation(player_pool_df, roles)
self._last_allocation = self._fractions_to_allocation(best_allocation, roles)
self._last_value = best_value
logger.info(
f"Local optimization complete: {self._last_allocation} "
f"=> value={self._last_value:.1f}"
)
return self._last_allocation
# ------------------------------------------------------------------
# Adaptive rebalancing
# ------------------------------------------------------------------
def optimize_adaptive(
self,
player_pool_df: pd.DataFrame,
remaining_slots: Dict[str, int],
spent_per_role: Dict[str, float],
n_calls: int = 30,
) -> Dict[str, float]:
"""Mid-auction rebalancing — optimize remaining budget for unfilled slots.
Args:
player_pool_df: remaining available players pool.
remaining_slots: dict of role -> slots still needed.
spent_per_role: dict of role -> credits already spent.
n_calls: GP evaluation budget.
Returns:
Dict mapping role -> recommended budget for remaining slots.
"""
remaining_budget = self.total_budget - sum(spent_per_role.values())
remaining_budget = max(1.0, remaining_budget)
if sum(remaining_slots.values()) == 0:
logger.info("All slots filled. No budget to allocate.")
return {r: 0.0 for r in self.roster_quotas}
saved_quotas = self.roster_quotas
saved_total = self.total_budget
self.roster_quotas = dict(remaining_slots)
self.total_budget = remaining_budget
available = player_pool_df[
player_pool_df["role"].isin(
[r for r, s in remaining_slots.items() if s > 0]
)
]
if len(available) == 0:
logger.warning("No available players for remaining slots.")
self.roster_quotas = saved_quotas
self.total_budget = saved_total
return {r: 0.0 for r in saved_quotas}
allocation = self.optimize(
available,
n_calls=n_calls,
use_skopt=True,
)
self.roster_quotas = saved_quotas
self.total_budget = saved_total
result = {}
for role in saved_quotas:
result[role] = allocation.get(role, 0.0)
return result
# ------------------------------------------------------------------
# Objective function
# ------------------------------------------------------------------
def _objective(
self,
fractions: np.ndarray,
roles: list,
player_pool_df: pd.DataFrame,
) -> float:
"""Simulate greedy fill within role budgets; return total projected points.
For each role, pick the best players by projected_points until the
role budget or slot quota is exhausted.
"""
total_value = 0.0
for i, role in enumerate(roles):
budget = fractions[i] * self.total_budget
slots = self.roster_quotas.get(role, 0)
role_players = player_pool_df[player_pool_df["role"] == role].copy()
if len(role_players) == 0 or slots == 0:
continue
role_players = role_players.sort_values(
"projected_points", ascending=False
)
total_cost = 0.0
filled = 0
for _, player in role_players.iterrows():
points = float(player["projected_points"])
estimated_price = self._estimate_price_simple(points, budget, role)
if total_cost + estimated_price > budget:
continue
total_cost += estimated_price
total_value += points
filled += 1
if filled >= slots:
break
return total_value
def _estimate_price_simple(
self, projected_points: float, role_budget: float, role: str
) -> float:
"""Simple price estimate: points * role_factor clamped within budget."""
role_factor = {"P": 4.0, "D": 2.5, "C": 3.0, "A": 4.5}.get(role, 3.0)
price = projected_points * role_factor
max_price = role_budget * 0.50
return min(price, max_price, role_budget)
# ------------------------------------------------------------------
# Allocation access and visualization data
# ------------------------------------------------------------------
def get_allocation(self) -> Dict[str, float]:
"""Return last computed allocation (role -> budget amount)."""
if self._last_allocation is None:
return {r: self.total_budget / len(self.roster_quotas) for r in self.roster_quotas}
return dict(self._last_allocation)
def get_role_value_curves(
self,
player_pool_df: pd.DataFrame,
n_points: int = 20,
) -> Dict[str, Tuple[np.ndarray, np.ndarray]]:
"""Compute diminishing returns curves: budget vs. expected points per role.
Returns:
dict role -> (budget_array, value_array).
"""
curves = {}
budget_step = self.total_budget / n_points
for role, slots in self.roster_quotas.items():
role_players = player_pool_df[player_pool_df["role"] == role].sort_values(
"projected_points", ascending=False
)
budgets = np.linspace(0, self.total_budget, n_points)
values = np.zeros(n_points)
for i, budget_limit in enumerate(budgets):
total_cost = 0.0
total_value = 0.0
filled = 0
for _, player in role_players.iterrows():
points = float(player["projected_points"])
price = self._estimate_price_simple(points, budget_limit, role)
if total_cost + price > budget_limit:
continue
total_cost += price
total_value += points
filled += 1
if filled >= slots:
break
values[i] = total_value
curves[role] = (budgets.copy(), values.copy())
return curves
# ------------------------------------------------------------------
# Fallback: proportional allocation
# ------------------------------------------------------------------
def _proportional_allocation(
self, player_pool_df: pd.DataFrame, roles: list
) -> np.ndarray:
"""Allocate budget proportional to (points_variance * slots) per role."""
weights = np.zeros(len(roles))
for i, role in enumerate(roles):
role_players = player_pool_df[player_pool_df["role"] == role]
if len(role_players) > 1:
variance = role_players["projected_points"].var()
else:
variance = 1.0
slots = self.roster_quotas.get(role, 1)
weights[i] = variance * slots
weight_sum = weights.sum()
if weight_sum <= 0:
return np.ones(len(roles)) / len(roles)
fractions = weights / weight_sum
fractions = np.clip(fractions, 0.02, 0.70)
fractions = fractions / fractions.sum()
logger.info(
f"Proportional allocation (fallback): "
f"{dict(zip(roles, fractions.round(3)))}"
)
return fractions
# ------------------------------------------------------------------
# Utilities
# ------------------------------------------------------------------
def _normalize_fractions(self, fractions: np.ndarray) -> np.ndarray:
"""Normalize fractions to sum to 1.0 with minimum per role."""
fractions = np.clip(fractions, 0.01, 0.70)
total = fractions.sum()
if total <= 0:
return np.ones_like(fractions) / len(fractions)
return fractions / total
def _fractions_to_allocation(
self, fractions: np.ndarray, roles: list
) -> Dict[str, float]:
"""Convert fractions to absolute budget per role."""
return {
role: round(float(fractions[i] * self.total_budget), 1)
for i, role in enumerate(roles)
}
def _random_allocation(self, n_roles: int) -> np.ndarray:
"""Generate a random allocation via Dirichlet."""
alpha = np.ones(n_roles) * 2.0
return self.rng.dirichlet(alpha)
def get_optimization_trace(self) -> pd.DataFrame:
"""Return DataFrame of all evaluated allocations during optimization."""
if not self._optimization_history:
return pd.DataFrame()
return pd.DataFrame(self._optimization_history)
def estimate_team_composition(
self,
player_pool_df: pd.DataFrame,
allocation: Optional[Dict[str, float]] = None,
) -> pd.DataFrame:
"""Given final allocation, return the recommended player selections.
Returns:
DataFrame with selected players, their estimated costs, and value.
"""
roles = list(self.roster_quotas.keys())
alloc = allocation or self._last_allocation
if alloc is None:
alloc = self._proportional_allocation_as_dict(roles)
selections = []
for role in roles:
budget = alloc.get(role, 0.0)
slots = self.roster_quotas.get(role, 0)
role_players = player_pool_df[player_pool_df["role"] == role].sort_values(
"projected_points", ascending=False
)
total_cost = 0.0
filled = 0
for _, player in role_players.iterrows():
points = float(player["projected_points"])
price = self._estimate_price_simple(points, budget, role)
if total_cost + price > budget:
continue
total_cost += price
name = player.get("name", player.get("player_name", "unknown"))
selections.append({
"player": name,
"role": role,
"projected_points": points,
"estimated_cost": round(price, 1),
"value_ratio": round(points / max(price, 1), 3),
})
filled += 1
if filled >= slots:
break
return pd.DataFrame(selections)
def _proportional_allocation_as_dict(self, roles: list) -> Dict[str, float]:
fractions = self._proportional_allocation(
pd.DataFrame(columns=["role", "projected_points"]), roles
)
return {roles[i]: round(float(fractions[i] * self.total_budget), 1) for i in range(len(roles))}
# ------------------------------------------------------------------
# Sensitivity analysis
# ------------------------------------------------------------------
def sensitivity_analysis(
self,
player_pool_df: pd.DataFrame,
role: Optional[str] = None,
delta_pct: float = 0.05,
n_steps: int = 11,
n_calls: int = 50,
) -> dict:
"""Test how shifting budget into/out of one role affects total value.
Args:
player_pool_df: current player pool.
role: role to perturb. If None, tests all roles.
delta_pct: fractional step size.
n_steps: number of steps in each direction.
n_calls: number of optimization calls (for compatibility).
Returns:
Dict with at least a 'per_role' key containing per-role analysis.
"""
base_allocation = self.get_allocation()
roles_to_test = [role] if role else list(base_allocation.keys())
per_role = {}
for test_role in roles_to_test:
results = []
shifts = np.linspace(-delta_pct * n_steps, delta_pct * n_steps, 2 * n_steps + 1)
for shift in shifts:
adjusted = {}
for r, val in base_allocation.items():
adjusted[r] = val * (1.0 + (shift if r == test_role else -shift / 3.0))
total_adj = sum(adjusted.values())
for r in adjusted:
adjusted[r] = adjusted[r] / total_adj * self.total_budget
fractions = np.array([adjusted[r] / self.total_budget for r in base_allocation.keys()])
value = self._objective(
fractions,
list(base_allocation.keys()),
player_pool_df,
)
results.append({
"shift_pct": round(shift * 100, 1),
"allocation": {r: round(v, 1) for r, v in adjusted.items()},
"total_value": round(value, 1),
})
per_role[test_role] = pd.DataFrame(results)
return {"per_role": per_role, "base_allocation": base_allocation}
+412
View File
@@ -0,0 +1,412 @@
"""Opponent bidding behavior modeling using LightGBM.
Predicts what competitors will bid for each player in a live auction round.
Supports Monte Carlo simulation of auction outcomes and win probability estimates.
"""
import logging
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple
import numpy as np
import pandas as pd
logger = logging.getLogger(__name__)
@dataclass
class OpponentState:
budget_remaining: float = 500.0
initial_budget: float = 500.0
slots_filled: Dict[str, int] = field(default_factory=lambda: {"P": 0, "D": 0, "C": 0, "A": 0})
slots_total: Dict[str, int] = field(default_factory=lambda: {"P": 3, "D": 8, "C": 8, "A": 6})
round_number: int = 1
aggression_factor: float = 1.0
def _get_attr(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
class OpponentBidModel:
"""Predicts opponent bids using LightGBM with heuristic fallback.
Trains on historical auction logs and outputs estimated max opponent
bid, win probability per player, and Monte Carlo round simulations.
"""
def __init__(self, random_state: int = 42):
self.random_state = random_state
self.model = None
self.fitted = False
self.feature_names: list = []
self.rng = np.random.RandomState(random_state)
self._role_scarcity_cache: Dict[str, float] = {}
# ------------------------------------------------------------------
# Feature engineering
# ------------------------------------------------------------------
def _extract_features(
self,
players_df: pd.DataFrame,
opponent_state: OpponentState,
) -> pd.DataFrame:
"""Build feature matrix for LightGBM prediction.
Args:
players_df: DataFrame with columns [player_name, player_role,
player_projected_points, ...].
opponent_state: OpponentState describing current opponent.
Returns:
Feature DataFrame ready for model input.
"""
df = players_df.copy()
df["role_P"] = (df["player_role"] == "P").astype(float)
df["role_D"] = (df["player_role"] == "D").astype(float)
df["role_C"] = (df["player_role"] == "C").astype(float)
df["role_A"] = (df["player_role"] == "A").astype(float)
df["budget_remaining_frac"] = (
opponent_state.budget_remaining / opponent_state.initial_budget
)
for role in ["P", "D", "C", "A"]:
slots_total = opponent_state.slots_total.get(role, 1)
slots_filled = opponent_state.slots_filled.get(role, 0)
scarcity = (slots_total - slots_filled) / slots_total
df[f"scarcity_{role}"] = scarcity
role_map = {"P": 0, "D": 1, "C": 2, "A": 3}
df["role_code"] = df["player_role"].map(role_map)
if "player_projected_points" in df.columns:
df["points_sq"] = df["player_projected_points"] ** 2
df["points_log"] = np.log1p(df["player_projected_points"].clip(lower=0))
df["slots_needed_total"] = sum(
opponent_state.slots_total.get(r, 0) - opponent_state.slots_filled.get(r, 0)
for r in ["P", "D", "C", "A"]
)
df["slots_needed_total"] = df["slots_needed_total"].clip(lower=1)
df["round_number"] = opponent_state.round_number
df["urgency"] = 1.0 - (opponent_state.budget_remaining / opponent_state.initial_budget)
df["aggression"] = opponent_state.aggression_factor
self.feature_names = [
"player_projected_points",
"role_P",
"role_D",
"role_C",
"role_A",
"role_code",
"budget_remaining_frac",
"scarcity_P",
"scarcity_D",
"scarcity_C",
"scarcity_A",
"points_sq",
"points_log",
"slots_needed_total",
"round_number",
"urgency",
"aggression",
]
for col in self.feature_names:
if col not in df.columns:
df[col] = 0.0
return df[self.feature_names]
# ------------------------------------------------------------------
# Training
# ------------------------------------------------------------------
def fit(self, auction_logs: pd.DataFrame):
"""Train LightGBM regressor on historical auction logs.
Args:
auction_logs: DataFrame with columns [player_name, player_role,
player_projected_points, opponent_budget_remaining,
opponent_slots_remaining, role_needed_count,
round_number, winning_bid].
"""
required_cols = [
"player_role", "player_projected_points",
"winning_bid",
]
for col in required_cols:
if col not in auction_logs.columns:
raise ValueError(f"Missing required column: '{col}' in auction_logs")
df = auction_logs.dropna(subset=required_cols).copy()
if len(df) < 20:
logger.warning(
f"Only {len(df)} auction records. Not enough to fit LightGBM. "
"Using heuristic fallback."
)
self.fitted = False
return
try:
import lightgbm as lgb
except ImportError:
logger.warning("LightGBM not available. Using heuristic fallback.")
self.fitted = False
return
dummy_state = OpponentState(
budget_remaining=df.get("opponent_budget_remaining", 500),
initial_budget=500,
slots_filled={"P": 0, "D": 0, "C": 0, "A": 0},
slots_total={"P": 3, "D": 8, "C": 8, "A": 6},
round_number=1,
aggression_factor=1.0,
)
df = df.rename(columns={
"player_role": "player_role",
"player_projected_points": "player_projected_points",
})
dummy_df = df[["player_role", "player_projected_points"]].copy()
dummy_df.columns = ["player_role", "player_projected_points"]
X = self._extract_features(dummy_df, dummy_state)
y = df["winning_bid"].astype(float)
y_min, y_max = y.min(), y.max()
self.model = lgb.LGBMRegressor(
n_estimators=100,
max_depth=6,
learning_rate=0.05,
num_leaves=31,
min_child_samples=10,
subsample=0.8,
colsample_bytree=0.8,
random_state=self.random_state,
verbose=-1,
)
self.model.fit(X, y)
self.fitted = True
self._y_min = y_min
self._y_max = y_max
logger.info(
f"OpponentBidModel trained on {len(df)} records. "
f"Target range: [{y_min:.0f}, {y_max:.0f}]"
)
# ------------------------------------------------------------------
# Prediction
# ------------------------------------------------------------------
def predict_opponent_bids(
self,
players_df: pd.DataFrame,
opponent_state: OpponentState,
) -> pd.Series:
"""Predict max opponent bid for each player.
Args:
players_df: DataFrame with player info.
opponent_state: current opponent state.
Returns:
Series of predicted max opponent bids (index = player index).
"""
if not self.fitted or self.model is None:
bids = self._heuristic_bid(players_df, opponent_state)
return pd.Series(bids, index=players_df.index)
X = self._extract_features(players_df, opponent_state)
predictions = self.model.predict(X)
predictions = np.clip(predictions, 1, _get_attr(opponent_state, "budget_remaining", 500))
return pd.Series(predictions, index=players_df.index)
def predict_p_acquire(
self,
players_df: pd.DataFrame,
my_bids: np.ndarray,
opponent_state: OpponentState,
temperature: float = 0.1,
) -> np.ndarray:
"""Probability I acquire each player given my bids vs opponent.
Uses sigmoid: P = 1 / (1 + exp(-(my_bid - opp_bid) / temperature)).
Args:
players_df: DataFrame with player info.
my_bids: array of my bid amounts per player.
opponent_state: current opponent state.
temperature: softmax temperature (lower = sharper).
Returns:
Array of acquisition probabilities per player.
"""
opp_bids = self.predict_opponent_bids(players_df, opponent_state).values
margin = np.array(my_bids, dtype=float) - opp_bids
scaled_temp = max(temperature * max(opp_bids.max(), 1), 0.01)
probabilities = 1.0 / (1.0 + np.exp(-margin / scaled_temp))
return np.clip(probabilities, 0.01, 0.99)
# ------------------------------------------------------------------
# Monte Carlo round simulation
# ------------------------------------------------------------------
def simulate_live_round(
self,
available_players: pd.DataFrame,
my_budget: float,
opponent_state: OpponentState,
n_sims: int = 1000,
) -> dict:
"""Monte Carlo simulation of a live auction round.
Args:
available_players: DataFrame of players up for bidding this round.
my_budget: my remaining budget.
opponent_state: opponent's current state.
n_sims: number of simulation runs.
Returns:
dict with expected_players_acquired, expected_cost, value_matrix.
"""
opp_bids = self.predict_opponent_bids(available_players, opponent_state).values
n_players = len(available_players)
players_acquired = np.zeros(n_sims, dtype=int)
total_cost = np.zeros(n_sims, dtype=float)
value_matrix = np.zeros((n_sims, n_players), dtype=float)
for sim_idx in range(n_sims):
my_budget_left = my_budget
acquired = 0
cost = 0.0
for p_idx in range(n_players):
opp_bid = opp_bids[p_idx] + self.rng.normal(0, max(opp_bids[p_idx] * 0.15, 1))
opp_bid = max(opp_bid, 1)
my_bid = self._heuristic_bid_single(
available_players.iloc[p_idx], opponent_state
)
my_bid = min(my_bid, my_budget_left)
if my_bid > opp_bid:
acquired += 1
cost += my_bid
my_budget_left -= my_bid
value_matrix[sim_idx, p_idx] = 1.0
players_acquired[sim_idx] = acquired
total_cost[sim_idx] = cost
return {
"expected_players_acquired": float(np.mean(players_acquired)),
"expected_cost": float(np.mean(total_cost)),
"cost_std": float(np.std(total_cost)),
"acquired_std": float(np.std(players_acquired)),
"cost_percentile_25": float(np.percentile(total_cost, 25)),
"cost_percentile_50": float(np.percentile(total_cost, 50)),
"cost_percentile_75": float(np.percentile(total_cost, 75)),
"acquisition_rate": float(players_acquired.mean() / n_players),
"value_matrix": value_matrix,
"n_sims": n_sims,
}
# ------------------------------------------------------------------
# Heuristic fallback
# ------------------------------------------------------------------
def _heuristic_bid_single(
self, player_row: pd.Series, opponent_state: OpponentState
) -> float:
"""Heuristic bid for a single player (scalar version)."""
role = player_row.get("player_role", None)
points = float(player_row.get("player_projected_points", 6.5))
if isinstance(role, pd.Series):
role = role.iloc[0]
scarcity = self._compute_role_scarcity(role, opponent_state)
aggression = _get_attr(opponent_state, "aggression_factor", 1.0)
budget_rem = _get_attr(opponent_state, "budget_remaining", 500.0)
budget_init = _get_attr(opponent_state, "initial_budget", 500.0) or _get_attr(opponent_state, "total_budget", 500.0)
base_bid = 0.4 * points * scarcity * aggression
bid = base_bid * (budget_rem / budget_init)
return max(bid, 1.0)
def _heuristic_bid(
self, players_df: pd.DataFrame, opponent_state: OpponentState
) -> np.ndarray:
"""Heuristic bid array for all players."""
bids = []
for _, row in players_df.iterrows():
bids.append(self._heuristic_bid_single(row, opponent_state))
return np.array(bids, dtype=float)
def _compute_role_scarcity(
self, role: Optional[str], opponent_state
) -> float:
"""Compute how scarce a role is for the opponent."""
slots_total_dict = _get_attr(opponent_state, "slots_total", {"P": 3, "D": 8, "C": 8, "A": 6})
slots_filled_dict = _get_attr(opponent_state, "slots_filled", {"P": 0, "D": 0, "C": 0, "A": 0})
if role is None or role not in slots_total_dict:
return 1.0
slots_total = slots_total_dict[role]
filled = slots_filled_dict.get(role, 0)
remaining = max(slots_total - filled, 1)
return slots_total / remaining
# ------------------------------------------------------------------
# Batch simulation with opponent model integration
# ------------------------------------------------------------------
def run_auction_simulation(
self,
player_groups: List[pd.DataFrame],
initial_budgets: List[float],
opponent_states: List[OpponentState],
n_sims: int = 500,
) -> dict:
"""Simulate multi-round auction against multiple opponents.
Args:
player_groups: list of DataFrames, one per round, with available players.
initial_budgets: my starting budget per round/concept.
opponent_states: OpponentState for each round.
n_sims: number of Monte Carlo runs.
Returns:
dict with aggregated simulation results.
"""
all_results = []
for idx, (players, budget, opp_state) in enumerate(
zip(player_groups, initial_budgets, opponent_states)
):
result = self.simulate_live_round(
players, budget, opp_state, n_sims=n_sims
)
result["round"] = idx
all_results.append(result)
total_acquired = sum(r["expected_players_acquired"] for r in all_results)
total_cost = sum(r["expected_cost"] for r in all_results)
return {
"rounds": all_results,
"total_expected_acquired": total_acquired,
"total_expected_cost": total_cost,
"n_sims": n_sims,
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff