Spaces:
Runtime error
Runtime error
| """ | |
| ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| β CopaVision AI β Phase 1: Match Outcome Predictor β | |
| β Senior ML Engineer Pipeline | International Football β | |
| ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| Pipeline Sections: | |
| 1. Data Loading & Preprocessing | |
| 2. Target Variable Engineering | |
| 3. Feature Engineering (rolling/Elo β no data leakage) | |
| 4. Model Training (Logistic Regression + Random Forest) | |
| 5. Evaluation & Comparison | |
| 6. Visualizations | |
| 7. Model Export | |
| 8. Summary & Next Steps | |
| """ | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 0. IMPORTS | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| import warnings | |
| warnings.filterwarnings("ignore") | |
| import numpy as np | |
| import pandas as pd | |
| import matplotlib.pyplot as plt | |
| import matplotlib.gridspec as gridspec | |
| import seaborn as sns | |
| import joblib | |
| from pathlib import Path | |
| from sklearn.linear_model import LogisticRegression | |
| from sklearn.ensemble import RandomForestClassifier | |
| from sklearn.preprocessing import LabelEncoder | |
| from sklearn.metrics import ( | |
| accuracy_score, confusion_matrix, | |
| classification_report, f1_score | |
| ) | |
| # βββ Output directory ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| OUTPUT_DIR = Path("/mnt/user-data/outputs") | |
| OUTPUT_DIR.mkdir(parents=True, exist_ok=True) | |
| RANDOM_STATE = 42 | |
| N_FORM_MATCHES = 5 # rolling window for recent form | |
| TRAIN_CUTOFF_YEAR = 2017 # matches before this β train; >= this β test | |
| print("=" * 70) | |
| print(" CopaVision AI | Phase 1: Match Outcome Predictor") | |
| print("=" * 70) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 1. DATA LOADING & PREPROCESSING | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # WHY: Clean, correctly-typed data is the foundation of any ML project. | |
| # We parse dates so we can sort chronologically β critical for leak-free | |
| # feature engineering. We filter to post-2000 because modern football | |
| # (tactics, fitness, data quality) differs structurally from older eras. | |
| def load_and_preprocess(path: str) -> pd.DataFrame: | |
| """Load raw CSV, enforce types, sort chronologically, filter to 2000+.""" | |
| print("\n[1/7] Loading & preprocessing data...") | |
| df = pd.read_csv(path) | |
| # Convert date string β datetime | |
| df["date"] = pd.to_datetime(df["date"]) | |
| # Sort chronologically β ESSENTIAL before any rolling operation | |
| df = df.sort_values("date").reset_index(drop=True) | |
| # Filter: modern football era only | |
| df = df[df["date"].dt.year >= 2000].reset_index(drop=True) | |
| # Cast neutral to int (Trueβ1, Falseβ0) for ML | |
| df["neutral"] = df["neutral"].astype(int) | |
| # Add a match_id column for traceability | |
| df["match_id"] = df.index | |
| print(f" β Loaded {len(df):,} matches | " | |
| f"{df['date'].dt.year.min()}β{df['date'].dt.year.max()}") | |
| print(f" β Teams: {df['home_team'].nunique()} | " | |
| f"Tournaments: {df['tournament'].nunique()}") | |
| return df | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 2. TARGET VARIABLE | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # WHY: We frame prediction as a 3-class problem: Home Win (0), Away Win (1), | |
| # Draw (2). This is standard in football betting/analytics literature and | |
| # captures the full match outcome space. | |
| def create_target(df: pd.DataFrame) -> pd.DataFrame: | |
| """Add outcome column: 0=Home Win, 1=Away Win, 2=Draw.""" | |
| print("\n[2/7] Creating target variable...") | |
| conditions = [ | |
| df["home_score"] > df["away_score"], # Home Win | |
| df["home_score"] < df["away_score"], # Away Win | |
| df["home_score"] == df["away_score"], # Draw | |
| ] | |
| df["outcome"] = np.select(conditions, [0, 1, 2]) | |
| counts = df["outcome"].value_counts().sort_index() | |
| labels = {0: "Home Win", 1: "Away Win", 2: "Draw"} | |
| print(" Class distribution:") | |
| for cls, cnt in counts.items(): | |
| pct = cnt / len(df) * 100 | |
| print(f" {labels[cls]:10s} β {cnt:,} ({pct:.1f}%)") | |
| return df | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 3. FEATURE ENGINEERING | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # CRITICAL DESIGN RULE β NO DATA LEAKAGE: | |
| # Every feature for match i must be computed using ONLY matches 0..(i-1). | |
| # We achieve this by iterating through the sorted dataframe and maintaining | |
| # per-team running histories β never "looking ahead". | |
| # | |
| # FEATURE RATIONALE: | |
| # β’ Recent Points (3/1/0): captures current form, the single best proxy | |
| # for short-term team performance in sports analytics. | |
| # β’ Avg Goals Scored/Conceded: offensive & defensive strength signals. | |
| # β’ Rolling Goal Difference: net attacking dominance over recent games. | |
| # β’ Neutral Venue: eliminates home advantage, shifts dynamics significantly. | |
| # β’ Tournament Type: FIFA World Cup β friendly β context matters enormously. | |
| # β’ Elo Rating: globally recognised continuous team-strength estimator. | |
| # Invented for chess, widely used in football analytics. Accounts for | |
| # opponent quality and recency better than raw win/loss records. | |
| # ββ 3a. Tournament importance encoder ββββββββββββββββββββββββββββββββββββββββ | |
| TOURNAMENT_IMPORTANCE = { | |
| "FIFA World Cup": 5, | |
| "UEFA Euro": 5, | |
| "Copa America": 5, | |
| "AFC Asian Cup": 4, | |
| "African Cup of Nations": 4, | |
| "Gold Cup": 4, | |
| "FIFA World Cup qualification": 3, | |
| "UEFA Euro qualification": 3, | |
| "UEFA Nations League": 3, | |
| "Friendly": 1, | |
| } | |
| def get_tournament_importance(tournament: str) -> int: | |
| """Map tournament name to an importance tier (1β5).""" | |
| for key, val in TOURNAMENT_IMPORTANCE.items(): | |
| if key.lower() in tournament.lower(): | |
| return val | |
| return 2 # default for unrecognised tournaments | |
| # ββ 3b. Elo Rating System βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Standard football Elo implementation: | |
| # β’ K-factor scales with match importance | |
| # β’ Expected score computed from rating difference via logistic curve | |
| # β’ Ratings updated after every match | |
| ELO_DEFAULT = 1500.0 # starting rating for all teams | |
| ELO_K_BASE = 20.0 # base K-factor | |
| def elo_expected(rating_a: float, rating_b: float) -> float: | |
| """Expected score for team A vs team B under Elo model.""" | |
| return 1.0 / (1.0 + 10 ** ((rating_b - rating_a) / 400.0)) | |
| def update_elo(rating: float, expected: float, actual: float, k: float) -> float: | |
| """Return updated Elo rating after one match.""" | |
| return rating + k * (actual - expected) | |
| # ββ 3c. Main feature-engineering function βββββββββββββββββββββββββββββββββββββ | |
| def engineer_features(df: pd.DataFrame) -> pd.DataFrame: | |
| """ | |
| Sequentially build all features for every match row. | |
| Processes matches in chronological order; for each match reads | |
| only past records from `team_history` β zero leakage guaranteed. | |
| """ | |
| print("\n[3/7] Engineering features (sequential β no leakage)...") | |
| print(f" Rolling window: last {N_FORM_MATCHES} matches per team") | |
| # Per-team history: list of dicts {goals_for, goals_against, points} | |
| team_history: dict[str, list[dict]] = {} | |
| # Per-team Elo rating (live, updates after each processed match) | |
| elo_ratings: dict[str, float] = {} | |
| rows = [] # accumulate feature dicts | |
| for _, row in df.iterrows(): | |
| home = row["home_team"] | |
| away = row["away_team"] | |
| # ββ Fetch histories (empty list if first appearance) ββββββββββββββ | |
| h_hist = team_history.get(home, []) | |
| a_hist = team_history.get(away, []) | |
| h_recent = h_hist[-N_FORM_MATCHES:] | |
| a_recent = a_hist[-N_FORM_MATCHES:] | |
| # ββ Recent form points (3=W, 1=D, 0=L) βββββββββββββββββββββββββββ | |
| h_pts = np.mean([m["points"] for m in h_recent]) if h_recent else 1.0 | |
| a_pts = np.mean([m["points"] for m in a_recent]) if a_recent else 1.0 | |
| # ββ Avg goals scored / conceded βββββββββββββββββββββββββββββββββββ | |
| h_scored = np.mean([m["goals_for"] for m in h_recent]) if h_recent else 1.0 | |
| h_conceded = np.mean([m["goals_against"] for m in h_recent]) if h_recent else 1.0 | |
| a_scored = np.mean([m["goals_for"] for m in a_recent]) if a_recent else 1.0 | |
| a_conceded = np.mean([m["goals_against"] for m in a_recent]) if a_recent else 1.0 | |
| # ββ Rolling goal difference βββββββββββββββββββββββββββββββββββββββ | |
| h_gd = np.mean([m["goals_for"] - m["goals_against"] for m in h_recent]) if h_recent else 0.0 | |
| a_gd = np.mean([m["goals_for"] - m["goals_against"] for m in a_recent]) if a_recent else 0.0 | |
| # ββ Elo ratings BEFORE this match βββββββββββββββββββββββββββββββββ | |
| h_elo = elo_ratings.get(home, ELO_DEFAULT) | |
| a_elo = elo_ratings.get(away, ELO_DEFAULT) | |
| elo_diff = h_elo - a_elo # positive β home team is stronger | |
| # ββ Tournament features βββββββββββββββββββββββββββββββββββββββββββ | |
| tournament_importance = get_tournament_importance(row["tournament"]) | |
| # ββ Store this match's feature vector βββββββββββββββββββββββββββββ | |
| rows.append({ | |
| "match_id": row["match_id"], | |
| "home_recent_points": h_pts, | |
| "away_recent_points": a_pts, | |
| "home_avg_goals_scored": h_scored, | |
| "away_avg_goals_scored": a_scored, | |
| "home_avg_goals_conceded": h_conceded, | |
| "away_avg_goals_conceded": a_conceded, | |
| "home_rolling_gd": h_gd, | |
| "away_rolling_gd": a_gd, | |
| "elo_diff": elo_diff, | |
| "home_elo": h_elo, | |
| "away_elo": a_elo, | |
| "neutral_venue": row["neutral"], | |
| "tournament_importance": tournament_importance, | |
| }) | |
| # ββ NOW update histories and Elo with actual match result βββββββββ | |
| hs, as_ = row["home_score"], row["away_score"] | |
| if hs > as_: | |
| h_pts_earned, a_pts_earned = 3, 0 | |
| h_actual, a_actual = 1.0, 0.0 | |
| elif hs < as_: | |
| h_pts_earned, a_pts_earned = 0, 3 | |
| h_actual, a_actual = 0.0, 1.0 | |
| else: | |
| h_pts_earned, a_pts_earned = 1, 1 | |
| h_actual, a_actual = 0.5, 0.5 | |
| k = ELO_K_BASE * tournament_importance # higher-stakes β larger update | |
| h_exp = elo_expected(h_elo, a_elo) | |
| a_exp = elo_expected(a_elo, h_elo) | |
| elo_ratings[home] = update_elo(h_elo, h_exp, h_actual, k) | |
| elo_ratings[away] = update_elo(a_elo, a_exp, a_actual, k) | |
| for team, gf, ga, pts in [ | |
| (home, hs, as_, h_pts_earned), | |
| (away, as_, hs, a_pts_earned), | |
| ]: | |
| team_history.setdefault(team, []).append({ | |
| "goals_for": gf, | |
| "goals_against": ga, | |
| "points": pts, | |
| }) | |
| features_df = pd.DataFrame(rows) | |
| df = df.merge(features_df, on="match_id") | |
| print(f" β Features built for {len(df):,} matches") | |
| print(f" β Feature columns: {features_df.columns.drop('match_id').tolist()}") | |
| return df | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 4. TRAIN / TEST SPLIT (time-based β never random) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # WHY TIME-BASED SPLIT: | |
| # A random split would let the model "see" 2019 matches while training and | |
| # predict 2010 matches β a form of leakage. In sports analytics the correct | |
| # split mirrors real deployment: train on the past, predict the future. | |
| FEATURE_COLS = [ | |
| "home_recent_points", "away_recent_points", | |
| "home_avg_goals_scored", "away_avg_goals_scored", | |
| "home_avg_goals_conceded", "away_avg_goals_conceded", | |
| "home_rolling_gd", "away_rolling_gd", | |
| "elo_diff", "home_elo", "away_elo", | |
| "neutral_venue", "tournament_importance", | |
| ] | |
| def time_split(df: pd.DataFrame): | |
| """Split into train / test strictly by date (no shuffle).""" | |
| print(f"\n[4/7] Time-based train/test split (cutoff: {TRAIN_CUTOFF_YEAR})...") | |
| train = df[df["date"].dt.year < TRAIN_CUTOFF_YEAR] | |
| test = df[df["date"].dt.year >= TRAIN_CUTOFF_YEAR] | |
| X_train = train[FEATURE_COLS].values | |
| y_train = train["outcome"].values | |
| X_test = test[FEATURE_COLS].values | |
| y_test = test["outcome"].values | |
| print(f" Train: {len(train):,} matches " | |
| f"({train['date'].dt.year.min()}β{train['date'].dt.year.max()})") | |
| print(f" Test : {len(test):,} matches " | |
| f"({test['date'].dt.year.min()}β{test['date'].dt.year.max()})") | |
| return X_train, X_test, y_train, y_test, train, test | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 5. MODEL TRAINING | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def train_models(X_train, y_train): | |
| """Train Logistic Regression (baseline) and Random Forest.""" | |
| print("\n[5/7] Training models...") | |
| # Logistic Regression β fast, interpretable baseline. | |
| # C=0.1 provides mild L2 regularisation to prevent overfit on noisy labels. | |
| lr = LogisticRegression( | |
| C=0.1, | |
| max_iter=1000, | |
| solver="lbfgs", | |
| random_state=RANDOM_STATE, | |
| ) | |
| lr.fit(X_train, y_train) | |
| print(" β Logistic Regression trained") | |
| # Random Forest β captures non-linear interactions (e.g., Elo Γ form). | |
| # 300 trees; class_weight='balanced' handles class imbalance automatically. | |
| rf = RandomForestClassifier( | |
| n_estimators=300, | |
| max_depth=8, | |
| min_samples_leaf=20, | |
| class_weight="balanced", | |
| random_state=RANDOM_STATE, | |
| n_jobs=-1, | |
| ) | |
| rf.fit(X_train, y_train) | |
| print(" β Random Forest trained") | |
| return lr, rf | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 6. EVALUATION | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| CLASS_NAMES = ["Home Win", "Away Win", "Draw"] | |
| def evaluate_model(model, X_test, y_test, model_name: str) -> dict: | |
| """Return accuracy, macro F1, confusion matrix, and class report.""" | |
| y_pred = model.predict(X_test) | |
| acc = accuracy_score(y_test, y_pred) | |
| f1_mac = f1_score(y_test, y_pred, average="macro") | |
| cm = confusion_matrix(y_test, y_pred) | |
| report = classification_report(y_test, y_pred, target_names=CLASS_NAMES) | |
| print(f"\n ββ {model_name} ββ") | |
| print(f" Accuracy : {acc:.4f} ({acc*100:.1f}%)") | |
| print(f" F1 Macro : {f1_mac:.4f}") | |
| print(f"\n Classification Report:\n{report}") | |
| # Probability predictions (for display purposes) | |
| probs = model.predict_proba(X_test) | |
| return { | |
| "name": model_name, | |
| "model": model, | |
| "y_pred": y_pred, | |
| "probs": probs, | |
| "accuracy": acc, | |
| "f1_macro": f1_mac, | |
| "cm": cm, | |
| } | |
| def run_evaluation(lr, rf, X_test, y_test): | |
| print("\n[6/7] Evaluating models...") | |
| lr_res = evaluate_model(lr, X_test, y_test, "Logistic Regression") | |
| rf_res = evaluate_model(rf, X_test, y_test, "Random Forest") | |
| return lr_res, rf_res | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 7. VISUALIZATIONS | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| PALETTE = { | |
| "bg": "#0d1117", | |
| "panel": "#161b22", | |
| "border": "#30363d", | |
| "accent1": "#58a6ff", | |
| "accent2": "#3fb950", | |
| "accent3": "#f78166", | |
| "accent4": "#d2a8ff", | |
| "text": "#e6edf3", | |
| "muted": "#8b949e", | |
| } | |
| def _apply_style(fig, axes_list): | |
| """Apply dark CopaVision theme to figure and axes.""" | |
| fig.patch.set_facecolor(PALETTE["bg"]) | |
| for ax in axes_list: | |
| ax.set_facecolor(PALETTE["panel"]) | |
| ax.tick_params(colors=PALETTE["text"], labelsize=9) | |
| ax.xaxis.label.set_color(PALETTE["text"]) | |
| ax.yaxis.label.set_color(PALETTE["text"]) | |
| ax.title.set_color(PALETTE["text"]) | |
| for spine in ax.spines.values(): | |
| spine.set_edgecolor(PALETTE["border"]) | |
| def plot_class_distribution(df: pd.DataFrame, save_path: Path): | |
| """Bar chart showing Home Win / Away Win / Draw distribution.""" | |
| fig, ax = plt.subplots(figsize=(8, 5)) | |
| _apply_style(fig, [ax]) | |
| counts = df["outcome"].value_counts().sort_index() | |
| colors = [PALETTE["accent2"], PALETTE["accent3"], PALETTE["accent1"]] | |
| bars = ax.bar(CLASS_NAMES, counts.values, color=colors, | |
| edgecolor=PALETTE["border"], linewidth=0.8, width=0.55) | |
| for bar, cnt in zip(bars, counts.values): | |
| pct = cnt / len(df) * 100 | |
| ax.text(bar.get_x() + bar.get_width() / 2, | |
| bar.get_height() + 40, | |
| f"{cnt:,}\n({pct:.1f}%)", | |
| ha="center", va="bottom", | |
| color=PALETTE["text"], fontsize=10, fontweight="bold") | |
| ax.set_title("Match Outcome Distribution (2000β2020)", fontsize=13, | |
| fontweight="bold", pad=14) | |
| ax.set_ylabel("Number of Matches") | |
| ax.set_ylim(0, counts.max() * 1.18) | |
| ax.grid(axis="y", color=PALETTE["border"], linestyle="--", alpha=0.5) | |
| ax.set_axisbelow(True) | |
| fig.tight_layout() | |
| fig.savefig(save_path, dpi=150, bbox_inches="tight", facecolor=PALETTE["bg"]) | |
| plt.close(fig) | |
| print(f" β Saved: {save_path.name}") | |
| def plot_confusion_matrices(lr_res: dict, rf_res: dict, save_path: Path): | |
| """Side-by-side normalised confusion matrices for both models.""" | |
| fig, axes = plt.subplots(1, 2, figsize=(14, 5)) | |
| _apply_style(fig, axes) | |
| for ax, res in zip(axes, [lr_res, rf_res]): | |
| cm_norm = res["cm"].astype(float) / res["cm"].sum(axis=1, keepdims=True) | |
| sns.heatmap( | |
| cm_norm, | |
| annot=True, | |
| fmt=".2f", | |
| cmap="Blues", | |
| xticklabels=CLASS_NAMES, | |
| yticklabels=CLASS_NAMES, | |
| ax=ax, | |
| linewidths=0.5, | |
| linecolor=PALETTE["border"], | |
| cbar_kws={"shrink": 0.8}, | |
| ) | |
| ax.set_title( | |
| f"{res['name']}\nAcc: {res['accuracy']*100:.1f}% | " | |
| f"F1: {res['f1_macro']:.3f}", | |
| fontsize=11, fontweight="bold", color=PALETTE["text"] | |
| ) | |
| ax.set_xlabel("Predicted", color=PALETTE["text"]) | |
| ax.set_ylabel("Actual", color=PALETTE["text"]) | |
| ax.tick_params(colors=PALETTE["text"]) | |
| fig.suptitle("Confusion Matrices β CopaVision AI Phase 1", | |
| fontsize=14, fontweight="bold", color=PALETTE["text"], y=1.02) | |
| fig.tight_layout() | |
| fig.savefig(save_path, dpi=150, bbox_inches="tight", facecolor=PALETTE["bg"]) | |
| plt.close(fig) | |
| print(f" β Saved: {save_path.name}") | |
| def plot_feature_importance(rf_model, save_path: Path): | |
| """Horizontal bar chart of Random Forest feature importances.""" | |
| importances = rf_model.feature_importances_ | |
| idx = np.argsort(importances) | |
| features_sorted = [FEATURE_COLS[i] for i in idx] | |
| imp_sorted = importances[idx] | |
| colors = [PALETTE["accent1"] if "elo" in f | |
| else PALETTE["accent2"] if "points" in f | |
| else PALETTE["accent4"] if "goal" in f or "gd" in f | |
| else PALETTE["muted"] | |
| for f in features_sorted] | |
| fig, ax = plt.subplots(figsize=(9, 6)) | |
| _apply_style(fig, [ax]) | |
| bars = ax.barh(features_sorted, imp_sorted, color=colors, | |
| edgecolor=PALETTE["border"], linewidth=0.6, height=0.65) | |
| for bar, val in zip(bars, imp_sorted): | |
| ax.text(val + 0.001, bar.get_y() + bar.get_height() / 2, | |
| f"{val:.3f}", va="center", fontsize=8, color=PALETTE["text"]) | |
| ax.set_title("Feature Importances β Random Forest", fontsize=13, | |
| fontweight="bold", pad=12) | |
| ax.set_xlabel("Importance (Gini)") | |
| ax.grid(axis="x", color=PALETTE["border"], linestyle="--", alpha=0.5) | |
| ax.set_axisbelow(True) | |
| # Legend | |
| from matplotlib.patches import Patch | |
| legend_elements = [ | |
| Patch(facecolor=PALETTE["accent1"], label="Elo Rating"), | |
| Patch(facecolor=PALETTE["accent2"], label="Recent Form"), | |
| Patch(facecolor=PALETTE["accent4"], label="Goals / GD"), | |
| Patch(facecolor=PALETTE["muted"], label="Context"), | |
| ] | |
| ax.legend(handles=legend_elements, loc="lower right", | |
| facecolor=PALETTE["panel"], edgecolor=PALETTE["border"], | |
| labelcolor=PALETTE["text"], fontsize=9) | |
| fig.tight_layout() | |
| fig.savefig(save_path, dpi=150, bbox_inches="tight", facecolor=PALETTE["bg"]) | |
| plt.close(fig) | |
| print(f" β Saved: {save_path.name}") | |
| def plot_model_comparison(lr_res: dict, rf_res: dict, save_path: Path): | |
| """Grouped bar chart comparing accuracy & F1 between models.""" | |
| metrics = ["Accuracy", "F1 Macro"] | |
| lr_vals = [lr_res["accuracy"], lr_res["f1_macro"]] | |
| rf_vals = [rf_res["accuracy"], rf_res["f1_macro"]] | |
| x = np.arange(len(metrics)) | |
| width = 0.32 | |
| fig, ax = plt.subplots(figsize=(7, 5)) | |
| _apply_style(fig, [ax]) | |
| b1 = ax.bar(x - width / 2, lr_vals, width, label="Logistic Regression", | |
| color=PALETTE["accent1"], edgecolor=PALETTE["border"], linewidth=0.8) | |
| b2 = ax.bar(x + width / 2, rf_vals, width, label="Random Forest", | |
| color=PALETTE["accent2"], edgecolor=PALETTE["border"], linewidth=0.8) | |
| for bars in [b1, b2]: | |
| for bar in bars: | |
| ax.text(bar.get_x() + bar.get_width() / 2, | |
| bar.get_height() + 0.005, | |
| f"{bar.get_height():.3f}", | |
| ha="center", va="bottom", | |
| color=PALETTE["text"], fontsize=10, fontweight="bold") | |
| ax.set_xticks(x) | |
| ax.set_xticklabels(metrics, fontsize=11) | |
| ax.set_ylim(0, 0.85) | |
| ax.set_title("Model Comparison β CopaVision AI Phase 1", | |
| fontsize=13, fontweight="bold", pad=12) | |
| ax.set_ylabel("Score") | |
| ax.legend(facecolor=PALETTE["panel"], edgecolor=PALETTE["border"], | |
| labelcolor=PALETTE["text"], fontsize=9) | |
| ax.grid(axis="y", color=PALETTE["border"], linestyle="--", alpha=0.5) | |
| ax.set_axisbelow(True) | |
| fig.tight_layout() | |
| fig.savefig(save_path, dpi=150, bbox_inches="tight", facecolor=PALETTE["bg"]) | |
| plt.close(fig) | |
| print(f" β Saved: {save_path.name}") | |
| def generate_visualizations(df, lr_res, rf_res, rf_model, output_dir: Path): | |
| print("\n[7/7] Generating visualizations...") | |
| plot_class_distribution(df, | |
| output_dir / "1_class_distribution.png") | |
| plot_confusion_matrices(lr_res, rf_res, | |
| output_dir / "2_confusion_matrices.png") | |
| plot_feature_importance(rf_model, | |
| output_dir / "3_feature_importance.png") | |
| plot_model_comparison(lr_res, rf_res, | |
| output_dir / "4_model_comparison.png") | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 8. MODEL EXPORT | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def save_models(lr, rf, output_dir: Path): | |
| """Persist trained models with joblib for later inference.""" | |
| joblib.dump(lr, output_dir / "copavision_lr.pkl") | |
| joblib.dump(rf, output_dir / "copavision_rf.pkl") | |
| print("\n β Models saved:") | |
| print(f" {output_dir / 'copavision_lr.pkl'}") | |
| print(f" {output_dir / 'copavision_rf.pkl'}") | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 9. PROBABILITY PREDICTION DEMO | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def predict_match_proba(model, feature_vector: list, model_name: str = "Model"): | |
| """ | |
| Given a pre-built feature vector, return win/draw probabilities. | |
| In production this would be called via a REST API endpoint. | |
| feature_vector order matches FEATURE_COLS exactly. | |
| """ | |
| X = np.array(feature_vector).reshape(1, -1) | |
| probs = model.predict_proba(X)[0] | |
| pred = model.predict(X)[0] | |
| label_map = {0: "Home Win", 1: "Away Win", 2: "Draw"} | |
| print(f"\n [{model_name}] Probability Prediction:") | |
| for cls, prob in enumerate(probs): | |
| marker = " β predicted" if cls == pred else "" | |
| print(f" {label_map[cls]:10s}: {prob:.3f} ({prob*100:.1f}%){marker}") | |
| return probs | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # MAIN PIPELINE | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def main(): | |
| DATA_PATH = "/mnt/user-data/uploads/results.csv" | |
| # 1. Load & preprocess | |
| df = load_and_preprocess(DATA_PATH) | |
| # 2. Target variable | |
| df = create_target(df) | |
| # 3. Feature engineering | |
| df = engineer_features(df) | |
| # 4. Train/test split | |
| X_train, X_test, y_train, y_test, train_df, test_df = time_split(df) | |
| # 5. Train models | |
| lr, rf = train_models(X_train, y_train) | |
| # 6. Evaluate | |
| lr_res, rf_res = run_evaluation(lr, rf, X_test, y_test) | |
| # 7. Visualizations | |
| generate_visualizations(df, lr_res, rf_res, rf, OUTPUT_DIR) | |
| # 8. Save models | |
| save_models(lr, rf, OUTPUT_DIR) | |
| # 9. Probability demo: Brazil vs Argentina on neutral ground | |
| print("\nββββββββββββββββββββββββββββββββββββββββββββββ") | |
| print(" DEMO: Brazil vs Argentina (neutral venue)") | |
| print("ββββββββββββββββββββββββββββββββββββββββββββββ") | |
| # Feature vector: [h_pts, a_pts, h_scr, a_scr, h_con, a_con, | |
| # h_gd, a_gd, elo_diff, h_elo, a_elo, neutral, importance] | |
| demo_features = [2.2, 2.1, 1.9, 1.8, 0.9, 1.0, | |
| 0.8, 0.7, 50.0, 1900.0, 1850.0, 1, 5] | |
| predict_match_proba(rf, demo_features, "Random Forest") | |
| predict_match_proba(lr, demo_features, "Logistic Regression") | |
| # Final summary | |
| best = rf_res if rf_res["accuracy"] >= lr_res["accuracy"] else lr_res | |
| print("\n" + "=" * 70) | |
| print(" FINAL SUMMARY") | |
| print("=" * 70) | |
| print(f" Best model : {best['name']}") | |
| print(f" Accuracy : {best['accuracy']*100:.1f}%") | |
| print(f" Macro F1 : {best['f1_macro']:.4f}") | |
| print(f" Train period : 2000β{TRAIN_CUTOFF_YEAR - 1}") | |
| print(f" Test period : {TRAIN_CUTOFF_YEAR}β2020") | |
| print(f" Outputs : {OUTPUT_DIR}") | |
| print("=" * 70) | |
| if __name__ == "__main__": | |
| main() | |