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.distributions import Dirichlet 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 / "bayesian_dirichlet_map_report.html" JSON_PATH = MODEL_DIR / "bayesian_dirichlet_map_metrics.json" MODEL_PATH = MODEL_DIR / "bayesian_dirichlet_map_bundle.pt" ATTACK_PRED_PATH = MODEL_DIR / "attack_bayesian_dirichlet_map_test_predictions.parquet" PV_PRED_PATH = MODEL_DIR / "pv_bayesian_dirichlet_map_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_mod # noqa: E402 import experiment_pv_distribution_gnn as pv_base # noqa: E402 import train_attack_prediction_ffn as base # noqa: E402 import train_attack_prediction_ffn as train_model_mod # noqa: E402 RANDOM_SEED = 42 BATCH_SIZE = 256 MAX_EPOCHS = 220 PATIENCE = 28 TARGET_EPS = 1e-4 @dataclass class SplitData: x: np.ndarray y: np.ndarray prior: np.ndarray prior_short: np.ndarray metadata: pd.DataFrame @dataclass class Config: name: str lr: float prior_lambda: float init_concentration: float class BayesianDirichletMAP(nn.Module): def __init__(self, input_dim: int, out_dim: int, init_concentration: float) -> None: super().__init__() self.linear = nn.Linear(input_dim, out_dim) init_raw = np.log(np.exp(max(init_concentration - 1.0, 1e-3)) - 1.0) self.raw_concentration = nn.Parameter(torch.tensor(float(init_raw), dtype=torch.float32)) def forward(self, x: torch.Tensor, prior: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: delta = self.linear(x) logits = torch.log(torch.clamp(prior, min=1e-6)) + delta mean = torch.softmax(logits, dim=1) concentration = torch.nn.functional.softplus(self.raw_concentration) + 1.0 alpha = mean * concentration + 1e-4 return mean, alpha, concentration def _set_seed(seed: int = RANDOM_SEED) -> None: random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) def _smooth_target(y: np.ndarray) -> np.ndarray: k = y.shape[1] smoothed = ((y + TARGET_EPS) / (1.0 + (TARGET_EPS * k))).astype(np.float32) return base._normalize_rows(smoothed).astype(np.float32) def _load_attack_data() -> tuple[pd.DataFrame, pd.DataFrame, list[str]]: df = base._load_dataset() df = df[df["usable_for_model"]].copy().reset_index(drop=True) features, _numeric_cols, _attack_targets_sorted, _pv_targets, _dummy_cols = base._feature_matrix(df) attack_targets = [f"target_attack_share__{zone}" for zone in attack_mod.ZONE_ORDER] return df, features, attack_targets def _load_pv_data() -> tuple[pd.DataFrame, pd.DataFrame, np.ndarray]: df = base._load_dataset() df = df[df["usable_for_model"]].copy().reset_index(drop=True) y_dist, baseline_long, baseline_short = pv_base._build_distributions(df) keep_mask = baseline_long.sum(axis=1) > 0 df = df.loc[keep_mask].copy().reset_index(drop=True) features, _numeric_cols, _attack_targets, _pv_targets, _dummy_cols = base._feature_matrix(df) y_dist, baseline_long, baseline_short = pv_base._build_distributions(df) return df, features, y_dist def _make_split( df: pd.DataFrame, idx: pd.Index | np.ndarray, x_scaled: pd.DataFrame, y: np.ndarray, prior: np.ndarray, prior_short: np.ndarray, ) -> SplitData: arr_idx = np.asarray(idx) return SplitData( x=x_scaled.iloc[arr_idx].to_numpy(dtype=np.float32), y=y[arr_idx].astype(np.float32), prior=prior[arr_idx].astype(np.float32), prior_short=prior_short[arr_idx].astype(np.float32), metadata=df.iloc[arr_idx].copy().reset_index(drop=True), ) def _loader(split: SplitData, shuffle: bool) -> DataLoader: ds = TensorDataset( torch.from_numpy(split.x), torch.from_numpy(split.y), torch.from_numpy(split.prior), torch.from_numpy(split.prior_short), ) return DataLoader(ds, batch_size=BATCH_SIZE, shuffle=shuffle) def _l2_penalty(model: BayesianDirichletMAP) -> torch.Tensor: total = torch.zeros((), device=model.linear.weight.device) for param in model.parameters(): total = total + torch.sum(param ** 2) return total def _loss( model: BayesianDirichletMAP, mean: torch.Tensor, alpha: torch.Tensor, y: torch.Tensor, config: Config, ) -> tuple[torch.Tensor, dict[str, float]]: dist = Dirichlet(alpha) nll = -dist.log_prob(y).mean() prior_penalty = config.prior_lambda * _l2_penalty(model) loss = nll + prior_penalty return loss, {"nll": float(nll.item()), "prior_penalty": float(prior_penalty.item())} def _evaluate(model: BayesianDirichletMAP, split: SplitData, config: Config) -> dict[str, float]: device = next(model.parameters()).device model.eval() loader = _loader(split, shuffle=False) preds = [] total_loss = 0.0 n_batches = 0 with torch.no_grad(): for x, y, prior, _prior_short in loader: x = x.to(device) y = y.to(device) prior = prior.to(device) mean, alpha, concentration = model(x, prior) loss, _parts = _loss(model, mean, alpha, y, config) preds.append(mean.cpu().numpy()) total_loss += float(loss.item()) n_batches += 1 pred = np.vstack(preds) return { "loss": total_loss / max(n_batches, 1), "mae": base._mean_abs_error(split.y, pred), "jsd": base._jsd_mean(split.y, pred), "kl_proxy": float(np.mean(np.sum(split.y * (np.log(split.y + 1e-12) - np.log(pred + 1e-12)), axis=1))), } def _train_single( train: SplitData, val: SplitData, config: Config, ) -> tuple[BayesianDirichletMAP, list[dict[str, float]], dict[str, float]]: device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = BayesianDirichletMAP(input_dim=train.x.shape[1], out_dim=train.y.shape[1], init_concentration=config.init_concentration).to(device) optimizer = torch.optim.Adam(model.parameters(), lr=config.lr) train_loader = _loader(train, shuffle=True) best_state = None best_val = float("inf") patience_left = PATIENCE history: list[dict[str, float]] = [] for epoch in range(1, MAX_EPOCHS + 1): model.train() running = 0.0 batches = 0 for x, y, prior, _prior_short in train_loader: x = x.to(device) y = y.to(device) prior = prior.to(device) optimizer.zero_grad() mean, alpha, concentration = model(x, prior) loss, parts = _loss(model, mean, alpha, y, config) loss.backward() optimizer.step() running += float(loss.item()) batches += 1 val_metrics = _evaluate(model, val, config) history.append( { "epoch": epoch, "train_loss": running / max(batches, 1), "val_loss": val_metrics["loss"], "val_mae": val_metrics["mae"], "val_jsd": val_metrics["jsd"], } ) if val_metrics["mae"] < best_val - 1e-6: best_val = val_metrics["mae"] 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) final_val = _evaluate(model, val, config) return model, history, final_val def _run_grid( train: SplitData, val: SplitData, configs: list[Config], ) -> tuple[BayesianDirichletMAP, list[dict[str, float]], dict[str, float], Config]: best = None best_model = None best_history = None best_val = None for config in configs: model, history, val_metrics = _train_single(train, val, config) score = val_metrics["mae"] if best is None or score < best: best = score best_model = model best_history = history best_val = val_metrics best_config = config assert best_model is not None and best_history is not None and best_val is not None return best_model, best_history, best_val, best_config def _predict(model: BayesianDirichletMAP, split: SplitData) -> np.ndarray: device = next(model.parameters()).device model.eval() preds = [] loader = _loader(split, shuffle=False) with torch.no_grad(): for x, _y, prior, _prior_short in loader: mean, alpha, concentration = model(x.to(device), prior.to(device)) preds.append(mean.cpu().numpy()) return np.vstack(preds) def _metrics(y_true: np.ndarray, pred_model: np.ndarray, pred_long: np.ndarray, pred_short: np.ndarray) -> dict[str, float]: return { "model_mae": base._mean_abs_error(y_true, pred_model), "season_baseline_mae": base._mean_abs_error(y_true, pred_long), "short8_baseline_mae": base._mean_abs_error(y_true, pred_short), "model_jsd": base._jsd_mean(y_true, pred_model), "season_baseline_jsd": base._jsd_mean(y_true, pred_long), "short8_baseline_jsd": base._jsd_mean(y_true, pred_short), "model_kl_proxy": float(np.mean(np.sum(y_true * (np.log(y_true + 1e-12) - np.log(pred_model + 1e-12)), axis=1))), "season_baseline_kl_proxy": float(np.mean(np.sum(y_true * (np.log(y_true + 1e-12) - np.log(pred_long + 1e-12)), axis=1))), "short8_baseline_kl_proxy": float(np.mean(np.sum(y_true * (np.log(y_true + 1e-12) - np.log(pred_short + 1e-12)), axis=1))), } 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] short_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[short_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: row["fecha"] = pd.Timestamp(row["fecha"]).strftime("%Y-%m-%d") return rows def _build_attack_task() -> tuple[SplitData, SplitData, SplitData, pd.DataFrame, dict]: df, features, attack_targets = _load_attack_data() train_df = df[df["split"] == "train"].copy() train_idx, val_idx, val_start_date = base._build_temporal_validation(train_df) test_idx = df.index[df["split"] == "test"] x_scaled, scaler_bundle = base._standardize_features(features, train_idx) y = _smooth_target(df[attack_targets].to_numpy(dtype=np.float32)) prior_long = train_model_mod._normalize_rows(df[[f"long_mean__actual_attack_share__{zone}" for zone in attack_mod.ZONE_ORDER]].to_numpy(dtype=float)).astype(np.float32) prior_short = train_model_mod._normalize_rows(df[[f"short_mean__actual_attack_share__{zone}" for zone in attack_mod.ZONE_ORDER]].to_numpy(dtype=float)).astype(np.float32) bundle = { "attack_targets": attack_targets, "val_start_date": val_start_date, "scaler_bundle": scaler_bundle, } return ( _make_split(df, train_idx, x_scaled, y, prior_long, prior_short), _make_split(df, val_idx, x_scaled, y, prior_long, prior_short), _make_split(df, test_idx, x_scaled, y, prior_long, prior_short), df, bundle, ) def _build_pv_task() -> tuple[SplitData, SplitData, SplitData, pd.DataFrame, dict]: df, features, y_dist = _load_pv_data() train_df = df[df["split"] == "train"].copy() train_idx, val_idx, val_start_date = base._build_temporal_validation(train_df) test_idx = df.index[df["split"] == "test"] x_scaled, scaler_bundle = base._standardize_features(features, train_idx) y = _smooth_target(y_dist.astype(np.float32)) raw_long = df[[f"long_mean__zone_pvAdded__{zone}" for zone in pv_base.ZONE_ORDER]].to_numpy(dtype=float) raw_short = df[[f"short_mean__zone_pvAdded__{zone}" for zone in pv_base.ZONE_ORDER]].to_numpy(dtype=float) prior_long = train_model_mod._normalize_rows(np.clip(raw_long, 0.0, None)).astype(np.float32) prior_short = train_model_mod._normalize_rows(np.clip(raw_short, 0.0, None)).astype(np.float32) bundle = { "val_start_date": val_start_date, "scaler_bundle": scaler_bundle, } return ( _make_split(df, train_idx, x_scaled, y, prior_long, prior_short), _make_split(df, val_idx, x_scaled, y, prior_long, prior_short), _make_split(df, test_idx, x_scaled, y, prior_long, prior_short), df, bundle, ) def _report_html(summary: dict) -> str: def rows_html(rows: list[dict[str, float | str]]) -> str: return "".join( f"
Modelo bayesiano práctico: likelihood Dirichlet sobre la distribución observada, media a priori centrada en el baseline de temporada del partido, y priors gaussianos sobre los pesos. Los features ajustan esa distribución base vía correcciones lineales en logit-space.
| Metrica | Modelo | Temporada | Ultimos 8 | GNN 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} |
Config elegida: {html.escape(summary['attack']['best_config']['name'])}
| Metrica | Modelo | Temporada | Ultimos 8 | GNN 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} |
Config elegida: {html.escape(summary['pv']['best_config']['name'])}
| Fecha | Rival | Modelo | Temporada | Ultimos 8 | GNN previo |
|---|
| Fecha | Rival | Modelo | Temporada | Ultimos 8 | GNN previo |
|---|