"""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(""" """, 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"""
AI Model Prediction Challenge
⚽ World Cup 2026
{today}  ·  48 teams  ·  104 matches  ·  7 AI models competing
""", unsafe_allow_html=True) def render_scoring_rules() -> None: with st.expander("📋 Scoring Rules", expanded=False): st.markdown("""
⚽ Group Stage
Correct W/D/L outcome+1 pt
Correct group winner (1st)+3 pts
Correct group runner-up (2nd)+2 pts
🏆 Knockout Rounds
Correct Round of 32+2 pts
Correct Round of 16+3 pts
Correct Quarter-Final+4 pts
Correct Semi-Final+5 pts
Correct Finalist+8 pts
Correct Champion+15 pts
✨ Bonus Points
Both finalists correct+5 pts
Correct final result+3 pts
Max possible score: ~185 pts
""", unsafe_allow_html=True) def render_leaderboard() -> None: st.markdown('
🏆 Model Leaderboard
', 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('
📊 Match Predictions
', 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("""
Built with FootyML — AI football prediction framework
Models trained on historical international tournaments: WC, EC, Copa América, AFCON, Gold Cup
""", unsafe_allow_html=True) # ── Entry point ──────────────────────────────────────────────────────────────── def main() -> None: render_hero() render_scoring_rules() render_leaderboard() render_predictions() render_footer() if __name__ == "__main__": main()