RRC / vendor /scripts /experiment_attack_prediction_variants.py
pablogrois's picture
Deploy MVP: API JSON + SPA + bundle/cache de artifacts CORE
e58615a
Raw
History Blame Contribute Delete
29.1 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 / "attack_prediction_variant_experiments.html"
JSON_PATH = MODEL_DIR / "attack_prediction_variant_experiments.json"
BEST_MODEL_PATH = MODEL_DIR / "attack_prediction_best_variant_bundle.pt"
BEST_PREDICTIONS_PATH = MODEL_DIR / "attack_prediction_best_variant_test_predictions.parquet"
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
import train_attack_prediction_ffn as base # noqa: E402
RANDOM_SEED = 42
BATCH_SIZE = 256
MAX_EPOCHS = 180
PATIENCE = 22
LEARNING_RATE = 1e-3
WEIGHT_DECAY = 1e-4
@dataclass
class SplitData:
x: np.ndarray
team_idx: np.ndarray
opp_idx: np.ndarray
y_attack: np.ndarray
y_pv: np.ndarray
metadata: pd.DataFrame
class BaseVariantModel(nn.Module):
def forward(self, x: torch.Tensor, team_idx: torch.Tensor, opp_idx: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
raise NotImplementedError
class FFNVariant(BaseVariantModel):
def __init__(self, input_dim: int, attack_dim: int, pv_dim: int, hidden_dims: list[int], dropouts: list[float]) -> None:
super().__init__()
layers: list[nn.Module] = []
prev = input_dim
for width, drop in zip(hidden_dims, dropouts):
layers.extend([nn.Linear(prev, width), nn.ReLU(), nn.Dropout(drop)])
prev = width
self.backbone = nn.Sequential(*layers)
self.attack_head = nn.Linear(prev, attack_dim)
self.pv_head = nn.Linear(prev, pv_dim)
def forward(self, x: torch.Tensor, team_idx: torch.Tensor, opp_idx: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
h = self.backbone(x)
attack = torch.softmax(self.attack_head(h), dim=1)
pv = self.pv_head(h)
return attack, pv
class EmbeddingVariant(BaseVariantModel):
def __init__(
self,
input_dim: int,
attack_dim: int,
pv_dim: int,
num_teams: int,
team_embed_dim: int = 24,
hidden_dims: list[int] | None = None,
dropouts: list[float] | None = None,
use_embeddings: bool = True,
) -> None:
super().__init__()
self.use_embeddings = use_embeddings
hidden_dims = hidden_dims or [384, 192, 96]
dropouts = dropouts or [0.20, 0.15, 0.10]
embed_extra = 0
if use_embeddings:
self.team_embedding = nn.Embedding(num_teams + 1, team_embed_dim)
self.opp_embedding = nn.Embedding(num_teams + 1, team_embed_dim)
embed_extra = team_embed_dim * 2
layers: list[nn.Module] = []
prev = input_dim + embed_extra
for width, drop in zip(hidden_dims, dropouts):
layers.extend([nn.Linear(prev, width), nn.ReLU(), nn.Dropout(drop)])
prev = width
self.backbone = nn.Sequential(*layers)
self.attack_head = nn.Linear(prev, attack_dim)
self.pv_head = nn.Linear(prev, pv_dim)
def forward(self, x: torch.Tensor, team_idx: torch.Tensor, opp_idx: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
if self.use_embeddings:
team_emb = self.team_embedding(team_idx)
opp_emb = self.opp_embedding(opp_idx)
x = torch.cat([x, team_emb, opp_emb], dim=1)
h = self.backbone(x)
attack = torch.softmax(self.attack_head(h), dim=1)
pv = self.pv_head(h)
return attack, pv
def _set_seed(seed: int = RANDOM_SEED) -> None:
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
def _loss_fn(pred_attack: torch.Tensor, pred_pv: torch.Tensor, y_attack: torch.Tensor, y_pv: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
attack_loss = torch.mean((pred_attack - y_attack) ** 2)
pv_loss = torch.nn.functional.smooth_l1_loss(pred_pv, y_pv)
total = attack_loss + pv_loss
return total, attack_loss, pv_loss
def _make_loader(split: SplitData, shuffle: bool) -> DataLoader:
dataset = TensorDataset(
torch.from_numpy(split.x),
torch.from_numpy(split.team_idx),
torch.from_numpy(split.opp_idx),
torch.from_numpy(split.y_attack),
torch.from_numpy(split.y_pv),
)
return DataLoader(dataset, batch_size=BATCH_SIZE, shuffle=shuffle)
def _evaluate_loader(model: BaseVariantModel, loader: DataLoader, device: torch.device) -> dict[str, float]:
model.eval()
total_loss = 0.0
attack_loss = 0.0
pv_loss = 0.0
n_batches = 0
with torch.no_grad():
for x, team_idx, opp_idx, y_attack, y_pv in loader:
x = x.to(device)
team_idx = team_idx.to(device)
opp_idx = opp_idx.to(device)
y_attack = y_attack.to(device)
y_pv = y_pv.to(device)
pred_attack, pred_pv = model(x, team_idx, opp_idx)
loss, la, lp = _loss_fn(pred_attack, pred_pv, y_attack, y_pv)
total_loss += float(loss.item())
attack_loss += float(la.item())
pv_loss += float(lp.item())
n_batches += 1
return {
"loss": total_loss / max(n_batches, 1),
"attack_loss": attack_loss / max(n_batches, 1),
"pv_loss": pv_loss / max(n_batches, 1),
}
def _train_model(model: BaseVariantModel, train: SplitData, val: SplitData) -> tuple[BaseVariantModel, list[dict[str, float]]]:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = model.to(device)
optimizer = torch.optim.AdamW(model.parameters(), lr=LEARNING_RATE, 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")
history: list[dict[str, float]] = []
patience_left = PATIENCE
for epoch in range(1, MAX_EPOCHS + 1):
model.train()
running_total = 0.0
n_batches = 0
for x, team_idx, opp_idx, y_attack, y_pv in train_loader:
x = x.to(device)
team_idx = team_idx.to(device)
opp_idx = opp_idx.to(device)
y_attack = y_attack.to(device)
y_pv = y_pv.to(device)
optimizer.zero_grad()
pred_attack, pred_pv = model(x, team_idx, opp_idx)
loss, _, _ = _loss_fn(pred_attack, pred_pv, y_attack, y_pv)
loss.backward()
optimizer.step()
running_total += float(loss.item())
n_batches += 1
val_metrics = _evaluate_loader(model, val_loader, device)
history.append(
{
"epoch": epoch,
"train_loss": running_total / max(n_batches, 1),
"val_loss": val_metrics["loss"],
"val_attack_loss": val_metrics["attack_loss"],
"val_pv_loss": val_metrics["pv_loss"],
}
)
if val_metrics["loss"] < best_val - 1e-6:
best_val = val_metrics["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(model: BaseVariantModel, split: SplitData) -> tuple[np.ndarray, np.ndarray]:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model.eval()
attack_preds: list[np.ndarray] = []
pv_preds: list[np.ndarray] = []
loader = _make_loader(split, shuffle=False)
with torch.no_grad():
for x, team_idx, opp_idx, _, _ in loader:
x = x.to(device)
team_idx = team_idx.to(device)
opp_idx = opp_idx.to(device)
attack, pv = model(x, team_idx, opp_idx)
attack_preds.append(attack.cpu().numpy())
pv_preds.append(pv.cpu().numpy())
return np.vstack(attack_preds), np.vstack(pv_preds)
def _team_index_arrays(df: pd.DataFrame) -> tuple[np.ndarray, np.ndarray, dict[str, int]]:
team_ids = pd.Index(sorted(set(df["teamId"].astype(str)) | set(df["opponent_team_id"].astype(str))))
mapping = {team_id: i + 1 for i, team_id in enumerate(team_ids)}
team_idx = df["teamId"].astype(str).map(mapping).fillna(0).astype(np.int64).to_numpy()
opp_idx = df["opponent_team_id"].astype(str).map(mapping).fillna(0).astype(np.int64).to_numpy()
return team_idx, opp_idx, mapping
def _prepare_targets(df: pd.DataFrame, attack_targets: list[str], pv_targets: list[str], train_idx: pd.Index) -> tuple[np.ndarray, np.ndarray, dict[str, np.ndarray]]:
y_attack = df[attack_targets].to_numpy(dtype=np.float32)
y_pv_raw = df[pv_targets].to_numpy(dtype=np.float32)
pv_train_raw = y_pv_raw[train_idx]
scaler = {
"mean": pv_train_raw.mean(axis=0, keepdims=True).astype(np.float32),
"std": np.where(pv_train_raw.std(axis=0, keepdims=True) > 0, pv_train_raw.std(axis=0, keepdims=True), 1.0).astype(np.float32),
}
y_pv = ((y_pv_raw - scaler["mean"]) / scaler["std"]).astype(np.float32)
return y_attack, y_pv, scaler
def _pack_split(
df: pd.DataFrame,
x_values: np.ndarray,
team_idx: np.ndarray,
opp_idx: np.ndarray,
y_attack: np.ndarray,
y_pv: np.ndarray,
idx: np.ndarray,
) -> SplitData:
return SplitData(
x=x_values[idx].astype(np.float32),
team_idx=team_idx[idx].astype(np.int64),
opp_idx=opp_idx[idx].astype(np.int64),
y_attack=y_attack[idx].astype(np.float32),
y_pv=y_pv[idx].astype(np.float32),
metadata=df.iloc[idx].copy().reset_index(drop=True),
)
def _select_feature_columns(feature_columns: list[str], mode: str) -> list[str]:
if mode == "full":
return feature_columns
if mode == "no_opp":
return [c for c in feature_columns if not c.startswith("opp__")]
if mode == "no_short":
return [c for c in feature_columns if not c.startswith("short_mean__")]
if mode == "no_long":
return [c for c in feature_columns if not (c.startswith("long_mean__") or c.startswith("long_std__"))]
if mode == "no_pressrec":
keys = ("press", "pressure", "recov", "recovery", "interception")
return [c for c in feature_columns if not any(key in c.lower() for key in keys)]
raise ValueError(f"Ablation no reconocida: {mode}")
def _evaluate_predictions(
metadata: pd.DataFrame,
attack_targets: list[str],
pv_targets: list[str],
attack_pred: np.ndarray,
pv_pred: np.ndarray,
) -> dict[str, float]:
baseline_attack_cols = [base._baseline_col_from_target(c) for c in attack_targets]
baseline_pv_cols = [base._baseline_col_from_target(c) for c in pv_targets]
true_attack = metadata[attack_targets].to_numpy(dtype=float)
true_pv = metadata[pv_targets].to_numpy(dtype=float)
baseline_attack = base._normalize_rows(metadata[baseline_attack_cols].to_numpy(dtype=float))
baseline_pv = metadata[baseline_pv_cols].to_numpy(dtype=float)
return {
"attack_mae_model": base._mean_abs_error(true_attack, attack_pred),
"attack_mae_baseline": base._mean_abs_error(true_attack, baseline_attack),
"attack_jsd_model": base._jsd_mean(true_attack, attack_pred),
"attack_jsd_baseline": base._jsd_mean(true_attack, baseline_attack),
"pv_mae_model": base._mean_abs_error(true_pv, pv_pred),
"pv_mae_baseline": base._mean_abs_error(true_pv, baseline_pv),
"pv_r2_model": base._r2_score_mean(true_pv, pv_pred),
"pv_r2_baseline": base._r2_score_mean(true_pv, baseline_pv),
}
def _run_single_experiment(
df: pd.DataFrame,
x_scaled: pd.DataFrame,
y_attack: np.ndarray,
y_pv: np.ndarray,
team_idx: np.ndarray,
opp_idx: np.ndarray,
attack_targets: list[str],
pv_targets: list[str],
pv_scaler: dict[str, np.ndarray],
train_idx: np.ndarray,
val_idx: np.ndarray,
test_idx: np.ndarray,
variant_name: str,
feature_mode: str,
model_factory,
) -> dict:
selected_cols = _select_feature_columns(list(x_scaled.columns), feature_mode)
x_values = x_scaled[selected_cols].to_numpy(dtype=np.float32)
train_split = _pack_split(df, x_values, team_idx, opp_idx, y_attack, y_pv, train_idx)
val_split = _pack_split(df, x_values, team_idx, opp_idx, y_attack, y_pv, val_idx)
test_split = _pack_split(df, x_values, team_idx, opp_idx, y_attack, y_pv, test_idx)
model = model_factory(input_dim=len(selected_cols), attack_dim=len(attack_targets), pv_dim=len(pv_targets))
model, history = _train_model(model, train_split, val_split)
val_attack_pred, val_pv_pred_scaled = _predict(model, val_split)
test_attack_pred, test_pv_pred_scaled = _predict(model, test_split)
val_pv_pred = (val_pv_pred_scaled * pv_scaler["std"]) + pv_scaler["mean"]
test_pv_pred = (test_pv_pred_scaled * pv_scaler["std"]) + pv_scaler["mean"]
val_metrics = _evaluate_predictions(val_split.metadata, attack_targets, pv_targets, val_attack_pred, val_pv_pred)
test_metrics = _evaluate_predictions(test_split.metadata, attack_targets, pv_targets, test_attack_pred, test_pv_pred)
return {
"variant_name": variant_name,
"feature_mode": feature_mode,
"selected_feature_count": len(selected_cols),
"history": history,
"val_metrics": val_metrics,
"test_metrics": test_metrics,
"model_state_dict": {k: v.detach().cpu() for k, v in model.state_dict().items()},
"selected_columns": selected_cols,
"test_attack_pred": test_attack_pred,
"test_pv_pred": test_pv_pred,
}
def _variant_factories(num_teams: int) -> dict[str, object]:
return {
"small_ffn": lambda input_dim, attack_dim, pv_dim: FFNVariant(
input_dim=input_dim,
attack_dim=attack_dim,
pv_dim=pv_dim,
hidden_dims=[128, 64],
dropouts=[0.10, 0.05],
),
"large_ffn": lambda input_dim, attack_dim, pv_dim: FFNVariant(
input_dim=input_dim,
attack_dim=attack_dim,
pv_dim=pv_dim,
hidden_dims=[768, 384, 192],
dropouts=[0.25, 0.20, 0.15],
),
"team_embeddings": lambda input_dim, attack_dim, pv_dim: EmbeddingVariant(
input_dim=input_dim,
attack_dim=attack_dim,
pv_dim=pv_dim,
num_teams=num_teams,
team_embed_dim=24,
hidden_dims=[384, 192, 96],
dropouts=[0.20, 0.15, 0.10],
use_embeddings=True,
),
"team_embeddings_no_embed": lambda input_dim, attack_dim, pv_dim: EmbeddingVariant(
input_dim=input_dim,
attack_dim=attack_dim,
pv_dim=pv_dim,
num_teams=num_teams,
team_embed_dim=24,
hidden_dims=[384, 192, 96],
dropouts=[0.20, 0.15, 0.10],
use_embeddings=False,
),
}
def _comparison_rows(results: list[dict], split_key: str) -> pd.DataFrame:
rows = []
for result in results:
metrics = result[f"{split_key}_metrics"]
rows.append(
{
"modelo": result["variant_name"],
"features": result["feature_mode"],
"attack_mae_model": metrics["attack_mae_model"],
"attack_mae_baseline": metrics["attack_mae_baseline"],
"attack_delta": metrics["attack_mae_baseline"] - metrics["attack_mae_model"],
"pv_mae_model": metrics["pv_mae_model"],
"pv_mae_baseline": metrics["pv_mae_baseline"],
"pv_delta": metrics["pv_mae_baseline"] - metrics["pv_mae_model"],
"pv_r2_model": metrics["pv_r2_model"],
"pv_r2_baseline": metrics["pv_r2_baseline"],
}
)
return pd.DataFrame(rows).sort_values(["attack_mae_model", "pv_mae_model"]).reset_index(drop=True)
def _render_table(df: pd.DataFrame, title: str) -> str:
rows = []
for _, row in df.iterrows():
rows.append(
"<tr>"
f"<td>{html.escape(str(row['modelo']))}</td>"
f"<td>{html.escape(str(row['features']))}</td>"
f"<td>{row['attack_mae_model']:.4f}</td>"
f"<td>{row['attack_mae_baseline']:.4f}</td>"
f"<td>{row['attack_delta']:+.4f}</td>"
f"<td>{row['pv_mae_model']:.4f}</td>"
f"<td>{row['pv_mae_baseline']:.4f}</td>"
f"<td>{row['pv_delta']:+.4f}</td>"
f"<td>{row['pv_r2_model']:.4f}</td>"
f"<td>{row['pv_r2_baseline']:.4f}</td>"
"</tr>"
)
return f"""
<section class="card">
<h2>{html.escape(title)}</h2>
<table>
<tr>
<th>Modelo</th><th>Features</th><th>Attack MAE</th><th>Baseline</th><th>Mejora</th>
<th>PV MAE</th><th>Baseline</th><th>Mejora</th><th>PV R2</th><th>Baseline R2</th>
</tr>
{''.join(rows)}
</table>
</section>
"""
def _render_match_table(best_predictions: pd.DataFrame, attack_targets: list[str], pv_targets: list[str]) -> str:
rows = []
racing = best_predictions[best_predictions["teamId"] == base.RACING_TEAM_ID].sort_values(["fecha", "matchId"]).tail(3)
for _, row in racing.iterrows():
attack_true = row[attack_targets].to_numpy(dtype=float)
attack_model = row[[f"pred_model__{c}" for c in attack_targets]].to_numpy(dtype=float)
attack_base = row[[f"pred_baseline__{c}" for c in attack_targets]].to_numpy(dtype=float)
pv_true = row[pv_targets].to_numpy(dtype=float)
pv_model = row[[f"pred_model__{c}" for c in pv_targets]].to_numpy(dtype=float)
pv_base = row[[f"pred_baseline__{c}" for c in pv_targets]].to_numpy(dtype=float)
rows.append(
"<tr>"
f"<td>{row['fecha'].strftime('%Y-%m-%d')}</td>"
f"<td>{html.escape(str(row.get('opponent_name', '')))}</td>"
f"<td>{np.mean(np.abs(attack_true - attack_model)):.4f}</td>"
f"<td>{np.mean(np.abs(attack_true - attack_base)):.4f}</td>"
f"<td>{np.mean(np.abs(pv_true - pv_model)):.4f}</td>"
f"<td>{np.mean(np.abs(pv_true - pv_base)):.4f}</td>"
"</tr>"
)
return f"""
<section class="card">
<h2>Ultimos 3 partidos de Racing en test</h2>
<table>
<tr><th>Fecha</th><th>Rival</th><th>Attack MAE modelo</th><th>Attack MAE baseline</th><th>PV MAE modelo</th><th>PV MAE baseline</th></tr>
{''.join(rows)}
</table>
</section>
"""
def _build_report(summary: dict, variant_table_val: pd.DataFrame, variant_table_test: pd.DataFrame, ablation_table: pd.DataFrame, racing_table: str) -> str:
return f"""<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8" />
<title>Experimentos de variantes del modelo</title>
<style>
body {{ margin: 0; background: #f1f4ef; color: #14342B; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }}
.wrap {{ max-width: 1360px; margin: 0 auto; padding: 30px 24px 48px; }}
h1 {{ margin: 0 0 10px; font-size: 40px; }}
.lead {{ margin: 0 0 22px; font-size: 18px; color: #35574D; }}
.hero {{ display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-bottom: 22px; }}
.stat, .card {{ background: white; border-radius: 20px; padding: 18px 20px; box-shadow: 0 8px 24px rgba(12, 36, 28, 0.08); }}
.stat h3 {{ margin: 0 0 8px; font-size: 12px; text-transform: uppercase; letter-spacing: .08em; color: #587468; }}
.stat p {{ margin: 0; font-size: 28px; font-weight: 800; }}
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; }}
.card {{ margin-bottom: 18px; }}
@media (max-width: 980px) {{ .hero {{ grid-template-columns: 1fr; }} }}
</style>
</head>
<body>
<div class="wrap">
<h1>Experimentos de variantes del modelo</h1>
<p class="lead">Se compararon tres variantes de arquitectura sobre el mismo split temporal. La mejor se eligio por `attack MAE` en validacion, usando `PV MAE` como desempate.</p>
<section class="hero">
<div class="stat"><h3>Mejor variante</h3><p>{html.escape(summary['best_variant'])}</p></div>
<div class="stat"><h3>Train</h3><p>{summary['train_rows']}</p></div>
<div class="stat"><h3>Val</h3><p>{summary['val_rows']}</p></div>
<div class="stat"><h3>Test</h3><p>{summary['test_rows']}</p></div>
</section>
{_render_table(variant_table_val, "Comparacion de variantes - validacion")}
{_render_table(variant_table_test, "Comparacion de variantes - test")}
{_render_table(ablation_table, f"Ablation test - {summary['best_variant']}")}
{racing_table}
</div>
</body>
</html>"""
def main() -> None:
_set_seed()
MODEL_DIR.mkdir(parents=True, exist_ok=True)
REPORTS_DIR.mkdir(parents=True, exist_ok=True)
df = base._load_dataset()
df = df[df["usable_for_model"]].copy().reset_index(drop=True)
x_scaled, numeric_cols, attack_targets, pv_targets, dummy_cols = None, None, None, None, None
features, numeric_cols, attack_targets, pv_targets, dummy_cols = base._feature_matrix(df)
train_df = df[df["split"] == "train"].copy()
train_idx_pd, val_idx_pd, val_start_date = base._build_temporal_validation(train_df)
x_scaled_df, scaler_bundle = base._standardize_features(features, train_idx_pd)
team_idx, opp_idx, team_mapping = _team_index_arrays(df)
y_attack, y_pv, pv_scaler = _prepare_targets(df, attack_targets, pv_targets, train_idx_pd.to_numpy())
train_idx = train_idx_pd.to_numpy()
val_idx = val_idx_pd.to_numpy()
test_idx = df.index[df["split"] == "test"].to_numpy()
factories = _variant_factories(num_teams=len(team_mapping))
variant_order = ["small_ffn", "large_ffn", "team_embeddings"]
variant_results: list[dict] = []
for variant_name in variant_order:
result = _run_single_experiment(
df=df,
x_scaled=x_scaled_df,
y_attack=y_attack,
y_pv=y_pv,
team_idx=team_idx,
opp_idx=opp_idx,
attack_targets=attack_targets,
pv_targets=pv_targets,
pv_scaler=pv_scaler,
train_idx=train_idx,
val_idx=val_idx,
test_idx=test_idx,
variant_name=variant_name,
feature_mode="full",
model_factory=factories[variant_name],
)
variant_results.append(result)
variant_table_val = _comparison_rows(variant_results, "val")
variant_table_test = _comparison_rows(variant_results, "test")
best_row = variant_table_val.sort_values(["attack_mae_model", "pv_mae_model"]).iloc[0]
best_variant = str(best_row["modelo"])
best_result = next(r for r in variant_results if r["variant_name"] == best_variant)
ablation_modes = ["full", "no_opp", "no_short", "no_long", "no_pressrec"]
ablation_results: list[dict] = []
for feature_mode in ablation_modes:
ablation_results.append(
_run_single_experiment(
df=df,
x_scaled=x_scaled_df,
y_attack=y_attack,
y_pv=y_pv,
team_idx=team_idx,
opp_idx=opp_idx,
attack_targets=attack_targets,
pv_targets=pv_targets,
pv_scaler=pv_scaler,
train_idx=train_idx,
val_idx=val_idx,
test_idx=test_idx,
variant_name=best_variant,
feature_mode=feature_mode,
model_factory=factories[best_variant],
)
)
if best_variant == "team_embeddings":
ablation_results.append(
_run_single_experiment(
df=df,
x_scaled=x_scaled_df,
y_attack=y_attack,
y_pv=y_pv,
team_idx=team_idx,
opp_idx=opp_idx,
attack_targets=attack_targets,
pv_targets=pv_targets,
pv_scaler=pv_scaler,
train_idx=train_idx,
val_idx=val_idx,
test_idx=test_idx,
variant_name="team_embeddings_no_embed",
feature_mode="full",
model_factory=factories["team_embeddings_no_embed"],
)
)
ablation_table = _comparison_rows(ablation_results, "test")
best_test_meta = _pack_split(
df,
x_scaled_df[best_result["selected_columns"]].to_numpy(dtype=np.float32),
team_idx,
opp_idx,
y_attack,
y_pv,
test_idx,
).metadata
best_attack_pred = best_result["test_attack_pred"]
best_pv_pred = best_result["test_pv_pred"]
baseline_attack_cols = [base._baseline_col_from_target(c) for c in attack_targets]
baseline_pv_cols = [base._baseline_col_from_target(c) for c in pv_targets]
baseline_attack = base._normalize_rows(best_test_meta[baseline_attack_cols].to_numpy(dtype=float))
baseline_pv = best_test_meta[baseline_pv_cols].to_numpy(dtype=float)
for i, col in enumerate(attack_targets):
best_test_meta[f"pred_model__{col}"] = best_attack_pred[:, i]
best_test_meta[f"pred_baseline__{col}"] = baseline_attack[:, i]
for i, col in enumerate(pv_targets):
best_test_meta[f"pred_model__{col}"] = best_pv_pred[:, i]
best_test_meta[f"pred_baseline__{col}"] = baseline_pv[:, i]
keep_cols = [
"matchId", "fecha", "league", "season", "teamId", "team_name", "opponent_name", "is_home",
"goals_for", "goals_against", "n_prior_matches", "opp_n_prior_matches",
]
pred_cols = keep_cols + attack_targets + pv_targets
pred_cols += [f"pred_model__{c}" for c in attack_targets + pv_targets]
pred_cols += [f"pred_baseline__{c}" for c in attack_targets + pv_targets]
best_test_meta[pred_cols].to_parquet(BEST_PREDICTIONS_PATH, index=False)
racing_table = _render_match_table(best_test_meta, attack_targets, pv_targets)
summary = {
"best_variant": best_variant,
"selection_rule": "min val attack_mae_model, tie-break by val pv_mae_model",
"train_rows": int(len(train_idx)),
"val_rows": int(len(val_idx)),
"test_rows": int(len(test_idx)),
"val_start_date": val_start_date,
"feature_count_full": int(x_scaled_df.shape[1]),
"attack_target_count": len(attack_targets),
"pv_target_count": len(pv_targets),
"variant_results": [
{
"variant_name": r["variant_name"],
"feature_mode": r["feature_mode"],
"selected_feature_count": r["selected_feature_count"],
"val_metrics": r["val_metrics"],
"test_metrics": r["test_metrics"],
}
for r in variant_results
],
"ablation_results": [
{
"variant_name": r["variant_name"],
"feature_mode": r["feature_mode"],
"selected_feature_count": r["selected_feature_count"],
"val_metrics": r["val_metrics"],
"test_metrics": r["test_metrics"],
}
for r in ablation_results
],
}
best_bundle = {
"variant_name": best_variant,
"feature_mode": best_result["feature_mode"],
"selected_columns": best_result["selected_columns"],
"model_state_dict": best_result["model_state_dict"],
"feature_bundle": scaler_bundle,
"pv_target_scaler": {
"mean": pv_scaler["mean"].tolist(),
"std": pv_scaler["std"].tolist(),
},
"team_mapping": team_mapping,
"attack_targets": attack_targets,
"pv_targets": pv_targets,
"summary": summary,
}
torch.save(best_bundle, BEST_MODEL_PATH)
JSON_PATH.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
report_html = _build_report(summary, variant_table_val, variant_table_test, ablation_table, racing_table)
REPORT_PATH.write_text(report_html, encoding="utf-8")
print(f"Resumen guardado en: {JSON_PATH}")
print(f"Mejor modelo guardado en: {BEST_MODEL_PATH}")
print(f"Predicciones test guardadas en: {BEST_PREDICTIONS_PATH}")
print(f"Reporte guardado en: {REPORT_PATH}")
print(json.dumps(summary, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()