Spaces:
Running
Running
Commit ·
9da4e9c
1
Parent(s): e01700b
Deploy: modelo de ataque matchup GNN + artifacts bundleados (LFS)
Browse filesSobre 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 +5 -1
- README.md +52 -0
- src/racing_reports/vendor_env.py +3 -1
- tests/conftest.py +54 -0
- tests/test_api.py +142 -0
- tests/test_attack_matchup_model.py +68 -0
- tests/test_block_zscore.py +64 -0
- tests/test_match_index.py +11 -0
- tests/test_runner.py +62 -0
- tests/test_web_app.py +106 -0
- tests/test_yaml.py +26 -0
- vendor/data/modeling/attack_matchup_gnn_bundle.pt +3 -0
- vendor/data/modeling/attack_prediction_dataset.parquet +3 -0
- vendor/data/modeling/pv_distribution_gnn_bundle.pt +3 -0
- vendor/scripts/build_head_to_head_report_pro.py +2 -2
- vendor/scripts/train_attack_matchup_gnn.py +526 -0
.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 |
-
|
|
|
|
|
|
|
| 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" / "
|
| 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
|
| 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, >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()
|