Spaces:
Running
Running
| from __future__ import annotations | |
| from pathlib import Path | |
| import argparse | |
| import re | |
| import unicodedata | |
| import numpy as np | |
| import pandas as pd | |
| import plotly.graph_objects as go | |
| import plotly.io as pio | |
| from plotly.subplots import make_subplots | |
| PROJECT_ROOT = Path(__file__).resolve().parent.parent | |
| DATA_PATH = Path("/Users/pagrois/Documents/Racing/preprocessed_SSD_25-26.csv") | |
| EVENTS_PARQUET_PATH = Path( | |
| "/Users/pagrois/Racing/data/processed/events_parquet/league=Spanish%20Segunda%20Division/season=25-26" | |
| ) | |
| REPORTS_DIR = PROJECT_ROOT / "reports" | |
| DATA_DIR = PROJECT_ROOT / "data" / "analysis" | |
| REPORTS_DIR.mkdir(parents=True, exist_ok=True) | |
| DATA_DIR.mkdir(parents=True, exist_ok=True) | |
| BLOCK_MAP = { | |
| "Build Up against Low Block": "bloque_bajo", | |
| "Build Up against Medium Block": "bloque_medio", | |
| "Build Up against High Block": "bloque_alto", | |
| } | |
| BLOCK_ORDER = ["bloque_bajo", "bloque_medio", "bloque_alto"] | |
| BLOCK_RANK = {b: i for i, b in enumerate(BLOCK_ORDER)} | |
| ROLE_ORDER = ["Local", "Visitante"] | |
| OFF_METRICS = [ | |
| "goles_por_posesion__bloque_bajo", | |
| "goles_por_posesion__bloque_medio", | |
| "goles_por_posesion__bloque_alto", | |
| "xg_por_posesion__bloque_bajo", | |
| "xg_por_posesion__bloque_medio", | |
| "xg_por_posesion__bloque_alto", | |
| "peligro_por_posesion__bloque_bajo", | |
| "peligro_por_posesion__bloque_medio", | |
| "peligro_por_posesion__bloque_alto", | |
| "pct_partidos_ganados__mayoria_bloque_medio", | |
| "pct_partidos_ganados__mayoria_bloque_alto", | |
| ] | |
| DEF_METRICS = [ | |
| "goles_recibidos_por_posesion__bloque_bajo", | |
| "goles_recibidos_por_posesion__bloque_medio", | |
| "goles_recibidos_por_posesion__bloque_alto", | |
| "xg_recibido_por_posesion__bloque_bajo", | |
| "xg_recibido_por_posesion__bloque_medio", | |
| "xg_recibido_por_posesion__bloque_alto", | |
| "peligro_recibido_por_posesion__bloque_bajo", | |
| "peligro_recibido_por_posesion__bloque_medio", | |
| "peligro_recibido_por_posesion__bloque_alto", | |
| "pct_puntos_ganados__mayoria_bloque_medio", | |
| "pct_puntos_ganados__mayoria_bloque_alto", | |
| ] | |
| EXPOSURE_METRICS = [ | |
| "partidos_contra__bloque_bajo", | |
| "partidos_contra__bloque_medio", | |
| "partidos_contra__bloque_alto", | |
| "posesiones_contra__bloque_bajo", | |
| "posesiones_contra__bloque_medio", | |
| "posesiones_contra__bloque_alto", | |
| "partidos_con__bloque_bajo", | |
| "partidos_con__bloque_medio", | |
| "partidos_con__bloque_alto", | |
| "posesiones_con__bloque_bajo", | |
| "posesiones_con__bloque_medio", | |
| "posesiones_con__bloque_alto", | |
| ] | |
| OFF_EXPOSURE_METRICS = [ | |
| "partidos_contra__bloque_bajo", | |
| "partidos_contra__bloque_medio", | |
| "partidos_contra__bloque_alto", | |
| "posesiones_contra__bloque_bajo", | |
| "posesiones_contra__bloque_medio", | |
| "posesiones_contra__bloque_alto", | |
| ] | |
| DEF_EXPOSURE_METRICS = [ | |
| "partidos_con__bloque_bajo", | |
| "partidos_con__bloque_medio", | |
| "partidos_con__bloque_alto", | |
| "posesiones_con__bloque_bajo", | |
| "posesiones_con__bloque_medio", | |
| "posesiones_con__bloque_alto", | |
| ] | |
| METRIC_LABEL = { | |
| "goles_por_posesion__bloque_bajo": "Goles por posesion vs bloque bajo", | |
| "goles_por_posesion__bloque_medio": "Goles por posesion vs bloque medio", | |
| "goles_por_posesion__bloque_alto": "Goles por posesion vs bloque alto", | |
| "xg_por_posesion__bloque_bajo": "xG por posesion vs bloque bajo", | |
| "xg_por_posesion__bloque_medio": "xG por posesion vs bloque medio", | |
| "xg_por_posesion__bloque_alto": "xG por posesion vs bloque alto", | |
| "peligro_por_posesion__bloque_bajo": "Peligro por posesion vs bloque bajo", | |
| "peligro_por_posesion__bloque_medio": "Peligro por posesion vs bloque medio", | |
| "peligro_por_posesion__bloque_alto": "Peligro por posesion vs bloque alto", | |
| "pct_partidos_ganados__mayoria_bloque_medio": "% partidos ganados si el rival defendio mayormente en bloque medio", | |
| "pct_partidos_ganados__mayoria_bloque_alto": "% partidos ganados si el rival defendio mayormente en bloque alto", | |
| "goles_recibidos_por_posesion__bloque_bajo": "Goles recibidos por posesion defendiendo bloque bajo", | |
| "goles_recibidos_por_posesion__bloque_medio": "Goles recibidos por posesion defendiendo bloque medio", | |
| "goles_recibidos_por_posesion__bloque_alto": "Goles recibidos por posesion defendiendo bloque alto", | |
| "xg_recibido_por_posesion__bloque_bajo": "xG recibido por posesion defendiendo bloque bajo", | |
| "xg_recibido_por_posesion__bloque_medio": "xG recibido por posesion defendiendo bloque medio", | |
| "xg_recibido_por_posesion__bloque_alto": "xG recibido por posesion defendiendo bloque alto", | |
| "peligro_recibido_por_posesion__bloque_bajo": "Peligro recibido por posesion defendiendo bloque bajo", | |
| "peligro_recibido_por_posesion__bloque_medio": "Peligro recibido por posesion defendiendo bloque medio", | |
| "peligro_recibido_por_posesion__bloque_alto": "Peligro recibido por posesion defendiendo bloque alto", | |
| "pct_puntos_ganados__mayoria_bloque_medio": "% puntos ganados defendiendo mayormente en bloque medio", | |
| "pct_puntos_ganados__mayoria_bloque_alto": "% puntos ganados defendiendo mayormente en bloque alto", | |
| "partidos_contra__bloque_bajo": "Partidos contra bloque bajo", | |
| "partidos_contra__bloque_medio": "Partidos contra bloque medio", | |
| "partidos_contra__bloque_alto": "Partidos contra bloque alto", | |
| "posesiones_contra__bloque_bajo": "Posesiones contra bloque bajo", | |
| "posesiones_contra__bloque_medio": "Posesiones contra bloque medio", | |
| "posesiones_contra__bloque_alto": "Posesiones contra bloque alto", | |
| "partidos_con__bloque_bajo": "Partidos con bloque bajo propio", | |
| "partidos_con__bloque_medio": "Partidos con bloque medio propio", | |
| "partidos_con__bloque_alto": "Partidos con bloque alto propio", | |
| "posesiones_con__bloque_bajo": "Posesiones con bloque bajo propio", | |
| "posesiones_con__bloque_medio": "Posesiones con bloque medio propio", | |
| "posesiones_con__bloque_alto": "Posesiones con bloque alto propio", | |
| } | |
| GOOD_HIGH_METRICS = { | |
| "goles_por_posesion__bloque_bajo", | |
| "goles_por_posesion__bloque_medio", | |
| "goles_por_posesion__bloque_alto", | |
| "xg_por_posesion__bloque_bajo", | |
| "xg_por_posesion__bloque_medio", | |
| "xg_por_posesion__bloque_alto", | |
| "peligro_por_posesion__bloque_bajo", | |
| "peligro_por_posesion__bloque_medio", | |
| "peligro_por_posesion__bloque_alto", | |
| "pct_partidos_ganados__mayoria_bloque_medio", | |
| "pct_partidos_ganados__mayoria_bloque_alto", | |
| "pct_puntos_ganados__mayoria_bloque_medio", | |
| "pct_puntos_ganados__mayoria_bloque_alto", | |
| } | |
| NEUTRAL_HIGH_METRICS = set(EXPOSURE_METRICS) | |
| def _slug(text: str) -> str: | |
| normalized = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode("ascii") | |
| out = re.sub(r"[^A-Za-z0-9]+", "_", normalized.strip()) | |
| return re.sub(r"_+", "_", out).strip("_") | |
| def _role_lookup() -> pd.DataFrame: | |
| events = pd.read_parquet(EVENTS_PARQUET_PATH, columns=["matchId", "source_path"]) | |
| lookup = events[["matchId", "source_path"]].drop_duplicates("matchId").copy() | |
| names = lookup["source_path"].astype(str).str.extract(r"\d{4}-\d{2}-\d{2} - (.*) vs (.*)\.xlsx$") | |
| lookup["home_name"] = names[0] | |
| lookup["away_name"] = names[1] | |
| return lookup[["matchId", "home_name", "away_name"]] | |
| def _load_data() -> tuple[pd.DataFrame, dict[str, int]]: | |
| cols = [ | |
| "matchId", | |
| "fecha", | |
| "TeamName", | |
| "TeamRival", | |
| "phaseLabel", | |
| "possessionId", | |
| "Posesion_id", | |
| "pvAdded", | |
| "xG", | |
| "isGoal", | |
| "isOwnGoal", | |
| "goles_equipo", | |
| "goles_rival", | |
| "estado_partido", | |
| ] | |
| df = pd.read_csv(DATA_PATH, usecols=cols, engine="python", on_bad_lines="skip") | |
| df["block"] = df["phaseLabel"].map(BLOCK_MAP) | |
| df["possession_key"] = df["possessionId"].astype("string") | |
| df["possession_key"] = df["possession_key"].fillna(df["Posesion_id"].astype("string")) | |
| df["pvAdded"] = pd.to_numeric(df["pvAdded"], errors="coerce").fillna(0.0) | |
| df["xG"] = pd.to_numeric(df["xG"], errors="coerce").fillna(0.0) | |
| df["isGoal"] = df["isGoal"].fillna(False).astype(bool) | |
| df["isOwnGoal"] = df["isOwnGoal"].fillna(False).astype(bool) | |
| df["goles_equipo"] = pd.to_numeric(df["goles_equipo"], errors="coerce") | |
| df["goles_rival"] = pd.to_numeric(df["goles_rival"], errors="coerce") | |
| lookup = _role_lookup() | |
| df = df.merge(lookup, on="matchId", how="left") | |
| df["is_home_team"] = np.where( | |
| df["TeamName"] == df["home_name"], | |
| True, | |
| np.where(df["TeamName"] == df["away_name"], False, np.nan), | |
| ) | |
| df["team_role"] = np.where(df["is_home_team"] == True, "Local", np.where(df["is_home_team"] == False, "Visitante", pd.NA)) | |
| df["rival_role"] = np.where(df["is_home_team"] == True, "Visitante", np.where(df["is_home_team"] == False, "Local", pd.NA)) | |
| df["entity_team_overall"] = df["TeamName"] | |
| df["entity_def_overall"] = df["TeamRival"] | |
| df["entity_team_role"] = np.where(df["team_role"].notna(), df["TeamName"] + " (" + df["team_role"].astype(str) + ")", pd.NA) | |
| df["entity_def_role"] = np.where(df["rival_role"].notna(), df["TeamRival"] + " (" + df["rival_role"].astype(str) + ")", pd.NA) | |
| coverage = { | |
| "csv_matches": int(df["matchId"].nunique()), | |
| "matched_role_matches": int(df.loc[df["team_role"].notna(), "matchId"].nunique()), | |
| } | |
| return df, coverage | |
| def _build_possession_table(df: pd.DataFrame) -> pd.DataFrame: | |
| keys = [ | |
| "matchId", | |
| "TeamName", | |
| "TeamRival", | |
| "entity_team_overall", | |
| "entity_team_role", | |
| "entity_def_overall", | |
| "entity_def_role", | |
| "possession_key", | |
| ] | |
| sub = df[df["block"].notna() & df["possession_key"].notna()].copy() | |
| counts = ( | |
| sub.groupby(keys + ["block"], dropna=False) | |
| .size() | |
| .reset_index(name="event_count") | |
| ) | |
| counts["block_rank"] = counts["block"].map(BLOCK_RANK) | |
| counts = counts.sort_values(keys + ["event_count", "block_rank"], ascending=[True] * len(keys) + [False, True]) | |
| possession_block = counts.drop_duplicates(keys).copy() | |
| tmp = df.copy() | |
| tmp["goal_for_team"] = (tmp["isGoal"].fillna(False)) & (~tmp["isOwnGoal"].fillna(False)) | |
| base_pos = ( | |
| tmp[tmp["possession_key"].notna()] | |
| .groupby(keys, dropna=False) | |
| .agg( | |
| pv_possession=("pvAdded", "sum"), | |
| xg_possession=("xG", "sum"), | |
| goal_in_possession=("goal_for_team", "max"), | |
| start_goals_for=("goles_equipo", "first"), | |
| start_goals_against=("goles_rival", "first"), | |
| start_estado_partido=("estado_partido", "first"), | |
| ) | |
| .reset_index() | |
| ) | |
| base_pos["goal_in_possession"] = base_pos["goal_in_possession"].astype(int) | |
| pos = base_pos.merge(possession_block[keys + ["block"]], on=keys, how="inner") | |
| return pos | |
| def _apply_state_filter(pos: pd.DataFrame, state_filter: str | None) -> pd.DataFrame: | |
| if not state_filter: | |
| return pos.copy() | |
| if state_filter == "empatado": | |
| return pos.loc[ | |
| (pd.to_numeric(pos["start_goals_for"], errors="coerce") == pd.to_numeric(pos["start_goals_against"], errors="coerce")) | |
| | (pos["start_estado_partido"].astype(str) == "Empate") | |
| ].copy() | |
| raise ValueError(f"Filtro de estado no soportado: {state_filter}") | |
| def _coverage_from_pos(pos: pd.DataFrame) -> dict[str, int]: | |
| return { | |
| "csv_matches": int(pos["matchId"].nunique()), | |
| "matched_role_matches": int(pos.loc[pos["entity_team_role"].notna(), "matchId"].nunique()), | |
| } | |
| def _match_results(df: pd.DataFrame, entity_col: str) -> pd.DataFrame: | |
| out = ( | |
| df[df[entity_col].notna()] | |
| .groupby(["matchId", "TeamName", "TeamRival", entity_col], dropna=False) | |
| .agg( | |
| fecha=("fecha", "first"), | |
| goals_for=("goles_equipo", "max"), | |
| goals_against=("goles_rival", "max"), | |
| ) | |
| .reset_index() | |
| ) | |
| out["win"] = (out["goals_for"] > out["goals_against"]).astype(float) | |
| out["points"] = np.select( | |
| [out["goals_for"] > out["goals_against"], out["goals_for"] == out["goals_against"]], | |
| [3.0, 1.0], | |
| default=0.0, | |
| ) | |
| return out[["matchId", entity_col, "win", "points"]] | |
| def _majority_block(df: pd.DataFrame, entity_col: str) -> pd.DataFrame: | |
| majority = ( | |
| df.groupby(["matchId", entity_col, "block"], dropna=False) | |
| .agg(posesiones_bloque=("possession_key", "nunique")) | |
| .reset_index() | |
| ) | |
| majority["block_rank"] = majority["block"].map(BLOCK_RANK) | |
| majority = majority.sort_values( | |
| ["matchId", entity_col, "posesiones_bloque", "block_rank"], | |
| ascending=[True, True, False, True], | |
| ) | |
| return majority.drop_duplicates(["matchId", entity_col])[[ "matchId", entity_col, "block"]].copy() | |
| def _finalize_metric_table(rows: list[dict[str, object]]) -> pd.DataFrame: | |
| df = pd.DataFrame(rows).sort_values("entity").reset_index(drop=True) | |
| for metric in sorted(c for c in df.columns if c not in {"entity"}): | |
| df[f"pctil__{metric}"] = _percentile(df[metric]) | |
| return df | |
| def _compute_offensive_metrics(pos: pd.DataFrame, match_results: pd.DataFrame, entity_col: str) -> pd.DataFrame: | |
| team_block = ( | |
| pos[pos[entity_col].notna()] | |
| .groupby([entity_col, "block"], dropna=False) | |
| .agg( | |
| posesiones=("possession_key", "nunique"), | |
| goles=("goal_in_possession", "sum"), | |
| xg=("xg_possession", "sum"), | |
| peligro=("pv_possession", "sum"), | |
| ) | |
| .reset_index() | |
| ) | |
| team_block["goles_por_posesion"] = team_block["goles"] / team_block["posesiones"] | |
| team_block["xg_por_posesion"] = team_block["xg"] / team_block["posesiones"] | |
| team_block["peligro_por_posesion"] = team_block["peligro"] / team_block["posesiones"] | |
| majority = _majority_block(pos[pos[entity_col].notna()], entity_col) | |
| win_by_block = match_results.merge(majority, on=["matchId", entity_col], how="inner") | |
| win_by_block = ( | |
| win_by_block.groupby([entity_col, "block"], dropna=False) | |
| .agg(partidos=("matchId", "nunique"), pct_ganados=("win", "mean")) | |
| .reset_index() | |
| ) | |
| rows: list[dict[str, object]] = [] | |
| for entity in sorted(pos[entity_col].dropna().unique().tolist()): | |
| row: dict[str, object] = {"entity": entity} | |
| for block in BLOCK_ORDER: | |
| sub = team_block[(team_block[entity_col] == entity) & (team_block["block"] == block)] | |
| win = win_by_block[(win_by_block[entity_col] == entity) & (win_by_block["block"] == block)] | |
| row[f"posesiones__{block}"] = float(sub["posesiones"].iloc[0]) if not sub.empty else np.nan | |
| row[f"goles_por_posesion__{block}"] = float(sub["goles_por_posesion"].iloc[0]) if not sub.empty else np.nan | |
| row[f"xg_por_posesion__{block}"] = float(sub["xg_por_posesion"].iloc[0]) if not sub.empty else np.nan | |
| row[f"peligro_por_posesion__{block}"] = float(sub["peligro_por_posesion"].iloc[0]) if not sub.empty else np.nan | |
| row[f"partidos__mayoria_{block}"] = float(win["partidos"].iloc[0]) if not win.empty else np.nan | |
| row[f"pct_partidos_ganados__mayoria_{block}"] = float(win["pct_ganados"].iloc[0]) if not win.empty else np.nan | |
| rows.append(row) | |
| return _finalize_metric_table(rows) | |
| def _compute_defensive_metrics(pos: pd.DataFrame, match_results_def: pd.DataFrame, def_entity_col: str) -> pd.DataFrame: | |
| pos_def = pos[pos[def_entity_col].notna()].copy() | |
| team_block = ( | |
| pos_def.groupby([def_entity_col, "block"], dropna=False) | |
| .agg( | |
| posesiones_rivales=("possession_key", "nunique"), | |
| goles_recibidos=("goal_in_possession", "sum"), | |
| xg_recibido=("xg_possession", "sum"), | |
| peligro_recibido=("pv_possession", "sum"), | |
| ) | |
| .reset_index() | |
| ) | |
| team_block["goles_recibidos_por_posesion"] = team_block["goles_recibidos"] / team_block["posesiones_rivales"] | |
| team_block["xg_recibido_por_posesion"] = team_block["xg_recibido"] / team_block["posesiones_rivales"] | |
| team_block["peligro_recibido_por_posesion"] = team_block["peligro_recibido"] / team_block["posesiones_rivales"] | |
| majority = _majority_block(pos_def.rename(columns={def_entity_col: "entity"}), "entity").rename(columns={"entity": def_entity_col}) | |
| points_by_block = match_results_def.merge(majority, on=["matchId", def_entity_col], how="inner") | |
| points_by_block = ( | |
| points_by_block.groupby([def_entity_col, "block"], dropna=False) | |
| .agg(partidos=("matchId", "nunique"), pct_puntos=("points", lambda s: float(np.mean(s) / 3.0))) | |
| .reset_index() | |
| ) | |
| rows: list[dict[str, object]] = [] | |
| for entity in sorted(pos_def[def_entity_col].dropna().unique().tolist()): | |
| row: dict[str, object] = {"entity": entity} | |
| for block in BLOCK_ORDER: | |
| sub = team_block[(team_block[def_entity_col] == entity) & (team_block["block"] == block)] | |
| pts = points_by_block[(points_by_block[def_entity_col] == entity) & (points_by_block["block"] == block)] | |
| row[f"posesiones_rivales__{block}"] = float(sub["posesiones_rivales"].iloc[0]) if not sub.empty else np.nan | |
| row[f"goles_recibidos_por_posesion__{block}"] = float(sub["goles_recibidos_por_posesion"].iloc[0]) if not sub.empty else np.nan | |
| row[f"xg_recibido_por_posesion__{block}"] = float(sub["xg_recibido_por_posesion"].iloc[0]) if not sub.empty else np.nan | |
| row[f"peligro_recibido_por_posesion__{block}"] = float(sub["peligro_recibido_por_posesion"].iloc[0]) if not sub.empty else np.nan | |
| row[f"partidos__mayoria_{block}"] = float(pts["partidos"].iloc[0]) if not pts.empty else np.nan | |
| row[f"pct_puntos_ganados__mayoria_{block}"] = float(pts["pct_puntos"].iloc[0]) if not pts.empty else np.nan | |
| rows.append(row) | |
| return _finalize_metric_table(rows) | |
| def _compute_exposure_metrics(pos: pd.DataFrame) -> pd.DataFrame: | |
| pos_role = pos[pos["entity_team_role"].notna() & pos["entity_def_role"].notna()].copy() | |
| off_counts = ( | |
| pos_role.groupby(["entity_team_role", "block"], dropna=False) | |
| .agg(posesiones_contra=("possession_key", "nunique")) | |
| .reset_index() | |
| ) | |
| off_majority = ( | |
| _majority_block(pos_role, "entity_team_role") | |
| .groupby(["entity_team_role", "block"], dropna=False) | |
| .agg(partidos_contra=("matchId", "nunique")) | |
| .reset_index() | |
| ) | |
| def_counts = ( | |
| pos_role.groupby(["entity_def_role", "block"], dropna=False) | |
| .agg(posesiones_con=("possession_key", "nunique")) | |
| .reset_index() | |
| ) | |
| def_majority = ( | |
| _majority_block(pos_role.rename(columns={"entity_def_role": "entity"}), "entity") | |
| .groupby(["entity", "block"], dropna=False) | |
| .agg(partidos_con=("matchId", "nunique")) | |
| .reset_index() | |
| .rename(columns={"entity": "entity_def_role"}) | |
| ) | |
| entities = sorted(set(pos_role["entity_team_role"].dropna().tolist()) | set(pos_role["entity_def_role"].dropna().tolist())) | |
| rows: list[dict[str, object]] = [] | |
| for entity in entities: | |
| row: dict[str, object] = {"entity": entity} | |
| for block in BLOCK_ORDER: | |
| off_sub = off_counts[(off_counts["entity_team_role"] == entity) & (off_counts["block"] == block)] | |
| off_maj = off_majority[(off_majority["entity_team_role"] == entity) & (off_majority["block"] == block)] | |
| def_sub = def_counts[(def_counts["entity_def_role"] == entity) & (def_counts["block"] == block)] | |
| def_maj = def_majority[(def_majority["entity_def_role"] == entity) & (def_majority["block"] == block)] | |
| row[f"partidos_contra__{block}"] = float(off_maj["partidos_contra"].iloc[0]) if not off_maj.empty else np.nan | |
| row[f"posesiones_contra__{block}"] = float(off_sub["posesiones_contra"].iloc[0]) if not off_sub.empty else np.nan | |
| row[f"partidos_con__{block}"] = float(def_maj["partidos_con"].iloc[0]) if not def_maj.empty else np.nan | |
| row[f"posesiones_con__{block}"] = float(def_sub["posesiones_con"].iloc[0]) if not def_sub.empty else np.nan | |
| rows.append(row) | |
| return _finalize_metric_table(rows) | |
| def _percentile(series: pd.Series) -> pd.Series: | |
| valid = series.dropna() | |
| out = pd.Series(np.nan, index=series.index, dtype=float) | |
| if valid.empty: | |
| return out | |
| if len(valid) == 1: | |
| out.loc[valid.index] = 50.0 | |
| return out | |
| out.loc[valid.index] = valid.rank(method="average", pct=True) * 100.0 | |
| return out | |
| def _value_fmt(metric: str, value: float) -> str: | |
| if pd.isna(value): | |
| return "NA" | |
| if metric.startswith("pct_"): | |
| return f"{value * 100:.0f}%" | |
| if metric.startswith("partidos_") or metric.startswith("posesiones_"): | |
| return f"{value:.0f}" | |
| if "goles" in metric: | |
| return f"{value:.3f}" | |
| return f"{value:.4f}" | |
| def _sample_col_for_metric(metric: str) -> tuple[str | None, str | None]: | |
| if metric in EXPOSURE_METRICS: | |
| return None, None | |
| block = metric.split("__")[-1] | |
| if metric.startswith("pct_partidos_ganados__") or metric.startswith("pct_puntos_ganados__"): | |
| return f"partidos__mayoria_{block}", "partidos" | |
| if metric.startswith("goles_recibidos_") or metric.startswith("xg_recibido_") or metric.startswith("peligro_recibido_"): | |
| return f"posesiones_rivales__{block}", "posesiones rivales" | |
| return f"posesiones__{block}", "posesiones" | |
| def _metric_group(metric: str) -> str: | |
| if metric.startswith("goles_por_posesion__"): | |
| return "goles_of" | |
| if metric.startswith("xg_por_posesion__"): | |
| return "xg_of" | |
| if metric.startswith("peligro_por_posesion__"): | |
| return "peligro_of" | |
| if metric.startswith("pct_partidos_ganados__"): | |
| return "win_of" | |
| if metric.startswith("goles_recibidos_por_posesion__"): | |
| return "goles_def" | |
| if metric.startswith("xg_recibido_por_posesion__"): | |
| return "xg_def" | |
| if metric.startswith("peligro_recibido_por_posesion__"): | |
| return "peligro_def" | |
| if metric.startswith("pct_puntos_ganados__"): | |
| return "pts_def" | |
| if metric.startswith("partidos_contra__"): | |
| return "partidos_contra" | |
| if metric.startswith("posesiones_contra__"): | |
| return "posesiones_contra" | |
| if metric.startswith("partidos_con__"): | |
| return "partidos_con" | |
| if metric.startswith("posesiones_con__"): | |
| return "posesiones_con" | |
| return metric | |
| def _highlight_points(title_mode: str, team: str, rival: str) -> list[dict[str, str]]: | |
| if title_mode == "overall": | |
| return [ | |
| {"entity": team, "color": "#00A86B", "label": team}, | |
| {"entity": rival, "color": "#E06C5F", "label": rival}, | |
| ] | |
| return [ | |
| {"entity": f"{team} (Local)", "color": "#00A86B", "label": f"{team} local"}, | |
| {"entity": f"{team} (Visitante)", "color": "#5FCF9A", "label": f"{team} visitante"}, | |
| {"entity": f"{rival} (Local)", "color": "#E06C5F", "label": f"{rival} local"}, | |
| {"entity": f"{rival} (Visitante)", "color": "#F29A8A", "label": f"{rival} visitante"}, | |
| ] | |
| def _make_figure(metric_df: pd.DataFrame, metrics: list[str], title: str, subtitle: str, note: str, highlights: list[dict[str, str]]) -> go.Figure: | |
| fig = make_subplots( | |
| rows=len(metrics), | |
| cols=1, | |
| shared_xaxes=False, | |
| vertical_spacing=0.04, | |
| subplot_titles=[METRIC_LABEL[m] for m in metrics], | |
| ) | |
| for ann in fig.layout.annotations: | |
| ann.font = dict(size=15, color="#F1F5F3") | |
| ann.x = 0.0 | |
| ann.xanchor = "left" | |
| group_ranges: dict[str, tuple[float, float]] = {} | |
| for group in sorted({_metric_group(metric) for metric in metrics}): | |
| group_metrics = [metric for metric in metrics if _metric_group(metric) == group] | |
| vals: list[np.ndarray] = [] | |
| for metric in group_metrics: | |
| if metric in metric_df.columns: | |
| cur = metric_df[metric].dropna().to_numpy(dtype=float) | |
| if len(cur): | |
| vals.append(cur) | |
| if not vals: | |
| continue | |
| merged = np.concatenate(vals) | |
| x_min = float(np.nanmin(merged)) | |
| x_max = float(np.nanmax(merged)) | |
| pad = (x_max - x_min) * 0.15 if x_max > x_min else max(abs(x_max) * 0.25, 0.05) | |
| group_ranges[group] = (x_min - pad, x_max + pad) | |
| for i, metric in enumerate(metrics, start=1): | |
| sample_col, sample_label = _sample_col_for_metric(metric) | |
| keep_cols = ["entity", metric, f"pctil__{metric}"] | |
| if sample_col in metric_df.columns: | |
| keep_cols.append(sample_col) | |
| current = metric_df[keep_cols].dropna(subset=[metric]).copy() | |
| if current.empty: | |
| axis_ref = "x domain" if i == 1 else f"x{i} domain" | |
| y_axis_ref = "y domain" if i == 1 else f"y{i} domain" | |
| fig.add_annotation( | |
| row=i, | |
| col=1, | |
| x=0.5, | |
| y=0.5, | |
| xref=axis_ref, | |
| yref=y_axis_ref, | |
| text="Sin muestra suficiente", | |
| showarrow=False, | |
| font=dict(size=12, color="#99A7A1"), | |
| ) | |
| continue | |
| other_entities = {h["entity"] for h in highlights} | |
| others = current[~current["entity"].isin(other_entities)] | |
| fig.add_trace( | |
| go.Scatter( | |
| x=others[metric], | |
| y=np.full(len(others), 0.18), | |
| mode="markers", | |
| marker=dict(size=9, color="#8693A0", opacity=0.7), | |
| text=others["entity"], | |
| customdata=np.stack([others[f"pctil__{metric}"]], axis=1), | |
| hovertemplate="<b>%{text}</b><br>Valor: %{x}<br>Percentil liga: %{customdata[0]:.1f}<extra></extra>", | |
| name="Resto liga", | |
| showlegend=(i == 1), | |
| ), | |
| row=i, | |
| col=1, | |
| ) | |
| for h in highlights: | |
| sub = current[current["entity"] == h["entity"]] | |
| if sub.empty: | |
| continue | |
| x = float(sub[metric].iloc[0]) | |
| pct = float(sub[f"pctil__{metric}"].iloc[0]) | |
| fig.add_trace( | |
| go.Scatter( | |
| x=[x], | |
| y=[0.18], | |
| mode="markers", | |
| marker=dict(size=14, color=h["color"], line=dict(color="white", width=1.4)), | |
| customdata=[[pct]], | |
| hovertemplate=f"<b>{h['label']}</b><br>Valor: %{{x}}<br>Percentil liga: %{{customdata[0]:.1f}}<extra></extra>", | |
| name=h["label"], | |
| showlegend=(i == 1), | |
| ), | |
| row=i, | |
| col=1, | |
| ) | |
| if sample_col and sample_col in current.columns: | |
| sample_axis_num = len(metrics) + i | |
| sample_axis_ref = f"x{sample_axis_num}" | |
| sample_layout_key = f"xaxis{sample_axis_num}" | |
| sample_values = current[sample_col].to_numpy(dtype=float) | |
| s_min = float(np.nanmin(sample_values)) | |
| s_max = float(np.nanmax(sample_values)) | |
| s_pad = (s_max - s_min) * 0.15 if s_max > s_min else max(abs(s_max) * 0.25, 1.0) | |
| main_axis_ref = "x" if i == 1 else f"x{i}" | |
| fig.update_layout( | |
| **{ | |
| sample_layout_key: dict( | |
| overlaying=main_axis_ref, | |
| side="top", | |
| range=[max(0.0, s_min - s_pad), s_max + s_pad], | |
| showgrid=False, | |
| zeroline=False, | |
| showline=False, | |
| ticks="outside", | |
| tickfont=dict(color="#8A97A5", size=9), | |
| title=dict(text=f"Muestra ({sample_label})", font=dict(color="#8A97A5", size=9)), | |
| ) | |
| } | |
| ) | |
| others_sample = others.dropna(subset=[sample_col]) if sample_col in others.columns else others.iloc[0:0] | |
| fig.add_trace( | |
| go.Scatter( | |
| x=others_sample[sample_col], | |
| y=np.full(len(others_sample), -0.18), | |
| mode="markers", | |
| marker=dict(size=7, color="#64707C", opacity=0.75, symbol="circle-open"), | |
| text=others_sample["entity"], | |
| hovertemplate=f"<b>%{{text}}</b><br>Muestra: %{{x:.0f}} {sample_label}<extra></extra>", | |
| name=f"Muestra {sample_label}", | |
| showlegend=False, | |
| xaxis=sample_axis_ref, | |
| ), | |
| row=i, | |
| col=1, | |
| ) | |
| for h in highlights: | |
| sub = current[current["entity"] == h["entity"]].dropna(subset=[sample_col]) | |
| if sub.empty: | |
| continue | |
| sx = float(sub[sample_col].iloc[0]) | |
| fig.add_trace( | |
| go.Scatter( | |
| x=[sx], | |
| y=[-0.18], | |
| mode="markers", | |
| marker=dict(size=11, color=h["color"], line=dict(color="white", width=1.1), symbol="circle-open"), | |
| hovertemplate=f"<b>{h['label']}</b><br>Muestra: %{{x:.0f}} {sample_label}<extra></extra>", | |
| name=f"{h['label']} muestra", | |
| showlegend=False, | |
| xaxis=sample_axis_ref, | |
| ), | |
| row=i, | |
| col=1, | |
| ) | |
| x_range = group_ranges.get(_metric_group(metric)) | |
| fig.update_xaxes( | |
| row=i, | |
| col=1, | |
| range=x_range, | |
| showgrid=False, | |
| zeroline=False, | |
| showline=True, | |
| linecolor="#35413C", | |
| tickfont=dict(color="#D7DBE0", size=10), | |
| title_text="Valor real" if i == len(metrics) else None, | |
| title_font=dict(color="#D7DBE0", size=11), | |
| ) | |
| fig.update_yaxes( | |
| row=i, | |
| col=1, | |
| range=[-0.42, 0.42], | |
| showticklabels=False, | |
| showgrid=False, | |
| zeroline=False, | |
| ) | |
| fig.update_layout( | |
| height=220 * len(metrics) + 100, | |
| width=1400, | |
| template="plotly_dark", | |
| paper_bgcolor="#0B1721", | |
| plot_bgcolor="#0B1721", | |
| font=dict(color="#F5F5F5"), | |
| title=dict( | |
| text=f"{title}<br><sup>{subtitle}</sup>", | |
| x=0.01, | |
| xanchor="left", | |
| font=dict(size=28, color="#F5F8F6"), | |
| ), | |
| legend=dict( | |
| orientation="h", | |
| yanchor="bottom", | |
| y=1.02, | |
| xanchor="right", | |
| x=1.0, | |
| bgcolor="rgba(0,0,0,0)", | |
| font=dict(size=11), | |
| ), | |
| margin=dict(l=95, r=40, t=125, b=90), | |
| ) | |
| fig.add_annotation( | |
| x=0.0, | |
| y=0.0, | |
| xref="paper", | |
| yref="paper", | |
| xanchor="left", | |
| yanchor="bottom", | |
| text=note, | |
| showarrow=False, | |
| font=dict(size=11, color="#9CA3AF"), | |
| ) | |
| return fig | |
| def _plot(metric_df: pd.DataFrame, metrics: list[str], title: str, subtitle: str, note: str, output_path: Path, highlights: list[dict[str, str]]) -> None: | |
| fig = _make_figure(metric_df, metrics, title, subtitle, note, highlights) | |
| fig.write_html(output_path, include_plotlyjs="cdn") | |
| def _figure_html(metric_df: pd.DataFrame, metrics: list[str], title: str, subtitle: str, note: str, highlights: list[dict[str, str]]) -> str: | |
| fig = _make_figure(metric_df, metrics, title, subtitle, note, highlights) | |
| return pio.to_html(fig, include_plotlyjs="cdn", full_html=False) | |
| def _hallazgos(metric_df: pd.DataFrame, entity: str, metrics: list[str], top_n: int = 2) -> list[str]: | |
| sub = metric_df[metric_df["entity"] == entity] | |
| if sub.empty: | |
| return [] | |
| row = sub.iloc[0] | |
| ranked: list[tuple[float, str]] = [] | |
| for metric in metrics: | |
| pct_col = f"pctil__{metric}" | |
| if metric not in row.index or pct_col not in row.index or pd.isna(row[metric]) or pd.isna(row[pct_col]): | |
| continue | |
| score = abs(float(row[pct_col]) - 50.0) | |
| ranked.append((score, metric)) | |
| ranked.sort(reverse=True) | |
| out: list[str] = [] | |
| for _score, metric in ranked[:top_n]: | |
| value = float(row[metric]) | |
| pct = float(row[f"pctil__{metric}"]) | |
| direction = "muy alto" if pct >= 50 else "muy bajo" | |
| if metric in NEUTRAL_HIGH_METRICS: | |
| sense = "mucha exposicion" if pct >= 50 else "poca exposicion" | |
| else: | |
| sense = "positivo" if ( | |
| (metric in GOOD_HIGH_METRICS and pct >= 50) or | |
| (metric not in GOOD_HIGH_METRICS and pct < 50) | |
| ) else "riesgo" | |
| value_txt = _value_fmt(metric, value) | |
| out.append( | |
| f"{METRIC_LABEL[metric]}: valor {value_txt}, {direction} respecto a la liga (percentil {pct:.1f}). Lectura: {sense}." | |
| ) | |
| return out | |
| def main() -> None: | |
| parser = argparse.ArgumentParser() | |
| parser.add_argument("--team", default="Racing de Santander") | |
| parser.add_argument("--rival", default="Cultural Leonesa") | |
| parser.add_argument("--state", default=None) | |
| args = parser.parse_args() | |
| df_all, _coverage_all = _load_data() | |
| pos_all = _build_possession_table(df_all) | |
| pos = _apply_state_filter(pos_all, args.state) | |
| coverage = _coverage_from_pos(pos) | |
| overall_results = _match_results(df_all, "entity_team_overall") | |
| role_results = _match_results(df_all[df_all["entity_team_role"].notna()].copy(), "entity_team_role") | |
| off_overall = _compute_offensive_metrics(pos, overall_results, "entity_team_overall") | |
| off_role = _compute_offensive_metrics(pos[pos["entity_team_role"].notna()].copy(), role_results, "entity_team_role") | |
| def_role = _compute_defensive_metrics(pos[pos["entity_def_role"].notna()].copy(), role_results.rename(columns={"entity_team_role": "entity_def_role"}), "entity_def_role") | |
| exposure_role = _compute_exposure_metrics(pos) | |
| team_slug = _slug(args.team) | |
| rival_slug = _slug(args.rival) | |
| state_suffix = f"_{_slug(args.state)}" if args.state else "" | |
| off_overall_csv = DATA_DIR / f"ssd_block_metrics_overall_{team_slug}_vs_{rival_slug}{state_suffix}.csv" | |
| off_role_csv = DATA_DIR / f"ssd_block_metrics_home_away_{team_slug}_vs_{rival_slug}{state_suffix}.csv" | |
| def_role_csv = DATA_DIR / f"ssd_block_metrics_def_home_away_{team_slug}_vs_{rival_slug}{state_suffix}.csv" | |
| exposure_role_csv = DATA_DIR / f"ssd_block_metrics_exposure_home_away_{team_slug}_vs_{rival_slug}{state_suffix}.csv" | |
| off_overall_html = REPORTS_DIR / f"ssd_block_profile_{team_slug}_vs_{rival_slug}{state_suffix}.html" | |
| off_role_html = REPORTS_DIR / f"ssd_block_profile_home_away_{team_slug}_vs_{rival_slug}{state_suffix}.html" | |
| def_role_html = REPORTS_DIR / f"ssd_block_profile_def_home_away_{team_slug}_vs_{rival_slug}{state_suffix}.html" | |
| off_exposure_role_html = REPORTS_DIR / f"ssd_block_profile_off_exposure_home_away_{team_slug}_vs_{rival_slug}{state_suffix}.html" | |
| def_exposure_role_html = REPORTS_DIR / f"ssd_block_profile_def_exposure_home_away_{team_slug}_vs_{rival_slug}{state_suffix}.html" | |
| state_title = " con partido empatado" if args.state == "empatado" else "" | |
| state_note = " Solo se consideran eventos y posesiones con el marcador igualado." if args.state == "empatado" else "" | |
| off_overall.to_csv(off_overall_csv, index=False) | |
| off_role.to_csv(off_role_csv, index=False) | |
| def_role.to_csv(def_role_csv, index=False) | |
| exposure_role.to_csv(exposure_role_csv, index=False) | |
| _plot( | |
| off_overall, | |
| OFF_METRICS, | |
| f"Rendimiento ofensivo segun bloque rival{state_title}", | |
| f"{args.team} en verde, {args.rival} en rojo, resto de la liga en gris", | |
| "Bloque rival inferido desde phaseLabel y resumido por posesion con el bloque dominante. Goles, xG y peligro normalizados por posesion. Se excluye el win% con mayoria de bloque bajo porque no hay muestra util." + state_note, | |
| off_overall_html, | |
| _highlight_points("overall", args.team, args.rival), | |
| ) | |
| role_subtitle = ( | |
| f"{args.team} local/visitante en verde, {args.rival} local/visitante en rojo. " | |
| f"Cobertura con localia inferida: {coverage['matched_role_matches']} de {coverage['csv_matches']} partidos." | |
| ) | |
| _plot( | |
| off_role, | |
| OFF_METRICS, | |
| f"Rendimiento ofensivo segun bloque rival y condicion de localia{state_title}", | |
| role_subtitle, | |
| "Bloque rival inferido desde phaseLabel y resumido por posesion con el bloque dominante. Goles, xG y peligro normalizados por posesion. El win% se calcula solo en partidos donde ese bloque fue el dominante del rival." + state_note, | |
| off_role_html, | |
| _highlight_points("role", args.team, args.rival), | |
| ) | |
| _plot( | |
| def_role, | |
| DEF_METRICS, | |
| f"Rendimiento defensivo segun bloque propio y condicion de localia{state_title}", | |
| role_subtitle, | |
| "Las posesiones rivales se asignan al bloque defensivo dominante del equipo que defiende. Goles, xG y peligro recibidos estan normalizados por posesion rival. El % de puntos ganados usa solo partidos donde ese bloque fue el predominante del propio equipo en defensa." + state_note, | |
| def_role_html, | |
| _highlight_points("role", args.team, args.rival), | |
| ) | |
| _plot( | |
| exposure_role, | |
| OFF_EXPOSURE_METRICS, | |
| f"Exposicion ofensiva a bloques por localia{state_title}", | |
| role_subtitle, | |
| "Volumen de partidos y posesiones contra bloque rival. Sirve para contextualizar la muestra del perfil ofensivo." + state_note, | |
| off_exposure_role_html, | |
| _highlight_points("role", args.team, args.rival), | |
| ) | |
| _plot( | |
| exposure_role, | |
| DEF_EXPOSURE_METRICS, | |
| f"Exposicion defensiva a bloques por localia{state_title}", | |
| role_subtitle, | |
| "Volumen de partidos y posesiones con bloque propio. Sirve para contextualizar la muestra del perfil defensivo." + state_note, | |
| def_exposure_role_html, | |
| _highlight_points("role", args.team, args.rival), | |
| ) | |
| print(f"Metricas ofensivas generales: {off_overall_csv}") | |
| print(f"Metricas ofensivas local/visitante: {off_role_csv}") | |
| print(f"Metricas defensivas local/visitante: {def_role_csv}") | |
| print(f"Metricas de exposicion local/visitante: {exposure_role_csv}") | |
| print(f"Grafico ofensivo general: {off_overall_html}") | |
| print(f"Grafico ofensivo local/visitante: {off_role_html}") | |
| print(f"Grafico defensivo local/visitante: {def_role_html}") | |
| print(f"Grafico de exposicion ofensiva local/visitante: {off_exposure_role_html}") | |
| print(f"Grafico de exposicion defensiva local/visitante: {def_exposure_role_html}") | |
| if __name__ == "__main__": | |
| main() | |