Spaces:
Running
Running
| from __future__ import annotations | |
| import ast | |
| import json | |
| import os | |
| import unicodedata | |
| from pathlib import Path | |
| import joblib | |
| import numpy as np | |
| import pandas as pd | |
| from sklearn.ensemble import RandomForestClassifier | |
| from sklearn.multiclass import OneVsRestClassifier | |
| 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", | |
| ] | |
| DEFAULT_BLOCK_TRAIN_PREPROCESSED = Path( | |
| os.environ.get("RACING_BLOCK_TRAIN_PREPROCESSED", "/Users/pagrois/Documents/Racing/preprocessed_SSD_25-26.csv") | |
| ) | |
| DEFAULT_BLOCK_TRAIN_RAW_DIR = Path( | |
| os.environ.get("RACING_BLOCK_TRAIN_RAW_DIR", "/Users/pagrois/Documents/Racing/raw_events") | |
| ) | |
| DEFAULT_BLOCK_MODEL_PATH = Path( | |
| os.environ.get("RACING_BLOCK_MODEL_PATH", "/Users/pagrois/Racing/data/modeling/sequence_block_model.joblib") | |
| ) | |
| DEFAULT_BLOCK_FEATURE_CACHE = Path( | |
| os.environ.get( | |
| "RACING_BLOCK_TRAIN_FEATURE_CACHE", | |
| "/Users/pagrois/Racing/reports/league_sequence_features_20260505_rawrefresh.pkl", | |
| ) | |
| ) | |
| SHOT_EVENT_TYPES = {"Goal", "MissedShots", "SavedShot", "ShotOnPost", "ChanceMissed"} | |
| PASS_EVENT_NAME = "Pass" | |
| TIME_BANDS = [ | |
| ("t00_15", 0, 15), | |
| ("t15_30", 15, 30), | |
| ("t30_45", 30, 45), | |
| ("t45_60", 45, 60), | |
| ("t60_75", 60, 75), | |
| ("t75_90", 75, 90), | |
| ("t90_plus", 90, np.inf), | |
| ] | |
| LANE_BINS = [ | |
| ("ext_der", 0.0, 21.0), | |
| ("int_der", 21.0, 37.0), | |
| ("centro", 37.0, 63.0), | |
| ("int_izq", 63.0, 79.0), | |
| ("ext_izq", 79.0, 100.0), | |
| ] | |
| BAND_BINS = [ | |
| ("creacion", 50.0, 68.0), | |
| ("tres_cuartos", 68.0, 83.0), | |
| ("area", 83.0, 100.0), | |
| ] | |
| ZONE_NAMES = [f"{band}__{lane}" for band, _, _ in BAND_BINS for lane, _, _ in LANE_BINS] | |
| def safe_parse(val): | |
| if pd.isna(val): | |
| return None | |
| if isinstance(val, (dict, list)): | |
| return val | |
| txt = str(val) | |
| for loader in (json.loads, ast.literal_eval): | |
| try: | |
| return loader(txt) | |
| except Exception: | |
| pass | |
| return None | |
| def norm_text(txt: str | None) -> str: | |
| if txt is None: | |
| return "" | |
| txt = unicodedata.normalize("NFKD", str(txt)).encode("ascii", "ignore").decode("ascii") | |
| txt = txt.lower().strip() | |
| return " ".join(txt.replace("-", " ").split()) | |
| def parse_match_file_name(path: Path) -> dict[str, str | None]: | |
| stem = path.stem | |
| out = {"fecha_str": None, "home_name": None, "away_name": None} | |
| if " - " not in stem or " vs " not in stem: | |
| return out | |
| fecha_str, teams_part = stem.split(" - ", 1) | |
| home_name, away_name = teams_part.split(" vs ", 1) | |
| out["fecha_str"] = fecha_str | |
| out["home_name"] = home_name | |
| out["away_name"] = away_name | |
| return out | |
| def infer_league_season_from_path(path: Path, input_root: Path) -> tuple[str | None, str | None]: | |
| try: | |
| rel = path.relative_to(input_root) | |
| except ValueError: | |
| rel = path | |
| parts = list(rel.parts) | |
| if len(parts) >= 3: | |
| return parts[0], parts[1] | |
| if len(parts) >= 2: | |
| return input_root.name, parts[0] | |
| return None, None | |
| def time_band_from_minute(minute: float) -> str: | |
| for label, start, end in TIME_BANDS: | |
| if start <= minute < end: | |
| return label | |
| return "t90_plus" | |
| def assign_attack_zone(x: float, y: float) -> str | None: | |
| if pd.isna(x) or pd.isna(y): | |
| return None | |
| x = float(x) | |
| y = float(y) | |
| if x < 50 or x > 100 or y < 0 or y > 100: | |
| return None | |
| band_name = None | |
| lane_name = None | |
| for name, x0, x1 in BAND_BINS: | |
| if x0 <= x < x1 or (name == "area" and x <= x1): | |
| band_name = name | |
| break | |
| for name, y0, y1 in LANE_BINS: | |
| if y0 <= y < y1 or (name == "izq" and y <= y1): | |
| lane_name = name | |
| break | |
| if band_name is None or lane_name is None: | |
| return None | |
| return f"{band_name}__{lane_name}" | |
| def normalize_rows(arr: np.ndarray) -> np.ndarray: | |
| 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 probs_from_ovr(model, X: np.ndarray) -> np.ndarray: | |
| 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 dominant_label(df: pd.DataFrame) -> np.ndarray: | |
| return np.select( | |
| [ | |
| (df["w_high"] >= df["w_medium"]) & (df["w_high"] >= df["w_low"]), | |
| (df["w_medium"] >= df["w_high"]) & (df["w_medium"] >= df["w_low"]), | |
| ], | |
| ["High", "Medium"], | |
| default="Low", | |
| ) | |
| def apply_manual_rules(df: pd.DataFrame) -> pd.DataFrame: | |
| out = df.copy() | |
| out["label_before_rules"] = dominant_label(out) | |
| low_a = (out["feat_n_pressure_own_half"] <= 0) & (out["n_passes_7opt_rival"] > out["n_passes"] / 2.0) | |
| low_b = out["feat_back4_avg_x"] > 50 | |
| high_c = (out["n_passes"] > 7) & (out["feat_back4_avg_x"] < 25) | |
| high_d = (out["n_passes_own_half"] >= 6) & ( | |
| out["feat_n_pressure_own_half"] >= (out["n_passes_own_half"] / 2.0) | |
| ) | |
| has_low = low_a | low_b | |
| has_high = high_c | high_d | |
| both = has_low & has_high | |
| out["rule_low_a"] = low_a | |
| out["rule_low_b"] = low_b | |
| out["rule_high_c"] = high_c | |
| out["rule_high_d"] = high_d | |
| out["rule_both_high_low"] = both | |
| label_after = out["label_before_rules"].copy() | |
| label_after = np.where(has_high & ~has_low, "High", label_after) | |
| label_after = np.where(has_low & ~has_high, "Low", label_after) | |
| label_after = np.where(both, "High+Low", label_after) | |
| out["label_after_rules"] = label_after | |
| out.loc[has_high & ~has_low, ["w_high", "w_medium", "w_low"]] = [1.0, 0.0, 0.0] | |
| out.loc[has_low & ~has_high, ["w_high", "w_medium", "w_low"]] = [0.0, 0.0, 1.0] | |
| out.loc[both, ["w_high", "w_medium", "w_low"]] = [0.5, 0.0, 0.5] | |
| return out | |
| def extract_team_points(ev: dict) -> list[tuple[float, float]]: | |
| players: dict[str, tuple[float, float]] = {} | |
| def add_one(obj): | |
| if not isinstance(obj, dict): | |
| return | |
| pid = obj.get("playerId") or obj.get("id") or obj.get("shirtNumber") | |
| x = obj.get("positionX") | |
| y = obj.get("positionY") | |
| if pid is None or x is None or y is None: | |
| return | |
| try: | |
| players[str(pid)] = (float(x), float(y)) | |
| except Exception: | |
| return | |
| add_one(safe_parse(ev.get("mainActor"))) | |
| pass_target = safe_parse(ev.get("passTarget")) | |
| if isinstance(pass_target, dict): | |
| for obj in pass_target.get("player", []): | |
| add_one(obj) | |
| elif isinstance(pass_target, list): | |
| for obj in pass_target: | |
| add_one(obj) | |
| pass_option = safe_parse(ev.get("passOption")) | |
| if isinstance(pass_option, dict): | |
| objs = pass_option.get("player", []) | |
| elif isinstance(pass_option, list): | |
| objs = pass_option | |
| else: | |
| objs = [] | |
| for obj in objs: | |
| add_one(obj) | |
| return list(players.values()) | |
| def without_goalkeeper(points: list[tuple[float, float]]) -> list[tuple[float, float]]: | |
| if len(points) <= 1: | |
| return [] | |
| points_sorted = sorted(points, key=lambda p: (p[0], p[1])) | |
| return points_sorted[1:] | |
| def convex_hull_area(points: list[tuple[float, float]]) -> float: | |
| if len(points) < 3: | |
| return 0.0 | |
| pts = sorted(set((float(x), float(y)) for x, y in points)) | |
| if len(pts) < 3: | |
| return 0.0 | |
| def cross(o, a, b): | |
| return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]) | |
| lower = [] | |
| for p in pts: | |
| while len(lower) >= 2 and cross(lower[-2], lower[-1], p) <= 0: | |
| lower.pop() | |
| lower.append(p) | |
| upper = [] | |
| for p in reversed(pts): | |
| while len(upper) >= 2 and cross(upper[-2], upper[-1], p) <= 0: | |
| upper.pop() | |
| upper.append(p) | |
| hull = lower[:-1] + upper[:-1] | |
| if len(hull) < 3: | |
| return 0.0 | |
| area = 0.0 | |
| for i in range(len(hull)): | |
| x1, y1 = hull[i] | |
| x2, y2 = hull[(i + 1) % len(hull)] | |
| area += x1 * y2 - x2 * y1 | |
| return abs(area) / 2.0 | |
| def pass_options_rival_half(ev: dict) -> float: | |
| pass_option = safe_parse(ev.get("passOption")) | |
| if isinstance(pass_option, dict): | |
| players = pass_option.get("player", []) | |
| elif isinstance(pass_option, list): | |
| players = pass_option | |
| else: | |
| return np.nan | |
| count = 0 | |
| for obj in players: | |
| if not isinstance(obj, dict): | |
| continue | |
| x = obj.get("positionX") | |
| if x is None: | |
| continue | |
| try: | |
| if float(x) > 50: | |
| count += 1 | |
| except Exception: | |
| continue | |
| return float(count) | |
| def pressure_intensity_counts(ev: dict) -> tuple[int, int, int]: | |
| pressure = safe_parse(ev.get("pressure")) | |
| high = medium = low = 0 | |
| if isinstance(pressure, dict): | |
| for obj in pressure.get("player", []): | |
| if not isinstance(obj, dict): | |
| continue | |
| intensity = str(obj.get("intensity", "")).lower() | |
| if intensity == "high": | |
| high += 1 | |
| elif intensity == "medium": | |
| medium += 1 | |
| elif intensity == "low": | |
| low += 1 | |
| return high, medium, low | |
| def compute_own_half_passes(df_events: pd.DataFrame) -> pd.DataFrame: | |
| cols = ["matchId", "sequenceId", "event_name", "x"] | |
| missing = [c for c in cols if c not in df_events.columns] | |
| if missing: | |
| return pd.DataFrame(columns=["matchId", "sequenceId", "n_passes_own_half"]) | |
| tmp = df_events[cols].copy() | |
| tmp["sequenceId"] = pd.to_numeric(tmp["sequenceId"], errors="coerce") | |
| tmp = tmp.dropna(subset=["sequenceId"]).copy() | |
| tmp["sequenceId"] = tmp["sequenceId"].astype(int).astype(str) | |
| tmp["x"] = pd.to_numeric(tmp["x"], errors="coerce") | |
| tmp = tmp[(tmp["event_name"] == PASS_EVENT_NAME) & (tmp["x"] < 50)].copy() | |
| if tmp.empty: | |
| return pd.DataFrame(columns=["matchId", "sequenceId", "n_passes_own_half"]) | |
| return tmp.groupby(["matchId", "sequenceId"], dropna=False).size().reset_index(name="n_passes_own_half") | |
| def extract_sequence_features_from_events(df_events: pd.DataFrame) -> pd.DataFrame: | |
| raw_cols = ["matchId", "sequenceId", "event_name", "mainActor", "passOption", "passTarget", "pressure", "x"] | |
| missing = [c for c in raw_cols if c not in df_events.columns] | |
| if missing: | |
| raise ValueError(f"Faltan columnas para extraer features de sequence: {missing}") | |
| df_ev = df_events[raw_cols].copy() | |
| df_ev["sequenceId"] = pd.to_numeric(df_ev["sequenceId"], errors="coerce") | |
| df_ev = df_ev.dropna(subset=["sequenceId"]).copy() | |
| df_ev["sequenceId"] = df_ev["sequenceId"].astype(int).astype(str) | |
| df_ev["x"] = pd.to_numeric(df_ev["x"], errors="coerce") | |
| df_ev["is_pass"] = df_ev["event_name"].eq(PASS_EVENT_NAME) | |
| feature_rows = [] | |
| for (mid, sid), grp in df_ev.groupby(["matchId", "sequenceId"], sort=False): | |
| pass_grp = grp[grp["is_pass"]] | |
| top2_vals = [] | |
| team_avg_vals = [] | |
| back4_vals = [] | |
| lowest_def_no_gk_vals = [] | |
| hull_area_vals = [] | |
| team_width_vals = [] | |
| team_depth_vals = [] | |
| n_players_rival_vals = [] | |
| n_opt_rival_vals = [] | |
| n_passes_7opt_rival = 0 | |
| for ev in pass_grp.to_dict("records"): | |
| pts = extract_team_points(ev) | |
| xs = sorted([p[0] for p in pts]) | |
| if xs: | |
| team_avg_vals.append(float(np.mean(xs))) | |
| top2_vals.append(float(np.mean(xs[-2:])) if len(xs) >= 2 else float(xs[-1])) | |
| back4_vals.append(float(np.mean(xs[:4])) if len(xs) >= 4 else float(np.mean(xs))) | |
| if len(xs) >= 2: | |
| lowest_def_no_gk_vals.append(float(xs[1])) | |
| n_players_rival_vals.append(float(np.sum(np.array(xs) > 50))) | |
| pts_no_gk = without_goalkeeper(pts) | |
| if pts_no_gk: | |
| xs_no_gk = [p[0] for p in pts_no_gk] | |
| ys_no_gk = [p[1] for p in pts_no_gk] | |
| hull_area_vals.append(convex_hull_area(pts_no_gk)) | |
| team_width_vals.append(float(max(ys_no_gk) - min(ys_no_gk))) | |
| team_depth_vals.append(float(max(xs_no_gk) - min(xs_no_gk))) | |
| n_opt_rival = pass_options_rival_half(ev) | |
| if not np.isnan(n_opt_rival): | |
| n_opt_rival_vals.append(float(n_opt_rival)) | |
| if n_opt_rival > 7: | |
| n_passes_7opt_rival += 1 | |
| high_p = medium_p = low_p = 0 | |
| n_pressure_own_half = 0 | |
| n_pressure_rival = 0 | |
| for ev in grp.to_dict("records"): | |
| x = ev.get("x") | |
| has_pressure = pd.notna(ev.get("pressure")) | |
| if has_pressure and pd.notna(x): | |
| if float(x) < 50: | |
| n_pressure_own_half += 1 | |
| elif float(x) > 50: | |
| n_pressure_rival += 1 | |
| h, m, l = pressure_intensity_counts(ev) | |
| high_p += h | |
| medium_p += m | |
| low_p += l | |
| x_vals = grp["x"].dropna().astype(float) | |
| n_actions_rival = int((x_vals > 50).sum()) | |
| feature_rows.append( | |
| { | |
| "matchId": mid, | |
| "sequenceId": sid, | |
| "n_passes": int(pass_grp.shape[0]), | |
| "feat_top2_avg_x": float(np.mean(top2_vals)) if top2_vals else np.nan, | |
| "feat_avg_pass_options_rival": float(np.mean(n_opt_rival_vals)) if n_opt_rival_vals else np.nan, | |
| "feat_n_pressure_own_half": float(n_pressure_own_half), | |
| "feat_team_avg_x": float(np.mean(team_avg_vals)) if team_avg_vals else np.nan, | |
| "feat_back4_avg_x": float(np.mean(back4_vals)) if back4_vals else np.nan, | |
| "feat_lowest_def_no_gk_avg_x": float(np.mean(lowest_def_no_gk_vals)) if lowest_def_no_gk_vals else np.nan, | |
| "feat_convex_hull_area_no_gk": float(np.mean(hull_area_vals)) if hull_area_vals else np.nan, | |
| "feat_team_width_no_gk": float(np.mean(team_width_vals)) if team_width_vals else np.nan, | |
| "feat_team_depth_no_gk": float(np.mean(team_depth_vals)) if team_depth_vals else np.nan, | |
| "feat_n_players_rival_half_avg": float(np.mean(n_players_rival_vals)) if n_players_rival_vals else np.nan, | |
| "feat_n_pressure_high": float(high_p), | |
| "feat_n_pressure_medium": float(medium_p), | |
| "feat_n_pressure_low": float(low_p), | |
| "feat_avg_event_x": float(x_vals.mean()) if len(x_vals) else np.nan, | |
| "feat_pct_actions_rival": float((x_vals > 50).mean()) if len(x_vals) else np.nan, | |
| "feat_n_actions_rival": float(n_actions_rival), | |
| "n_pressure_rival": float(n_pressure_rival), | |
| "n_passes_7opt_rival": float(n_passes_7opt_rival), | |
| } | |
| ) | |
| df_feat = pd.DataFrame(feature_rows) | |
| own_half = compute_own_half_passes(df_events) | |
| if not own_half.empty: | |
| df_feat = df_feat.merge(own_half, on=["matchId", "sequenceId"], how="left") | |
| if "n_passes_own_half" not in df_feat.columns: | |
| df_feat["n_passes_own_half"] = 0.0 | |
| df_feat["n_passes_own_half"] = df_feat["n_passes_own_half"].fillna(0.0).astype(float) | |
| return df_feat | |
| def resolve_blocks(grp: pd.DataFrame) -> list[str]: | |
| 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: | |
| no_medium = [b for b in blocks if b != "Medium"] | |
| if no_medium: | |
| blocks = no_medium | |
| return blocks | |
| def build_sequence_targets_from_phase_label(df_events: pd.DataFrame) -> pd.DataFrame: | |
| if "phaseLabel" not in df_events.columns: | |
| return pd.DataFrame(columns=["matchId", "sequenceId", "w_high", "w_medium", "w_low", "source"]) | |
| tmp = df_events[["matchId", "sequenceId", "phaseLabel"]].copy() | |
| tmp["sequenceId"] = pd.to_numeric(tmp["sequenceId"], errors="coerce") | |
| tmp = tmp.dropna(subset=["sequenceId"]).copy() | |
| tmp["sequenceId"] = tmp["sequenceId"].astype(int).astype(str) | |
| tmp["block_raw"] = tmp["phaseLabel"].map(PHASE_TO_BLOCK) | |
| tmp = tmp.dropna(subset=["block_raw"]).copy() | |
| if tmp.empty: | |
| return pd.DataFrame(columns=["matchId", "sequenceId", "w_high", "w_medium", "w_low", "source"]) | |
| block_ev = tmp.groupby(["matchId", "sequenceId", "block_raw"]).size().reset_index(name="n_ev") | |
| feature_pass_counts = extract_sequence_features_from_events(df_events)[["matchId", "sequenceId", "n_passes"]] | |
| pass_count_map = feature_pass_counts.set_index(["matchId", "sequenceId"])["n_passes"].to_dict() | |
| rows = [] | |
| for (mid, sid), grp in block_ev.groupby(["matchId", "sequenceId"], sort=False): | |
| 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_multi = grp_multi["n_ev"].sum() | |
| weights = {b: 0.0 for b in BLOCKS} | |
| for row in grp_multi.itertuples(index=False): | |
| weights[row.block_raw] = row.n_ev / total_multi | |
| else: | |
| blocks = resolve_blocks(grp) | |
| weights = {b: 0.0 for b in BLOCKS} | |
| share = 1.0 / len(blocks) | |
| for block in blocks: | |
| weights[block] = share | |
| rows.append( | |
| { | |
| "matchId": mid, | |
| "sequenceId": sid, | |
| "w_high": weights["High"], | |
| "w_medium": weights["Medium"], | |
| "w_low": weights["Low"], | |
| "source": "opta", | |
| } | |
| ) | |
| return pd.DataFrame(rows) | |
| def _build_block_training_frame( | |
| preprocessed_path: Path = DEFAULT_BLOCK_TRAIN_PREPROCESSED, | |
| feature_cache_path: Path = DEFAULT_BLOCK_FEATURE_CACHE, | |
| raw_dir: Path = DEFAULT_BLOCK_TRAIN_RAW_DIR, | |
| ) -> pd.DataFrame: | |
| pre_cols = ["matchId", "sequenceId", "phaseLabel"] | |
| df_pre = pd.read_csv(preprocessed_path, usecols=pre_cols, low_memory=False) | |
| df_pre["matchId"] = df_pre["matchId"].astype(str) | |
| df_pre["sequenceId"] = pd.to_numeric(df_pre["sequenceId"], errors="coerce") | |
| df_pre = df_pre.dropna(subset=["sequenceId"]).copy() | |
| df_pre["sequenceId"] = df_pre["sequenceId"].astype(int).astype(str) | |
| df_pre["block_raw"] = df_pre["phaseLabel"].map(PHASE_TO_BLOCK) | |
| if feature_cache_path.exists(): | |
| df_feat = pd.read_pickle(feature_cache_path) | |
| else: | |
| frames = [] | |
| for path in sorted(raw_dir.glob("*.xlsx")): | |
| try: | |
| df_ev = pd.read_excel(path, sheet_name="Eventos", engine="openpyxl") | |
| except Exception: | |
| continue | |
| if "matchId" not in df_ev.columns: | |
| continue | |
| df_ev["matchId"] = df_ev["matchId"].astype(str) | |
| try: | |
| frames.append(extract_sequence_features_from_events(df_ev)) | |
| except Exception: | |
| continue | |
| if not frames: | |
| raise RuntimeError("No se pudieron construir features de sequence para entrenar el modelo de bloques.") | |
| df_feat = pd.concat(frames, ignore_index=True) | |
| feature_cache_path.parent.mkdir(parents=True, exist_ok=True) | |
| df_feat.to_pickle(feature_cache_path) | |
| block_ev = ( | |
| df_pre.dropna(subset=["block_raw"]) | |
| .groupby(["matchId", "sequenceId", "block_raw"]) | |
| .size() | |
| .reset_index(name="n_ev") | |
| ) | |
| pass_count_map = df_feat.set_index(["matchId", "sequenceId"])["n_passes"].to_dict() | |
| target_rows = [] | |
| for (mid, sid), grp in block_ev.groupby(["matchId", "sequenceId"], sort=False): | |
| 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_multi = grp_multi["n_ev"].sum() | |
| weights = {b: 0.0 for b in BLOCKS} | |
| for row in grp_multi.itertuples(index=False): | |
| weights[row.block_raw] = row.n_ev / total_multi | |
| else: | |
| blocks = resolve_blocks(grp) | |
| weights = {b: 0.0 for b in BLOCKS} | |
| share = 1.0 / len(blocks) | |
| for block in blocks: | |
| weights[block] = share | |
| target_rows.append( | |
| { | |
| "matchId": mid, | |
| "sequenceId": sid, | |
| "w_high": weights["High"], | |
| "w_medium": weights["Medium"], | |
| "w_low": weights["Low"], | |
| } | |
| ) | |
| df_target = pd.DataFrame(target_rows) | |
| if df_target.empty: | |
| raise RuntimeError("No se pudieron construir targets Opta para el modelo de bloques.") | |
| df_model = df_feat.merge(df_target, on=["matchId", "sequenceId"], how="inner") | |
| df_model = df_model[df_model["n_passes"] >= 3].dropna(subset=FEATURE_COLS).copy() | |
| return df_model | |
| def fit_or_load_block_model( | |
| model_path: Path = DEFAULT_BLOCK_MODEL_PATH, | |
| preprocessed_path: Path = DEFAULT_BLOCK_TRAIN_PREPROCESSED, | |
| feature_cache_path: Path = DEFAULT_BLOCK_FEATURE_CACHE, | |
| raw_dir: Path = DEFAULT_BLOCK_TRAIN_RAW_DIR, | |
| force_retrain: bool = False, | |
| ): | |
| if model_path.exists() and not force_retrain: | |
| return joblib.load(model_path) | |
| df_model = _build_block_training_frame( | |
| preprocessed_path=preprocessed_path, | |
| feature_cache_path=feature_cache_path, | |
| raw_dir=raw_dir, | |
| ) | |
| X_train = df_model[FEATURE_COLS].to_numpy(dtype=float) | |
| y_train = (df_model[["w_high", "w_medium", "w_low"]].to_numpy(dtype=float) > 0).astype(int) | |
| model = OneVsRestClassifier( | |
| RandomForestClassifier( | |
| n_estimators=400, | |
| min_samples_leaf=5, | |
| random_state=RANDOM_SEED, | |
| n_jobs=1, | |
| class_weight="balanced_subsample", | |
| ), | |
| n_jobs=1, | |
| ) | |
| model.fit(X_train, y_train) | |
| artifact = {"model": model, "feature_cols": FEATURE_COLS} | |
| model_path.parent.mkdir(parents=True, exist_ok=True) | |
| joblib.dump(artifact, model_path) | |
| return artifact | |
| def label_sequences_with_block_model(df_events: pd.DataFrame, model_artifact: dict) -> pd.DataFrame: | |
| df_feat = extract_sequence_features_from_events(df_events) | |
| if df_feat.empty: | |
| return pd.DataFrame() | |
| seq_targets = build_sequence_targets_from_phase_label(df_events) | |
| if not seq_targets.empty: | |
| seq_targets = seq_targets.rename( | |
| columns={ | |
| "w_high": "opta_w_high", | |
| "w_medium": "opta_w_medium", | |
| "w_low": "opta_w_low", | |
| "source": "opta_source", | |
| } | |
| ) | |
| df_all = df_feat.merge(seq_targets, on=["matchId", "sequenceId"], how="left") | |
| feature_cols = list(model_artifact["feature_cols"]) | |
| model = model_artifact["model"] | |
| predict_mask = df_all["n_passes"] >= 3 | |
| predict_mask &= df_all[feature_cols].notna().all(axis=1) | |
| if predict_mask.any(): | |
| probs = probs_from_ovr(model, df_all.loc[predict_mask, feature_cols].to_numpy(dtype=float)) | |
| probs = normalize_rows(probs) | |
| pred_idx = probs.argmax(axis=1) | |
| pred_labels = np.array(BLOCKS, dtype=object)[pred_idx] | |
| df_all.loc[predict_mask, "w_high"] = (pred_labels == "High").astype(float) | |
| df_all.loc[predict_mask, "w_medium"] = (pred_labels == "Medium").astype(float) | |
| df_all.loc[predict_mask, "w_low"] = (pred_labels == "Low").astype(float) | |
| df_all.loc[predict_mask, "source"] = "model" | |
| for col in ["w_high", "w_medium", "w_low"]: | |
| if col not in df_all.columns: | |
| df_all[col] = np.nan | |
| opta_available = ( | |
| df_all.get("opta_w_high", pd.Series(index=df_all.index)).notna() | |
| & df_all.get("opta_w_medium", pd.Series(index=df_all.index)).notna() | |
| & df_all.get("opta_w_low", pd.Series(index=df_all.index)).notna() | |
| ) | |
| missing_model = df_all[["w_high", "w_medium", "w_low"]].isna().all(axis=1) | |
| fallback_mask = missing_model & opta_available | |
| if fallback_mask.any(): | |
| df_all.loc[fallback_mask, "w_high"] = df_all.loc[fallback_mask, "opta_w_high"] | |
| df_all.loc[fallback_mask, "w_medium"] = df_all.loc[fallback_mask, "opta_w_medium"] | |
| df_all.loc[fallback_mask, "w_low"] = df_all.loc[fallback_mask, "opta_w_low"] | |
| df_all.loc[fallback_mask, "source"] = "opta_fallback" | |
| df_all = df_all.dropna(subset=["w_high", "w_medium", "w_low"], how="all").copy() | |
| if df_all.empty: | |
| return df_all | |
| df_all[["w_high", "w_medium", "w_low"]] = df_all[["w_high", "w_medium", "w_low"]].fillna(0.0) | |
| df_all["source"] = df_all["source"].fillna("model") | |
| df_all = apply_manual_rules(df_all) | |
| return df_all | |