Spaces:
Sleeping
Sleeping
| """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 ─────────────────────────────────────────────────────────────── | |
| 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 | |
| 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 ──────────────────────────────────────────────────────────────────── | |
| 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, | |
| } | |
| 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} · 48 teams · 104 matches · 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() | |