""" evaluate_sequence_block_model_calibrated.py ------------------------------------------ Compara el modelo OneVsRest actual vs una version calibrada (sigmoid) usando el cache de features v3 y el mismo split holdout por partido. """ import pandas as pd import numpy as np from pathlib import Path from sklearn.calibration import CalibratedClassifierCV from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import mean_absolute_error, roc_auc_score from sklearn.model_selection import GroupShuffleSplit from sklearn.multiclass import OneVsRestClassifier PREPROCESSED = Path("/Users/pagrois/Documents/Racing/preprocessed_SSD_25-26.csv") FEATURE_CACHE = Path("/tmp/sequence_block_model_cache/league_sequence_features_v3.pkl") REPORTS_DIR = Path("/Users/pagrois/Racing/reports") REPORTS_DIR.mkdir(parents=True, exist_ok=True) BLOCKS = ["High", "Medium", "Low"] BLOCK_TO_IDX = {b: i for i, b in enumerate(BLOCKS)} PHASE_TO_BLOCK = { "Build Up against High Block": "High", "Build Up against Medium Block": "Medium", "Build Up against Low Block": "Low", } RANDOM_SEED = 42 FEATURE_COLS = [ "feat_n_pressure_own_half", "feat_avg_pass_options_rival", "feat_top2_avg_x", "feat_team_avg_x", "feat_back4_avg_x", "feat_lowest_def_no_gk_avg_x", "feat_convex_hull_area_no_gk", "feat_team_width_no_gk", "feat_team_depth_no_gk", "feat_n_players_rival_half_avg", "feat_n_pressure_high", "feat_n_pressure_medium", "feat_n_pressure_low", "feat_avg_event_x", "feat_pct_actions_rival", "feat_n_actions_rival", ] def resolve_blocks(grp): max_n = grp["n_ev"].max() tied = set(grp.loc[grp["n_ev"] == max_n, "block_raw"]) if len(tied) == 1: return list(tied) valid = set(grp.loc[(grp["n_ev"] >= 3) & (grp["block_raw"].isin(tied)), "block_raw"]) blocks = list(valid) if valid else list(tied) if len(blocks) > 1 and "Medium" in blocks: nm = [b for b in blocks if b != "Medium"] if nm: blocks = nm return blocks def normalize_rows(arr): arr = np.asarray(arr, dtype=float) row_sum = arr.sum(axis=1, keepdims=True) out = arr.copy() mask = row_sum.squeeze() > 0 out[mask] = out[mask] / row_sum[mask] out[~mask] = 1.0 / arr.shape[1] return out def one_hot(labels_idx, n_classes=3): arr = np.zeros((len(labels_idx), n_classes), dtype=float) arr[np.arange(len(labels_idx)), labels_idx] = 1.0 return arr def probs_from_ovr(model, X): prob_list = model.predict_proba(X) if isinstance(prob_list, list): probs = np.column_stack([p[:, 1] for p in prob_list]) elif isinstance(prob_list, np.ndarray) and prob_list.ndim == 3: probs = np.column_stack([prob_list[i][:, 1] for i in range(prob_list.shape[0])]) else: probs = np.asarray(prob_list) if probs.ndim == 2 and probs.shape[1] == 3: return probs return probs def topk_prediction_sets(probs_norm, n_passes, prob_threshold=0.28, gap_threshold=0.08): pred_sets = [] for i in range(len(probs_norm)): order = np.argsort(probs_norm[i])[::-1] top1, top2 = order[0], order[1] if ( n_passes[i] >= 7 and probs_norm[i, top1] >= prob_threshold and probs_norm[i, top2] >= prob_threshold and abs(probs_norm[i, top1] - probs_norm[i, top2]) <= gap_threshold ): pred_sets.append({top1, top2}) else: pred_sets.append({top1}) return pred_sets def set_metrics(y_bin, pred_sets): true_sets = [set(np.where(row > 0)[0]) for row in y_bin] overlap_hit = np.mean([len(t & p) > 0 for t, p in zip(true_sets, pred_sets)]) exact_hit = np.mean([t == p for t, p in zip(true_sets, pred_sets)]) predicted_multi = np.mean([len(p) > 1 for p in pred_sets]) multi_rows = [(t, p) for t, p in zip(true_sets, pred_sets) if len(p) > 1] multi_overlap = np.mean([len(t & p) > 0 for t, p in multi_rows]) if multi_rows else np.nan return { "overlap_hit": overlap_hit, "exact_hit": exact_hit, "predicted_multi_rate": predicted_multi, "predicted_multi_overlap": multi_overlap, } def evaluate(y_bin, y_weight, probs, n_passes): probs_norm = normalize_rows(probs) pred_idx = probs_norm.argmax(axis=1) top1_hit = y_bin[np.arange(len(y_bin)), pred_idx].mean() true_mult = y_bin.sum(axis=1) > 1 multi_top1 = y_bin[np.arange(len(y_bin)), pred_idx][true_mult].mean() if true_mult.any() else np.nan multi_mae = mean_absolute_error(y_weight[true_mult], probs_norm[true_mult]) if true_mult.any() else np.nan pred_sets = topk_prediction_sets(probs_norm, n_passes) set_stats = set_metrics(y_bin, pred_sets) return { "auc_macro": roc_auc_score(y_bin, probs, average="macro"), "auc_micro": roc_auc_score(y_bin, probs, average="micro"), "mae": mean_absolute_error(y_weight, probs_norm), "top1_hit": top1_hit, "multi_top1_hit": multi_top1, "multi_mae": multi_mae, **set_stats, } print("Cargando targets y features...", flush=True) pre = pd.read_csv(PREPROCESSED, usecols=["matchId", "sequenceId", "phaseLabel"], low_memory=False) pre["matchId"] = pre["matchId"].astype(str) pre["sequenceId"] = pd.to_numeric(pre["sequenceId"], errors="coerce") pre = pre.dropna(subset=["sequenceId"]).copy() pre["sequenceId"] = pre["sequenceId"].astype(int).astype(str) pre["block_raw"] = pre["phaseLabel"].map(PHASE_TO_BLOCK) feat = pd.read_pickle(FEATURE_CACHE) pass_count_map = feat.set_index(["matchId", "sequenceId"])["n_passes"].to_dict() block_ev = ( pre.dropna(subset=["block_raw"]) .groupby(["matchId", "sequenceId", "block_raw"]) .size() .reset_index(name="n_ev") ) rows = [] for (mid, sid), grp in block_ev.groupby(["matchId", "sequenceId"]): n_passes = int(pass_count_map.get((mid, sid), -1)) if n_passes < 3: continue grp_multi = grp[grp["n_ev"] >= 3] if n_passes >= 7 and len(grp_multi) > 1: total = grp_multi["n_ev"].sum() weights = {b: 0.0 for b in BLOCKS} for r in grp_multi.itertuples(index=False): weights[r.block_raw] = r.n_ev / total else: blocks = resolve_blocks(grp) weights = {b: 0.0 for b in BLOCKS} w = 1.0 / len(blocks) for b in blocks: weights[b] = w rows.append({"matchId": mid, "sequenceId": sid, "y_high": weights["High"], "y_medium": weights["Medium"], "y_low": weights["Low"]}) target = pd.DataFrame(rows) df_model = feat.merge(target, on=["matchId", "sequenceId"], how="inner") df_model = df_model[df_model["n_passes"] >= 3].dropna(subset=FEATURE_COLS).copy() X = df_model[FEATURE_COLS].to_numpy(dtype=float) y_weight = df_model[["y_high", "y_medium", "y_low"]].to_numpy(dtype=float) y_bin = (y_weight > 0).astype(int) groups = df_model["matchId"].to_numpy() n_passes = df_model["n_passes"].to_numpy() train_idx, test_idx = next(GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=RANDOM_SEED).split(X, y_bin, groups)) X_train, X_test = X[train_idx], X[test_idx] y_train_bin, y_test_bin = y_bin[train_idx], y_bin[test_idx] y_train_weight, y_test_weight = y_weight[train_idx], y_weight[test_idx] n_passes_test = n_passes[test_idx] base_rf = RandomForestClassifier( n_estimators=400, min_samples_leaf=5, random_state=RANDOM_SEED, n_jobs=1, class_weight="balanced_subsample", ) uncal_model = OneVsRestClassifier(base_rf, n_jobs=1) cal_model = OneVsRestClassifier( CalibratedClassifierCV( estimator=RandomForestClassifier( n_estimators=300, min_samples_leaf=5, random_state=RANDOM_SEED, n_jobs=1, class_weight="balanced_subsample", ), method="sigmoid", cv=3, ), n_jobs=1, ) print("Entrenando modelo original...", flush=True) uncal_model.fit(X_train, y_train_bin) uncal_probs = probs_from_ovr(uncal_model, X_test) uncal_metrics = evaluate(y_test_bin, y_test_weight, uncal_probs, n_passes_test) print("Entrenando modelo calibrado...", flush=True) cal_model.fit(X_train, y_train_bin) cal_probs = probs_from_ovr(cal_model, X_test) cal_metrics = evaluate(y_test_bin, y_test_weight, cal_probs, n_passes_test) lines = [] lines.append("# Comparacion modelo calibrado") lines.append("") lines.append(f"- Train sequences: {len(train_idx):,}") lines.append(f"- Test sequences: {len(test_idx):,}") lines.append("") lines.append("## Holdout test") for name, metrics in [("Original", uncal_metrics), ("Calibrado", cal_metrics)]: lines.append( f"- {name}: " f"AUC macro={metrics['auc_macro']:.3f} | " f"AUC micro={metrics['auc_micro']:.3f} | " f"MAE={metrics['mae']:.3f} | " f"Top-1 hit={metrics['top1_hit']:.3f} | " f"Multi Top-1={metrics['multi_top1_hit']:.3f} | " f"Multi MAE={metrics['multi_mae']:.3f}" ) lines.append( f" Variante dual: overlap={metrics['overlap_hit']:.3f} | " f"exact={metrics['exact_hit']:.3f} | " f"% multi pred={100*metrics['predicted_multi_rate']:.1f}% | " f"overlap en multi pred={metrics['predicted_multi_overlap']:.3f}" ) out = REPORTS_DIR / "sequence_block_model_calibrated_report.md" out.write_text("\n".join(lines), encoding="utf-8") print(f"Reporte: {out}") print("\n".join(lines))