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()