Spaces:
Running
Running
| """ | |
| Compara predicción del modelo GNN vs baseline vs realidad para | |
| Racing de Santander vs Sporting de Gijón y FC Andorra vs Racing de Santander. | |
| Re-ejecuta el modelo usando los mismos datos que tenía cuando se generaron | |
| los reportes head-to-head (dataset hasta marzo 2026). | |
| """ | |
| import sys | |
| import io | |
| import warnings | |
| import base64 | |
| from pathlib import Path | |
| from typing import Dict, List | |
| import numpy as np | |
| import pandas as pd | |
| import matplotlib | |
| matplotlib.use("Agg") | |
| import matplotlib.pyplot as plt | |
| import matplotlib.patches as mpatches | |
| from matplotlib.gridspec import GridSpec | |
| warnings.filterwarnings("ignore") | |
| PROJECT_ROOT = Path(__file__).resolve().parents[1] | |
| sys.path.insert(0, str(PROJECT_ROOT / "scripts")) | |
| # Importar funciones del script de reportes | |
| from build_head_to_head_report_pro import ( | |
| _predict_single_team_future, | |
| _load_modeling_dataset, | |
| ModelPrediction, | |
| ) | |
| REPORTS_DIR = PROJECT_ROOT / "reports" | |
| REPORTS_DIR.mkdir(exist_ok=True) | |
| # ── Configuración ──────────────────────────────────────────────────────────── | |
| TEAM_IDS = { | |
| "Racing de Santander": "bzkwzatvwahmbzok1ymm5vqa1", | |
| "Sporting de Gijón": "9wfi6tgumbrnkp72z8zab89kr", | |
| "FC Andorra": "chhfxt372lm29p9b96fr2ho4q", | |
| } | |
| ZONE_ORDER = [ | |
| "Deep_Cross__Der_", | |
| "Half_Space__Der_", | |
| "Creativity_Zone", | |
| "Half_Space__Izq_", | |
| "Deep_Cross__Izq_", | |
| "Cross__Der_", | |
| "Cut_Back__Der_", | |
| "Scoring_Zone", | |
| "Cut_Back__Izq_", | |
| "Cross__Izq_", | |
| ] | |
| ZONE_LABELS = { | |
| "Deep_Cross__Der_": "ext der 3/4", | |
| "Half_Space__Der_": "int der 3/4", | |
| "Creativity_Zone": "centro 3/4", | |
| "Half_Space__Izq_": "int izq 3/4", | |
| "Deep_Cross__Izq_": "ext izq 3/4", | |
| "Cross__Der_": "ext der área", | |
| "Cut_Back__Der_": "int der área", | |
| "Scoring_Zone": "centro área", | |
| "Cut_Back__Izq_": "int izq área", | |
| "Cross__Izq_": "ext izq área", | |
| } | |
| ZONE_RECTS = { | |
| "Scoring_Zone": [(83, 100, 37, 63)], | |
| "Cut_Back__Izq_": [(83, 100, 63, 79)], | |
| "Cross__Izq_": [(83, 100, 79, 100)], | |
| "Cut_Back__Der_": [(83, 100, 21, 37)], | |
| "Cross__Der_": [(83, 100, 0, 21)], | |
| "Creativity_Zone": [(60, 83, 37, 63)], | |
| "Half_Space__Izq_": [(60, 83, 63, 79)], | |
| "Deep_Cross__Izq_": [(60, 83, 79, 100)], | |
| "Half_Space__Der_": [(60, 83, 21, 37)], | |
| "Deep_Cross__Der_": [(60, 83, 0, 21)], | |
| } | |
| COLORS = { | |
| "baseline": "#5B8DB8", | |
| "model": "#F0B429", | |
| "actual": "#E05A2B", | |
| } | |
| DARK_BG = "#1A1A2E" | |
| PANEL_BG = "#16213E" | |
| TEXT_COL = "#E8E8E8" | |
| GRID_COL = "#2E4057" | |
| # ── Datos reales desde eventos ─────────────────────────────────────────────── | |
| def assign_attack_zone(df: pd.DataFrame) -> pd.Series: | |
| zone_col = pd.Series(index=df.index, dtype="object") | |
| x = df["x"].astype(float) | |
| y = df["y"].astype(float) | |
| for zone_name, rects in ZONE_RECTS.items(): | |
| mask = pd.Series(False, index=df.index) | |
| for (x0, x1, y0, y1) in rects: | |
| mask |= x.ge(x0) & x.lt(x1) & y.ge(y0) & y.lt(y1) | |
| zone_col.mask(mask, zone_name, inplace=True) | |
| return zone_col | |
| def compute_actual(events_df: pd.DataFrame, match_id: str, team_id: str) -> Dict[str, Dict[str, float]]: | |
| df = events_df[ | |
| (events_df["matchId"] == match_id) & | |
| (events_df["teamId"] == team_id) | |
| ].copy() | |
| if df.empty: | |
| return {"attack": {z: 0.0 for z in ZONE_ORDER}, | |
| "pv": {z: 0.0 for z in ZONE_ORDER}} | |
| if "outcome_value" in df.columns: | |
| df_z = df[(df["outcome_value"] == 1) & df["x"].notna() & df["y"].notna()].copy() | |
| else: | |
| df_z = df[df["x"].notna() & df["y"].notna()].copy() | |
| df_z["attack_zone"] = assign_attack_zone(df_z) | |
| df_z = df_z[df_z["attack_zone"].notna()] | |
| # Attack share | |
| counts = df_z.groupby("attack_zone").size().reindex(ZONE_ORDER, fill_value=0).astype(float) | |
| total = counts.sum() | |
| attack = (counts / total).to_dict() if total > 0 else {z: 0.0 for z in ZONE_ORDER} | |
| # PV share | |
| df_z["pvAdded"] = pd.to_numeric(df_z["pvAdded"], errors="coerce").fillna(0) | |
| pv_by_zone = df_z[df_z["pvAdded"] > 0].groupby("attack_zone")["pvAdded"].sum().reindex(ZONE_ORDER, fill_value=0) | |
| pv_total = pv_by_zone.sum() | |
| pv = (pv_by_zone / pv_total).to_dict() if pv_total > 0 else {z: 0.0 for z in ZONE_ORDER} | |
| return {"attack": attack, "pv": pv} | |
| # ── Gráficos ───────────────────────────────────────────────────────────────── | |
| def _setup_ax(ax): | |
| ax.set_facecolor(PANEL_BG) | |
| for spine in ax.spines.values(): | |
| spine.set_color(GRID_COL) | |
| ax.tick_params(colors=TEXT_COL, labelsize=7) | |
| ax.yaxis.grid(True, color=GRID_COL, linewidth=0.5, zorder=0) | |
| ax.set_axisbelow(True) | |
| def make_triple_bar(ax, zones, baseline_vals, model_vals, actual_vals, title, ylabel): | |
| _setup_ax(ax) | |
| x = np.arange(len(zones)) | |
| w = 0.26 | |
| bl = [baseline_vals.get(z, 0) * 100 for z in zones] | |
| mo = [model_vals.get(z, 0) * 100 for z in zones] | |
| ac = [actual_vals.get(z, 0) * 100 for z in zones] | |
| b1 = ax.bar(x - w, bl, width=w, color=COLORS["baseline"], alpha=0.85, label="Baseline histórico", zorder=3) | |
| b2 = ax.bar(x, mo, width=w, color=COLORS["model"], alpha=0.90, label="Modelo GNN", zorder=3) | |
| b3 = ax.bar(x + w, ac, width=w, color=COLORS["actual"], alpha=0.90, label="Real (partido)", zorder=3) | |
| ax.set_xticks(x) | |
| ax.set_xticklabels([ZONE_LABELS[z] for z in zones], fontsize=7.5, color=TEXT_COL, rotation=30, ha="right") | |
| ax.set_ylabel(ylabel, color=TEXT_COL, fontsize=8) | |
| ax.set_title(title, color=TEXT_COL, fontsize=9, fontweight="bold", pad=6) | |
| ax.set_ylim(0, max(max(bl + mo + ac) * 1.35, 5)) | |
| leg = ax.legend(handles=[b1, b2, b3], fontsize=7, facecolor=PANEL_BG, | |
| edgecolor=GRID_COL, labelcolor=TEXT_COL, framealpha=0.85, | |
| loc="upper right") | |
| def make_error_panel(ax, zones, baseline_vals, model_vals, actual_vals, title): | |
| """ | |
| Error absoluto por zona: |modelo - real| vs |baseline - real| | |
| Barra verde = modelo ganó (se acercó más), roja = baseline ganó. | |
| """ | |
| _setup_ax(ax) | |
| err_model = [abs(model_vals.get(z, 0) - actual_vals.get(z, 0)) * 100 for z in zones] | |
| err_baseline = [abs(baseline_vals.get(z, 0) - actual_vals.get(z, 0)) * 100 for z in zones] | |
| diff = [eb - em for em, eb in zip(err_model, err_baseline)] # + = modelo mejor | |
| colors = ["#2ECC71" if d >= 0 else "#E74C3C" for d in diff] | |
| x = np.arange(len(zones)) | |
| bars = ax.bar(x, diff, color=colors, alpha=0.88, zorder=3) | |
| for bar, d in zip(bars, diff): | |
| if abs(d) > 0.2: | |
| va = "bottom" if d >= 0 else "top" | |
| ax.text(bar.get_x() + bar.get_width()/2, bar.get_height(), | |
| f"{'+' if d > 0 else ''}{d:.1f}pp", | |
| ha="center", va=va, fontsize=6, color=TEXT_COL, fontweight="bold") | |
| ax.axhline(0, color=TEXT_COL, linewidth=0.8, zorder=2) | |
| ax.set_xticks(x) | |
| ax.set_xticklabels([ZONE_LABELS[z] for z in zones], fontsize=7.5, color=TEXT_COL, rotation=30, ha="right") | |
| ax.set_ylabel("Ventaja modelo (pp)\nError baseline − Error modelo", color=TEXT_COL, fontsize=7.5) | |
| ax.set_title(title, color=TEXT_COL, fontsize=9, fontweight="bold", pad=6) | |
| lim = max(abs(d) for d in diff) * 1.4 if any(diff) else 5 | |
| ax.set_ylim(-lim, lim) | |
| # Mini leyenda | |
| p_green = mpatches.Patch(color="#2ECC71", label="Modelo más preciso") | |
| p_red = mpatches.Patch(color="#E74C3C", label="Baseline más preciso") | |
| ax.legend(handles=[p_green, p_red], fontsize=7, facecolor=PANEL_BG, | |
| edgecolor=GRID_COL, labelcolor=TEXT_COL, framealpha=0.85, | |
| loc="upper right") | |
| def compute_summary(zones, baseline_vals, model_vals, actual_vals, metric): | |
| """Devuelve MAE del modelo y del baseline, y en cuántas zonas ganó cada uno.""" | |
| err_model = [abs(model_vals.get(z, 0) - actual_vals.get(z, 0)) for z in zones] | |
| err_baseline = [abs(baseline_vals.get(z, 0) - actual_vals.get(z, 0)) for z in zones] | |
| mae_model = np.mean(err_model) * 100 | |
| mae_baseline = np.mean(err_baseline) * 100 | |
| zones_model = sum(1 for em, eb in zip(err_model, err_baseline) if em < eb) | |
| zones_base = len(zones) - zones_model | |
| winner = "Modelo" if mae_model < mae_baseline else "Baseline" | |
| return { | |
| "metric": metric, | |
| "mae_model": mae_model, | |
| "mae_baseline": mae_baseline, | |
| "zones_model": zones_model, | |
| "zones_base": zones_base, | |
| "winner": winner, | |
| } | |
| def fig_to_b64(fig) -> str: | |
| buf = io.BytesIO() | |
| fig.savefig(buf, format="png", dpi=150, bbox_inches="tight", facecolor=DARK_BG) | |
| plt.close(fig) | |
| buf.seek(0) | |
| return base64.b64encode(buf.read()).decode() | |
| def build_match_figure(match_title, teams_data) -> str: | |
| """ | |
| Genera figura completa para un partido: 2 equipos x 3 filas (ataque, pv, error). | |
| """ | |
| team_names = list(teams_data.keys()) | |
| n = len(team_names) | |
| fig = plt.figure(figsize=(16, 15), facecolor=DARK_BG) | |
| fig.suptitle(match_title, color=TEXT_COL, fontsize=13, fontweight="bold", y=0.99) | |
| gs = GridSpec(3, n, figure=fig, hspace=0.72, wspace=0.32, top=0.94, bottom=0.07) | |
| summaries = [] | |
| for col, team_name in enumerate(team_names): | |
| d = teams_data[team_name] | |
| bl = d["baseline"] | |
| mo = d["model"] | |
| ac = d["actual"] | |
| make_triple_bar( | |
| ax=fig.add_subplot(gs[0, col]), | |
| zones=ZONE_ORDER, | |
| baseline_vals=bl["attack"], model_vals=mo["attack"], actual_vals=ac["attack"], | |
| title=f"{team_name} — Share de Ataque", | |
| ylabel="% acciones en zona", | |
| ) | |
| make_triple_bar( | |
| ax=fig.add_subplot(gs[1, col]), | |
| zones=ZONE_ORDER, | |
| baseline_vals=bl["pv"], model_vals=mo["pv"], actual_vals=ac["pv"], | |
| title=f"{team_name} — Share de Peligro (pvAdded)", | |
| ylabel="% pvAdded en zona", | |
| ) | |
| make_error_panel( | |
| ax=fig.add_subplot(gs[2, col]), | |
| zones=ZONE_ORDER, | |
| baseline_vals=bl["attack"], model_vals=mo["attack"], actual_vals=ac["attack"], | |
| title=f"{team_name} — Ventaja del modelo vs baseline (Ataque)", | |
| ) | |
| summaries.append(compute_summary(ZONE_ORDER, bl["attack"], mo["attack"], ac["attack"], f"{team_name} Ataque")) | |
| summaries.append(compute_summary(ZONE_ORDER, bl["pv"], mo["pv"], ac["pv"], f"{team_name} Peligro")) | |
| # Leyenda global | |
| patches = [ | |
| mpatches.Patch(color=COLORS["baseline"], label="Baseline (promedio histórico)"), | |
| mpatches.Patch(color=COLORS["model"], label="Modelo GNN (predicción pre-partido)"), | |
| mpatches.Patch(color=COLORS["actual"], label="Real (lo que ocurrió)"), | |
| mpatches.Patch(color="#2ECC71", label="Zona donde el modelo ganó al baseline"), | |
| mpatches.Patch(color="#E74C3C", label="Zona donde el baseline fue mejor"), | |
| ] | |
| fig.legend(handles=patches, loc="lower center", ncol=3, fontsize=8, | |
| facecolor=PANEL_BG, edgecolor=GRID_COL, labelcolor=TEXT_COL, | |
| framealpha=0.9, bbox_to_anchor=(0.5, 0.01)) | |
| return fig_to_b64(fig), summaries | |
| def summary_table_html(summaries: list) -> str: | |
| rows = "" | |
| for s in summaries: | |
| winner_color = "#F0B429" if s["winner"] == "Modelo" else "#5B8DB8" | |
| rows += f""" | |
| <tr> | |
| <td>{s['metric']}</td> | |
| <td>{s['mae_baseline']:.2f} pp</td> | |
| <td>{s['mae_model']:.2f} pp</td> | |
| <td style="color:{winner_color}; font-weight:bold">{s['winner']}</td> | |
| <td>{s['zones_model']}/10 zonas</td> | |
| </tr>""" | |
| return f""" | |
| <table> | |
| <thead> | |
| <tr> | |
| <th>Métrica</th> | |
| <th>MAE Baseline</th> | |
| <th>MAE Modelo GNN</th> | |
| <th>Ganador</th> | |
| <th>Zonas modelo mejor</th> | |
| </tr> | |
| </thead> | |
| <tbody>{rows}</tbody> | |
| </table>""" | |
| def build_html(matches: list, output_path: Path): | |
| cards = "" | |
| all_summaries = [] | |
| for m in matches: | |
| img_b64, summaries = build_match_figure(m["title"], m["teams"]) | |
| all_summaries.extend(summaries) | |
| cards += f""" | |
| <div class="card"> | |
| <h2>{m['title']}</h2> | |
| <img src="data:image/png;base64,{img_b64}" /> | |
| </div>""" | |
| table_html = summary_table_html(all_summaries) | |
| html = f"""<!DOCTYPE html> | |
| <html lang="es"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Modelo GNN vs Baseline vs Realidad — Racing</title> | |
| <style> | |
| * {{ box-sizing: border-box; margin: 0; padding: 0; }} | |
| body {{ background: {DARK_BG}; color: {TEXT_COL}; font-family: 'Segoe UI', Arial, sans-serif; padding: 24px; }} | |
| header {{ text-align: center; margin-bottom: 32px; padding-bottom: 16px; border-bottom: 1px solid {GRID_COL}; }} | |
| header h1 {{ font-size: 1.8rem; margin-bottom: 6px; }} | |
| header p {{ color: #8899AA; font-size: 0.92rem; max-width: 760px; margin: 0 auto; line-height: 1.6; }} | |
| .badge {{ display:inline-block; background:#E05A2B; color:white; border-radius:12px; padding:3px 14px; font-size:0.78rem; font-weight:600; margin-bottom:12px; }} | |
| .card {{ background:{PANEL_BG}; border:1px solid {GRID_COL}; border-radius:12px; padding:24px; margin-bottom:32px; }} | |
| .card h2 {{ font-size:1.1rem; margin-bottom:16px; padding-bottom:10px; border-bottom:1px solid {GRID_COL}; }} | |
| .card img {{ width:100%; border-radius:8px; }} | |
| table {{ width:100%; border-collapse:collapse; margin-top:8px; font-size:0.88rem; }} | |
| thead tr {{ background:#0F3460; }} | |
| th, td {{ padding:10px 14px; text-align:left; border-bottom:1px solid {GRID_COL}; }} | |
| tr:hover {{ background:#1e2d45; }} | |
| .info {{ background:{PANEL_BG}; border:1px solid {GRID_COL}; border-radius:8px; padding:16px 20px; margin-bottom:28px; font-size:0.87rem; line-height:1.8; color:#B0C4D8; }} | |
| .info strong {{ color:{TEXT_COL}; }} | |
| .dot {{ display:inline-block; width:11px; height:11px; border-radius:3px; margin-right:5px; vertical-align:middle; }} | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <div class="badge">Post-partido · Análisis de predicción</div> | |
| <h1>Modelo GNN vs Baseline vs Realidad</h1> | |
| <p> | |
| Comparación de las predicciones del modelo GNN y el baseline histórico contra | |
| lo que realmente ocurrió en los partidos de Racing de Santander. | |
| El <strong>panel inferior de cada equipo</strong> muestra la ventaja del modelo sobre el baseline | |
| en cada zona: <span style="color:#2ECC71">verde = modelo más cercano a la realidad</span>, | |
| <span style="color:#E74C3C">rojo = baseline fue mejor</span>. | |
| </p> | |
| </header> | |
| <div class="info"> | |
| <span class="dot" style="background:{COLORS['baseline']}"></span><strong>Baseline:</strong> Promedio histórico de la temporada para cada equipo.<br> | |
| <span class="dot" style="background:{COLORS['model']}"></span><strong>Modelo GNN:</strong> Predicción generada antes del partido usando el dataset de modelado (hasta 08/03/2026).<br> | |
| <span class="dot" style="background:{COLORS['actual']}"></span><strong>Real:</strong> Distribución observada en el partido, calculada desde los eventos crudos.<br> | |
| <strong>MAE:</strong> Error Absoluto Medio en puntos porcentuales (pp). Menor = mejor. | |
| </div> | |
| {cards} | |
| <div class="card"> | |
| <h2>Resumen global — ¿Quién ganó?</h2> | |
| {table_html} | |
| </div> | |
| </body> | |
| </html>""" | |
| output_path.write_text(html, encoding="utf-8") | |
| print(f"Reporte generado: {output_path}") | |
| # ── Main ───────────────────────────────────────────────────────────────────── | |
| def main(): | |
| print("Cargando dataset de modelado...") | |
| df_model = _load_modeling_dataset() | |
| print("Cargando eventos crudos...") | |
| events_df = pd.read_csv( | |
| "/Users/pagrois/Documents/Racing/preprocessed_SSD_25-26.csv", | |
| usecols=["matchId", "teamId", "x", "y", "outcome_value", "pvAdded"], | |
| dtype={"matchId": str, "teamId": str}, | |
| low_memory=True, | |
| ) | |
| LEAGUE = "Spanish Segunda Division" | |
| SEASON = "25-26" | |
| MATCHES = [ | |
| { | |
| "title": "Racing de Santander vs Sporting de Gijón — 1 de abril 2026", | |
| "match_id": "7hegc9covicy699bxsi81xkb8", | |
| "home": ("Racing de Santander", "bzkwzatvwahmbzok1ymm5vqa1"), | |
| "away": ("Sporting de Gijón", "9wfi6tgumbrnkp72z8zab89kr"), | |
| }, | |
| { | |
| "title": "FC Andorra vs Racing de Santander — 5 de abril 2026", | |
| "match_id": "7n8819yv16f6hm7xt95007bis", | |
| "home": ("FC Andorra", "chhfxt372lm29p9b96fr2ho4q"), | |
| "away": ("Racing de Santander", "bzkwzatvwahmbzok1ymm5vqa1"), | |
| }, | |
| ] | |
| matches_out = [] | |
| for mc in MATCHES: | |
| print(f"\nProcesando: {mc['title']}") | |
| teams_out = {} | |
| for role, is_home in [("home", True), ("away", False)]: | |
| team_name, team_id = mc[role] | |
| opp_name, opp_id = mc["away" if role == "home" else "home"] | |
| print(f" → Predicción modelo: {team_name}...") | |
| pred: ModelPrediction = _predict_single_team_future( | |
| df_model=df_model, | |
| team_id=team_id, | |
| opponent_team_id=opp_id, | |
| team_name=team_name, | |
| opponent_name=opp_name, | |
| league=LEAGUE, | |
| season=SEASON, | |
| is_home=is_home, | |
| ) | |
| print(f" → Datos reales: {team_name}...") | |
| actual = compute_actual(events_df, mc["match_id"], team_id) | |
| teams_out[team_name] = { | |
| "baseline": { | |
| "attack": pred.attack_baseline, | |
| "pv": pred.pv_baseline, | |
| }, | |
| "model": { | |
| "attack": pred.attack, | |
| "pv": pred.pv, | |
| }, | |
| "actual": actual, | |
| } | |
| matches_out.append({"title": mc["title"], "teams": teams_out}) | |
| output = REPORTS_DIR / "modelo_vs_realidad_Racing.html" | |
| print("\nGenerando HTML...") | |
| build_html(matches_out, output) | |
| return output | |
| if __name__ == "__main__": | |
| out = main() | |
| print(f"\nListo: {out}") | |