Koddenbrock's picture
Replace group prediction cards with a per-game table + group/date filters
c110b2c
Raw
History Blame Contribute Delete
20.8 kB
"""World Cup 2026 AI Model Prediction Game — Streamlit App."""
from __future__ import annotations
import json
from datetime import date
from pathlib import Path
import pandas as pd
import streamlit as st
# ── Page config (must be first Streamlit call) ────────────────────────────────
st.set_page_config(
page_title="WC 2026 AI Prediction Game",
page_icon="⚽",
layout="wide",
initial_sidebar_state="collapsed",
)
# ── Paths ─────────────────────────────────────────────────────────────────────
DATA_DIR = Path(__file__).parent / "data" / "predictions"
# ── Model metadata ─────────────────────────────────────────────────────────────
MODEL_META: dict[str, dict] = {
"tabpfn": {
"label": "TabPFN",
"description": "Bayesian prior-fitted neural network — predicts without gradient updates",
"color": "#059669",
},
"tabicl": {
"label": "TabICL",
"description": "Large-scale tabular in-context learner trained on millions of datasets",
"color": "#2563eb",
},
"random_forest": {
"label": "Random Forest",
"description": "Ensemble of 500 decision trees with feature randomisation",
"color": "#ea580c",
},
"knn": {
"label": "K-Nearest Neighbours",
"description": "Classifies by the 15 most similar historical matches",
"color": "#9333ea",
},
"catboost": {
"label": "CatBoost",
"description": "GPU-accelerated gradient boosting with 500 iterations",
"color": "#e11d48",
},
"random_baseline": {
"label": "Random Baseline",
"description": "Naive model using historical base rates (away 30.7% / draw 23.2% / home 46.1%)",
"color": "#64748b",
},
"home_always_wins": {
"label": "Home-Always-Wins",
"description": "Extreme baseline: always predicts heavy home win",
"color": "#475569",
},
}
# ── CSS ────────────────────────────────────────────────────────────────────────
st.markdown("""
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap');
/* Global reset */
*, *::before, *::after { box-sizing: border-box; }
html, body, [data-testid="stAppViewContainer"], [data-testid="stMain"] {
background-color: #f6f8fb !important;
color: #0f172a !important;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif !important;
}
/* Hide the gray Streamlit header / toolbar bar */
[data-testid="stHeader"] {
background: transparent !important;
height: 0 !important;
}
[data-testid="stToolbar"] { display: none !important; }
#MainMenu { display: none !important; }
footer { display: none !important; }
[data-testid="stSidebar"] {
background-color: #ffffff !important;
}
/* Remove default padding */
.block-container {
padding-top: 0 !important;
padding-left: 1.5rem !important;
padding-right: 1.5rem !important;
max-width: 1400px !important;
}
/* Hero banner */
.hero-banner {
background: linear-gradient(135deg, #ecfdf5 0%, #eff6ff 45%, #f5f3ff 100%);
border: 1px solid #e2e8f0;
border-radius: 18px;
box-shadow: 0 10px 30px -12px rgba(15,23,42,0.12);
padding: 2.75rem 2rem 2.5rem;
margin: 1.25rem 0 2rem;
text-align: center;
position: relative;
overflow: hidden;
}
.hero-banner::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0; height: 4px;
background: linear-gradient(90deg, #059669, #2563eb, #9333ea);
}
.hero-title {
font-size: clamp(2rem, 5vw, 3.4rem);
font-weight: 900;
color: #0f172a;
letter-spacing: -0.03em;
margin: 0 0 0.35rem;
}
.hero-subtitle {
font-size: clamp(0.85rem, 2vw, 1.05rem);
font-weight: 700;
color: #059669;
letter-spacing: 0.14em;
text-transform: uppercase;
margin: 0 0 0.6rem;
}
.hero-date {
font-size: 0.9rem;
color: #64748b;
font-weight: 500;
}
/* Section headers */
.section-header {
font-size: 1.45rem;
font-weight: 800;
color: #0f172a;
margin: 2.25rem 0 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
letter-spacing: -0.01em;
border-bottom: 2px solid #e2e8f0;
padding-bottom: 0.55rem;
}
/* Scoring rules */
.rules-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
margin: 0.5rem 0;
}
.rules-card {
background: #ffffff;
border: 1px solid #e6eaf0;
border-radius: 12px;
padding: 1rem 1.1rem;
box-shadow: 0 4px 14px -10px rgba(15,23,42,0.1);
}
.rules-card-title {
font-size: 0.8rem;
font-weight: 800;
color: #047857;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 0.6rem;
}
.rule-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.3rem 0;
border-bottom: 1px solid #f1f5f9;
font-size: 0.82rem;
}
.rule-row:last-child { border-bottom: none; }
.rule-label { color: #475569; }
.rule-pts {
font-weight: 700;
color: #047857;
background: #ecfdf5;
padding: 0.1rem 0.55rem;
border-radius: 999px;
font-size: 0.78rem;
}
/* Selectbox styling */
[data-testid="stSelectbox"] label {
color: #475569 !important;
font-size: 0.85rem !important;
font-weight: 700 !important;
text-transform: uppercase !important;
letter-spacing: 0.05em !important;
}
/* Dataframe (leaderboard) */
[data-testid="stDataFrame"] {
border: 1px solid #e6eaf0 !important;
border-radius: 12px !important;
overflow: hidden !important;
box-shadow: 0 4px 14px -10px rgba(15,23,42,0.1) !important;
}
/* Expander */
[data-testid="stExpander"] {
background: #ffffff !important;
border: 1px solid #e6eaf0 !important;
border-radius: 12px !important;
box-shadow: 0 4px 14px -10px rgba(15,23,42,0.1) !important;
}
[data-testid="stExpander"] summary {
color: #1e293b !important;
font-weight: 700 !important;
}
/* Streamlit element overrides */
hr { border-color: #e2e8f0 !important; }
.stMarkdown p { color: #475569; }
</style>
""", unsafe_allow_html=True)
# ── Data loading ───────────────────────────────────────────────────────────────
@st.cache_data
def load_predictions() -> dict[str, list[dict]]:
"""Load all model prediction files from data/predictions/."""
preds: dict[str, list[dict]] = {}
for model_name in MODEL_META:
path = DATA_DIR / f"wc2026_{model_name}.json"
if path.exists():
with open(path) as f:
preds[model_name] = json.load(f)
return preds
@st.cache_data(ttl=300)
def load_results() -> list[dict]:
"""Load actual results, falling back to empty list if unavailable. Refreshes every 5 min."""
try:
from utils.storage import load_results as _load
return _load()
except Exception:
return []
# ── Scoring ────────────────────────────────────────────────────────────────────
@st.cache_data(ttl=300)
def compute_leaderboard(predictions_key: str = "all") -> tuple[pd.DataFrame, dict]:
"""Build leaderboard DataFrame + meta. Uses predictions_key as cache key.
Returns (df, meta) where meta carries the number of matches played and the
maximum points attainable so far (the ceiling for the 'of Possible' bar).
"""
from utils.scoring import compute_all_scores, compute_max_possible
all_preds = load_predictions()
results = load_results()
scores = compute_all_scores(all_preds, results)
template = next(iter(all_preds.values()), [])
max_possible = compute_max_possible(template, results).get("total", 0)
n_played = len(results)
n_group_played = sum(1 for r in results if str(r.get("stage", "")).upper() == "GROUP_STAGE")
rows = []
for model_name, score_dict in scores.items():
meta = MODEL_META.get(model_name, {})
breakdown = score_dict.get("breakdown", {})
correct_outcomes = breakdown.get("group_match_correct", 0)
rows.append({
"model": model_name,
"label": meta.get("label", model_name),
"description": meta.get("description", ""),
"color": meta.get("color", "#64748b"),
"total": score_dict.get("total", 0),
"outcomes": f"{correct_outcomes}/{n_group_played}" if n_group_played else "—",
"group_stage": (
correct_outcomes
+ breakdown.get("group_winner", 0)
+ breakdown.get("group_runner_up", 0)
),
"knockout": sum(
breakdown.get(k, 0)
for k in ("r32_correct", "r16_correct", "qf_correct",
"sf_correct", "finalist_correct", "champion_correct")
),
"bonus": (
breakdown.get("both_finalists_correct", 0)
+ breakdown.get("exact_final_result", 0)
),
})
df = pd.DataFrame(rows).sort_values("total", ascending=False).reset_index(drop=True)
return df, {"n_played": n_played, "max_possible": max_possible}
# ── Predictions table ──────────────────────────────────────────────────────────
# Map the internal "A Win"/"Draw"/"B Win" encoding to a readable pick.
_PICK_LABEL = {"A Win": "Home", "Draw": "Draw", "B Win": "Away"}
# Display order for stages so the table reads group stage → final.
_STAGE_ORDER = {
"GROUP_STAGE": 0,
"ROUND_OF_32": 1,
"ROUND_OF_16": 2,
"QUARTER_FINALS": 3,
"SEMI_FINALS": 4,
"THIRD_PLACE": 5,
"FINAL": 6,
}
@st.cache_data(ttl=300)
def build_predictions_table() -> pd.DataFrame:
"""One row per game: Group, Date, Home, Away, Actual, then a column per model.
Each model cell shows that model's predicted pick (Home/Draw/Away). Once a
game is played, the pick is prefixed ✅/❌ depending on whether it matched.
"""
all_preds = load_predictions()
results_by_id = {r.get("game_id"): r for r in load_results()}
# Per-model lookup: model -> {game_id -> prediction}
by_model = {
name: {p.get("game_id"): p for p in preds}
for name, preds in all_preds.items()
}
# Fixture template = union of all games seen across models.
template: dict[str, dict] = {}
for preds in all_preds.values():
for p in preds:
gid = p.get("game_id")
if gid and gid not in template:
template[gid] = p
rows = []
for gid, fx in template.items():
stage = str(fx.get("stage", "")).upper()
group = str(fx.get("group_id") or "").replace("GROUP_", "")
actual = results_by_id.get(gid)
actual_result = ""
if actual:
actual_result = actual.get("actual_result", "")
hg, ag = actual.get("home_goals"), actual.get("away_goals")
score = f"{hg}{ag}" if hg is not None and ag is not None else ""
pick = _PICK_LABEL.get(actual_result, "")
actual_str = f"{score} ({pick})".strip() if pick else (score or "—")
else:
actual_str = "—"
row = {
"_order": _STAGE_ORDER.get(stage, 9),
"Group": group or "—",
"Date": str(fx.get("date", ""))[:10],
"Home": fx.get("home_team", ""),
"Away": fx.get("away_team", ""),
"Actual": actual_str,
}
for model_name, meta in MODEL_META.items():
mp = by_model.get(model_name, {}).get(gid)
if mp is None:
row[meta["label"]] = ""
continue
predicted = mp.get("predicted_result", "")
cell = _PICK_LABEL.get(predicted, "")
if actual_result and cell:
cell = ("✅ " if predicted == actual_result else "❌ ") + cell
row[meta["label"]] = cell
rows.append(row)
if not rows:
return pd.DataFrame()
df = pd.DataFrame(rows).sort_values(["_order", "Group", "Date"]).reset_index(drop=True)
return df.drop(columns=["_order"])
# ── Main layout ────────────────────────────────────────────────────────────────
def render_hero() -> None:
today = date.today().strftime("%B %d, %Y")
st.markdown(f"""
<div class="hero-banner">
<div class="hero-subtitle">AI Model Prediction Challenge</div>
<div class="hero-title">⚽ World Cup 2026</div>
<div class="hero-date">{today} &nbsp;·&nbsp; 48 teams &nbsp;·&nbsp; 104 matches &nbsp;·&nbsp; 7 AI models competing</div>
</div>
""", unsafe_allow_html=True)
def render_scoring_rules() -> None:
with st.expander("📋 Scoring Rules", expanded=False):
st.markdown("""
<div class="rules-grid">
<div class="rules-card">
<div class="rules-card-title">⚽ Group Stage</div>
<div class="rule-row"><span class="rule-label">Correct W/D/L outcome</span><span class="rule-pts">+1 pt</span></div>
<div class="rule-row"><span class="rule-label">Correct group winner (1st)</span><span class="rule-pts">+3 pts</span></div>
<div class="rule-row"><span class="rule-label">Correct group runner-up (2nd)</span><span class="rule-pts">+2 pts</span></div>
</div>
<div class="rules-card">
<div class="rules-card-title">🏆 Knockout Rounds</div>
<div class="rule-row"><span class="rule-label">Correct Round of 32</span><span class="rule-pts">+2 pts</span></div>
<div class="rule-row"><span class="rule-label">Correct Round of 16</span><span class="rule-pts">+3 pts</span></div>
<div class="rule-row"><span class="rule-label">Correct Quarter-Final</span><span class="rule-pts">+4 pts</span></div>
<div class="rule-row"><span class="rule-label">Correct Semi-Final</span><span class="rule-pts">+5 pts</span></div>
<div class="rule-row"><span class="rule-label">Correct Finalist</span><span class="rule-pts">+8 pts</span></div>
<div class="rule-row"><span class="rule-label">Correct Champion</span><span class="rule-pts">+15 pts</span></div>
</div>
<div class="rules-card">
<div class="rules-card-title">✨ Bonus Points</div>
<div class="rule-row"><span class="rule-label">Both finalists correct</span><span class="rule-pts">+5 pts</span></div>
<div class="rule-row"><span class="rule-label">Correct final result</span><span class="rule-pts">+3 pts</span></div>
<div class="rule-row" style="margin-top:0.8rem;border-top:1px solid #e2e8f0;padding-top:0.5rem;">
<span class="rule-label" style="color:#94a3b8;font-size:0.75rem">Max possible score: ~185 pts</span>
</div>
</div>
</div>
""", unsafe_allow_html=True)
def render_leaderboard() -> None:
st.markdown('<div class="section-header">🏆 Model Leaderboard</div>', unsafe_allow_html=True)
df, meta = compute_leaderboard()
n_played = meta["n_played"]
possible = meta["max_possible"]
if n_played == 0 or possible == 0:
st.info("⚽ No matches have been played yet — the leaderboard fills in as results come in.")
return
leader = df.iloc[0]
st.caption(
f"Scored on **{n_played}** match{'es' if n_played != 1 else ''} played so far · "
f"up to **{possible}** pts were possible · "
f"leader **{leader['label']}** with **{int(leader['total'])}/{possible}**"
)
# Build display dataframe with medals
display_rows = []
rank_icons = {0: "🥇", 1: "🥈", 2: "🥉"}
for i, row in df.iterrows():
rank_icon = rank_icons.get(i, "")
rank_label = f"{rank_icon} {i + 1}" if rank_icon else f"{i + 1}"
display_rows.append({
"Rank": rank_label,
"Model": row["label"],
"Points": int(row["total"]),
"of Possible": int(row["total"]),
"Outcomes": row["outcomes"],
"Group": int(row["group_stage"]),
"Knockout": int(row["knockout"]),
"Bonus": int(row["bonus"]),
})
display_df = pd.DataFrame(display_rows)
st.dataframe(
display_df,
use_container_width=True,
hide_index=True,
column_config={
"Rank": st.column_config.TextColumn("Rank", width="small"),
"Model": st.column_config.TextColumn("Model", width="large"),
"Points": st.column_config.NumberColumn("Points", format="%d pts", width="small"),
"of Possible": st.column_config.ProgressColumn(
"of Possible",
help=f"Points earned out of the {possible} attainable so far",
format="%d",
min_value=0,
max_value=int(possible),
),
"Outcomes": st.column_config.TextColumn(
"Outcomes", help="Correct W/D/L group results", width="small"
),
"Group": st.column_config.NumberColumn("Group", format="%d", width="small"),
"Knockout": st.column_config.NumberColumn("Knockout", format="%d", width="small"),
"Bonus": st.column_config.NumberColumn("Bonus", format="%d", width="small"),
},
)
def render_predictions() -> None:
st.markdown('<div class="section-header">📊 Match Predictions</div>', unsafe_allow_html=True)
df = build_predictions_table()
if df.empty:
st.info("No predictions available.")
return
st.caption(
"Each model's predicted outcome per match (Home / Draw / Away). "
"Once a game is played, ✅ / ❌ shows whether the pick was correct."
)
# Filters: group and date (empty selection = show all)
col_g, col_d = st.columns(2)
with col_g:
group_opts = sorted(g for g in df["Group"].unique() if g and g != "—")
sel_groups = st.multiselect("Filter by group", options=group_opts, default=[])
with col_d:
date_opts = sorted(d for d in df["Date"].unique() if d)
sel_dates = st.multiselect("Filter by date", options=date_opts, default=[])
if sel_groups:
df = df[df["Group"].isin(sel_groups)]
if sel_dates:
df = df[df["Date"].isin(sel_dates)]
if df.empty:
st.info("No matches for the selected filters.")
return
model_labels = [meta["label"] for meta in MODEL_META.values()]
column_config = {
"Group": st.column_config.TextColumn("Group", width="small"),
"Date": st.column_config.TextColumn("Date", width="small"),
"Home": st.column_config.TextColumn("Home", width="medium"),
"Away": st.column_config.TextColumn("Away", width="medium"),
"Actual": st.column_config.TextColumn("Actual", width="small"),
}
for label in model_labels:
column_config[label] = st.column_config.TextColumn(label, width="small")
st.dataframe(
df,
use_container_width=True,
hide_index=True,
column_config=column_config,
)
def render_footer() -> None:
st.markdown("""
<hr style="margin-top:3rem"/>
<div style="text-align:center;padding:1.5rem 0;color:#94a3b8;font-size:0.8rem">
<div style="margin-bottom:0.3rem">
Built with <strong style="color:#059669">FootyML</strong> — AI football prediction framework
</div>
<div>
Models trained on historical international tournaments: WC, EC, Copa América, AFCON, Gold Cup
</div>
</div>
""", unsafe_allow_html=True)
# ── Entry point ────────────────────────────────────────────────────────────────
def main() -> None:
render_hero()
render_scoring_rules()
render_leaderboard()
render_predictions()
render_footer()
if __name__ == "__main__":
main()