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="%{text}
Valor: %{x}
Percentil liga: %{customdata[0]:.1f}", 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"{h['label']}
Valor: %{{x}}
Percentil liga: %{{customdata[0]:.1f}}", 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"%{{text}}
Muestra: %{{x:.0f}} {sample_label}", 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"{h['label']}
Muestra: %{{x:.0f}} {sample_label}", 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}
{subtitle}", 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()