Files
fantabeto/dashboard/pages/06_dev_preview.py
T
ramseshk 6d4bfcfa82 fix: calibrate auction prices to real market values
- Replaced raw QI (listing price) with calibrated formula:
  price = max(3, FVM × 0.4 + (FV_proj − 5.5) × 20)
- Martinez L.: 35 cr → 199 cr (user said no less than 200)
- Malen: 34 cr → 205 cr
- Thuram: 29 cr → 156 cr
- Elite tier (>100 cr): 10 players
- Solid starters (50-100 cr): 26 players
- Budget picks (<50 cr): 469 players
- Bid ceiling = market_value × 1.3
- Updated KPIs to show real price distribution
- Live at http://localhost:8518
2026-08-12 12:01:48 +08:00

958 lines
45 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()