Spaces:
Running
Running
Commit ·
e149eb4
1
Parent(s): 196acdd
Reportes de partido para cualquier liga/temporada en curso
Browse files- runner: resolver team IDs desde el preprocessed cuando el dataset del
modelo no cubre la temporada (p.ej. La Liga 26-27, donde juega Racing).
- pre_match: si falta historial de modelado (temporada en curso) con NN
habilitado, degradar la cancha de predicción a placeholder en vez de
romper el previo entero — el previo táctico igual se genera.
- ejes previo: normas separadas por localía (local vs visitante).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
src/racing_reports/ejes_data.py
CHANGED
|
@@ -206,13 +206,28 @@ def _eje_z(scales: dict, eje_col: str, value: float) -> float:
|
|
| 206 |
|
| 207 |
|
| 208 |
def pre_prediction(league: str, season: str, home: str, away: str) -> dict:
|
| 209 |
-
"""Para cada equipo: por eje, su norma y la predicción del partido (crece/decrece).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
sc = _scales(league, season)
|
| 211 |
pred = _predictor(league, season)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
out = []
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
if gen is None or con is None:
|
| 217 |
raise ValueError(f"Sin perfil de ejes para {team if gen is None else rival} "
|
| 218 |
f"en {league} {season}")
|
|
@@ -232,7 +247,8 @@ def pre_prediction(league: str, season: str, home: str, away: str) -> dict:
|
|
| 232 |
pred_z=round(_eje_z(sc, ec, p), 2),
|
| 233 |
delta_sigma=round((p - norma) / sigma, 2),
|
| 234 |
))
|
| 235 |
-
out.append(dict(team=team, rival=rival,
|
|
|
|
| 236 |
return {"league": league, "season": season, "teams": out}
|
| 237 |
|
| 238 |
|
|
|
|
| 206 |
|
| 207 |
|
| 208 |
def pre_prediction(league: str, season: str, home: str, away: str) -> dict:
|
| 209 |
+
"""Para cada equipo: por eje, su norma y la predicción del partido (crece/decrece).
|
| 210 |
+
|
| 211 |
+
Usa normas separadas por localía: el LOCAL ataca con su norma de local contra la
|
| 212 |
+
concesión de VISITANTE del rival, y viceversa. Cae a la norma combinada si falta el
|
| 213 |
+
split (artefactos viejos o equipo sin partidos en esa condición).
|
| 214 |
+
"""
|
| 215 |
sc = _scales(league, season)
|
| 216 |
pred = _predictor(league, season)
|
| 217 |
+
gen_all, con_all = sc["team_eje_norm_gen"], sc["team_eje_norm_con"]
|
| 218 |
+
gen_home = sc.get("team_eje_norm_gen_home", {})
|
| 219 |
+
gen_away = sc.get("team_eje_norm_gen_away", {})
|
| 220 |
+
con_home = sc.get("team_eje_norm_con_home", {})
|
| 221 |
+
con_away = sc.get("team_eje_norm_con_away", {})
|
| 222 |
out = []
|
| 223 |
+
# (equipo, rival, equipo_es_local)
|
| 224 |
+
for team, rival, team_home in [(home, away, True), (away, home, False)]:
|
| 225 |
+
if team_home: # team de local → rival defiende de visitante
|
| 226 |
+
gen = gen_home.get(team) or gen_all.get(team)
|
| 227 |
+
con = con_away.get(rival) or con_all.get(rival)
|
| 228 |
+
else: # team de visitante → rival defiende de local
|
| 229 |
+
gen = gen_away.get(team) or gen_all.get(team)
|
| 230 |
+
con = con_home.get(rival) or con_all.get(rival)
|
| 231 |
if gen is None or con is None:
|
| 232 |
raise ValueError(f"Sin perfil de ejes para {team if gen is None else rival} "
|
| 233 |
f"en {league} {season}")
|
|
|
|
| 247 |
pred_z=round(_eje_z(sc, ec, p), 2),
|
| 248 |
delta_sigma=round((p - norma) / sigma, 2),
|
| 249 |
))
|
| 250 |
+
out.append(dict(team=team, rival=rival,
|
| 251 |
+
venue="local" if team_home else "visitante", rows=rows))
|
| 252 |
return {"league": league, "season": season, "teams": out}
|
| 253 |
|
| 254 |
|
src/racing_reports/reports/ejes.py
CHANGED
|
@@ -69,7 +69,9 @@ def _fig_pre(pred: dict):
|
|
| 69 |
ax.set_yticks(list(ys))
|
| 70 |
ax.set_yticklabels([r["label"] for r in rows], color=FG, fontsize=10)
|
| 71 |
ax.invert_yaxis()
|
| 72 |
-
|
|
|
|
|
|
|
| 73 |
ax.set_xlabel("z vs liga (medias de partido)", color=MUT, fontsize=9)
|
| 74 |
fig.suptitle("Ejes de ataque esperados — punto amarillo = su norma, flecha = predicción del partido",
|
| 75 |
color=FG, fontsize=11, y=1.0)
|
|
@@ -93,18 +95,21 @@ def generate_pre(out_path: Path, league: str, season: str,
|
|
| 93 |
f"<td class='{_cls(r['delta_sigma'])}'>{r['delta_sigma']:+.2f}σ</td></tr>"
|
| 94 |
for r in sorted(tinfo["rows"], key=lambda x: -abs(x["delta_sigma"]))
|
| 95 |
)
|
|
|
|
|
|
|
| 96 |
tables.append(
|
| 97 |
-
f"<div><h2>{tinfo['team']}</h2>"
|
| 98 |
f"<table><tr><th>eje</th><th>su norma (z)</th><th>predicción (z)</th>"
|
| 99 |
f"<th>Δ esperado</th></tr>{rows}</table></div>"
|
| 100 |
)
|
| 101 |
body = (
|
| 102 |
f"<img src='{png_to_data_uri(png)}' alt='ejes previstos'>"
|
| 103 |
f"<div class='grid'>{''.join(tables)}</div>"
|
| 104 |
-
"<p class='note'>Predicción = generación propia
|
| 105 |
-
"
|
| 106 |
-
"
|
| 107 |
-
"
|
|
|
|
| 108 |
)
|
| 109 |
html = _html_shell(
|
| 110 |
f"Ejes de ataque — previo: {home_name} vs {away_name}",
|
|
|
|
| 69 |
ax.set_yticks(list(ys))
|
| 70 |
ax.set_yticklabels([r["label"] for r in rows], color=FG, fontsize=10)
|
| 71 |
ax.invert_yaxis()
|
| 72 |
+
venue = tinfo.get("venue")
|
| 73 |
+
vtag = f" — de {venue}" if venue else ""
|
| 74 |
+
ax.set_title(f"{tinfo['team']}{vtag} (vs {tinfo['rival']})", color=FG, fontsize=12)
|
| 75 |
ax.set_xlabel("z vs liga (medias de partido)", color=MUT, fontsize=9)
|
| 76 |
fig.suptitle("Ejes de ataque esperados — punto amarillo = su norma, flecha = predicción del partido",
|
| 77 |
color=FG, fontsize=11, y=1.0)
|
|
|
|
| 95 |
f"<td class='{_cls(r['delta_sigma'])}'>{r['delta_sigma']:+.2f}σ</td></tr>"
|
| 96 |
for r in sorted(tinfo["rows"], key=lambda x: -abs(x["delta_sigma"]))
|
| 97 |
)
|
| 98 |
+
venue = tinfo.get("venue")
|
| 99 |
+
vtag = f" <span style='color:{MUT};font-weight:400'>(de {venue})</span>" if venue else ""
|
| 100 |
tables.append(
|
| 101 |
+
f"<div><h2>{tinfo['team']}{vtag}</h2>"
|
| 102 |
f"<table><tr><th>eje</th><th>su norma (z)</th><th>predicción (z)</th>"
|
| 103 |
f"<th>Δ esperado</th></tr>{rows}</table></div>"
|
| 104 |
)
|
| 105 |
body = (
|
| 106 |
f"<img src='{png_to_data_uri(png)}' alt='ejes previstos'>"
|
| 107 |
f"<div class='grid'>{''.join(tables)}</div>"
|
| 108 |
+
"<p class='note'>Predicción = generación propia (de local/visitante según corresponda) × "
|
| 109 |
+
"concesión del rival en esa misma condición (regresión por eje, leave-one-match-out). "
|
| 110 |
+
"Por eso el reporte cambia según quién juega de local. Δ en σ partido-a-partido: ±1σ = "
|
| 111 |
+
"variación normal entre partidos. El rival moldea sobre todo la elaboración y la rotura de "
|
| 112 |
+
"líneas / profundidad; el robo alto es más oportunista (menos predecible).</p>"
|
| 113 |
)
|
| 114 |
html = _html_shell(
|
| 115 |
f"Ejes de ataque — previo: {home_name} vs {away_name}",
|
src/racing_reports/reports/pre_match.py
CHANGED
|
@@ -21,6 +21,24 @@ from racing_reports.reports.catalogs import PRE_MATCH_FIGURES, PRE_MATCH_TABLES
|
|
| 21 |
from racing_reports.reports.common import save_figure, save_table
|
| 22 |
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
@contextmanager
|
| 25 |
def _disable_nn_predictions(pro_module) -> Iterator[None]:
|
| 26 |
originals = {
|
|
@@ -36,25 +54,7 @@ def _disable_nn_predictions(pro_module) -> Iterator[None]:
|
|
| 36 |
return None
|
| 37 |
|
| 38 |
def _stub_plot(_prediction, title: str):
|
| 39 |
-
|
| 40 |
-
fig.patch.set_facecolor("#f9f8f4")
|
| 41 |
-
ax.set_facecolor("#f9f8f4")
|
| 42 |
-
ax.set_xticks([])
|
| 43 |
-
ax.set_yticks([])
|
| 44 |
-
for spine in ax.spines.values():
|
| 45 |
-
spine.set_visible(False)
|
| 46 |
-
ax.text(
|
| 47 |
-
0.5, 0.62, title,
|
| 48 |
-
ha="center", va="center", fontsize=14, fontweight="bold",
|
| 49 |
-
color="#1f2933", transform=ax.transAxes,
|
| 50 |
-
)
|
| 51 |
-
ax.text(
|
| 52 |
-
0.5, 0.42, "Predicciones del modelo no disponibles en el MVP.",
|
| 53 |
-
ha="center", va="center", fontsize=11, color="#52606d",
|
| 54 |
-
transform=ax.transAxes,
|
| 55 |
-
)
|
| 56 |
-
fig.tight_layout()
|
| 57 |
-
return fig
|
| 58 |
|
| 59 |
pro_module._load_modeling_dataset = _stub_dataset
|
| 60 |
pro_module._predict_single_team_future = _stub_predict
|
|
@@ -66,6 +66,37 @@ def _disable_nn_predictions(pro_module) -> Iterator[None]:
|
|
| 66 |
setattr(pro_module, name, fn)
|
| 67 |
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
@contextmanager
|
| 70 |
def _capture_figures(base_module, out_dir: Path, figures_out: list[FigureArtifact]) -> Iterator[None]:
|
| 71 |
"""Intercepta cada llamada a ``base._fig_to_base64`` para persistir a disco.
|
|
@@ -179,6 +210,10 @@ def generate(
|
|
| 179 |
with ExitStack() as stack:
|
| 180 |
if not settings.enable_nn_predictions:
|
| 181 |
stack.enter_context(_disable_nn_predictions(pro))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
stack.enter_context(_capture_figures(base, out_path.parent, figures))
|
| 183 |
stack.enter_context(_capture_tables(pro, base, out_path.parent, tables))
|
| 184 |
result_path = pro.generate_professional_head_to_head_report(
|
|
|
|
| 21 |
from racing_reports.reports.common import save_figure, save_table
|
| 22 |
|
| 23 |
|
| 24 |
+
def _placeholder_fig(title: str, message: str):
|
| 25 |
+
"""Figura vacía con un mensaje (reemplaza a la cancha de predicción del modelo
|
| 26 |
+
cuando ésta no está disponible)."""
|
| 27 |
+
fig, ax = plt.subplots(figsize=(10.0, 6.4))
|
| 28 |
+
fig.patch.set_facecolor("#f9f8f4")
|
| 29 |
+
ax.set_facecolor("#f9f8f4")
|
| 30 |
+
ax.set_xticks([])
|
| 31 |
+
ax.set_yticks([])
|
| 32 |
+
for spine in ax.spines.values():
|
| 33 |
+
spine.set_visible(False)
|
| 34 |
+
ax.text(0.5, 0.62, title, ha="center", va="center", fontsize=14,
|
| 35 |
+
fontweight="bold", color="#1f2933", transform=ax.transAxes)
|
| 36 |
+
ax.text(0.5, 0.42, message, ha="center", va="center", fontsize=11,
|
| 37 |
+
color="#52606d", transform=ax.transAxes)
|
| 38 |
+
fig.tight_layout()
|
| 39 |
+
return fig
|
| 40 |
+
|
| 41 |
+
|
| 42 |
@contextmanager
|
| 43 |
def _disable_nn_predictions(pro_module) -> Iterator[None]:
|
| 44 |
originals = {
|
|
|
|
| 54 |
return None
|
| 55 |
|
| 56 |
def _stub_plot(_prediction, title: str):
|
| 57 |
+
return _placeholder_fig(title, "Predicciones del modelo no disponibles en el MVP.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
pro_module._load_modeling_dataset = _stub_dataset
|
| 60 |
pro_module._predict_single_team_future = _stub_predict
|
|
|
|
| 66 |
setattr(pro_module, name, fn)
|
| 67 |
|
| 68 |
|
| 69 |
+
@contextmanager
|
| 70 |
+
def _guard_missing_model_history(pro_module) -> Iterator[None]:
|
| 71 |
+
"""Con NN habilitado, la predicción del modelo necesita historial de modelado
|
| 72 |
+
de la liga-temporada. La temporada EN CURSO (p.ej. La Liga 26-27) todavía no
|
| 73 |
+
está en el dataset del modelo → ``_predict_single_team_future`` tira ValueError.
|
| 74 |
+
En ese caso devolvemos None y dibujamos un placeholder, así el previo táctico
|
| 75 |
+
(zonas, presión, win/loss, local/visitante) igual se genera para cualquier liga."""
|
| 76 |
+
orig_predict = pro_module._predict_single_team_future
|
| 77 |
+
orig_plot = pro_module.plot_model_prediction_pitch
|
| 78 |
+
|
| 79 |
+
def _safe_predict(**kwargs):
|
| 80 |
+
try:
|
| 81 |
+
return orig_predict(**kwargs)
|
| 82 |
+
except Exception: # noqa: BLE001 — la predicción es un extra, nunca rompe el previo
|
| 83 |
+
return None
|
| 84 |
+
|
| 85 |
+
def _safe_plot(prediction, title: str):
|
| 86 |
+
if prediction is None:
|
| 87 |
+
return _placeholder_fig(
|
| 88 |
+
title, "Sin historial de modelado para esta temporada todavía.")
|
| 89 |
+
return orig_plot(prediction, title)
|
| 90 |
+
|
| 91 |
+
pro_module._predict_single_team_future = _safe_predict
|
| 92 |
+
pro_module.plot_model_prediction_pitch = _safe_plot
|
| 93 |
+
try:
|
| 94 |
+
yield
|
| 95 |
+
finally:
|
| 96 |
+
pro_module._predict_single_team_future = orig_predict
|
| 97 |
+
pro_module.plot_model_prediction_pitch = orig_plot
|
| 98 |
+
|
| 99 |
+
|
| 100 |
@contextmanager
|
| 101 |
def _capture_figures(base_module, out_dir: Path, figures_out: list[FigureArtifact]) -> Iterator[None]:
|
| 102 |
"""Intercepta cada llamada a ``base._fig_to_base64`` para persistir a disco.
|
|
|
|
| 210 |
with ExitStack() as stack:
|
| 211 |
if not settings.enable_nn_predictions:
|
| 212 |
stack.enter_context(_disable_nn_predictions(pro))
|
| 213 |
+
else:
|
| 214 |
+
# NN on: si falta historial de modelado (temporada en curso), degradá a
|
| 215 |
+
# placeholder en vez de romper el previo entero.
|
| 216 |
+
stack.enter_context(_guard_missing_model_history(pro))
|
| 217 |
stack.enter_context(_capture_figures(base, out_path.parent, figures))
|
| 218 |
stack.enter_context(_capture_tables(pro, base, out_path.parent, tables))
|
| 219 |
result_path = pro.generate_professional_head_to_head_report(
|
src/racing_reports/runner.py
CHANGED
|
@@ -6,6 +6,7 @@ from dataclasses import asdict
|
|
| 6 |
from pathlib import Path
|
| 7 |
from typing import Any
|
| 8 |
|
|
|
|
| 9 |
import yaml
|
| 10 |
|
| 11 |
from racing_reports import vendor_env
|
|
@@ -56,6 +57,19 @@ class ReportRunner:
|
|
| 56 |
pass
|
| 57 |
return ids
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
def _resolve_id(self, name: str, team_ids: dict[str, str]) -> str:
|
| 60 |
if name in team_ids:
|
| 61 |
return team_ids[name]
|
|
@@ -123,6 +137,11 @@ class ReportRunner:
|
|
| 123 |
pre_csv = self.datastore.sync_preprocessed(
|
| 124 |
request.league, request.season
|
| 125 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
gen_zones = vendor_env.patch_helper_paths(
|
| 127 |
preprocessed_csv=pre_csv, matches_csv=paths["matches_csv"],
|
| 128 |
league=request.league, season=request.season,
|
|
|
|
| 6 |
from pathlib import Path
|
| 7 |
from typing import Any
|
| 8 |
|
| 9 |
+
import pandas as pd
|
| 10 |
import yaml
|
| 11 |
|
| 12 |
from racing_reports import vendor_env
|
|
|
|
| 57 |
pass
|
| 58 |
return ids
|
| 59 |
|
| 60 |
+
@staticmethod
|
| 61 |
+
def _team_ids_from_csv(csv_path: Path) -> dict[str, str]:
|
| 62 |
+
# Mapa nombre→id leído del preprocessed. Cubre la temporada EN CURSO (que el
|
| 63 |
+
# dataset del modelo todavía no tiene: p.ej. La Liga 26-27), así el
|
| 64 |
+
# head-to-head funciona para cualquier liga/temporada que esté en Azure.
|
| 65 |
+
try:
|
| 66 |
+
df = pd.read_csv(csv_path, usecols=["TeamName", "teamId"],
|
| 67 |
+
dtype={"teamId": str}, low_memory=False)
|
| 68 |
+
df = df.dropna(subset=["TeamName", "teamId"]).drop_duplicates("TeamName")
|
| 69 |
+
return dict(zip(df["TeamName"].astype(str), df["teamId"].astype(str)))
|
| 70 |
+
except Exception:
|
| 71 |
+
return {}
|
| 72 |
+
|
| 73 |
def _resolve_id(self, name: str, team_ids: dict[str, str]) -> str:
|
| 74 |
if name in team_ids:
|
| 75 |
return team_ids[name]
|
|
|
|
| 137 |
pre_csv = self.datastore.sync_preprocessed(
|
| 138 |
request.league, request.season
|
| 139 |
)
|
| 140 |
+
# Completar los IDs con los del preprocessed (cubre la temporada en
|
| 141 |
+
# curso, que el dataset del modelo aún no tiene). Se pisa lo bundleado
|
| 142 |
+
# solo si falta; los nombres del preprocessed son los de Opta.
|
| 143 |
+
for name, tid in self._team_ids_from_csv(pre_csv).items():
|
| 144 |
+
team_ids.setdefault(name, tid)
|
| 145 |
gen_zones = vendor_env.patch_helper_paths(
|
| 146 |
preprocessed_csv=pre_csv, matches_csv=paths["matches_csv"],
|
| 147 |
league=request.league, season=request.season,
|