Skip to content

Commit c49278b

Browse files
refactor: modularizar paquete, mejorar tests y corregir CI
1 parent 88db396 commit c49278b

6 files changed

Lines changed: 343 additions & 111 deletions

File tree

.github/workflows/ci.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,18 @@ jobs:
1111
runs-on: ubuntu-latest
1212
steps:
1313
- uses: actions/checkout@v4
14+
1415
- name: Set up Python
15-
uses: actions/setup-python@v4
16+
uses: actions/setup-python@v5
1617
with:
1718
python-version: '3.10'
19+
1820
- name: Install dependencies
1921
run: |
2022
python -m pip install --upgrade pip
23+
pip install -e .
2124
pip install -r requirements.txt
25+
2226
- name: Run tests
2327
run: |
2428
pytest -q

requirements.txt

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,4 @@
11
pandas
2-
pytest
32
matplotlib
43
seaborn
5-
64
pytest
7-
matplotlib
8-
seaborn

src/united_passing/analysis.py

Lines changed: 47 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,60 @@
1-
"""Módulo de análisis de datos."""
1+
"""Módulo de análisis de eficiencia de pases del Manchester United."""
22
import pandas as pd
33

4+
45
def top_by_prog_ratio(df: pd.DataFrame, top_n: int = 10) -> pd.DataFrame:
56
"""
6-
Obtiene el top N de jugadores ordenados por Prog_Ratio.
7-
Calcula Prog_Ratio si no existe (Prog / Cmp).
7+
Top N jugadores por Prog_Ratio (pases progresivos / completados).
8+
9+
Si Prog_Ratio no existe en el DataFrame, se calcula desde Prog y Cmp.
10+
Si tampoco existen esas columnas, devuelve las primeras top_n filas.
11+
12+
Args:
13+
df: DataFrame limpio con estadísticas de pases.
14+
top_n: Número de jugadores a retornar.
15+
16+
Returns:
17+
DataFrame ordenado por Prog_Ratio descendente.
818
"""
919
df = df.copy()
10-
11-
# Calcular Prog_Ratio si no está presente y tenemos las columnas necesarias
12-
if 'Prog_Ratio' not in df.columns:
13-
if 'Prog' in df.columns and 'Cmp' in df.columns:
14-
# Evitar división por cero
15-
df['Prog_Ratio'] = df.apply(
16-
lambda x: x['Prog'] / x['Cmp'] if x['Cmp'] > 0 else 0, axis=1
20+
21+
if "Prog_Ratio" not in df.columns:
22+
if {"Prog", "Cmp"}.issubset(df.columns):
23+
df["Prog_Ratio"] = df.apply(
24+
lambda r: round(r["Prog"] / r["Cmp"], 4) if r["Cmp"] > 0 else 0.0,
25+
axis=1,
1726
)
1827
else:
19-
# Si no podemos calcularlo y no está, devolvemos vacío o error
20-
# Por compatibilidad con el script anterior, asumiremos que el input puede ser el 'reporte' que ya lo tenga
21-
pass
28+
return df.head(top_n)
29+
30+
return df.sort_values("Prog_Ratio", ascending=False).head(top_n).reset_index(drop=True)
2231

23-
if 'Prog_Ratio' in df.columns:
24-
df_sorted = df.sort_values('Prog_Ratio', ascending=False)
25-
return df_sorted.head(top_n)
26-
else:
27-
# Fallback si no hay métrica
28-
return df.head(top_n)
2932

3033
def filter_midfielders(df: pd.DataFrame) -> pd.DataFrame:
3134
"""
32-
Filtra jugadores que son mediocampistas (MF).
33-
Asume columna 'Pos' con valores como 'MF', 'MFDF', etc.
35+
Filtra filas cuya columna 'Pos' contenga 'MF'.
36+
37+
Args:
38+
df: DataFrame con columna 'Pos'.
39+
40+
Returns:
41+
DataFrame filtrado. Si no existe 'Pos', retorna el DataFrame original.
42+
"""
43+
if "Pos" not in df.columns:
44+
return df
45+
return df[df["Pos"].str.contains("MF", na=False)].reset_index(drop=True)
46+
47+
48+
def resumen_estadisticas(df: pd.DataFrame) -> pd.DataFrame:
49+
"""
50+
Tabla resumen con métricas clave por jugador.
51+
52+
Args:
53+
df: DataFrame limpio de pases.
54+
55+
Returns:
56+
DataFrame con Player, Cmp, Att, Cmp%, Prog, Prog_Ratio (si disponibles).
3457
"""
35-
if 'Pos' in df.columns:
36-
return df[df['Pos'].str.contains('MF', na=False)]
37-
return df
58+
cols_deseadas = ["Player", "Pos", "90s", "Cmp", "Att", "Cmp%", "Prog", "Prog_Ratio", "KP", "xA"]
59+
cols_presentes = [c for c in cols_deseadas if c in df.columns]
60+
return df[cols_presentes].copy()

src/united_passing/data.py

Lines changed: 97 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,105 @@
1-
"""Módulo para carga y limpieza de datos."""
2-
import pandas as pd
1+
"""Módulo para carga y limpieza de datos de pases del Manchester United."""
32
from pathlib import Path
3+
from typing import Optional, Tuple
4+
5+
import pandas as pd
6+
7+
# Columnas numéricas conocidas del dataset FBref
8+
_NUMERIC_COLS = [
9+
"90s", "Cmp", "Att", "Cmp%", "TotDist", "PrgDist",
10+
"Ast", "xA", "KP", "1/3", "PPA", "CrsPA", "Prog",
11+
]
12+
13+
_PROJECT_ROOT = Path(__file__).parent.parent.parent
14+
15+
16+
def _resolver_ruta(nombre: str) -> Path:
17+
"""Busca el archivo en CWD y en la raíz del proyecto."""
18+
candidatos = [Path(nombre), Path.cwd() / nombre, _PROJECT_ROOT / nombre]
19+
for c in candidatos:
20+
if c.exists():
21+
return c.resolve()
22+
raise FileNotFoundError(
23+
f"No se encontró '{nombre}'. Colócalo en la raíz del proyecto."
24+
)
25+
26+
27+
def load_data(
28+
passes_path: str = "passing.csv",
29+
report_path: str = "reporte_mediocampo.csv",
30+
) -> Tuple[pd.DataFrame, pd.DataFrame]:
31+
"""
32+
Carga los datos de pases y el reporte de mediocampo.
33+
34+
Args:
35+
passes_path: Nombre/ruta del CSV de pases (FBref).
36+
report_path: Nombre/ruta del CSV de reporte de mediocampo.
37+
38+
Returns:
39+
Tupla (df_pases, df_reporte). df_reporte puede ser vacío
40+
si el archivo no existe todavía.
41+
"""
42+
df = pd.read_csv(_resolver_ruta(passes_path))
43+
44+
try:
45+
report = pd.read_csv(_resolver_ruta(report_path))
46+
except FileNotFoundError:
47+
report = pd.DataFrame()
448

5-
def load_data(passes_path: str = 'passing.csv', report_path: str = 'reporte_mediocampo.csv') -> tuple:
6-
"""Carga los datos de pases y el reporte de mediocampo desde CSV."""
7-
# Usar Path para mayor robustez, asumiendo ejecución desde la raíz o configurando paths relativos
8-
base_path = Path.cwd()
9-
p_path = base_path / passes_path
10-
r_path = base_path / report_path
11-
12-
if not p_path.exists():
13-
raise FileNotFoundError(f"No se encontró el archivo: {p_path}")
14-
15-
df = pd.read_csv(p_path)
16-
17-
if r_path.exists():
18-
report = pd.read_csv(r_path)
19-
else:
20-
report = pd.DataFrame() # Return empty if report doesn't exist yet
21-
2249
return df, report
2350

51+
2452
def clean_passes(df: pd.DataFrame) -> pd.DataFrame:
25-
"""Limpia el DataFrame de pases."""
53+
"""
54+
Limpia el DataFrame de pases:
55+
- Elimina columnas completamente vacías.
56+
- Convierte strings vacíos a NaN.
57+
- Coerce columnas numéricas conocidas.
58+
59+
Args:
60+
df: DataFrame crudo de pases.
61+
62+
Returns:
63+
DataFrame limpio.
64+
"""
2665
df = df.copy()
27-
# Remover columnas completamente vacías
28-
df.dropna(axis=1, how='all', inplace=True)
29-
# Reemplazar cadenas vacías por NaN (usando forward fill para compatibilidad o pd.NA)
30-
df = df.replace(r'^\s*$', pd.NA, regex=True)
31-
32-
# Intentar convertir columnas numéricas conocidas
33-
numeric_cols = ['90s', 'Cmp', 'Att', 'TotDist', 'PrgDist', 'Ast', 'xA', 'KP', '1/3', 'PPA', 'CrsPA', 'Prog']
34-
for col in numeric_cols:
66+
67+
# Eliminar columnas 100% vacías
68+
df.dropna(axis=1, how="all", inplace=True)
69+
70+
# Strings vacíos → NaN
71+
df.replace(r"^\s*$", pd.NA, regex=True, inplace=True)
72+
73+
# Convertir numéricas conocidas
74+
for col in _NUMERIC_COLS:
3575
if col in df.columns:
36-
df[col] = pd.to_numeric(df[col], errors='coerce')
37-
76+
df[col] = pd.to_numeric(df[col], errors="coerce")
77+
3878
return df
79+
80+
81+
def build_midfield_report(df: pd.DataFrame) -> pd.DataFrame:
82+
"""
83+
Genera el reporte de mediocampo calculando Prog_Ratio y filtrando MF.
84+
85+
Args:
86+
df: DataFrame limpio de pases.
87+
88+
Returns:
89+
DataFrame con columna Prog_Ratio, ordenado descendente.
90+
"""
91+
df = df.copy()
92+
93+
if "Prog" in df.columns and "Cmp" in df.columns:
94+
df["Prog_Ratio"] = df.apply(
95+
lambda r: round(r["Prog"] / r["Cmp"], 4) if r["Cmp"] and r["Cmp"] > 0 else 0.0,
96+
axis=1,
97+
)
98+
99+
if "Pos" in df.columns:
100+
df = df[df["Pos"].str.contains("MF", na=False)].copy()
101+
102+
if "Prog_Ratio" in df.columns:
103+
df.sort_values("Prog_Ratio", ascending=False, inplace=True)
104+
105+
return df.reset_index(drop=True)

src/united_passing/plot.py

Lines changed: 98 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,109 @@
1-
"""Módulo para visualización de datos."""
1+
"""Módulo de visualización de pases del Manchester United."""
2+
from pathlib import Path
3+
24
import matplotlib.pyplot as plt
5+
import matplotlib.ticker as mticker
36
import pandas as pd
7+
48
try:
59
import seaborn as sns
10+
sns.set_theme(style="whitegrid", palette="muted")
611
HAS_SEABORN = True
712
except ImportError:
813
HAS_SEABORN = False
914

10-
def plot_top_passers(df: pd.DataFrame, metric: str = 'Cmp', top_n: int = 10, out_path: str = 'grafico_pases_united.png'):
11-
"""Genera un gráfico de barras de los top N jugadores según una métrica."""
15+
_RED = "#c0392b"
16+
_DARK = "#2c3e50"
17+
18+
19+
def _guardar(fig: plt.Figure, out_path: str) -> str:
20+
Path(out_path).parent.mkdir(parents=True, exist_ok=True)
21+
fig.savefig(out_path, dpi=150, bbox_inches="tight")
22+
plt.close(fig)
23+
print(f" ✅ Guardado: {out_path}")
24+
return out_path
25+
26+
27+
def plot_top_passers(
28+
df: pd.DataFrame,
29+
metric: str = "Cmp",
30+
top_n: int = 10,
31+
out_path: str = "grafico_pases_united.png",
32+
) -> str:
33+
"""
34+
Gráfico de barras horizontales con los top N jugadores según una métrica.
35+
36+
Args:
37+
df: DataFrame limpio de pases.
38+
metric: Columna a usar como métrica (ej. 'Cmp', 'Prog', 'xA').
39+
top_n: Número de jugadores a mostrar.
40+
out_path: Ruta de salida del gráfico.
41+
42+
Returns:
43+
Ruta donde se guardó el gráfico.
44+
"""
1245
if metric not in df.columns:
13-
raise KeyError(f'La columna {metric} no existe en los datos.')
14-
15-
# Ordenar y seleccionar top
16-
df_top = df.sort_values(metric, ascending=False).head(top_n)
17-
18-
plt.figure(figsize=(10, 6))
19-
46+
raise KeyError(f"La columna '{metric}' no existe en los datos.")
47+
if "Player" not in df.columns:
48+
raise KeyError("El DataFrame no tiene columna 'Player'.")
49+
50+
df_top = df.dropna(subset=[metric]).sort_values(metric, ascending=False).head(top_n)
51+
52+
fig, ax = plt.subplots(figsize=(10, 6))
53+
54+
colores = [_RED if i == 0 else _DARK for i in range(len(df_top))]
55+
2056
if HAS_SEABORN:
21-
sns.set_theme(style="whitegrid")
22-
sns.barplot(x=metric, y='Player', data=df_top, palette='viridis')
57+
sns.barplot(x=metric, y="Player", data=df_top, palette=colores, ax=ax)
2358
else:
24-
# Fallback a matplotlib puro
25-
plt.barh(df_top['Player'][::-1], df_top[metric][::-1])
26-
27-
plt.xlabel(metric)
28-
plt.title(f'Top {top_n} jugadores por {metric}')
29-
plt.tight_layout()
30-
plt.savefig(out_path)
31-
plt.close()
32-
return out_path
59+
ax.barh(df_top["Player"][::-1], df_top[metric][::-1], color=colores[::-1])
60+
61+
ax.set_xlabel(metric, fontsize=11)
62+
ax.set_ylabel("")
63+
ax.set_title(f"Top {top_n} jugadores — {metric} | Man Utd 2024/25", fontweight="bold")
64+
ax.axvline(df_top[metric].mean(), color="gray", linestyle="--", linewidth=0.8,
65+
label=f"Media: {df_top[metric].mean():.1f}")
66+
ax.legend(fontsize=8)
67+
68+
return _guardar(fig, out_path)
69+
70+
71+
def plot_prog_ratio_scatter(
72+
df: pd.DataFrame,
73+
out_path: str = "analisis_mediocampo_united.png",
74+
) -> str:
75+
"""
76+
Scatter: Pases completados (volumen) vs Prog_Ratio (eficiencia progresiva).
77+
78+
Args:
79+
df: DataFrame con columnas Cmp y Prog_Ratio.
80+
out_path: Ruta de salida.
81+
82+
Returns:
83+
Ruta donde se guardó el gráfico.
84+
"""
85+
if "Prog_Ratio" not in df.columns or "Cmp" not in df.columns:
86+
raise KeyError("Se necesitan columnas 'Prog_Ratio' y 'Cmp'.")
87+
88+
fig, ax = plt.subplots(figsize=(10, 7))
89+
90+
ax.scatter(df["Cmp"], df["Prog_Ratio"], color=_RED, s=100, edgecolors=_DARK,
91+
linewidth=0.6, zorder=3)
92+
93+
if "Player" in df.columns:
94+
for _, row in df.iterrows():
95+
ax.annotate(row["Player"], xy=(row["Cmp"], row["Prog_Ratio"]),
96+
xytext=(4, 3), textcoords="offset points", fontsize=8)
97+
98+
ax.axhline(df["Prog_Ratio"].mean(), color="gray", linestyle="--", linewidth=0.8,
99+
label=f"Media Prog_Ratio: {df['Prog_Ratio'].mean():.3f}")
100+
ax.axvline(df["Cmp"].mean(), color="gray", linestyle=":", linewidth=0.8,
101+
label=f"Media Cmp: {df['Cmp'].mean():.0f}")
102+
103+
ax.set_xlabel("Pases Completados (Cmp)", fontsize=11)
104+
ax.set_ylabel("Ratio de Pases Progresivos (Prog/Cmp)", fontsize=11)
105+
ax.set_title("Volumen vs Eficiencia Progresiva — Mediocampistas Man Utd", fontweight="bold")
106+
ax.legend(fontsize=8)
107+
ax.grid(True, alpha=0.2)
108+
109+
return _guardar(fig, out_path)

0 commit comments

Comments
 (0)