pablogrois Claude Opus 4.8 commited on
Commit
9da4e9c
·
1 Parent(s): e01700b

Deploy: modelo de ataque matchup GNN + artifacts bundleados (LFS)

Browse files

Sobre la historia del Space (preserva login/proxy fixes). Agrega:
- train_attack_matchup_gnn.py (modelo de producción nuevo) + integración en la app.
- Artifacts bundleados en vendor/data/modeling vía git-LFS (subset 10MB + bundles),
para servir sin Azure.
- vendor_env apunta al bundle nuevo; .gitignore actualizado; tests del modelo.

Activar en el Space con el secret RR_ENABLE_NN_PREDICTIONS=1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

.gitignore CHANGED
@@ -21,10 +21,14 @@ cache/
21
  # Vendor: versionar scripts y datasets chicos, ignorar artifacts pesados/symlinks
22
  vendor/reports/
23
  vendor/data/*.parquet
24
- vendor/data/modeling/
25
  vendor/data/processed/
26
  !vendor/data/team_ids_ssd.json
27
  !vendor/data/matches.csv
28
  # CORE artifacts bundled en el repo para no requerir upload a Azure
29
  !vendor/data/match_features.parquet
30
  !vendor/data/team_match_zone_features.parquet
 
 
 
 
 
21
  # Vendor: versionar scripts y datasets chicos, ignorar artifacts pesados/symlinks
22
  vendor/reports/
23
  vendor/data/*.parquet
24
+ vendor/data/modeling/*
25
  vendor/data/processed/
26
  !vendor/data/team_ids_ssd.json
27
  !vendor/data/matches.csv
28
  # CORE artifacts bundled en el repo para no requerir upload a Azure
29
  !vendor/data/match_features.parquet
30
  !vendor/data/team_match_zone_features.parquet
31
+ # Modelo de ataque bundleado (subset recortado ~10 MB + bundles): sin Azure
32
+ !vendor/data/modeling/attack_prediction_dataset.parquet
33
+ !vendor/data/modeling/attack_matchup_gnn_bundle.pt
34
+ !vendor/data/modeling/pv_distribution_gnn_bundle.pt
README.md CHANGED
@@ -77,6 +77,58 @@ python ../scripts/upload_preprocessed_labeled.py \
77
  --remote-name preprocessed_SSD_25-26_etiquetado_modelo.csv
78
  ```
79
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  ## Salida
81
 
82
  ```text
 
77
  --remote-name preprocessed_SSD_25-26_etiquetado_modelo.csv
78
  ```
79
 
80
+ ## Modelo de predicción de ataque (matchup GNN)
81
+
82
+ El reporte previo / head-to-head puede mostrar la **distribución de ataque
83
+ esperada por zona** (local y visitante) según un modelo de red neuronal. El
84
+ modelo de producción es el **matchup GNN** (`train_attack_matchup_gnn.py`), que
85
+ reemplaza al viejo `attack_distribution_gnn`: selecciona ~200 features de alta
86
+ señal (en vez de ~950, atacando el sobreajuste), agrega una interacción bilineal
87
+ explícita atacante × defensor por zona, y aprende un *delta* sobre el promedio
88
+ del equipo. En test le gana al baseline "promedio de temporada" de forma
89
+ estadísticamente robusta (IC bootstrap del 95% que excluye el cero).
90
+
91
+ Las predicciones están **apagadas por defecto** (MVP). Hay dos formas de servir
92
+ los artifacts del modelo; en ambas se activa con el secret
93
+ `RR_ENABLE_NN_PREDICTIONS=1` en el Space.
94
+
95
+ **Opción A — bundlear en el repo (sin Azure, recomendada).** El datastore usa los
96
+ artifacts reales de `vendor/data/modeling/` antes de ir a Azure. Como la app
97
+ recalcula los rolling al vuelo desde las columnas crudas, el dataset se recorta de
98
+ **71 MB → ~10 MB** sin cambiar ninguna predicción:
99
+
100
+ ```bash
101
+ python ../scripts/build_app_modeling_subset.py --copy-bundles
102
+ git add racing-reports/vendor/data/modeling/{attack_prediction_dataset.parquet,attack_matchup_gnn_bundle.pt,pv_distribution_gnn_bundle.pt}
103
+ git commit -m "bundle modelo de ataque" # parquet ~10 MB: usar git-lfs si tu remoto lo pide
104
+ ```
105
+
106
+ En el próximo arranque la app usa esos archivos directamente, sin tocar Azure.
107
+
108
+ **Opción B — subir a Azure.** Si preferís no commitear artifacts:
109
+
110
+ ```bash
111
+ RR_ENABLE_NN_PREDICTIONS=1 python scripts/upload_report_artifacts.py --source /ruta/Racing/data --overwrite
112
+ ```
113
+
114
+ En el cold start la app descarga `modeling/attack_matchup_gnn_bundle.pt` (y el de
115
+ PV + el dataset) desde Azure.
116
+
117
+ ## Actualizar la data y el modelo (pipeline)
118
+
119
+ Un solo orquestador idempotente encadena todo el ciclo, desde los eventos crudos
120
+ hasta el bundle publicado que consume la app:
121
+
122
+ ```bash
123
+ python ../scripts/run_attack_model_pipeline.py # todo (saltea lo que ya está al día)
124
+ python ../scripts/run_attack_model_pipeline.py --list # ver los stages
125
+ python ../scripts/run_attack_model_pipeline.py --only train --train-args "--top-k 200 --seeds 3"
126
+ python ../scripts/run_attack_model_pipeline.py --from dataset --force
127
+ ```
128
+
129
+ Stages: `events` → `zone_features` → `dataset` → `rolling` → `train` → `publish`.
130
+ Los stages `events` y `publish` requieren credenciales de Azure.
131
+
132
  ## Salida
133
 
134
  ```text
src/racing_reports/vendor_env.py CHANGED
@@ -41,7 +41,9 @@ ARTIFACT_RELATIVE_PATHS_CORE = [
41
  ]
42
  ARTIFACT_RELATIVE_PATHS_NN = [
43
  "modeling/attack_prediction_dataset.parquet",
44
- "modeling/attack_distribution_gnn_bundle.pt",
 
 
45
  "modeling/pv_distribution_gnn_bundle.pt",
46
  ]
47
 
 
41
  ]
42
  ARTIFACT_RELATIVE_PATHS_NN = [
43
  "modeling/attack_prediction_dataset.parquet",
44
+ # Modelo de ataque rediseñado (matchup GNN): reemplaza al viejo
45
+ # attack_distribution_gnn_bundle.pt como modelo de producción de la app.
46
+ "modeling/attack_matchup_gnn_bundle.pt",
47
  "modeling/pv_distribution_gnn_bundle.pt",
48
  ]
49
 
tests/conftest.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import pandas as pd
6
+ import pytest
7
+
8
+ from racing_reports.config import Settings
9
+ from racing_reports.datastore import DataStore
10
+ from racing_reports.utils import slugify
11
+
12
+
13
+ @pytest.fixture()
14
+ def sample_store(tmp_path: Path) -> DataStore:
15
+ settings = Settings(cache_dir=tmp_path / "cache", output_dir=tmp_path / "outputs")
16
+ store = DataStore(settings=settings)
17
+ league = "Spanish Segunda Division"
18
+ season = "25-26"
19
+ path = store.preprocessed_path(league, season)
20
+ path.parent.mkdir(parents=True, exist_ok=True)
21
+ rows = []
22
+ for match_id, home, away, home_id, away_id, date in [
23
+ ("m1", "Racing de Santander", "Almería", "racing", "almeria", "2026-04-12"),
24
+ ("m2", "Huesca", "Racing de Santander", "huesca", "racing", "2026-04-19"),
25
+ ]:
26
+ for sequence_id, phase in [
27
+ ("s1", "Build Up against Low Block"),
28
+ ("s2", "Build Up against Medium Block"),
29
+ ("s3", "Build Up against High Block"),
30
+ ]:
31
+ for team, rival, team_id in [(home, away, home_id), (away, home, away_id)]:
32
+ rows.append(
33
+ {
34
+ "matchId": match_id,
35
+ "fecha": date,
36
+ "home_team_id": home_id,
37
+ "away_team_id": away_id,
38
+ "teamId": team_id,
39
+ "TeamName": team,
40
+ "TeamRival": rival,
41
+ "Competencia": league,
42
+ "Temporada": season,
43
+ "sequenceId": sequence_id,
44
+ "phaseLabel": phase,
45
+ "event_name": "Goal" if sequence_id == "s1" and team == home else "Pass",
46
+ "x": 88,
47
+ "y": 50,
48
+ "xG": 0.08 if sequence_id == "s1" else 0,
49
+ "pvAdded": 0.03,
50
+ "goal_int": 1 if sequence_id == "s1" and team == home else 0,
51
+ }
52
+ )
53
+ pd.DataFrame(rows).to_csv(path, index=False)
54
+ return store
tests/test_api.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from pathlib import Path
5
+
6
+ from fastapi.testclient import TestClient
7
+
8
+ from racing_reports.api import routes_meta, routes_reports
9
+ from racing_reports.reports.catalogs import (
10
+ BLOCK_ZSCORE_FIGURES,
11
+ PRE_MATCH_FIGURES,
12
+ PRE_MATCH_TABLES,
13
+ )
14
+ from racing_reports.web import app as web_app
15
+
16
+
17
+ def test_health_endpoint():
18
+ client = TestClient(web_app.app)
19
+ resp = client.get("/api/health")
20
+ assert resp.status_code == 200
21
+ data = resp.json()
22
+ assert data["ok"] is True
23
+ assert data["nn_enabled"] is False # MVP default
24
+
25
+
26
+ def test_leagues_and_seasons():
27
+ client = TestClient(web_app.app)
28
+ leagues = client.get("/api/leagues").json()
29
+ assert "Spanish Segunda Division" in leagues
30
+
31
+ seasons = client.get("/api/seasons", params={"league": "Spanish Segunda Division"}).json()
32
+ assert "25-26" in seasons
33
+
34
+ teams = client.get(
35
+ "/api/teams", params={"league": "Spanish Segunda Division", "season": "25-26"}
36
+ ).json()
37
+ assert "Racing de Santander" in teams
38
+
39
+
40
+ def test_catalog_pre_match():
41
+ client = TestClient(web_app.app)
42
+ resp = client.get("/api/reports/pre_match/catalog")
43
+ assert resp.status_code == 200
44
+ data = resp.json()
45
+ assert data["report_type"] == "pre_match"
46
+ assert [f["name"] for f in data["figures"]] == [s.name for s in PRE_MATCH_FIGURES]
47
+ assert [t["name"] for t in data["tables"]] == [s.name for s in PRE_MATCH_TABLES]
48
+ # las 2 figuras NN están marcadas
49
+ nn_figs = [f for f in data["figures"] if f["requires_nn"]]
50
+ assert len(nn_figs) == 2
51
+
52
+
53
+ def test_catalog_block_zscore():
54
+ client = TestClient(web_app.app)
55
+ resp = client.get("/api/reports/block_zscore/catalog")
56
+ assert resp.status_code == 200
57
+ data = resp.json()
58
+ assert [f["name"] for f in data["figures"]] == [s.name for s in BLOCK_ZSCORE_FIGURES]
59
+ assert data["tables"] == []
60
+
61
+
62
+ def test_catalog_unknown_returns_404():
63
+ client = TestClient(web_app.app)
64
+ resp = client.get("/api/reports/unknown/catalog")
65
+ assert resp.status_code == 404
66
+
67
+
68
+ def test_post_match_run_returns_503_in_mvp():
69
+ client = TestClient(web_app.app)
70
+ resp = client.post(
71
+ "/api/reports/post_match/run",
72
+ json={"league": "X", "season": "Y", "team_a": "A", "team_b": "B"},
73
+ )
74
+ assert resp.status_code == 503
75
+
76
+
77
+ def test_run_requires_teams():
78
+ client = TestClient(web_app.app)
79
+ resp = client.post(
80
+ "/api/reports/pre_match/run",
81
+ json={"league": "X", "season": "Y"},
82
+ )
83
+ assert resp.status_code == 400
84
+
85
+
86
+ def test_block_zscore_e2e(monkeypatch, sample_store, tmp_path: Path):
87
+ """End-to-end: lanza job de bloques contra el sample_store, hace polling,
88
+ descarga una figura PNG individual y el ZIP completo."""
89
+ # Reemplazamos el ReportRunner() que instancia routes_reports por uno
90
+ # apuntado al sample_store (sin Azure).
91
+ from racing_reports.runner import ReportRunner
92
+
93
+ monkeypatch.setattr(
94
+ routes_reports,
95
+ "ReportRunner",
96
+ lambda: ReportRunner(sample_store, settings=sample_store.settings),
97
+ )
98
+ # Las settings del sample_store apuntan a un output_dir bajo tmp_path; la
99
+ # whitelist de _safe_output_path usa DEFAULT_SETTINGS.output_dir, que es
100
+ # distinto. Para el test parchamos el guard.
101
+ from racing_reports.api import routes_jobs
102
+
103
+ monkeypatch.setattr(
104
+ routes_jobs, "_safe_output_path", lambda p: Path(p)
105
+ )
106
+
107
+ client = TestClient(web_app.app)
108
+ resp = client.post(
109
+ "/api/reports/block_zscore/run",
110
+ json={
111
+ "league": "Spanish Segunda Division",
112
+ "season": "25-26",
113
+ "team_a": "Racing de Santander",
114
+ "team_b": "Huesca",
115
+ },
116
+ )
117
+ assert resp.status_code == 202
118
+ job_id = resp.json()["job_id"]
119
+
120
+ # Polling
121
+ for _ in range(100):
122
+ st = client.get(f"/api/jobs/{job_id}").json()
123
+ if st["status"] in ("done", "error"):
124
+ break
125
+ time.sleep(0.1)
126
+ assert st["status"] == "done", st.get("error")
127
+
128
+ bundle = st["bundles"]["block_zscore"]
129
+ assert len(bundle["figures"]) == len(BLOCK_ZSCORE_FIGURES)
130
+ first_fig = bundle["figures"][0]
131
+ assert first_fig["name"] == "block_team_a_attack"
132
+
133
+ # Descargar PNG individual
134
+ png_resp = client.get(first_fig["png_url"])
135
+ assert png_resp.status_code == 200
136
+ assert png_resp.headers["content-type"] == "image/png"
137
+ assert len(png_resp.content) > 1000 # algo razonable
138
+
139
+ # ZIP completo
140
+ zip_resp = client.get(f"/api/jobs/{job_id}/zip", params={"report": "block_zscore"})
141
+ assert zip_resp.status_code == 200
142
+ assert zip_resp.headers["content-type"] == "application/zip"
tests/test_attack_matchup_model.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Verifica el contrato del modelo de ataque rediseñado (matchup GNN) tal como lo
2
+ consume la app: importa el módulo vendorizado, carga el bundle y predice una
3
+ distribución válida de ataque sobre las 10 zonas (suma ~1).
4
+
5
+ Se saltea si los artifacts NN no están disponibles localmente (cold CI sin Azure).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import numpy as np
11
+ import pytest
12
+
13
+ matplotlib = pytest.importorskip("matplotlib")
14
+ matplotlib.use("Agg")
15
+ torch = pytest.importorskip("torch")
16
+
17
+ from racing_reports import vendor_env # noqa: E402
18
+
19
+ ATTACK_BUNDLE = vendor_env.DATA_DIR / "modeling" / "attack_matchup_gnn_bundle.pt"
20
+ MODELING_DATASET = vendor_env.DATA_DIR / "modeling" / "attack_prediction_dataset.parquet"
21
+
22
+ pytestmark = pytest.mark.skipif(
23
+ not (ATTACK_BUNDLE.exists() and MODELING_DATASET.exists()),
24
+ reason="Artifacts NN (bundle/dataset) no disponibles localmente.",
25
+ )
26
+
27
+
28
+ def test_app_imports_matchup_model() -> None:
29
+ pro = vendor_env.import_script("build_head_to_head_report_pro")
30
+ assert pro.attack_model_mod.__name__ == "train_attack_matchup_gnn"
31
+ assert pro.ATTACK_MODEL_PATH.name == "attack_matchup_gnn_bundle.pt"
32
+
33
+
34
+ def test_bundle_loads_without_key_mismatch() -> None:
35
+ attack_mod = vendor_env.import_script("train_attack_matchup_gnn")
36
+ bundle = torch.load(ATTACK_BUNDLE, map_location="cpu", weights_only=False)
37
+ global_dim = len(bundle["global_bundle"]["global_feature_columns"])
38
+ node_dim = len(bundle["node_bundle"]["means"][0])
39
+ model = attack_mod.ResidualAttackGNN(node_dim=node_dim, global_dim=global_dim)
40
+ missing, unexpected = model.load_state_dict(bundle["model_state_dict"], strict=False)
41
+ assert not missing, f"faltan claves: {missing}"
42
+ assert not unexpected, f"claves inesperadas: {unexpected}"
43
+ assert bundle["zone_order"] == attack_mod.ZONE_ORDER
44
+
45
+
46
+ def test_prediction_is_a_valid_distribution() -> None:
47
+ pro = vendor_env.import_script("build_head_to_head_report_pro")
48
+ df_model = pro._load_modeling_dataset()
49
+ racing = "bzkwzatvwahmbzok1ymm5vqa1"
50
+ sub = df_model[df_model["teamId"] == racing]
51
+ if sub.empty:
52
+ pytest.skip("Racing no presente en el dataset local.")
53
+ row = sub.iloc[-1]
54
+ opp_id = (
55
+ row["opponent_team_id"]
56
+ if "opponent_team_id" in sub.columns and row.get("opponent_team_id")
57
+ else df_model[df_model["team_name"] == row["opponent_name"]]["teamId"].iloc[-1]
58
+ )
59
+ pred = pro._predict_single_team_future(
60
+ df_model, racing, opp_id, "Racing de Santander", str(row["opponent_name"]),
61
+ row["league"], row["season"], is_home=True,
62
+ )
63
+ zones = list(pred.attack.keys())
64
+ assert len(zones) == 10
65
+ total = sum(pred.attack.values())
66
+ assert total == pytest.approx(1.0, abs=1e-4), f"la distribución no suma 1: {total}"
67
+ assert all(0.0 <= v <= 1.0 for v in pred.attack.values())
68
+ assert len(pred.pv) == 10
tests/test_block_zscore.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from racing_reports.reports import block_zscore
6
+ from racing_reports.reports.bundle import ReportBundle
7
+ from racing_reports.reports.catalogs import BLOCK_ZSCORE_FIGURES
8
+
9
+
10
+ def test_block_zscore_runs_with_phase_label_fallback(sample_store, tmp_path: Path):
11
+ """El preprocessed base de Azure sólo trae phaseLabel; build() tiene que
12
+ derivar w_high/medium/low + label_after_rules a partir de ahí."""
13
+ pre = sample_store.preprocessed_path("Spanish Segunda Division", "25-26")
14
+ out = tmp_path / "block.html"
15
+
16
+ bundle = block_zscore.generate(
17
+ out_path=out,
18
+ labeled_csv=pre,
19
+ team_a="Racing de Santander",
20
+ team_b="Huesca",
21
+ )
22
+
23
+ assert isinstance(bundle, ReportBundle)
24
+ assert bundle.report_type == "block_zscore"
25
+ assert out.exists()
26
+ html = out.read_text(encoding="utf-8")
27
+ assert "Z-score" in html
28
+ assert "Racing de Santander" in html
29
+ assert "Huesca" in html
30
+
31
+
32
+ def test_block_zscore_bundle_has_canonical_figures(sample_store, tmp_path: Path):
33
+ pre = sample_store.preprocessed_path("Spanish Segunda Division", "25-26")
34
+ out = tmp_path / "block.html"
35
+ bundle = block_zscore.generate(
36
+ out_path=out,
37
+ labeled_csv=pre,
38
+ team_a="Racing de Santander",
39
+ team_b="Huesca",
40
+ )
41
+ assert [f.name for f in bundle.figures] == [s.name for s in BLOCK_ZSCORE_FIGURES]
42
+ for fig in bundle.figures:
43
+ assert fig.png_path.exists()
44
+ assert fig.svg_path is not None and fig.svg_path.exists()
45
+
46
+
47
+ def test_block_zscore_with_date_filter(sample_store, tmp_path: Path):
48
+ pre = sample_store.preprocessed_path("Spanish Segunda Division", "25-26")
49
+ out = tmp_path / "block_filtered.html"
50
+ block_zscore.generate(
51
+ out_path=out,
52
+ labeled_csv=pre,
53
+ team_a="Racing de Santander",
54
+ team_b="Huesca",
55
+ date_from="2026-04-15",
56
+ )
57
+ assert out.exists()
58
+ assert "desde 15/04/2026" in out.read_text(encoding="utf-8")
59
+
60
+
61
+ def test_require_labeled_falls_back_to_base(sample_store):
62
+ """Si Azure no tiene el labeled, require_labeled devuelve el base."""
63
+ path = sample_store.require_labeled("Spanish Segunda Division", "25-26")
64
+ assert path == sample_store.preprocessed_path("Spanish Segunda Division", "25-26")
tests/test_match_index.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from racing_reports.match_index import MatchIndex
4
+
5
+
6
+ def test_build_match_index(sample_store):
7
+ idx = MatchIndex(sample_store).build("Spanish Segunda Division", "25-26")
8
+ assert len(idx) == 2
9
+ first = idx[idx["match_id"] == "m1"].iloc[0]
10
+ assert first["home_team"] == "Racing de Santander"
11
+ assert first["away_team"] == "Almería"
tests/test_runner.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ from racing_reports import runner as runner_mod
6
+ from racing_reports.models import ReportRequest
7
+ from racing_reports.runner import ReportRunner
8
+
9
+
10
+ def _stub_adapters(monkeypatch):
11
+ """Evita correr los scripts pesados reales (torch); sólo orquestación."""
12
+ calls: list[str] = []
13
+
14
+ def fake_pre(*, out_path, **_):
15
+ out_path.write_text("pre", encoding="utf-8")
16
+ calls.append("pre_match")
17
+ return out_path
18
+
19
+ def fake_post(*, out_path, **_):
20
+ out_path.write_text("post", encoding="utf-8")
21
+ calls.append("post_match")
22
+ return out_path
23
+
24
+ def fake_block(*, out_path, **_):
25
+ out_path.write_text("block", encoding="utf-8")
26
+ calls.append("block_zscore")
27
+ return out_path
28
+
29
+ monkeypatch.setattr(runner_mod.pre_match, "generate", fake_pre)
30
+ monkeypatch.setattr(runner_mod.post_match, "generate", fake_post)
31
+ monkeypatch.setattr(runner_mod.block_zscore, "generate", fake_block)
32
+ return calls
33
+
34
+
35
+ def test_post_match_runs_pre_first_and_writes_manifest(monkeypatch, sample_store):
36
+ calls = _stub_adapters(monkeypatch)
37
+ monkeypatch.setattr(
38
+ sample_store, "prepare_vendor", lambda *a, **k: {"matches_csv": "x"}
39
+ )
40
+ monkeypatch.setattr(
41
+ sample_store,
42
+ "require_labeled",
43
+ lambda *a, **k: sample_store.preprocessed_path(
44
+ "Spanish Segunda Division", "25-26"
45
+ ),
46
+ )
47
+
48
+ request = ReportRequest(
49
+ league="Spanish Segunda Division",
50
+ season="25-26",
51
+ home_team="",
52
+ away_team="",
53
+ report_types=["post_match"],
54
+ match_id="m1",
55
+ )
56
+ result = ReportRunner(sample_store, settings=sample_store.settings).run(request)
57
+
58
+ assert calls == ["pre_match", "post_match"] # pre se antepone solo
59
+ assert set(result.html_paths) == {"pre_match", "post_match"}
60
+ manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
61
+ assert manifest["report_types_run"] == ["pre_match", "post_match"]
62
+ assert manifest["request"]["match_id"] == "m1"
tests/test_web_app.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from pathlib import Path
5
+
6
+ from fastapi.testclient import TestClient
7
+
8
+ from racing_reports.config import Settings
9
+ from racing_reports.models import ReportResult
10
+ from racing_reports.web import app as web_app
11
+
12
+
13
+ def test_home_is_spa_with_teams_and_report_cards():
14
+ """El nuevo home es un SPA que consume /api/. Verificamos que el HTML
15
+ contiene los selects de equipos y las cards de los reportes."""
16
+ client = TestClient(web_app.app)
17
+ resp = client.get("/")
18
+ assert resp.status_code == 200
19
+ assert "Racing de Santander" in resp.text # desde vendor/data/team_ids_ssd.json
20
+ assert "Reporte previo" in resp.text
21
+ assert "Bloques" in resp.text
22
+ assert "post-partido" in resp.text # card deshabilitada
23
+ assert "/api/reports/" in resp.text # alpine consume la API
24
+
25
+
26
+ def test_legacy_home_still_works():
27
+ """La UI vieja sigue accesible vía /legacy para retrocompatibilidad."""
28
+ client = TestClient(web_app.app)
29
+ resp = client.get("/legacy")
30
+ assert resp.status_code == 200
31
+ assert "Reporte previo / Bloques" in resp.text
32
+ assert "Reporte post-partido" in resp.text
33
+
34
+
35
+ def test_legacy_routes_require_password_when_set(monkeypatch, tmp_path):
36
+ """Si ACCESS_PASSWORD está seteada, las rutas /view, /download, /status,
37
+ /legacy, / NO deben servir contenido sin cookie válida."""
38
+ # Settings con password forzada + DEFAULT_SETTINGS también, porque ambos
39
+ # se usan en el app code.
40
+ new_settings = Settings(
41
+ cache_dir=tmp_path / "cache",
42
+ output_dir=tmp_path / "outputs",
43
+ access_password="secret-1234",
44
+ )
45
+ monkeypatch.setattr(web_app, "DEFAULT_SETTINGS", new_settings)
46
+ from racing_reports import config as cfg
47
+ monkeypatch.setattr(cfg, "DEFAULT_SETTINGS", new_settings)
48
+ from racing_reports.api import deps
49
+ monkeypatch.setattr(deps, "DEFAULT_SETTINGS", new_settings)
50
+
51
+ client = TestClient(web_app.app)
52
+
53
+ # / → redirect a /login
54
+ assert client.get("/", follow_redirects=False).status_code in (303, 307)
55
+ # /legacy → redirect a /login
56
+ assert client.get("/legacy", follow_redirects=False).status_code in (303, 307)
57
+ # /view sin cookie → 401
58
+ assert client.get("/view", params={"path": str(tmp_path / "x.html")}).status_code == 401
59
+ # /download sin cookie → 401
60
+ assert client.get("/download", params={"path": str(tmp_path / "x.html")}).status_code == 401
61
+ # /status/<id> sin cookie → 401
62
+ assert client.get("/status/deadbeef").status_code == 401
63
+ # API también
64
+ assert client.get("/api/leagues").status_code == 401
65
+ # /api/health sigue siendo público (no expone data)
66
+ assert client.get("/api/health").status_code == 200
67
+
68
+
69
+ def test_generate_enqueues_job_and_reports_links(monkeypatch, tmp_path: Path):
70
+ out = tmp_path / "pre_match.html"
71
+ out.write_text("<html>ok</html>", encoding="utf-8")
72
+ manifest = tmp_path / "manifest.json"
73
+ manifest.write_text("{}", encoding="utf-8")
74
+
75
+ class FakeRunner:
76
+ def run(self, request):
77
+ return ReportResult(
78
+ output_dir=tmp_path,
79
+ html_paths={"pre_match": out},
80
+ manifest_path=manifest,
81
+ )
82
+
83
+ monkeypatch.setattr(web_app, "ReportRunner", lambda *a, **k: FakeRunner())
84
+ client = TestClient(web_app.app)
85
+
86
+ resp = client.post(
87
+ "/generate",
88
+ data={
89
+ "flow": "pre",
90
+ "team_a": "Racing de Santander",
91
+ "team_b": "Huesca",
92
+ "pre_match": "on",
93
+ },
94
+ follow_redirects=False,
95
+ )
96
+ assert resp.status_code == 303
97
+ status_url = resp.headers["location"]
98
+
99
+ body = ""
100
+ for _ in range(50):
101
+ body = client.get(status_url).text
102
+ if "Volver al inicio" in body or "problema" in body:
103
+ break
104
+ time.sleep(0.05)
105
+ assert "Reporte previo (head-to-head)" in body
106
+ assert "/view?path=" in body
tests/test_yaml.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from racing_reports.runner import request_from_yaml
6
+
7
+
8
+ def test_request_from_yaml(tmp_path: Path):
9
+ cfg = tmp_path / "request.yml"
10
+ cfg.write_text(
11
+ """
12
+ league: Spanish Segunda Division
13
+ season: 25-26
14
+ match_id: m1
15
+ home_team: Racing de Santander
16
+ away_team: Almería
17
+ report_types: [pre_match, block_zscore]
18
+ settings:
19
+ block_zscore:
20
+ date_from: 2026-03-17
21
+ """,
22
+ encoding="utf-8",
23
+ )
24
+ request = request_from_yaml(cfg)
25
+ assert request.report_types == ["pre_match", "block_zscore"]
26
+ assert request.settings["block_zscore"]["date_from"] == "2026-03-17"
vendor/data/modeling/attack_matchup_gnn_bundle.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:dcb1309239fc7a438e94385d8e94aedd6a3f53a33ff24cf38c00d8bdaad8d3de
3
+ size 583663
vendor/data/modeling/attack_prediction_dataset.parquet ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:885726cff0233d7ab8a2c6fea06d4d66c7707bf60c1b40b7a6560874e6904164
3
+ size 10009594
vendor/data/modeling/pv_distribution_gnn_bundle.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6e65acabebf66b9ba96b122c60d3ec3c747750485f1f4af73a16d7adca49111a
3
+ size 1430997
vendor/scripts/build_head_to_head_report_pro.py CHANGED
@@ -24,14 +24,14 @@ REPORTS_DIR.mkdir(parents=True, exist_ok=True)
24
  MATCH_FEATURES_PATH = PROJECT_ROOT / "data" / "match_features.parquet"
25
  EVENTS_ROOT = PROJECT_ROOT / "data" / "processed" / "events_parquet"
26
  MODELING_DATASET_PATH = PROJECT_ROOT / "data" / "modeling" / "attack_prediction_dataset.parquet"
27
- ATTACK_MODEL_PATH = PROJECT_ROOT / "data" / "modeling" / "attack_distribution_gnn_bundle.pt"
28
  PV_MODEL_PATH = PROJECT_ROOT / "data" / "modeling" / "pv_distribution_gnn_bundle.pt"
29
 
30
  if str(SCRIPT_DIR) not in sys.path:
31
  sys.path.insert(0, str(SCRIPT_DIR))
32
 
33
  import build_head_to_head_report as base # noqa: E402
34
- import experiment_attack_distribution_gnn as attack_model_mod # noqa: E402
35
  import experiment_pv_distribution_gnn as pv_model_mod # noqa: E402
36
  import train_attack_prediction_ffn as train_model_mod # noqa: E402
37
 
 
24
  MATCH_FEATURES_PATH = PROJECT_ROOT / "data" / "match_features.parquet"
25
  EVENTS_ROOT = PROJECT_ROOT / "data" / "processed" / "events_parquet"
26
  MODELING_DATASET_PATH = PROJECT_ROOT / "data" / "modeling" / "attack_prediction_dataset.parquet"
27
+ ATTACK_MODEL_PATH = PROJECT_ROOT / "data" / "modeling" / "attack_matchup_gnn_bundle.pt"
28
  PV_MODEL_PATH = PROJECT_ROOT / "data" / "modeling" / "pv_distribution_gnn_bundle.pt"
29
 
30
  if str(SCRIPT_DIR) not in sys.path:
31
  sys.path.insert(0, str(SCRIPT_DIR))
32
 
33
  import build_head_to_head_report as base # noqa: E402
34
+ import train_attack_matchup_gnn as attack_model_mod # noqa: E402
35
  import experiment_pv_distribution_gnn as pv_model_mod # noqa: E402
36
  import train_attack_prediction_ffn as train_model_mod # noqa: E402
37
 
vendor/scripts/train_attack_matchup_gnn.py ADDED
@@ -0,0 +1,526 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rediseño del modelo de predicción de distribución de ataque por zona.
2
+
3
+ Mejoras vs ``experiment_attack_distribution_gnn`` (ResidualAttackGNN):
4
+
5
+ 1. **Selección de features**: de ~950 features globales (muchas redundantes de la
6
+ familia ``mf`` y rolling) se queda con un subconjunto de alta señal (varianza +
7
+ NaN + poda por correlación + top-K por correlación con los targets). Esto ataca
8
+ el sobreajuste (¡~950 features para ~9800 filas!) que hacía que el modelo apenas
9
+ le ganara al baseline de promedio de temporada.
10
+ 2. **Interacción atacante × defensor explícita**: una rama bilineal por zona que
11
+ cruza la proyección "ofensiva" con la "defensiva" de los node features (que ya
12
+ incluyen ataque/PV propios + concedidos + del rival). Esto modela el *matchup*,
13
+ no solo el promedio del equipo.
14
+ 3. **Regularización real**: weight decay fuerte, dropout calibrado, early stopping y
15
+ promedio multi-seed para reportar estabilidad (el bundle usa la mejor seed).
16
+ 4. **Evaluación robusta**: métricas vs dos baselines (temporada y últimos 8) con
17
+ **intervalos bootstrap** sobre el delta modelo-baseline, y foco en Racing.
18
+
19
+ El módulo preserva el contrato que consume la app
20
+ (``build_head_to_head_report_pro.py``): expone ``ZONE_ORDER``, ``ADJ_MATRIX``,
21
+ ``_build_feature_matrices(df, attack_targets)`` y una clase ``ResidualAttackGNN``
22
+ con la firma ``forward(node_x, global_x, baseline_long, baseline_short, adj)`` y un
23
+ bundle con ``model_state_dict`` / ``global_bundle`` / ``node_bundle``. La app solo
24
+ necesita importar este módulo en vez del viejo y apuntar al bundle nuevo.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from io import BytesIO
30
+ from pathlib import Path
31
+ import argparse
32
+ import base64
33
+ import html
34
+ import json
35
+ import random
36
+ import sys
37
+
38
+ import matplotlib.pyplot as plt
39
+ import numpy as np
40
+ import pandas as pd
41
+ import torch
42
+ from torch import nn
43
+
44
+ SCRIPT_DIR = Path(__file__).resolve().parent
45
+ PROJECT_ROOT = SCRIPT_DIR.parent
46
+ MODEL_DIR = PROJECT_ROOT / "data" / "modeling"
47
+ REPORTS_DIR = PROJECT_ROOT / "reports"
48
+ REPORT_PATH = REPORTS_DIR / "attack_matchup_gnn_report.html"
49
+ JSON_PATH = MODEL_DIR / "attack_matchup_gnn_metrics.json"
50
+ MODEL_PATH = MODEL_DIR / "attack_matchup_gnn_bundle.pt"
51
+ PRED_PATH = MODEL_DIR / "attack_matchup_gnn_test_predictions.parquet"
52
+
53
+ if str(SCRIPT_DIR) not in sys.path:
54
+ sys.path.insert(0, str(SCRIPT_DIR))
55
+
56
+ import train_attack_prediction_ffn as base # noqa: E402
57
+ import experiment_attack_distribution_gnn as gnn_base # noqa: E402
58
+
59
+ # --- Contrato re-exportado para la app (mismos nombres que el modelo viejo) ---
60
+ ZONE_ORDER = gnn_base.ZONE_ORDER
61
+ PRETTY_ZONE = gnn_base.PRETTY_ZONE
62
+ NODE_FEATURE_PREFIXES = gnn_base.NODE_FEATURE_PREFIXES
63
+ ADJ_MATRIX = gnn_base.ADJ_MATRIX
64
+ EDGE_INDEX = gnn_base.EDGE_INDEX
65
+ _build_feature_matrices = gnn_base._build_feature_matrices
66
+ SplitData = gnn_base.SplitData
67
+
68
+ # --- Hiperparámetros ---
69
+ RANDOM_SEED = 42
70
+ BATCH_SIZE = 256
71
+ MAX_EPOCHS = 280
72
+ PATIENCE = 34
73
+ LEARNING_RATE = 4e-4
74
+ WEIGHT_DECAY = 3e-4 # mucho más fuerte que el viejo (1e-5)
75
+ GLOBAL_DROPOUT = 0.15
76
+ GRAPH_DROPOUT = 0.10
77
+ INTERACTION_DIM = 24
78
+ DEFAULT_N_SEEDS = 3
79
+ DEFAULT_TOP_K = 200 # features globales a conservar tras la poda
80
+ VAR_EPS = 1e-9
81
+ NAN_FRAC_MAX = 0.60
82
+ CORR_PRUNE_THRESHOLD = 0.97
83
+ SMOOTH_LAMBDA = gnn_base.SMOOTH_LAMBDA
84
+ GATE_ENTROPY_LAMBDA = gnn_base.GATE_ENTROPY_LAMBDA
85
+ BOOTSTRAP_SAMPLES = 1000
86
+
87
+
88
+ def _set_seed(seed: int = RANDOM_SEED) -> None:
89
+ random.seed(seed)
90
+ np.random.seed(seed)
91
+ torch.manual_seed(seed)
92
+
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # Selección de features (clave contra el sobreajuste)
96
+ # ---------------------------------------------------------------------------
97
+ def select_global_features(
98
+ global_features: pd.DataFrame,
99
+ targets: np.ndarray,
100
+ train_idx: np.ndarray,
101
+ top_k: int = DEFAULT_TOP_K,
102
+ ) -> list[str]:
103
+ """Devuelve el subconjunto de columnas globales de alta señal.
104
+
105
+ Pasos (todos calculados SOLO sobre filas de train para no filtrar test):
106
+ 1. Descartar columnas con demasiados NaN.
107
+ 2. Descartar columnas de varianza ~nula.
108
+ 3. Poda por correlación: ante pares muy correlacionados, quedarse con el de
109
+ mayor señal contra los targets.
110
+ 4. Top-K por máxima |correlación| con cualquiera de los 10 targets.
111
+ """
112
+ train = global_features.iloc[train_idx]
113
+ n = len(train)
114
+
115
+ # 1) NaN
116
+ nan_frac = train.isna().mean(axis=0)
117
+ keep = nan_frac[nan_frac <= NAN_FRAC_MAX].index.tolist()
118
+ train = train[keep]
119
+
120
+ # rellenar con mediana de train para los cálculos siguientes
121
+ medians = train.median(numeric_only=False)
122
+ filled = train.fillna(medians)
123
+
124
+ # 2) varianza
125
+ variances = filled.var(axis=0, ddof=0)
126
+ keep = variances[variances > VAR_EPS].index.tolist()
127
+ filled = filled[keep]
128
+
129
+ # señal: máxima |corr| con los targets
130
+ X = filled.to_numpy(dtype=float)
131
+ Xc = X - X.mean(axis=0, keepdims=True)
132
+ Xstd = Xc.std(axis=0, ddof=0)
133
+ Xstd[Xstd == 0] = 1.0
134
+ Xn = Xc / Xstd
135
+ Y = targets[train_idx].astype(float)
136
+ Yc = Y - Y.mean(axis=0, keepdims=True)
137
+ Ystd = Yc.std(axis=0, ddof=0)
138
+ Ystd[Ystd == 0] = 1.0
139
+ Yn = Yc / Ystd
140
+ # corr matrix (features x targets)
141
+ corr_ft = (Xn.T @ Yn) / max(n, 1)
142
+ signal = np.abs(corr_ft).max(axis=1)
143
+ signal_s = pd.Series(signal, index=keep).sort_values(ascending=False)
144
+
145
+ # 3) poda por correlación entre features, recorriendo por señal descendente
146
+ ordered = signal_s.index.tolist()
147
+ col_to_pos = {c: i for i, c in enumerate(keep)}
148
+ selected: list[str] = []
149
+ selected_pos: list[int] = []
150
+ for col in ordered:
151
+ pos = col_to_pos[col]
152
+ if selected_pos:
153
+ corrs = (Xn[:, pos] @ Xn[:, selected_pos]) / max(n, 1)
154
+ if np.abs(corrs).max() > CORR_PRUNE_THRESHOLD:
155
+ continue
156
+ selected.append(col)
157
+ selected_pos.append(pos)
158
+ if len(selected) >= top_k:
159
+ break
160
+ return selected
161
+
162
+
163
+ # ---------------------------------------------------------------------------
164
+ # Modelo: GNN residual con interacción bilineal atacante × defensor
165
+ # ---------------------------------------------------------------------------
166
+ class MatchupAttackGNN(nn.Module):
167
+ """Como ResidualAttackGNN pero con una rama de interacción explícita por zona.
168
+
169
+ Aprende un *delta* sobre un baseline mixto (gate entre promedio de temporada y
170
+ de los últimos 8 partidos), por lo que solo modela la corrección de matchup.
171
+ """
172
+
173
+ def __init__(
174
+ self,
175
+ node_dim: int,
176
+ global_dim: int,
177
+ hidden_dim: int = 96,
178
+ global_hidden: int = 96,
179
+ num_layers: int = 3,
180
+ interaction_dim: int = INTERACTION_DIM,
181
+ global_dropout: float = GLOBAL_DROPOUT,
182
+ graph_dropout: float = GRAPH_DROPOUT,
183
+ ) -> None:
184
+ super().__init__()
185
+ self.global_encoder = nn.Sequential(
186
+ nn.Linear(global_dim, 192),
187
+ nn.ReLU(),
188
+ nn.Dropout(global_dropout),
189
+ nn.Linear(192, global_hidden),
190
+ nn.ReLU(),
191
+ )
192
+ # rama de interacción: proyección ofensiva y defensiva por nodo (zona)
193
+ self.off_proj = nn.Linear(node_dim, interaction_dim)
194
+ self.def_proj = nn.Linear(node_dim, interaction_dim)
195
+ self.node_encoder = nn.Sequential(
196
+ nn.Linear(node_dim + global_hidden + interaction_dim, hidden_dim),
197
+ nn.ReLU(),
198
+ )
199
+ self.graph_blocks = nn.ModuleList(
200
+ [gnn_base.GraphBlock(hidden_dim, hidden_dim, dropout=graph_dropout) for _ in range(num_layers)]
201
+ )
202
+ self.gate_head = nn.Linear(hidden_dim, 1)
203
+ self.delta_head = nn.Linear(hidden_dim, 1)
204
+
205
+ def forward(
206
+ self,
207
+ node_x: torch.Tensor,
208
+ global_x: torch.Tensor,
209
+ baseline_long: torch.Tensor,
210
+ baseline_short: torch.Tensor,
211
+ adj: torch.Tensor,
212
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
213
+ g = self.global_encoder(global_x)
214
+ g_rep = g.unsqueeze(1).expand(-1, node_x.size(1), -1)
215
+ # interacción multiplicativa atacante × defensor (Hadamard de proyecciones)
216
+ inter = self.off_proj(node_x) * self.def_proj(node_x)
217
+ h = self.node_encoder(torch.cat([node_x, g_rep, inter], dim=-1))
218
+
219
+ gate = torch.sigmoid(self.gate_head(h)).squeeze(-1)
220
+ mixed = gate * baseline_short + (1.0 - gate) * baseline_long
221
+
222
+ for block in self.graph_blocks:
223
+ h = h + block(h, adj)
224
+ delta = self.delta_head(h).squeeze(-1)
225
+ logits = torch.log(torch.clamp(mixed, min=1e-6)) + delta
226
+ pred = torch.softmax(logits, dim=1)
227
+ return pred, delta, gate, mixed
228
+
229
+
230
+ # Alias para que la app (que hace ``attack_model_mod.ResidualAttackGNN(...)``) funcione.
231
+ ResidualAttackGNN = MatchupAttackGNN
232
+
233
+
234
+ # ---------------------------------------------------------------------------
235
+ # Entrenamiento
236
+ # ---------------------------------------------------------------------------
237
+ def _train_one_seed(
238
+ train: SplitData,
239
+ val: SplitData,
240
+ seed: int,
241
+ max_epochs: int = MAX_EPOCHS,
242
+ ) -> tuple[MatchupAttackGNN, float, list[dict[str, float]]]:
243
+ _set_seed(seed)
244
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
245
+ model = MatchupAttackGNN(
246
+ node_dim=train.node_x.shape[2],
247
+ global_dim=train.global_x.shape[1],
248
+ ).to(device)
249
+ optimizer = torch.optim.AdamW(model.parameters(), lr=LEARNING_RATE, weight_decay=WEIGHT_DECAY)
250
+ train_loader = gnn_base._make_loader(train, shuffle=True)
251
+ val_loader = gnn_base._make_loader(val, shuffle=False)
252
+ adj = ADJ_MATRIX.to(device)
253
+
254
+ best_state = None
255
+ best_val = float("inf")
256
+ patience_left = PATIENCE
257
+ history: list[dict[str, float]] = []
258
+
259
+ for epoch in range(1, max_epochs + 1):
260
+ model.train()
261
+ running = 0.0
262
+ n_batches = 0
263
+ for global_x, node_x, y_attack, baseline_long, baseline_short in train_loader:
264
+ global_x = global_x.to(device)
265
+ node_x = node_x.to(device)
266
+ y_attack = y_attack.to(device)
267
+ baseline_long = baseline_long.to(device)
268
+ baseline_short = baseline_short.to(device)
269
+ optimizer.zero_grad()
270
+ pred, delta, gate, mixed = model(node_x, global_x, baseline_long, baseline_short, adj)
271
+ loss, _ = gnn_base._distribution_loss(pred, y_attack, delta, gate)
272
+ loss.backward()
273
+ optimizer.step()
274
+ running += float(loss.item())
275
+ n_batches += 1
276
+
277
+ val_metrics = gnn_base._evaluate_loader(model, val_loader, device)
278
+ history.append({"epoch": epoch, "train_loss": running / max(n_batches, 1), "val_loss": val_metrics["loss"]})
279
+ if val_metrics["loss"] < best_val - 1e-6:
280
+ best_val = val_metrics["loss"]
281
+ best_state = {k: v.detach().cpu().clone() for k, v in model.state_dict().items()}
282
+ patience_left = PATIENCE
283
+ else:
284
+ patience_left -= 1
285
+ if patience_left <= 0:
286
+ break
287
+
288
+ if best_state is None:
289
+ best_state = {k: v.detach().cpu().clone() for k, v in model.state_dict().items()}
290
+ model.load_state_dict(best_state)
291
+ return model, best_val, history
292
+
293
+
294
+ # ---------------------------------------------------------------------------
295
+ # Evaluación con bootstrap
296
+ # ---------------------------------------------------------------------------
297
+ def _bootstrap_delta(
298
+ y_true: np.ndarray,
299
+ pred_model: np.ndarray,
300
+ pred_baseline: np.ndarray,
301
+ metric: str = "mae",
302
+ n_samples: int = BOOTSTRAP_SAMPLES,
303
+ seed: int = RANDOM_SEED,
304
+ ) -> dict[str, float]:
305
+ """IC bootstrap del delta (baseline - modelo); positivo = el modelo gana."""
306
+ rng = np.random.default_rng(seed)
307
+ n = y_true.shape[0]
308
+
309
+ def _per_row(yt: np.ndarray, yp: np.ndarray) -> np.ndarray:
310
+ if metric == "mae":
311
+ return np.abs(yt - yp).mean(axis=1)
312
+ # jsd por fila
313
+ p = base._normalize_rows(yt)
314
+ q = base._normalize_rows(yp)
315
+ m = 0.5 * (p + q)
316
+ eps = 1e-12
317
+ kl_pm = np.sum(p * (np.log(p + eps) - np.log(m + eps)), axis=1)
318
+ kl_qm = np.sum(q * (np.log(q + eps) - np.log(m + eps)), axis=1)
319
+ return 0.5 * (kl_pm + kl_qm)
320
+
321
+ err_model = _per_row(y_true, pred_model)
322
+ err_base = _per_row(y_true, pred_baseline)
323
+ diff = err_base - err_model # positivo = modelo mejor
324
+ deltas = np.empty(n_samples, dtype=float)
325
+ for i in range(n_samples):
326
+ idx = rng.integers(0, n, size=n)
327
+ deltas[i] = diff[idx].mean()
328
+ lo, hi = np.percentile(deltas, [2.5, 97.5])
329
+ return {
330
+ "delta_mean": float(diff.mean()),
331
+ "delta_ci_low": float(lo),
332
+ "delta_ci_high": float(hi),
333
+ "wins": bool(lo > 0),
334
+ "rel_improvement": float(diff.mean() / max(err_base.mean(), 1e-12)),
335
+ }
336
+
337
+
338
+ def _history_plot(history: list[dict[str, float]]) -> str:
339
+ hist = pd.DataFrame(history)
340
+ fig, ax = plt.subplots(figsize=(8, 4.2), facecolor="#F6F7F4")
341
+ ax.plot(hist["epoch"], hist["train_loss"], label="Train", color="#2B7A5A", linewidth=2)
342
+ ax.plot(hist["epoch"], hist["val_loss"], label="Validación", color="#D1495B", linewidth=2)
343
+ ax.set_xlabel("Epoch")
344
+ ax.set_ylabel("Loss")
345
+ ax.legend()
346
+ ax.set_facecolor("#FFFFFF")
347
+ buf = BytesIO()
348
+ fig.savefig(buf, format="png", dpi=150, bbox_inches="tight", facecolor=fig.get_facecolor())
349
+ plt.close(fig)
350
+ return base64.b64encode(buf.getvalue()).decode("ascii")
351
+
352
+
353
+ def _report_html(summary: dict, history_img: str) -> str:
354
+ tm = summary["test_metrics"]
355
+ vm = summary["val_metrics"]
356
+ bs = summary["bootstrap_test"]
357
+
358
+ def _row(name: str, key: str) -> str:
359
+ return (
360
+ f"<tr><td>{name}</td><td>{tm['model_' + key]:.4f}</td>"
361
+ f"<td>{tm['season_baseline_' + key]:.4f}</td>"
362
+ f"<td>{tm['short8_baseline_' + key]:.4f}</td></tr>"
363
+ )
364
+
365
+ return f"""<!doctype html><html lang="es"><head><meta charset="utf-8">
366
+ <title>Attack Matchup GNN</title>
367
+ <style>body{{font-family:system-ui;margin:24px;background:#F6F7F4;color:#102a21}}
368
+ table{{border-collapse:collapse;margin:8px 0}} td,th{{border:1px solid #ccc;padding:6px 12px;text-align:right}}
369
+ th:first-child,td:first-child{{text-align:left}} .card{{background:#fff;padding:16px;border-radius:12px;margin:12px 0;box-shadow:0 1px 4px rgba(0,0,0,.06)}}
370
+ .win{{color:#2B7A5A;font-weight:700}} .lose{{color:#D1495B;font-weight:700}}</style></head>
371
+ <body>
372
+ <h1>Attack Matchup GNN — rediseño</h1>
373
+ <p>Train {summary['train_rows']} · Val {summary['val_rows']} · Test {summary['test_rows']} ·
374
+ features globales {summary['global_feature_count']} (de {summary['global_feature_count_full']}) ·
375
+ node {summary['node_feature_count']} · seeds {summary['n_seeds']}</p>
376
+ <div class="card"><h2>Test — modelo vs baselines</h2>
377
+ <table><tr><th>Métrica</th><th>Modelo</th><th>Temporada</th><th>Últimos 8</th></tr>
378
+ {_row('MAE', 'mae')}{_row('JSD', 'jsd')}{_row('KL', 'kl_proxy')}</table></div>
379
+ <div class="card"><h2>Validación</h2>
380
+ <table><tr><th>Métrica</th><th>Modelo</th><th>Temporada</th><th>Últimos 8</th></tr>
381
+ <tr><td>MAE</td><td>{vm['model_mae']:.4f}</td><td>{vm['season_baseline_mae']:.4f}</td><td>{vm['short8_baseline_mae']:.4f}</td></tr>
382
+ <tr><td>JSD</td><td>{vm['model_jsd']:.4f}</td><td>{vm['season_baseline_jsd']:.4f}</td><td>{vm['short8_baseline_jsd']:.4f}</td></tr></table></div>
383
+ <div class="card"><h2>Bootstrap (test, delta = baseline − modelo, &gt;0 = modelo gana)</h2>
384
+ <table><tr><th>vs baseline</th><th>Métrica</th><th>Delta medio</th><th>IC 95%</th><th>Mejora rel.</th><th>Gana</th></tr>
385
+ <tr><td>Temporada</td><td>MAE</td><td>{bs['vs_season_mae']['delta_mean']:.5f}</td>
386
+ <td>[{bs['vs_season_mae']['delta_ci_low']:.5f}, {bs['vs_season_mae']['delta_ci_high']:.5f}]</td>
387
+ <td>{bs['vs_season_mae']['rel_improvement']*100:.1f}%</td>
388
+ <td class="{'win' if bs['vs_season_mae']['wins'] else 'lose'}">{'sí' if bs['vs_season_mae']['wins'] else 'no'}</td></tr>
389
+ <tr><td>Temporada</td><td>JSD</td><td>{bs['vs_season_jsd']['delta_mean']:.5f}</td>
390
+ <td>[{bs['vs_season_jsd']['delta_ci_low']:.5f}, {bs['vs_season_jsd']['delta_ci_high']:.5f}]</td>
391
+ <td>{bs['vs_season_jsd']['rel_improvement']*100:.1f}%</td>
392
+ <td class="{'win' if bs['vs_season_jsd']['wins'] else 'lose'}">{'sí' if bs['vs_season_jsd']['wins'] else 'no'}</td></tr>
393
+ </table></div>
394
+ <div class="card"><h2>Entrenamiento</h2><img src="data:image/png;base64,{history_img}"></div>
395
+ </body></html>"""
396
+
397
+
398
+ # ---------------------------------------------------------------------------
399
+ # Main
400
+ # ---------------------------------------------------------------------------
401
+ def main() -> None:
402
+ parser = argparse.ArgumentParser(description="Entrena el Attack Matchup GNN.")
403
+ parser.add_argument("--top-k", type=int, default=DEFAULT_TOP_K, help="Features globales a conservar.")
404
+ parser.add_argument("--seeds", type=int, default=DEFAULT_N_SEEDS, help="Cantidad de seeds (el bundle usa la mejor).")
405
+ parser.add_argument("--max-epochs", type=int, default=MAX_EPOCHS)
406
+ args = parser.parse_args()
407
+
408
+ _set_seed()
409
+ MODEL_DIR.mkdir(parents=True, exist_ok=True)
410
+ REPORTS_DIR.mkdir(parents=True, exist_ok=True)
411
+
412
+ df, attack_targets = gnn_base._load_data()
413
+ y_attack = df[attack_targets].to_numpy(dtype=np.float32)
414
+ train_idx, val_idx, test_idx, val_start_date = gnn_base._train_val_test_indices(df)
415
+
416
+ global_features_full, node_tensor, _zone_arr, global_cols_full, node_feat_names = _build_feature_matrices(df, attack_targets)
417
+
418
+ # selección de features (solo con train)
419
+ selected_cols = select_global_features(global_features_full, y_attack, train_idx, top_k=args.top_k)
420
+ global_features = global_features_full[selected_cols].copy()
421
+ print(f"Features globales: {len(global_cols_full)} -> {len(selected_cols)} (top_k={args.top_k})")
422
+
423
+ global_x, global_bundle = gnn_base._standardize_global(train_idx, global_features)
424
+ node_x, node_bundle = gnn_base._standardize_node(train_idx, node_tensor)
425
+
426
+ baseline_long = base._normalize_rows(
427
+ df[[f"long_mean__actual_attack_share__{zone}" for zone in ZONE_ORDER]].to_numpy(dtype=float)
428
+ ).astype(np.float32)
429
+ baseline_short = base._normalize_rows(
430
+ df[[f"short_mean__actual_attack_share__{zone}" for zone in ZONE_ORDER]].to_numpy(dtype=float)
431
+ ).astype(np.float32)
432
+
433
+ train_split = gnn_base._make_split(df, train_idx, global_x, node_x, y_attack, baseline_long, baseline_short)
434
+ val_split = gnn_base._make_split(df, val_idx, global_x, node_x, y_attack, baseline_long, baseline_short)
435
+ test_split = gnn_base._make_split(df, test_idx, global_x, node_x, y_attack, baseline_long, baseline_short)
436
+
437
+ # multi-seed: entrena varias, elige la de mejor val para el bundle
438
+ seeds = [RANDOM_SEED + i for i in range(max(args.seeds, 1))]
439
+ trained: list[tuple[MatchupAttackGNN, float, list]] = []
440
+ for s in seeds:
441
+ model_s, val_s, hist_s = _train_one_seed(train_split, val_split, s, max_epochs=args.max_epochs)
442
+ print(f" seed {s}: best val loss {val_s:.5f}")
443
+ trained.append((model_s, val_s, hist_s))
444
+ best_model, best_val, history = min(trained, key=lambda t: t[1])
445
+
446
+ val_pred, val_gates, _ = gnn_base._predict(best_model, val_split)
447
+ test_pred, test_gates, test_mixed = gnn_base._predict(best_model, test_split)
448
+
449
+ val_true = val_split.metadata[attack_targets].to_numpy(dtype=float)
450
+ test_true = test_split.metadata[attack_targets].to_numpy(dtype=float)
451
+ val_long, val_short = val_split.baseline_long.astype(float), val_split.baseline_short.astype(float)
452
+ test_long, test_short = test_split.baseline_long.astype(float), test_split.baseline_short.astype(float)
453
+
454
+ val_metrics = gnn_base._metrics_against_baselines(val_true, val_pred, val_long, val_short)
455
+ test_metrics = gnn_base._metrics_against_baselines(test_true, test_pred, test_long, test_short)
456
+
457
+ bootstrap_test = {
458
+ "vs_season_mae": _bootstrap_delta(test_true, test_pred, test_long, metric="mae"),
459
+ "vs_short8_mae": _bootstrap_delta(test_true, test_pred, test_short, metric="mae"),
460
+ "vs_season_jsd": _bootstrap_delta(test_true, test_pred, test_long, metric="jsd"),
461
+ "vs_short8_jsd": _bootstrap_delta(test_true, test_pred, test_short, metric="jsd"),
462
+ }
463
+
464
+ # predicciones de test para inspección
465
+ pred_df = test_split.metadata.copy().reset_index(drop=True)
466
+ for i, zone in enumerate(ZONE_ORDER):
467
+ tcol = f"target_attack_share__{zone}"
468
+ pred_df[f"pred_model__{tcol}"] = test_pred[:, i]
469
+ pred_df[f"pred_season__{tcol}"] = test_long[:, i]
470
+ pred_df[f"pred_short8__{tcol}"] = test_short[:, i]
471
+ pred_df[f"gate__{zone}"] = test_gates[:, i]
472
+ keep = [c for c in ["matchId", "fecha", "league", "season", "teamId", "team_name", "opponent_name", "is_home"] if c in pred_df.columns]
473
+ keep += attack_targets
474
+ keep += [f"pred_{k}__target_attack_share__{z}" for k in ("model", "season", "short8") for z in ZONE_ORDER]
475
+ keep += [f"gate__{z}" for z in ZONE_ORDER]
476
+ pred_df[keep].to_parquet(PRED_PATH, index=False)
477
+
478
+ summary = {
479
+ "model": "attack_matchup_gnn",
480
+ "train_rows": int(len(train_split.metadata)),
481
+ "val_rows": int(len(val_split.metadata)),
482
+ "test_rows": int(len(test_split.metadata)),
483
+ "val_start_date": val_start_date,
484
+ "n_seeds": len(seeds),
485
+ "best_seed_val_loss": float(best_val),
486
+ "top_k": int(args.top_k),
487
+ "global_feature_count": int(global_x.shape[1]),
488
+ "global_feature_count_full": int(len(global_cols_full)),
489
+ "node_feature_count": int(node_x.shape[2]),
490
+ "zone_order": ZONE_ORDER,
491
+ "selected_global_features": selected_cols,
492
+ "val_metrics": val_metrics,
493
+ "test_metrics": test_metrics,
494
+ "bootstrap_test": bootstrap_test,
495
+ "weight_decay": WEIGHT_DECAY,
496
+ "interaction_dim": INTERACTION_DIM,
497
+ }
498
+
499
+ torch.save(
500
+ {
501
+ "model_state_dict": best_model.state_dict(),
502
+ "global_bundle": global_bundle,
503
+ "node_bundle": node_bundle,
504
+ "zone_order": ZONE_ORDER,
505
+ "attack_targets": attack_targets,
506
+ "summary": summary,
507
+ },
508
+ MODEL_PATH,
509
+ )
510
+ history_img = _history_plot(history)
511
+ JSON_PATH.write_text(json.dumps({k: v for k, v in summary.items() if k != "selected_global_features"}, ensure_ascii=False, indent=2), encoding="utf-8")
512
+ REPORT_PATH.write_text(_report_html(summary, history_img), encoding="utf-8")
513
+
514
+ print(f"Modelo guardado en: {MODEL_PATH}")
515
+ print(f"Métricas guardadas en: {JSON_PATH}")
516
+ print(f"Reporte guardado en: {REPORT_PATH}")
517
+ print("--- TEST ---")
518
+ print(f" MAE modelo {test_metrics['model_mae']:.4f} | temporada {test_metrics['season_baseline_mae']:.4f} | últimos8 {test_metrics['short8_baseline_mae']:.4f}")
519
+ print(f" JSD modelo {test_metrics['model_jsd']:.4f} | temporada {test_metrics['season_baseline_jsd']:.4f} | últimos8 {test_metrics['short8_baseline_jsd']:.4f}")
520
+ print(f" Bootstrap vs temporada (MAE): delta {bootstrap_test['vs_season_mae']['delta_mean']:.5f} "
521
+ f"IC[{bootstrap_test['vs_season_mae']['delta_ci_low']:.5f},{bootstrap_test['vs_season_mae']['delta_ci_high']:.5f}] "
522
+ f"mejora {bootstrap_test['vs_season_mae']['rel_improvement']*100:.1f}% gana={bootstrap_test['vs_season_mae']['wins']}")
523
+
524
+
525
+ if __name__ == "__main__":
526
+ main()