pablogrois Claude Opus 4.8 commited on
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
- for team, rival in [(home, away), (away, home)]:
214
- gen = sc["team_eje_norm_gen"].get(team)
215
- con = sc["team_eje_norm_con"].get(rival)
 
 
 
 
 
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, rows=rows))
 
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
- ax.set_title(f"{tinfo['team']} (vs {tinfo['rival']})", color=FG, fontsize=12)
 
 
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 × concesión del rival (regresión por eje, "
105
- "leave-one-match-out). Δ en σ partido-a-partido: ±1σ = variación normal entre partidos. "
106
- "El rival moldea sobre todo la elaboración y la rotura de líneas / profundidad; el robo alto "
107
- "es más oportunista (menos predecible).</p>"
 
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
- fig, ax = plt.subplots(figsize=(10.0, 6.4))
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,