6d4bfcfa82
- 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
958 lines
45 KiB
Python
958 lines
45 KiB
Python
"""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()
|