RRC / vendor /scripts /experiment_team_embedding_gnn.py
pablogrois's picture
Deploy MVP: API JSON + SPA + bundle/cache de artifacts CORE
e58615a
Raw
History Blame Contribute Delete
32.5 kB
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import html
import json
import random
import sys
import numpy as np
import pandas as pd
import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset
SCRIPT_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = SCRIPT_DIR.parent
MODEL_DIR = PROJECT_ROOT / "data" / "modeling"
REPORTS_DIR = PROJECT_ROOT / "reports"
REPORT_PATH = REPORTS_DIR / "team_embedding_gnn_report.html"
JSON_PATH = MODEL_DIR / "team_embedding_gnn_metrics.json"
MODEL_PATH = MODEL_DIR / "team_embedding_gnn_bundle.pt"
ATTACK_PRED_PATH = MODEL_DIR / "attack_team_embedding_gnn_test_predictions.parquet"
PV_PRED_PATH = MODEL_DIR / "pv_team_embedding_gnn_test_predictions.parquet"
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
import experiment_attack_distribution_gnn as attack_base # noqa: E402
import experiment_pv_distribution_gnn as pv_base # noqa: E402
import train_attack_prediction_ffn as base # noqa: E402
RANDOM_SEED = 42
EMBED_DIM = 16
ATTACK_LR = 4e-4
PV_LR = 4e-4
WEIGHT_DECAY = 1e-5
MAX_EPOCHS = 260
PATIENCE = 32
BATCH_SIZE = 256
@dataclass
class AttackSplit:
global_x: np.ndarray
node_x: np.ndarray
y: np.ndarray
baseline_long: np.ndarray
baseline_short: np.ndarray
team_idx: np.ndarray
opp_idx: np.ndarray
metadata: pd.DataFrame
@dataclass
class PVSplit:
global_x: np.ndarray
node_x: np.ndarray
y: np.ndarray
baseline_long: np.ndarray
baseline_short: np.ndarray
team_idx: np.ndarray
opp_idx: np.ndarray
metadata: pd.DataFrame
def _set_seed(seed: int = RANDOM_SEED) -> None:
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
def _build_team_index(df: pd.DataFrame) -> tuple[pd.Series, pd.Series, dict[str, int]]:
ids = pd.concat(
[
df["teamId"].astype(str),
df["opponent_team_id"].astype(str),
],
ignore_index=True,
).dropna().drop_duplicates().sort_values()
mapping = {team_id: idx for idx, team_id in enumerate(ids.tolist())}
team_idx = df["teamId"].astype(str).map(mapping).astype(int)
opp_idx = df["opponent_team_id"].astype(str).map(mapping).astype(int)
return team_idx, opp_idx, mapping
class TeamEmbeddingAttackGNN(nn.Module):
def __init__(self, node_dim: int, global_dim: int, n_teams: int, hidden_dim: int = 96, global_hidden: int = 96) -> None:
super().__init__()
self.team_emb = nn.Embedding(n_teams, EMBED_DIM)
self.opp_emb = nn.Embedding(n_teams, EMBED_DIM)
self.global_encoder = nn.Sequential(
nn.Linear(global_dim + (EMBED_DIM * 2), 192),
nn.ReLU(),
nn.Dropout(0.10),
nn.Linear(192, global_hidden),
nn.ReLU(),
)
self.node_encoder = nn.Sequential(
nn.Linear(node_dim + global_hidden, hidden_dim),
nn.ReLU(),
)
self.graph_blocks = nn.ModuleList([attack_base.GraphBlock(hidden_dim, hidden_dim, dropout=0.06) for _ in range(3)])
self.gate_head = nn.Linear(hidden_dim, 1)
self.delta_head = nn.Linear(hidden_dim, 1)
def forward(
self,
node_x: torch.Tensor,
global_x: torch.Tensor,
baseline_long: torch.Tensor,
baseline_short: torch.Tensor,
team_idx: torch.Tensor,
opp_idx: torch.Tensor,
adj: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
team_emb = self.team_emb(team_idx)
opp_emb = self.opp_emb(opp_idx)
g = self.global_encoder(torch.cat([global_x, team_emb, opp_emb], dim=1))
g_rep = g.unsqueeze(1).expand(-1, node_x.size(1), -1)
h = self.node_encoder(torch.cat([node_x, g_rep], dim=-1))
gate = torch.sigmoid(self.gate_head(h)).squeeze(-1)
mixed = gate * baseline_short + (1.0 - gate) * baseline_long
for block in self.graph_blocks:
h = h + block(h, adj)
delta = self.delta_head(h).squeeze(-1)
logits = torch.log(torch.clamp(mixed, min=1e-6)) + delta
pred = torch.softmax(logits, dim=1)
return pred, delta, gate, mixed
class TeamEmbeddingPVGNN(nn.Module):
def __init__(self, node_dim: int, global_dim: int, n_teams: int, hidden_dim: int = 96, global_hidden: int = 96) -> None:
super().__init__()
self.team_emb = nn.Embedding(n_teams, EMBED_DIM)
self.opp_emb = nn.Embedding(n_teams, EMBED_DIM)
self.global_encoder = nn.Sequential(
nn.Linear(global_dim + (EMBED_DIM * 2), 192),
nn.ReLU(),
nn.Dropout(0.10),
nn.Linear(192, global_hidden),
nn.ReLU(),
)
self.node_encoder = nn.Sequential(
nn.Linear(node_dim + global_hidden, hidden_dim),
nn.ReLU(),
)
self.graph_blocks = nn.ModuleList([pv_base.GraphBlock(hidden_dim, hidden_dim, dropout=0.06) for _ in range(3)])
self.gate_head = nn.Linear(hidden_dim, 1)
self.delta_head = nn.Linear(hidden_dim, 1)
def forward(
self,
node_x: torch.Tensor,
global_x: torch.Tensor,
baseline_long: torch.Tensor,
baseline_short: torch.Tensor,
team_idx: torch.Tensor,
opp_idx: torch.Tensor,
adj: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
team_emb = self.team_emb(team_idx)
opp_emb = self.opp_emb(opp_idx)
g = self.global_encoder(torch.cat([global_x, team_emb, opp_emb], dim=1))
g_rep = g.unsqueeze(1).expand(-1, node_x.size(1), -1)
h = self.node_encoder(torch.cat([node_x, g_rep], dim=-1))
gate = torch.sigmoid(self.gate_head(h)).squeeze(-1)
mixed = gate * baseline_short + (1.0 - gate) * baseline_long
for block in self.graph_blocks:
h = h + block(h, adj)
delta = self.delta_head(h).squeeze(-1)
logits = torch.log(torch.clamp(mixed, min=1e-6)) + delta
pred = torch.softmax(logits, dim=1)
return pred, delta, gate, mixed
def _make_attack_split(
df: pd.DataFrame,
idx: np.ndarray,
global_x: np.ndarray,
node_x: np.ndarray,
y: np.ndarray,
baseline_long: np.ndarray,
baseline_short: np.ndarray,
team_idx: np.ndarray,
opp_idx: np.ndarray,
) -> AttackSplit:
return AttackSplit(
global_x=global_x[idx].astype(np.float32),
node_x=node_x[idx].astype(np.float32),
y=y[idx].astype(np.float32),
baseline_long=baseline_long[idx].astype(np.float32),
baseline_short=baseline_short[idx].astype(np.float32),
team_idx=team_idx[idx].astype(np.int64),
opp_idx=opp_idx[idx].astype(np.int64),
metadata=df.iloc[idx].copy().reset_index(drop=True),
)
def _make_pv_split(
df: pd.DataFrame,
idx: np.ndarray,
global_x: np.ndarray,
node_x: np.ndarray,
y: np.ndarray,
baseline_long: np.ndarray,
baseline_short: np.ndarray,
team_idx: np.ndarray,
opp_idx: np.ndarray,
) -> PVSplit:
return PVSplit(
global_x=global_x[idx].astype(np.float32),
node_x=node_x[idx].astype(np.float32),
y=y[idx].astype(np.float32),
baseline_long=baseline_long[idx].astype(np.float32),
baseline_short=baseline_short[idx].astype(np.float32),
team_idx=team_idx[idx].astype(np.int64),
opp_idx=opp_idx[idx].astype(np.int64),
metadata=df.iloc[idx].copy().reset_index(drop=True),
)
def _make_loader(split: AttackSplit | PVSplit, shuffle: bool) -> DataLoader:
dataset = TensorDataset(
torch.from_numpy(split.global_x),
torch.from_numpy(split.node_x),
torch.from_numpy(split.y),
torch.from_numpy(split.baseline_long),
torch.from_numpy(split.baseline_short),
torch.from_numpy(split.team_idx),
torch.from_numpy(split.opp_idx),
)
return DataLoader(dataset, batch_size=BATCH_SIZE, shuffle=shuffle)
def _train_attack(train: AttackSplit, val: AttackSplit, n_teams: int) -> tuple[TeamEmbeddingAttackGNN, list[dict[str, float]]]:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = TeamEmbeddingAttackGNN(train.node_x.shape[2], train.global_x.shape[1], n_teams=n_teams).to(device)
optimizer = torch.optim.AdamW(model.parameters(), lr=ATTACK_LR, weight_decay=WEIGHT_DECAY)
train_loader = _make_loader(train, shuffle=True)
val_loader = _make_loader(val, shuffle=False)
best_state = None
best_val = float("inf")
patience_left = PATIENCE
history: list[dict[str, float]] = []
adj = attack_base.ADJ_MATRIX.to(device)
for epoch in range(1, MAX_EPOCHS + 1):
model.train()
running = 0.0
n_batches = 0
for global_x, node_x, y, baseline_long, baseline_short, team_idx, opp_idx in train_loader:
global_x = global_x.to(device)
node_x = node_x.to(device)
y = y.to(device)
baseline_long = baseline_long.to(device)
baseline_short = baseline_short.to(device)
team_idx = team_idx.to(device)
opp_idx = opp_idx.to(device)
optimizer.zero_grad()
pred, delta, gate, _mixed = model(node_x, global_x, baseline_long, baseline_short, team_idx, opp_idx, adj)
loss, _parts = attack_base._distribution_loss(pred, y, delta, gate)
loss.backward()
optimizer.step()
running += float(loss.item())
n_batches += 1
model.eval()
total_val = 0.0
n_val = 0
with torch.no_grad():
for global_x, node_x, y, baseline_long, baseline_short, team_idx, opp_idx in val_loader:
global_x = global_x.to(device)
node_x = node_x.to(device)
y = y.to(device)
baseline_long = baseline_long.to(device)
baseline_short = baseline_short.to(device)
team_idx = team_idx.to(device)
opp_idx = opp_idx.to(device)
pred, delta, gate, _mixed = model(node_x, global_x, baseline_long, baseline_short, team_idx, opp_idx, adj)
loss, parts = attack_base._distribution_loss(pred, y, delta, gate)
total_val += float(loss.item())
val_kl = parts["kl"]
n_val += 1
val_loss = total_val / max(n_val, 1)
history.append({"epoch": epoch, "train_loss": running / max(n_batches, 1), "val_loss": val_loss, "val_kl": val_kl})
if val_loss < best_val - 1e-6:
best_val = val_loss
best_state = {k: v.detach().cpu().clone() for k, v in model.state_dict().items()}
patience_left = PATIENCE
else:
patience_left -= 1
if patience_left <= 0:
break
if best_state is None:
best_state = {k: v.detach().cpu().clone() for k, v in model.state_dict().items()}
model.load_state_dict(best_state)
return model, history
def _train_pv(train: PVSplit, val: PVSplit, n_teams: int) -> tuple[TeamEmbeddingPVGNN, list[dict[str, float]]]:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = TeamEmbeddingPVGNN(train.node_x.shape[2], train.global_x.shape[1], n_teams=n_teams).to(device)
optimizer = torch.optim.AdamW(model.parameters(), lr=PV_LR, weight_decay=WEIGHT_DECAY)
train_loader = _make_loader(train, shuffle=True)
val_loader = _make_loader(val, shuffle=False)
best_state = None
best_val = float("inf")
patience_left = PATIENCE
history: list[dict[str, float]] = []
adj = pv_base.ADJ_MATRIX.to(device)
for epoch in range(1, MAX_EPOCHS + 1):
model.train()
running = 0.0
n_batches = 0
for global_x, node_x, y, baseline_long, baseline_short, team_idx, opp_idx in train_loader:
global_x = global_x.to(device)
node_x = node_x.to(device)
y = y.to(device)
baseline_long = baseline_long.to(device)
baseline_short = baseline_short.to(device)
team_idx = team_idx.to(device)
opp_idx = opp_idx.to(device)
optimizer.zero_grad()
pred, delta, gate, _mixed = model(node_x, global_x, baseline_long, baseline_short, team_idx, opp_idx, adj)
loss, _parts = pv_base._dist_loss(pred, y, delta, gate)
loss.backward()
optimizer.step()
running += float(loss.item())
n_batches += 1
model.eval()
total_val = 0.0
n_val = 0
with torch.no_grad():
for global_x, node_x, y, baseline_long, baseline_short, team_idx, opp_idx in val_loader:
global_x = global_x.to(device)
node_x = node_x.to(device)
y = y.to(device)
baseline_long = baseline_long.to(device)
baseline_short = baseline_short.to(device)
team_idx = team_idx.to(device)
opp_idx = opp_idx.to(device)
pred, delta, gate, _mixed = model(node_x, global_x, baseline_long, baseline_short, team_idx, opp_idx, adj)
loss, parts = pv_base._dist_loss(pred, y, delta, gate)
total_val += float(loss.item())
val_kl = parts["kl"]
n_val += 1
val_loss = total_val / max(n_val, 1)
history.append({"epoch": epoch, "train_loss": running / max(n_batches, 1), "val_loss": val_loss, "val_kl": val_kl})
if val_loss < best_val - 1e-6:
best_val = val_loss
best_state = {k: v.detach().cpu().clone() for k, v in model.state_dict().items()}
patience_left = PATIENCE
else:
patience_left -= 1
if patience_left <= 0:
break
if best_state is None:
best_state = {k: v.detach().cpu().clone() for k, v in model.state_dict().items()}
model.load_state_dict(best_state)
return model, history
def _predict_attack(model: TeamEmbeddingAttackGNN, split: AttackSplit) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model.eval()
preds, gates, mixeds = [], [], []
loader = _make_loader(split, shuffle=False)
adj = attack_base.ADJ_MATRIX.to(device)
with torch.no_grad():
for global_x, node_x, _y, baseline_long, baseline_short, team_idx, opp_idx in loader:
pred, _delta, gate, mixed = model(
node_x.to(device),
global_x.to(device),
baseline_long.to(device),
baseline_short.to(device),
team_idx.to(device),
opp_idx.to(device),
adj,
)
preds.append(pred.cpu().numpy())
gates.append(gate.cpu().numpy())
mixeds.append(mixed.cpu().numpy())
return np.vstack(preds), np.vstack(gates), np.vstack(mixeds)
def _predict_pv(model: TeamEmbeddingPVGNN, split: PVSplit) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model.eval()
preds, gates, mixeds = [], [], []
loader = _make_loader(split, shuffle=False)
adj = pv_base.ADJ_MATRIX.to(device)
with torch.no_grad():
for global_x, node_x, _y, baseline_long, baseline_short, team_idx, opp_idx in loader:
pred, _delta, gate, mixed = model(
node_x.to(device),
global_x.to(device),
baseline_long.to(device),
baseline_short.to(device),
team_idx.to(device),
opp_idx.to(device),
adj,
)
preds.append(pred.cpu().numpy())
gates.append(gate.cpu().numpy())
mixeds.append(mixed.cpu().numpy())
return np.vstack(preds), np.vstack(gates), np.vstack(mixeds)
def _mae_rows(df: pd.DataFrame, target_prefix: str) -> pd.DataFrame:
target_cols = [c for c in df.columns if c.startswith(target_prefix)]
model_cols = [c.replace(target_prefix, f"pred_model__{target_prefix}") for c in target_cols]
season_cols = [c.replace(target_prefix, f"pred_season__{target_prefix}") for c in target_cols]
short8_cols = [c.replace(target_prefix, f"pred_short8__{target_prefix}") for c in target_cols]
out = df[["fecha", "team_name", "opponent_name"]].copy()
out["model_mae"] = np.abs(df[target_cols].to_numpy() - df[model_cols].to_numpy()).mean(axis=1)
out["season_mae"] = np.abs(df[target_cols].to_numpy() - df[season_cols].to_numpy()).mean(axis=1)
out["short8_mae"] = np.abs(df[target_cols].to_numpy() - df[short8_cols].to_numpy()).mean(axis=1)
return out
def _last3_rows(df: pd.DataFrame) -> list[dict[str, float | str]]:
sub = df[df["team_name"].eq("Racing de Santander")].sort_values("fecha").tail(3)
rows = sub.to_dict(orient="records")
for row in rows:
if "fecha" in row and pd.notna(row["fecha"]):
row["fecha"] = pd.Timestamp(row["fecha"]).strftime("%Y-%m-%d")
return rows
def _report_html(summary: dict) -> str:
attack_last3 = summary["attack"]["last3_racing"]
pv_last3 = summary["pv"]["last3_racing"]
def rows_html(rows: list[dict[str, float | str]]) -> str:
return "".join(
f"<tr><td>{html.escape(str(r['fecha'])[:10])}</td><td>{html.escape(str(r['opponent_name']))}</td>"
f"<td>{r['model_mae']:.4f}</td><td>{r['season_mae']:.4f}</td><td>{r['short8_mae']:.4f}</td><td>{r['previous_model_mae']:.4f}</td></tr>"
for r in rows
)
return f"""<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8" />
<title>GNN con embeddings de equipo</title>
<style>
body {{ margin: 0; background: #f1f4ef; color: #14342B; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }}
.wrap {{ max-width: 1200px; margin: 0 auto; padding: 30px 24px 48px; }}
h1 {{ margin: 0 0 10px; font-size: 38px; }}
.lead {{ margin: 0 0 22px; font-size: 18px; color: #35574D; }}
.grid2 {{ display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin-bottom: 18px; }}
.card {{ background: white; border-radius: 20px; padding: 18px 20px; box-shadow: 0 8px 24px rgba(12, 36, 28, 0.08); }}
table {{ width: 100%; border-collapse: collapse; font-size: 14px; }}
th, td {{ padding: 10px 8px; border-bottom: 1px solid #E5ECE6; text-align: left; }}
th {{ color: #587468; text-transform: uppercase; font-size: 12px; letter-spacing: .06em; }}
@media (max-width: 980px) {{ .grid2 {{ grid-template-columns: 1fr; }} }}
</style>
</head>
<body>
<div class="wrap">
<h1>GNN con embeddings de equipo y rival</h1>
<p class="lead">Mismo GNN espacial anterior, pero agregando embeddings aprendidos para equipo y rival dentro del encoder global. La comparacion se hace contra baseline temporada, baseline ultimos 8 y el GNN simple previo.</p>
<div class="grid2">
<section class="card">
<h2>Ataque - test</h2>
<table>
<tr><th>Metrica</th><th>Embeddings</th><th>Temporada</th><th>Ultimos 8</th><th>GNN previo</th></tr>
<tr><td>MAE</td><td>{summary['attack']['test_metrics']['model_mae']:.4f}</td><td>{summary['attack']['test_metrics']['season_baseline_mae']:.4f}</td><td>{summary['attack']['test_metrics']['short8_baseline_mae']:.4f}</td><td>{summary['attack']['previous_test_metrics']['model_mae']:.4f}</td></tr>
<tr><td>JSD</td><td>{summary['attack']['test_metrics']['model_jsd']:.4f}</td><td>{summary['attack']['test_metrics']['season_baseline_jsd']:.4f}</td><td>{summary['attack']['test_metrics']['short8_baseline_jsd']:.4f}</td><td>{summary['attack']['previous_test_metrics']['model_jsd']:.4f}</td></tr>
<tr><td>KL</td><td>{summary['attack']['test_metrics']['model_kl_proxy']:.4f}</td><td>{summary['attack']['test_metrics']['season_baseline_kl_proxy']:.4f}</td><td>{summary['attack']['test_metrics']['short8_baseline_kl_proxy']:.4f}</td><td>{summary['attack']['previous_test_metrics']['model_kl_proxy']:.4f}</td></tr>
</table>
</section>
<section class="card">
<h2>PV - test</h2>
<table>
<tr><th>Metrica</th><th>Embeddings</th><th>Temporada</th><th>Ultimos 8</th><th>GNN previo</th></tr>
<tr><td>MAE</td><td>{summary['pv']['test_metrics']['model_mae']:.4f}</td><td>{summary['pv']['test_metrics']['season_baseline_mae']:.4f}</td><td>{summary['pv']['test_metrics']['short8_baseline_mae']:.4f}</td><td>{summary['pv']['previous_test_metrics']['model_mae']:.4f}</td></tr>
<tr><td>JSD</td><td>{summary['pv']['test_metrics']['model_jsd']:.4f}</td><td>{summary['pv']['test_metrics']['season_baseline_jsd']:.4f}</td><td>{summary['pv']['test_metrics']['short8_baseline_jsd']:.4f}</td><td>{summary['pv']['previous_test_metrics']['model_jsd']:.4f}</td></tr>
<tr><td>KL</td><td>{summary['pv']['test_metrics']['model_kl_proxy']:.4f}</td><td>{summary['pv']['test_metrics']['season_baseline_kl_proxy']:.4f}</td><td>{summary['pv']['test_metrics']['short8_baseline_kl_proxy']:.4f}</td><td>{summary['pv']['previous_test_metrics']['model_kl_proxy']:.4f}</td></tr>
</table>
</section>
</div>
<div class="grid2">
<section class="card">
<h2>Ataque - ultimos 3 de Racing</h2>
<table>
<tr><th>Fecha</th><th>Rival</th><th>Embeddings</th><th>Temporada</th><th>Ultimos 8</th><th>GNN previo</th></tr>
{rows_html(attack_last3)}
</table>
</section>
<section class="card">
<h2>PV - ultimos 3 de Racing</h2>
<table>
<tr><th>Fecha</th><th>Rival</th><th>Embeddings</th><th>Temporada</th><th>Ultimos 8</th><th>GNN previo</th></tr>
{rows_html(pv_last3)}
</table>
</section>
</div>
</div>
</body>
</html>"""
def main() -> None:
_set_seed()
MODEL_DIR.mkdir(parents=True, exist_ok=True)
REPORTS_DIR.mkdir(parents=True, exist_ok=True)
attack_df, attack_targets = attack_base._load_data()
team_idx_attack, opp_idx_attack, team_mapping = _build_team_index(attack_df)
train_idx, val_idx, test_idx, val_start_date = attack_base._train_val_test_indices(attack_df)
attack_global_features, attack_node_tensor, _zone_order_array, attack_global_cols, attack_node_feat_names = attack_base._build_feature_matrices(attack_df, attack_targets)
attack_global_x, attack_global_bundle = attack_base._standardize_global(train_idx, attack_global_features)
attack_node_x, attack_node_bundle = attack_base._standardize_node(train_idx, attack_node_tensor)
attack_y = attack_df[attack_targets].to_numpy(dtype=np.float32)
attack_baseline_long = base._normalize_rows(attack_df[[f"long_mean__actual_attack_share__{zone}" for zone in attack_base.ZONE_ORDER]].to_numpy(dtype=float)).astype(np.float32)
attack_baseline_short = base._normalize_rows(attack_df[[f"short_mean__actual_attack_share__{zone}" for zone in attack_base.ZONE_ORDER]].to_numpy(dtype=float)).astype(np.float32)
attack_train = _make_attack_split(attack_df, train_idx, attack_global_x, attack_node_x, attack_y, attack_baseline_long, attack_baseline_short, team_idx_attack.to_numpy(), opp_idx_attack.to_numpy())
attack_val = _make_attack_split(attack_df, val_idx, attack_global_x, attack_node_x, attack_y, attack_baseline_long, attack_baseline_short, team_idx_attack.to_numpy(), opp_idx_attack.to_numpy())
attack_test = _make_attack_split(attack_df, test_idx, attack_global_x, attack_node_x, attack_y, attack_baseline_long, attack_baseline_short, team_idx_attack.to_numpy(), opp_idx_attack.to_numpy())
attack_model, attack_history = _train_attack(attack_train, attack_val, n_teams=len(team_mapping))
attack_val_pred, _attack_val_gates, _attack_val_mixed = _predict_attack(attack_model, attack_val)
attack_test_pred, attack_test_gates, attack_test_mixed = _predict_attack(attack_model, attack_test)
attack_val_metrics = attack_base._metrics_against_baselines(attack_val.y.astype(float), attack_val_pred, attack_val.baseline_long.astype(float), attack_val.baseline_short.astype(float))
attack_test_metrics = attack_base._metrics_against_baselines(attack_test.y.astype(float), attack_test_pred, attack_test.baseline_long.astype(float), attack_test.baseline_short.astype(float))
attack_pred_df = attack_test.metadata.copy().reset_index(drop=True)
for i, zone in enumerate(attack_base.ZONE_ORDER):
attack_pred_df[f"target_attack_share__{zone}"] = attack_test.y[:, i]
attack_pred_df[f"pred_model__target_attack_share__{zone}"] = attack_test_pred[:, i]
attack_pred_df[f"pred_season__target_attack_share__{zone}"] = attack_test.baseline_long[:, i]
attack_pred_df[f"pred_short8__target_attack_share__{zone}"] = attack_test.baseline_short[:, i]
attack_pred_df[f"gate__{zone}"] = attack_test_gates[:, i]
attack_pred_df[f"mixed_base__{zone}"] = attack_test_mixed[:, i]
attack_keep = ["matchId", "fecha", "league", "season", "teamId", "team_name", "opponent_name", "is_home", "goals_for", "goals_against", "n_prior_matches", "opp_n_prior_matches"]
attack_keep += [f"target_attack_share__{zone}" for zone in attack_base.ZONE_ORDER]
attack_keep += [f"pred_model__target_attack_share__{zone}" for zone in attack_base.ZONE_ORDER]
attack_keep += [f"pred_season__target_attack_share__{zone}" for zone in attack_base.ZONE_ORDER]
attack_keep += [f"pred_short8__target_attack_share__{zone}" for zone in attack_base.ZONE_ORDER]
attack_keep += [f"gate__{zone}" for zone in attack_base.ZONE_ORDER]
attack_keep += [f"mixed_base__{zone}" for zone in attack_base.ZONE_ORDER]
attack_pred_df[attack_keep].to_parquet(ATTACK_PRED_PATH, index=False)
pv_df = pv_base._load_data()
team_idx_pv, opp_idx_pv, team_mapping_pv = _build_team_index(pv_df)
pv_train_idx, pv_val_idx, pv_test_idx, pv_val_start_date = pv_base._train_val_test_indices(pv_df)
pv_y, pv_baseline_long, pv_baseline_short = pv_base._build_distributions(pv_df)
pv_global_features, pv_node_tensor, pv_node_feature_names = pv_base._build_feature_matrices(pv_df)
pv_global_x, pv_global_bundle = pv_base._standardize_global(pv_train_idx, pv_global_features)
pv_node_x, pv_node_bundle = pv_base._standardize_node(pv_train_idx, pv_node_tensor)
pv_train = _make_pv_split(pv_df, pv_train_idx, pv_global_x, pv_node_x, pv_y, pv_baseline_long, pv_baseline_short, team_idx_pv.to_numpy(), opp_idx_pv.to_numpy())
pv_val = _make_pv_split(pv_df, pv_val_idx, pv_global_x, pv_node_x, pv_y, pv_baseline_long, pv_baseline_short, team_idx_pv.to_numpy(), opp_idx_pv.to_numpy())
pv_test = _make_pv_split(pv_df, pv_test_idx, pv_global_x, pv_node_x, pv_y, pv_baseline_long, pv_baseline_short, team_idx_pv.to_numpy(), opp_idx_pv.to_numpy())
pv_model, pv_history = _train_pv(pv_train, pv_val, n_teams=len(team_mapping_pv))
pv_val_pred, _pv_val_gates, _pv_val_mixed = _predict_pv(pv_model, pv_val)
pv_test_pred, pv_test_gates, pv_test_mixed = _predict_pv(pv_model, pv_test)
pv_val_metrics = pv_base._metrics(pv_val.y.astype(float), pv_val_pred, pv_val.baseline_long.astype(float), pv_val.baseline_short.astype(float))
pv_test_metrics = pv_base._metrics(pv_test.y.astype(float), pv_test_pred, pv_test.baseline_long.astype(float), pv_test.baseline_short.astype(float))
pv_pred_df = pv_test.metadata.copy().reset_index(drop=True)
for i, zone in enumerate(pv_base.ZONE_ORDER):
pv_pred_df[f"target_pv_dist__{zone}"] = pv_test.y[:, i]
pv_pred_df[f"pred_model__target_pv_dist__{zone}"] = pv_test_pred[:, i]
pv_pred_df[f"pred_season__target_pv_dist__{zone}"] = pv_test.baseline_long[:, i]
pv_pred_df[f"pred_short8__target_pv_dist__{zone}"] = pv_test.baseline_short[:, i]
pv_pred_df[f"gate__{zone}"] = pv_test_gates[:, i]
pv_pred_df[f"mixed_base__{zone}"] = pv_test_mixed[:, i]
pv_keep = ["matchId", "fecha", "league", "season", "teamId", "team_name", "opponent_name", "is_home", "goals_for", "goals_against", "n_prior_matches", "opp_n_prior_matches"]
pv_keep += [f"target_pv_dist__{zone}" for zone in pv_base.ZONE_ORDER]
pv_keep += [f"pred_model__target_pv_dist__{zone}" for zone in pv_base.ZONE_ORDER]
pv_keep += [f"pred_season__target_pv_dist__{zone}" for zone in pv_base.ZONE_ORDER]
pv_keep += [f"pred_short8__target_pv_dist__{zone}" for zone in pv_base.ZONE_ORDER]
pv_keep += [f"gate__{zone}" for zone in pv_base.ZONE_ORDER]
pv_keep += [f"mixed_base__{zone}" for zone in pv_base.ZONE_ORDER]
pv_pred_df[pv_keep].to_parquet(PV_PRED_PATH, index=False)
attack_prev_metrics = json.loads((MODEL_DIR / "attack_distribution_gnn_metrics.json").read_text(encoding="utf-8"))["test_metrics"]
pv_prev_metrics = json.loads((MODEL_DIR / "pv_distribution_gnn_metrics.json").read_text(encoding="utf-8"))["test_metrics"]
attack_last3 = _mae_rows(attack_pred_df, "target_attack_share__")
attack_prev_last3 = _mae_rows(pd.read_parquet(MODEL_DIR / "attack_distribution_gnn_test_predictions.parquet"), "target_attack_share__")
attack_last3 = attack_last3.merge(
attack_prev_last3.rename(columns={"model_mae": "previous_model_mae", "season_mae": "previous_season_mae", "short8_mae": "previous_short8_mae"}),
on=["fecha", "team_name", "opponent_name"],
how="left",
)
pv_last3 = _mae_rows(pv_pred_df, "target_pv_dist__")
pv_prev_last3 = _mae_rows(pd.read_parquet(MODEL_DIR / "pv_distribution_gnn_test_predictions.parquet"), "target_pv_dist__")
pv_last3 = pv_last3.merge(
pv_prev_last3.rename(columns={"model_mae": "previous_model_mae", "season_mae": "previous_season_mae", "short8_mae": "previous_short8_mae"}),
on=["fecha", "team_name", "opponent_name"],
how="left",
)
summary = {
"embedding_dim": EMBED_DIM,
"attack": {
"train_rows": int(len(attack_train.metadata)),
"val_rows": int(len(attack_val.metadata)),
"test_rows": int(len(attack_test.metadata)),
"val_start_date": val_start_date,
"test_metrics": attack_test_metrics,
"val_metrics": attack_val_metrics,
"previous_test_metrics": attack_prev_metrics,
"last3_racing": _last3_rows(attack_last3),
"n_teams": len(team_mapping),
},
"pv": {
"train_rows": int(len(pv_train.metadata)),
"val_rows": int(len(pv_val.metadata)),
"test_rows": int(len(pv_test.metadata)),
"val_start_date": pv_val_start_date,
"test_metrics": pv_test_metrics,
"val_metrics": pv_val_metrics,
"previous_test_metrics": pv_prev_metrics,
"last3_racing": _last3_rows(pv_last3),
"n_teams": len(team_mapping_pv),
},
"artifacts": {
"attack_predictions": str(ATTACK_PRED_PATH),
"pv_predictions": str(PV_PRED_PATH),
},
"training": {
"attack_epochs": len(attack_history),
"pv_epochs": len(pv_history),
},
}
torch.save(
{
"embedding_dim": EMBED_DIM,
"team_mapping_attack": team_mapping,
"team_mapping_pv": team_mapping_pv,
"attack_state_dict": attack_model.state_dict(),
"pv_state_dict": pv_model.state_dict(),
"attack_global_bundle": attack_global_bundle,
"attack_node_bundle": attack_node_bundle,
"attack_global_cols": attack_global_cols,
"attack_node_feat_names": attack_node_feat_names,
"pv_global_bundle": pv_global_bundle,
"pv_node_bundle": pv_node_bundle,
"pv_node_feature_names": pv_node_feature_names,
"zone_order": attack_base.ZONE_ORDER,
},
MODEL_PATH,
)
JSON_PATH.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
REPORT_PATH.write_text(_report_html(summary), encoding="utf-8")
print(f"Modelo guardado en: {MODEL_PATH}")
print(f"Predicciones ataque guardadas en: {ATTACK_PRED_PATH}")
print(f"Predicciones PV guardadas en: {PV_PRED_PATH}")
print(f"Metricas guardadas en: {JSON_PATH}")
print(f"Reporte guardado en: {REPORT_PATH}")
print(json.dumps(summary, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()