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 pymc as pm import pytensor.tensor as pt 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 / "pymc_logistic_normal_report.html" JSON_PATH = MODEL_DIR / "pymc_logistic_normal_metrics.json" MODEL_PATH = MODEL_DIR / "pymc_logistic_normal_bundle.pt" ATTACK_PRED_PATH = MODEL_DIR / "attack_pymc_logistic_normal_test_predictions.parquet" PV_PRED_PATH = MODEL_DIR / "pv_pymc_logistic_normal_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_mod # noqa: E402 import train_attack_prediction_ffn as base # noqa: E402 RANDOM_SEED = 42 ADVI_STEPS = 3500 POSTERIOR_DRAWS = 200 ZONE_ORDER = attack_mod.ZONE_ORDER PRESS_CELLS = [f"x{x}_y{y}" for x in range(4) for y in range(3)] @dataclass class SplitData: x: np.ndarray y: np.ndarray prior_long: np.ndarray prior_short: np.ndarray metadata: pd.DataFrame def _set_seed(seed: int = RANDOM_SEED) -> None: random.seed(seed) np.random.seed(seed) def _smooth_simplex(y: np.ndarray, eps: float = 1e-4) -> np.ndarray: k = y.shape[1] out = (y + eps) / (1.0 + eps * k) return base._normalize_rows(out).astype(np.float32) def _alr(x: np.ndarray) -> np.ndarray: x = np.clip(x, 1e-6, None) ref = x[:, [-1]] return np.log(x[:, :-1] / ref).astype(np.float32) def _inverse_alr(z: np.ndarray) -> np.ndarray: exp_z = np.exp(z) denom = 1.0 + exp_z.sum(axis=1, keepdims=True) body = exp_z / denom last = 1.0 / denom return np.concatenate([body, last], axis=1).astype(np.float32) def _make_split( df: pd.DataFrame, idx: pd.Index | np.ndarray, x_scaled: pd.DataFrame, y: np.ndarray, prior_long: 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_long=prior_long[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 _select_feature_columns(df: pd.DataFrame, task: str) -> list[str]: cols: list[str] = [] zone_prefixes = [ "long_mean__actual_attack_share__", "short_mean__actual_attack_share__", "opp__long_mean__actual_conceded_attack_share__", "opp__short_mean__actual_conceded_attack_share__", "long_mean__actual_pv_share__", "short_mean__actual_pv_share__", "opp__long_mean__actual_conceded_pv_share__", "opp__short_mean__actual_conceded_pv_share__", ] for prefix in zone_prefixes: cols.extend(sorted(c for c in df.columns if c.startswith(prefix))) press_prefixes = [ "short_mean__press_cnt__", "long_mean__press_cnt__", "opp__short_mean__press_cnt__", "opp__long_mean__press_cnt__", "short_mean__mf__press_cnt__", "long_mean__mf__press_cnt__", "opp__short_mean__mf__press_cnt__", "opp__long_mean__mf__press_cnt__", ] for prefix in press_prefixes: cols.extend(sorted(c for c in df.columns if c.startswith(prefix))) scalar_candidates = [ "short_mean__recoveries_total", "long_mean__recoveries_total", "opp__short_mean__recoveries_total", "opp__long_mean__recoveries_total", "short_mean__shots_total", "long_mean__shots_total", "short_mean__passes_long", "long_mean__passes_long", "opp__short_mean__passes_long", "opp__long_mean__passes_long", "short_mean__goals_for", "long_mean__goals_for", "short_mean__goals_against", "long_mean__goals_against", "opp__short_mean__goals_for", "opp__long_mean__goals_for", "opp__short_mean__goals_against", "opp__long_mean__goals_against", "short_mean__mf__shots", "long_mean__mf__shots", "opp__short_mean__mf__shots", "opp__long_mean__mf__shots", "short_mean__mf__shots_on_target", "long_mean__mf__shots_on_target", "opp__short_mean__mf__shots_on_target", "opp__long_mean__mf__shots_on_target", ] cols.extend(c for c in scalar_candidates if c in df.columns) if task == "pv": pv_extra = [c for c in df.columns if c.startswith("short_mean__zone_pvAdded_shots__") or c.startswith("long_mean__zone_pvAdded_shots__")] opp_pv_extra = [c for c in df.columns if c.startswith("opp__short_mean__zone_pvAdded_shots__") or c.startswith("opp__long_mean__zone_pvAdded_shots__")] cols.extend(sorted(pv_extra)) cols.extend(sorted(opp_pv_extra)) return sorted(dict.fromkeys(cols)) def _load_task(task: str) -> tuple[SplitData, SplitData, SplitData, dict[str, object]]: df = base._load_dataset() df = df[df["usable_for_model"]].copy().reset_index(drop=True) if task == "attack": target_cols = [f"target_attack_share__{zone}" for zone in ZONE_ORDER] y = _smooth_simplex(df[target_cols].to_numpy(dtype=np.float32)) prior_long = base._normalize_rows( df[[f"long_mean__actual_attack_share__{zone}" for zone in ZONE_ORDER]].to_numpy(dtype=float) ).astype(np.float32) prior_short = base._normalize_rows( df[[f"short_mean__actual_attack_share__{zone}" for zone in ZONE_ORDER]].to_numpy(dtype=float) ).astype(np.float32) elif task == "pv": y_dist, prior_long_raw, prior_short_raw = pv_mod._build_distributions(df) keep_mask = prior_long_raw.sum(axis=1) > 0 df = df.loc[keep_mask].copy().reset_index(drop=True) y_dist, prior_long_raw, prior_short_raw = pv_mod._build_distributions(df) y = _smooth_simplex(y_dist.astype(np.float32)) prior_long = base._normalize_rows(np.clip(prior_long_raw, 0.0, None)).astype(np.float32) prior_short = base._normalize_rows(np.clip(prior_short_raw, 0.0, None)).astype(np.float32) target_cols = [f"target_pv_dist__{zone}" for zone in ZONE_ORDER] else: raise ValueError(task) feature_cols = _select_feature_columns(df, task) x = df[feature_cols].fillna(0.0) 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(x, train_idx) info = { "target_cols": target_cols, "feature_cols": feature_cols, "val_start_date": val_start_date, "scaler": 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), info, ) def _fit_model(train: SplitData) -> tuple[pm.Model, object]: x_train = train.x.astype(np.float32) y_train = _alr(train.y) prior_train = _alr(train.prior_long) n_features = x_train.shape[1] n_outputs = y_train.shape[1] with pm.Model() as model: X = pm.Data("X", x_train) prior_alr = pm.Data("prior_alr", prior_train) beta = pm.Normal("beta", mu=0.0, sigma=0.12, shape=(n_features, n_outputs)) bias = pm.Normal("bias", mu=0.0, sigma=0.08, shape=(n_outputs,)) sigma = pm.HalfNormal("sigma", sigma=0.20, shape=(n_outputs,)) mu = prior_alr + pt.dot(X, beta) + bias pm.Normal("obs", mu=mu, sigma=sigma, observed=y_train) approx = pm.fit( n=ADVI_STEPS, method="advi", obj_optimizer=pm.adam(learning_rate=0.01), progressbar=False, random_seed=RANDOM_SEED, ) return model, approx def _predict(model: pm.Model, approx: object, split: SplitData) -> np.ndarray: x_arr = split.x.astype(np.float32) prior_arr = _alr(split.prior_long) with model: pm.set_data({"X": x_arr, "prior_alr": prior_arr}) posterior = approx.sample(draws=POSTERIOR_DRAWS, return_inferencedata=True, random_seed=RANDOM_SEED) beta = posterior.posterior["beta"].mean(dim=("chain", "draw")).values bias = posterior.posterior["bias"].mean(dim=("chain", "draw")).values mu = prior_arr + x_arr @ beta + bias pred = _inverse_alr(mu) return base._normalize_rows(pred).astype(np.float32) 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] 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, object]]: 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 _report_html(summary: dict[str, object]) -> str: def rows_html(rows: list[dict[str, object]]) -> str: return "".join( f"{html.escape(str(r['fecha']))}{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""" PyMC Logistic-Normal

Bayesiano logistic-normal con PyMC

Regresion bayesiana en espacio log-ratio, con prior del partido basado en temporada y correccion por features de ataque, concesion rival, pases, presiones y recuperaciones.

Ataque - test

MetricaPyMCTemporadaUltimos 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

MetricaPyMCTemporadaUltimos 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(summary['attack']['last3_racing'])}
FechaRivalPyMCTemporadaUltimos 8GNN previo

PV - ultimos 3 de Racing

{rows_html(summary['pv']['last3_racing'])}
FechaRivalPyMCTemporadaUltimos 8GNN previo
""" def _run_task(task: str, pred_path: Path, prev_metrics_path: Path, prev_pred_path: Path) -> dict[str, object]: train, val, test, info = _load_task(task) model, approx = _fit_model(train) val_pred = _predict(model, approx, val) test_pred = _predict(model, approx, test) val_metrics = _metrics(val.y, val_pred, val.prior_long, val.prior_short) test_metrics = _metrics(test.y, test_pred, test.prior_long, test.prior_short) pred_df = test.metadata.copy().reset_index(drop=True) target_prefix = "target_attack_share__" if task == "attack" else "target_pv_dist__" for i, zone in enumerate(ZONE_ORDER): pred_df[f"{target_prefix}{zone}"] = test.y[:, i] pred_df[f"pred_model__{target_prefix}{zone}"] = test_pred[:, i] pred_df[f"pred_season__{target_prefix}{zone}"] = test.prior_long[:, i] pred_df[f"pred_short8__{target_prefix}{zone}"] = test.prior_short[:, i] pred_df.to_parquet(pred_path, index=False) prev_metrics = json.loads(prev_metrics_path.read_text(encoding="utf-8"))["test_metrics"] current_rows = _mae_rows(pred_df, target_prefix) prev_rows = _mae_rows(pd.read_parquet(prev_pred_path), target_prefix) current_rows = current_rows.merge( prev_rows.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", ) return { "train_rows": int(len(train.metadata)), "val_rows": int(len(val.metadata)), "test_rows": int(len(test.metadata)), "val_start_date": info["val_start_date"], "feature_count": len(info["feature_cols"]), "test_metrics": test_metrics, "val_metrics": val_metrics, "previous_test_metrics": prev_metrics, "last3_racing": _last3_rows(current_rows), } def main() -> None: _set_seed() MODEL_DIR.mkdir(parents=True, exist_ok=True) REPORTS_DIR.mkdir(parents=True, exist_ok=True) attack_summary = _run_task( "attack", ATTACK_PRED_PATH, MODEL_DIR / "attack_distribution_gnn_metrics.json", MODEL_DIR / "attack_distribution_gnn_test_predictions.parquet", ) pv_summary = _run_task( "pv", PV_PRED_PATH, MODEL_DIR / "pv_distribution_gnn_metrics.json", MODEL_DIR / "pv_distribution_gnn_test_predictions.parquet", ) summary = { "model": "pymc_logistic_normal", "attack": attack_summary, "pv": pv_summary, "config": { "advi_steps": ADVI_STEPS, "posterior_draws": POSTERIOR_DRAWS, }, "artifacts": { "attack_predictions": str(ATTACK_PRED_PATH), "pv_predictions": str(PV_PRED_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") MODEL_PATH.write_text(json.dumps({"model": "pymc_logistic_normal", "summary_path": str(JSON_PATH)}, ensure_ascii=False, indent=2), encoding="utf-8") 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()