Spaces:
Running
Running
| 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_v2_experiments.html" | |
| JSON_PATH = MODEL_DIR / "attack_prediction_v2_experiments.json" | |
| BEST_MODEL_PATH = MODEL_DIR / "attack_prediction_v2_best_bundle.pt" | |
| BEST_PREDICTIONS_PATH = MODEL_DIR / "attack_prediction_v2_best_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 = 220 | |
| PATIENCE = 28 | |
| class SplitData: | |
| x: np.ndarray | |
| y_attack: np.ndarray | |
| y_pv: np.ndarray | |
| baseline_attack: np.ndarray | |
| baseline_pv_scaled: np.ndarray | |
| metadata: pd.DataFrame | |
| class BaseModel(nn.Module): | |
| def forward( | |
| self, | |
| x: torch.Tensor, | |
| baseline_attack: torch.Tensor, | |
| baseline_pv_scaled: torch.Tensor, | |
| ) -> tuple[torch.Tensor, torch.Tensor]: | |
| raise NotImplementedError | |
| class FFNModel(BaseModel): | |
| 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, | |
| baseline_attack: torch.Tensor, | |
| baseline_pv_scaled: 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 ResidualModel(BaseModel): | |
| 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_delta = nn.Linear(prev, attack_dim) | |
| self.pv_delta = nn.Linear(prev, pv_dim) | |
| def forward( | |
| self, | |
| x: torch.Tensor, | |
| baseline_attack: torch.Tensor, | |
| baseline_pv_scaled: torch.Tensor, | |
| ) -> tuple[torch.Tensor, torch.Tensor]: | |
| h = self.backbone(x) | |
| attack_logits = torch.log(torch.clamp(baseline_attack, min=1e-6)) + self.attack_delta(h) | |
| attack = torch.softmax(attack_logits, dim=1) | |
| pv = baseline_pv_scaled + self.pv_delta(h) | |
| return attack, pv | |
| def _set_seed(seed: int = RANDOM_SEED) -> None: | |
| random.seed(seed) | |
| np.random.seed(seed) | |
| torch.manual_seed(seed) | |
| def _make_loader(split: SplitData, shuffle: bool) -> DataLoader: | |
| dataset = TensorDataset( | |
| torch.from_numpy(split.x), | |
| torch.from_numpy(split.y_attack), | |
| torch.from_numpy(split.y_pv), | |
| torch.from_numpy(split.baseline_attack), | |
| torch.from_numpy(split.baseline_pv_scaled), | |
| ) | |
| return DataLoader(dataset, batch_size=BATCH_SIZE, shuffle=shuffle) | |
| 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) | |
| return attack_loss + pv_loss, attack_loss, pv_loss | |
| def _evaluate_loader(model: BaseModel, 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, y_attack, y_pv, baseline_attack, baseline_pv_scaled in loader: | |
| x = x.to(device) | |
| y_attack = y_attack.to(device) | |
| y_pv = y_pv.to(device) | |
| baseline_attack = baseline_attack.to(device) | |
| baseline_pv_scaled = baseline_pv_scaled.to(device) | |
| pred_attack, pred_pv = model(x, baseline_attack, baseline_pv_scaled) | |
| 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: BaseModel, train: SplitData, val: SplitData, lr: float, weight_decay: float) -> tuple[BaseModel, 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=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") | |
| 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, y_attack, y_pv, baseline_attack, baseline_pv_scaled in train_loader: | |
| x = x.to(device) | |
| y_attack = y_attack.to(device) | |
| y_pv = y_pv.to(device) | |
| baseline_attack = baseline_attack.to(device) | |
| baseline_pv_scaled = baseline_pv_scaled.to(device) | |
| optimizer.zero_grad() | |
| pred_attack, pred_pv = model(x, baseline_attack, baseline_pv_scaled) | |
| 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: BaseModel, 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, _, _, baseline_attack, baseline_pv_scaled in loader: | |
| x = x.to(device) | |
| baseline_attack = baseline_attack.to(device) | |
| baseline_pv_scaled = baseline_pv_scaled.to(device) | |
| attack, pv = model(x, baseline_attack, baseline_pv_scaled) | |
| attack_preds.append(attack.cpu().numpy()) | |
| pv_preds.append(pv.cpu().numpy()) | |
| return np.vstack(attack_preds), np.vstack(pv_preds) | |
| def _prepare_data() -> tuple[pd.DataFrame, pd.DataFrame, list[str], list[str], np.ndarray, np.ndarray, dict[str, np.ndarray], np.ndarray, np.ndarray, np.ndarray]: | |
| df = base._load_dataset() | |
| df = df[df["usable_for_model"]].copy().reset_index(drop=True) | |
| features, _, attack_targets, pv_targets, _ = base._feature_matrix(df) | |
| train_df = df[df["split"] == "train"].copy() | |
| train_idx_pd, val_idx_pd, _ = base._build_temporal_validation(train_df) | |
| x_scaled, _ = base._standardize_features(features, train_idx_pd) | |
| 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_pd.to_numpy()] | |
| pv_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 - pv_scaler["mean"]) / pv_scaler["std"]).astype(np.float32) | |
| 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(df[baseline_attack_cols].to_numpy(dtype=float)).astype(np.float32) | |
| baseline_pv_scaled = ((df[baseline_pv_cols].to_numpy(dtype=np.float32) - pv_scaler["mean"]) / pv_scaler["std"]).astype(np.float32) | |
| return ( | |
| df, | |
| x_scaled, | |
| attack_targets, | |
| pv_targets, | |
| y_attack, | |
| y_pv, | |
| pv_scaler, | |
| baseline_attack, | |
| baseline_pv_scaled, | |
| train_idx_pd.to_numpy(), | |
| val_idx_pd.to_numpy(), | |
| df.index[df["split"] == "test"].to_numpy(), | |
| ) | |
| def _pack_split( | |
| df: pd.DataFrame, | |
| x: np.ndarray, | |
| y_attack: np.ndarray, | |
| y_pv: np.ndarray, | |
| baseline_attack: np.ndarray, | |
| baseline_pv_scaled: np.ndarray, | |
| idx: np.ndarray, | |
| ) -> SplitData: | |
| return SplitData( | |
| x=x[idx].astype(np.float32), | |
| y_attack=y_attack[idx].astype(np.float32), | |
| y_pv=y_pv[idx].astype(np.float32), | |
| baseline_attack=baseline_attack[idx].astype(np.float32), | |
| baseline_pv_scaled=baseline_pv_scaled[idx].astype(np.float32), | |
| metadata=df.iloc[idx].copy().reset_index(drop=True), | |
| ) | |
| 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_experiment( | |
| df: pd.DataFrame, | |
| x_scaled_df: pd.DataFrame, | |
| attack_targets: list[str], | |
| pv_targets: list[str], | |
| y_attack: np.ndarray, | |
| y_pv: np.ndarray, | |
| pv_scaler: dict[str, np.ndarray], | |
| baseline_attack: np.ndarray, | |
| baseline_pv_scaled: np.ndarray, | |
| train_idx: np.ndarray, | |
| val_idx: np.ndarray, | |
| test_idx: np.ndarray, | |
| name: str, | |
| hidden_dims: list[int], | |
| dropouts: list[float], | |
| lr: float, | |
| weight_decay: float, | |
| residual: bool, | |
| ) -> dict: | |
| x = x_scaled_df.to_numpy(dtype=np.float32) | |
| train_split = _pack_split(df, x, y_attack, y_pv, baseline_attack, baseline_pv_scaled, train_idx) | |
| val_split = _pack_split(df, x, y_attack, y_pv, baseline_attack, baseline_pv_scaled, val_idx) | |
| test_split = _pack_split(df, x, y_attack, y_pv, baseline_attack, baseline_pv_scaled, test_idx) | |
| if residual: | |
| model = ResidualModel(len(x_scaled_df.columns), len(attack_targets), len(pv_targets), hidden_dims, dropouts) | |
| else: | |
| model = FFNModel(len(x_scaled_df.columns), len(attack_targets), len(pv_targets), hidden_dims, dropouts) | |
| model, history = _train_model(model, train_split, val_split, lr=lr, weight_decay=weight_decay) | |
| val_attack_pred, val_pv_scaled = _predict(model, val_split) | |
| test_attack_pred, test_pv_scaled = _predict(model, test_split) | |
| val_pv_pred = (val_pv_scaled * pv_scaler["std"]) + pv_scaler["mean"] | |
| test_pv_pred = (test_pv_scaled * pv_scaler["std"]) + pv_scaler["mean"] | |
| return { | |
| "name": name, | |
| "residual": residual, | |
| "hidden_dims": hidden_dims, | |
| "dropouts": dropouts, | |
| "lr": lr, | |
| "weight_decay": weight_decay, | |
| "history": history, | |
| "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), | |
| "model_state_dict": {k: v.detach().cpu() for k, v in model.state_dict().items()}, | |
| "test_attack_pred": test_attack_pred, | |
| "test_pv_pred": test_pv_pred, | |
| } | |
| def _results_table(results: list[dict], split_key: str) -> pd.DataFrame: | |
| rows = [] | |
| for r in results: | |
| m = r[f"{split_key}_metrics"] | |
| rows.append( | |
| { | |
| "modelo": r["name"], | |
| "residual": r["residual"], | |
| "attack_mae_model": m["attack_mae_model"], | |
| "attack_mae_baseline": m["attack_mae_baseline"], | |
| "attack_delta": m["attack_mae_baseline"] - m["attack_mae_model"], | |
| "attack_jsd_model": m["attack_jsd_model"], | |
| "attack_jsd_baseline": m["attack_jsd_baseline"], | |
| "pv_mae_model": m["pv_mae_model"], | |
| "pv_mae_baseline": m["pv_mae_baseline"], | |
| "pv_delta": m["pv_mae_baseline"] - m["pv_mae_model"], | |
| "pv_r2_model": m["pv_r2_model"], | |
| "pv_r2_baseline": m["pv_r2_baseline"], | |
| } | |
| ) | |
| return pd.DataFrame(rows).sort_values(["attack_mae_model", "pv_mae_model"]).reset_index(drop=True) | |
| def _render_table(title: str, df: pd.DataFrame) -> str: | |
| rows = [] | |
| for _, row in df.iterrows(): | |
| rows.append( | |
| "<tr>" | |
| f"<td>{html.escape(str(row['modelo']))}</td>" | |
| f"<td>{'si' if row['residual'] else 'no'}</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['attack_jsd_model']:.4f}</td>" | |
| f"<td>{row['attack_jsd_baseline']:.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>Residual</th><th>Attack MAE</th><th>Baseline</th><th>Mejora</th> | |
| <th>Attack JSD</th><th>Baseline</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_racing_table(best_test_meta: pd.DataFrame, attack_targets: list[str], pv_targets: list[str]) -> str: | |
| racing = best_test_meta[best_test_meta["teamId"] == base.RACING_TEAM_ID].sort_values(["fecha", "matchId"]).tail(3) | |
| rows = [] | |
| for _, row in racing.iterrows(): | |
| att_true = row[attack_targets].to_numpy(dtype=float) | |
| att_model = row[[f"pred_model__{c}" for c in attack_targets]].to_numpy(dtype=float) | |
| att_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(att_true - att_model)):.4f}</td>" | |
| f"<td>{np.mean(np.abs(att_true - att_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, val_table: pd.DataFrame, test_table: pd.DataFrame, racing_table: str) -> str: | |
| return f"""<!DOCTYPE html> | |
| <html lang="es"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <title>Experimentos v2 ataque/PV</title> | |
| <style> | |
| body {{ margin: 0; background: #f1f4ef; color: #14342B; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }} | |
| .wrap {{ max-width: 1380px; 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; }} | |
| .card {{ margin-bottom: 18px; }} | |
| 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) {{ .hero {{ grid-template-columns: 1fr; }} }} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="wrap"> | |
| <h1>Experimentos v2: hiperparametros, red mas grande y residual</h1> | |
| <p class="lead">Segunda tanda enfocada en mejorar ataque: nuevas configuraciones, una red bastante mas grande y un modelo residual que parte del baseline de temporada y aprende una correccion.</p> | |
| <section class="hero"> | |
| <div class="stat"><h3>Mejor modelo</h3><p>{html.escape(summary['best_model'])}</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("Comparacion por validacion", val_table)} | |
| {_render_table("Comparacion por test", test_table)} | |
| {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, | |
| x_scaled_df, | |
| attack_targets, | |
| pv_targets, | |
| y_attack, | |
| y_pv, | |
| pv_scaler, | |
| baseline_attack, | |
| baseline_pv_scaled, | |
| train_idx, | |
| val_idx, | |
| test_idx, | |
| ) = _prepare_data() | |
| configs = [ | |
| { | |
| "name": "large_ffn_prev_like", | |
| "hidden_dims": [768, 384, 192], | |
| "dropouts": [0.25, 0.20, 0.15], | |
| "lr": 1e-3, | |
| "weight_decay": 1e-4, | |
| "residual": False, | |
| }, | |
| { | |
| "name": "xlarge_ffn_tuned", | |
| "hidden_dims": [1536, 768, 384, 192], | |
| "dropouts": [0.18, 0.12, 0.08, 0.05], | |
| "lr": 5e-4, | |
| "weight_decay": 5e-5, | |
| "residual": False, | |
| }, | |
| { | |
| "name": "xlarge_ffn_lowdrop", | |
| "hidden_dims": [2048, 1024, 512, 256], | |
| "dropouts": [0.10, 0.08, 0.05, 0.03], | |
| "lr": 3e-4, | |
| "weight_decay": 1e-5, | |
| "residual": False, | |
| }, | |
| { | |
| "name": "residual_xlarge", | |
| "hidden_dims": [1024, 512, 256, 128], | |
| "dropouts": [0.12, 0.08, 0.05, 0.03], | |
| "lr": 4e-4, | |
| "weight_decay": 1e-5, | |
| "residual": True, | |
| }, | |
| ] | |
| results = [] | |
| for cfg in configs: | |
| results.append( | |
| _run_experiment( | |
| df=df, | |
| x_scaled_df=x_scaled_df, | |
| attack_targets=attack_targets, | |
| pv_targets=pv_targets, | |
| y_attack=y_attack, | |
| y_pv=y_pv, | |
| pv_scaler=pv_scaler, | |
| baseline_attack=baseline_attack, | |
| baseline_pv_scaled=baseline_pv_scaled, | |
| train_idx=train_idx, | |
| val_idx=val_idx, | |
| test_idx=test_idx, | |
| **cfg, | |
| ) | |
| ) | |
| val_table = _results_table(results, "val") | |
| test_table = _results_table(results, "test") | |
| best_name = str(val_table.sort_values(["attack_mae_model", "pv_mae_model"]).iloc[0]["modelo"]) | |
| best_result = next(r for r in results if r["name"] == best_name) | |
| best_test_meta = df.iloc[test_idx].copy().reset_index(drop=True) | |
| 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] | |
| best_test_meta["goals_for"] = best_test_meta.get("goals_for") | |
| best_test_meta["goals_against"] = best_test_meta.get("goals_against") | |
| best_attack_pred = best_result["test_attack_pred"] | |
| best_pv_pred = best_result["test_pv_pred"] | |
| baseline_attack_test = base._normalize_rows(best_test_meta[baseline_attack_cols].to_numpy(dtype=float)) | |
| baseline_pv_test = 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_test[:, 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_test[:, 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) | |
| summary = { | |
| "best_model": best_name, | |
| "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)), | |
| "experiments": [ | |
| { | |
| "name": r["name"], | |
| "residual": r["residual"], | |
| "hidden_dims": r["hidden_dims"], | |
| "dropouts": r["dropouts"], | |
| "lr": r["lr"], | |
| "weight_decay": r["weight_decay"], | |
| "val_metrics": r["val_metrics"], | |
| "test_metrics": r["test_metrics"], | |
| } | |
| for r in results | |
| ], | |
| } | |
| torch.save( | |
| { | |
| "best_model": best_name, | |
| "model_state_dict": best_result["model_state_dict"], | |
| "attack_targets": attack_targets, | |
| "pv_targets": pv_targets, | |
| "feature_columns": list(x_scaled_df.columns), | |
| "pv_target_scaler": { | |
| "mean": pv_scaler["mean"].tolist(), | |
| "std": pv_scaler["std"].tolist(), | |
| }, | |
| "summary": summary, | |
| }, | |
| BEST_MODEL_PATH, | |
| ) | |
| JSON_PATH.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") | |
| REPORT_PATH.write_text(_build_report(summary, val_table, test_table, _render_racing_table(best_test_meta, attack_targets, pv_targets)), encoding="utf-8") | |
| print(f"Resumen guardado en: {JSON_PATH}") | |
| print(f"Mejor modelo guardado en: {BEST_MODEL_PATH}") | |
| print(f"Predicciones 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() | |