Compare commits
10 Commits
ee563a87d2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5461ff3197 | |||
| 095b139af8 | |||
| 35370c81f8 | |||
| 2c818e292a | |||
| 6d4bfcfa82 | |||
| 000d4b02e8 | |||
| 00dc7f2bcb | |||
| b0fab62a87 | |||
| 916278a640 | |||
| f83a521649 |
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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")
|
||||||
@@ -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 < 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 (>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 |
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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
@@ -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"
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
@@ -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
Reference in New Issue
Block a user