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"{html.escape(str(r['fecha'])[:10])}{html.escape(str(r['opponent_name']))}" f"{r['model_mae']:.4f}{r['season_mae']:.4f}{r['short8_mae']:.4f}{r['previous_model_mae']:.4f}" for r in rows ) return f""" GNN con embeddings de equipo

GNN con embeddings de equipo y rival

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.

Ataque - test

MetricaEmbeddingsTemporadaUltimos 8GNN previo
MAE{summary['attack']['test_metrics']['model_mae']:.4f}{summary['attack']['test_metrics']['season_baseline_mae']:.4f}{summary['attack']['test_metrics']['short8_baseline_mae']:.4f}{summary['attack']['previous_test_metrics']['model_mae']:.4f}
JSD{summary['attack']['test_metrics']['model_jsd']:.4f}{summary['attack']['test_metrics']['season_baseline_jsd']:.4f}{summary['attack']['test_metrics']['short8_baseline_jsd']:.4f}{summary['attack']['previous_test_metrics']['model_jsd']:.4f}
KL{summary['attack']['test_metrics']['model_kl_proxy']:.4f}{summary['attack']['test_metrics']['season_baseline_kl_proxy']:.4f}{summary['attack']['test_metrics']['short8_baseline_kl_proxy']:.4f}{summary['attack']['previous_test_metrics']['model_kl_proxy']:.4f}

PV - test

MetricaEmbeddingsTemporadaUltimos 8GNN previo
MAE{summary['pv']['test_metrics']['model_mae']:.4f}{summary['pv']['test_metrics']['season_baseline_mae']:.4f}{summary['pv']['test_metrics']['short8_baseline_mae']:.4f}{summary['pv']['previous_test_metrics']['model_mae']:.4f}
JSD{summary['pv']['test_metrics']['model_jsd']:.4f}{summary['pv']['test_metrics']['season_baseline_jsd']:.4f}{summary['pv']['test_metrics']['short8_baseline_jsd']:.4f}{summary['pv']['previous_test_metrics']['model_jsd']:.4f}
KL{summary['pv']['test_metrics']['model_kl_proxy']:.4f}{summary['pv']['test_metrics']['season_baseline_kl_proxy']:.4f}{summary['pv']['test_metrics']['short8_baseline_kl_proxy']:.4f}{summary['pv']['previous_test_metrics']['model_kl_proxy']:.4f}

Ataque - ultimos 3 de Racing

{rows_html(attack_last3)}
FechaRivalEmbeddingsTemporadaUltimos 8GNN previo

PV - ultimos 3 de Racing

{rows_html(pv_last3)}
FechaRivalEmbeddingsTemporadaUltimos 8GNN previo
""" 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()