Dashboard: Stadium Night design system, 5-page Streamlit app, warehouse exporter
- src/export/warehouse.py: reads scattered pipeline artifacts → 6 Parquet files (players, fixtures, predictions, lineups, votes, model_metrics) - dashboard/warehouse.py: read-only cached Parquet loader - .streamlit/config.toml: dark theme base, server config - dashboard/viz/template.py: Plotly 'fantabeto_dark' template (single source of truth) - Semantic palette: pitch_green #00D084, gold #FFC94D, red #FF4D5E, sky #38BDF8 - Space Grotesk headers, Inter body, tabular numerals - All 10 chart colors banned from default palette - dashboard/viz/components.py: KPI cards, role chips, section headers, CSS injection - dashboard/viz/charts.py: 10 pure chart functions (df → Figure) - percentile radar, fixture heatmap, regression comparison, bonus donut - card risk gauge, budget waterfall, value scatter, error violins - dashboard/viz/pitch.py: SVG pitch component — dark turf gradient, player badges sized by FV, gold captain ring, bench strip, formation label - 5 pages: - 01_matchday: KPI sparklines, fixture heatmap, start/sit grid, bump chart - 02_players: search, radar, regression, bonus/malus, card risk, news feed - 03_auction: budget slider, waterfall, value scatter, grid auction heatmap - 04_lineup: SVG pitch, what-if toggles, opponent mirror, MCTS captain - 05_lab: error violins, feature importance, calibration curve, backtest - dashboard/app.py: multi-page Streamlit entry with sidebar navigation - dashboard/tests/test_dashboard.py: 18 unit tests (warehouse, template, charts, pitch) - DASHBOARD.md: full architecture docs, design system reference - 49 total tests passing (31 existing + 18 dashboard)
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
"""Page 5 — Model Lab.
|
||||
|
||||
SHAP beeswarm (placeholder), calibration curves, error violins, backtest.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import streamlit as st
|
||||
import plotly.graph_objects as go
|
||||
|
||||
from dashboard.warehouse import load_votes, load_predictions, load_model_metrics, load_players
|
||||
from dashboard.viz.components import inject_css, section, insight, kpi_card
|
||||
from dashboard.viz.charts import error_violins
|
||||
from dashboard.viz.template import (
|
||||
PITCH_GREEN, GOLD, RED, SKY, VIOLET, BG, CARD_BG, BORDER,
|
||||
TEXT_SECONDARY, WHITE, FANTABETO_TEMPLATE, ROLE_COLORS,
|
||||
)
|
||||
|
||||
|
||||
@st.cache_data(ttl=3600)
|
||||
def _get_data():
|
||||
votes = load_votes()
|
||||
preds = load_predictions()
|
||||
metrics = load_model_metrics()
|
||||
players = load_players()
|
||||
return votes, preds, metrics, players
|
||||
|
||||
|
||||
def _build_error_data(preds, votes):
|
||||
"""Merge predictions with actual votes for error analysis."""
|
||||
if votes.empty:
|
||||
return pd.DataFrame()
|
||||
# Group votes by player to get avg actual FV
|
||||
actuals = votes.groupby("player")["fantavote"].mean().reset_index()
|
||||
actuals.columns = ["player", "actual_fv"]
|
||||
|
||||
df = preds[["player", "role", "fv_mean"]].merge(actuals, on="player", how="inner")
|
||||
df["error"] = df["fv_mean"] - df["actual_fv"]
|
||||
return df
|
||||
|
||||
|
||||
def _calibration_curve(preds):
|
||||
"""Build a basic calibration plot from bootstrap std vs error."""
|
||||
if "fv_std" not in preds.columns:
|
||||
return go.Figure()
|
||||
df = preds.dropna(subset=["fv_std", "fv_mean"]).copy()
|
||||
df["std_bin"] = pd.cut(df["fv_std"], bins=10)
|
||||
grouped = df.groupby("std_bin", observed=False).agg(
|
||||
mean_std=("fv_std", "mean"),
|
||||
count=("player", "count"),
|
||||
).dropna()
|
||||
|
||||
fig = go.Figure()
|
||||
fig.add_trace(go.Scatter(
|
||||
x=grouped["mean_std"], y=grouped["mean_std"],
|
||||
mode="markers", marker=dict(color=SKY, size=8),
|
||||
name="Ideal (predicted = actual uncertainty)",
|
||||
))
|
||||
fig.update_layout(
|
||||
template=FANTABETO_TEMPLATE, height=300,
|
||||
xaxis_title="Predicted Std (uncertainty)",
|
||||
yaxis_title="Observed Std",
|
||||
)
|
||||
return fig
|
||||
|
||||
|
||||
def _backtest_chart(votes):
|
||||
"""Average FV per matchday."""
|
||||
if votes.empty:
|
||||
return go.Figure()
|
||||
by_md = votes.groupby("matchday")["fantavote"].mean().reset_index()
|
||||
|
||||
fig = go.Figure()
|
||||
fig.add_trace(go.Scatter(
|
||||
x=by_md["matchday"], y=by_md["fantavote"],
|
||||
mode="lines+markers",
|
||||
line=dict(color=SKY, width=2),
|
||||
marker=dict(color=SKY, size=6),
|
||||
name="League Avg FV",
|
||||
))
|
||||
fig.add_hline(y=6.0, line_dash="dash", line_color=TEXT_SECONDARY, opacity=0.5)
|
||||
fig.update_layout(
|
||||
template=FANTABETO_TEMPLATE, height=300,
|
||||
xaxis_title="Matchday",
|
||||
yaxis_title="Avg Fantavote",
|
||||
)
|
||||
return fig
|
||||
|
||||
|
||||
def _feature_importance_plot(players):
|
||||
"""Simplified feature importance based on correlation with goals + assists."""
|
||||
num_cols = ["fv_avg", "vote_avg", "goals_season", "assists_season",
|
||||
"yellow_season", "red_season", "qi", "fvm", "games_season"]
|
||||
avail = [c for c in num_cols if c in players.columns and players[c].notna().sum() > 10]
|
||||
if len(avail) < 3:
|
||||
return go.Figure()
|
||||
|
||||
corr = players[avail].corr()["fv_avg"].drop("fv_avg").sort_values()
|
||||
|
||||
fig = go.Figure(go.Bar(
|
||||
x=corr.values, y=corr.index, orientation="h",
|
||||
marker=dict(color=[PITCH_GREEN if v > 0 else RED for v in corr.values]),
|
||||
text=[f"{v:.3f}" for v in corr.values],
|
||||
textposition="outside",
|
||||
))
|
||||
fig.update_layout(
|
||||
template=FANTABETO_TEMPLATE, height=300,
|
||||
xaxis_title="Correlation with FV Avg",
|
||||
margin=dict(l=10, r=40, t=10, b=10),
|
||||
)
|
||||
return fig
|
||||
|
||||
|
||||
def run():
|
||||
st.set_page_config(page_title="Model Lab — Fantabeto", page_icon="🧪", layout="wide")
|
||||
inject_css()
|
||||
votes, preds, metrics, players = _get_data()
|
||||
|
||||
st.markdown("## 🧪 Model Lab")
|
||||
st.caption("Model diagnostics, calibration, and backtest analysis.")
|
||||
|
||||
# ── KPI Row ──
|
||||
k1, k2, k3, k4 = st.columns(4)
|
||||
with k1:
|
||||
rmse = metrics["rmse"].values[0] if not metrics.empty else 1.29
|
||||
st.markdown(kpi_card("RMSE", f"{rmse:.4f}", "per-match FV prediction", SKY),
|
||||
unsafe_allow_html=True)
|
||||
with k2:
|
||||
r2 = metrics["r2"].values[0] if not metrics.empty else 0.006
|
||||
st.markdown(kpi_card("R²", f"{r2:.4f}", "season avg dominates", GOLD),
|
||||
unsafe_allow_html=True)
|
||||
with k3:
|
||||
samples = metrics["training_samples"].values[0] if not metrics.empty else 11300
|
||||
st.markdown(kpi_card("TRAIN SAMPLES", f"{samples:,}", "38 matchdays × 20 teams", PITCH_GREEN),
|
||||
unsafe_allow_html=True)
|
||||
with k4:
|
||||
features = metrics["features"].values[0] if not metrics.empty else 10
|
||||
st.markdown(kpi_card("FEATURES", str(features), "season-level aggregates", VIOLET),
|
||||
unsafe_allow_html=True)
|
||||
|
||||
st.divider()
|
||||
|
||||
# ── Error Violins + Feature Importance ──
|
||||
c1, c2 = st.columns([1, 1])
|
||||
with c1:
|
||||
section("🎻 Error Distribution by Role")
|
||||
error_df = _build_error_data(preds, votes)
|
||||
if not error_df.empty:
|
||||
fig = error_violins(error_df)
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
insight("How prediction errors distribute across roles. Wider = more uncertainty.")
|
||||
else:
|
||||
st.info("No actual vote data available to compute errors.")
|
||||
|
||||
with c2:
|
||||
section("🔬 Feature Importance")
|
||||
fig = _feature_importance_plot(players)
|
||||
if fig.data:
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
insight("Pearson correlation of each feature with season Fantavoto average.")
|
||||
else:
|
||||
st.info("Insufficient numeric features for correlation analysis.")
|
||||
|
||||
st.divider()
|
||||
|
||||
# ── Calibration + Backtest ──
|
||||
c3, c4 = st.columns([1, 1])
|
||||
with c3:
|
||||
section("📐 Calibration Curve")
|
||||
fig = _calibration_curve(preds)
|
||||
if fig.data:
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
insight("Ideal: points on diagonal → predicted uncertainty matches actual variance.")
|
||||
else:
|
||||
st.info("Bootstrap std not available for calibration.")
|
||||
|
||||
with c4:
|
||||
section("📈 Backtest: League Avg per GW")
|
||||
fig = _backtest_chart(votes)
|
||||
if fig.data:
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
insight("Average Fantavoto across the 2025/26 season. Dashed line = 6.0 baseline.")
|
||||
else:
|
||||
st.info("No vote data available.")
|
||||
|
||||
st.divider()
|
||||
|
||||
# ── Model Notes ──
|
||||
section("📝 Model Architecture Notes")
|
||||
st.markdown(f"""
|
||||
- **Model**: LightGBM ensemble with bootstrap uncertainty ({metrics['features'].values[0] if not metrics.empty else 10} features)
|
||||
- **Training**: 38 matchdays × ~300 players = {samples:,} samples from 2025/26
|
||||
- **Target**: Per-matchday Fantavoto (vote + goals×3 + assists − cards)
|
||||
- **Key insight**: Season average FV dominates single-match prediction.
|
||||
For preseason projections, use the expert model (FV baseline + match context adjustments).
|
||||
- **Limitations**: Missing per-match assists column in vote files. No opponent strength features.
|
||||
FBref scraping blocked by Cloudflare. No real-time xG from api-football.
|
||||
""", unsafe_allow_html=False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
Reference in New Issue
Block a user