Spaces:
Running
Running
File size: 13,611 Bytes
b3df273 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 | """Construye el dataset por equipo-partido (features RICAS) para los modelos de
bloque defensivo y pasillos, procesando los preprocessed liga por liga EN STREAMING
(baja → extrae 1 fila por (matchId,teamId) → borra el CSV). Resumable.
Por equipo-partido extrae (todo desde los eventos; NO carga la columna qualifiers —
centros/largos se derivan por geometría/distancia):
- Resultado: goles a favor/en contra (para ELO y splits local/visita).
- Estilo: % fast break / build up / juego directo / set piece / recovery /
progressive; % ataques que terminan en centro; % pases cortos/largos/exitosos;
pases progresivos; nº de ataques (llegan a último tercio); xT y xG por partido.
- Formación más usada (id_formation) → one-hot en el entrenamiento.
- Posición/presión: avg x/y, % campo rival, altura media de recuperación.
- Distribución de BLOQUE DEFENSIVO enfrentado (alto/medio/bajo).
- Distribución de PASILLOS de ataque (Izq/Centro/Der).
- Peligro (xT) por (bloque × pasillo) en ATAQUE y en DEFENSA (concedido).
El ELO, el rolling "hasta la fecha" y los splits local/visita se arman en el
entrenamiento (necesitan orden cronológico entre partidos).
Uso: python scripts/build_model_dataset.py --leagues "Spanish La Liga" ...
python scripts/build_model_dataset.py --all
"""
from __future__ import annotations
import argparse
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
import numpy as np
import pandas as pd
from racing_reports.datastore import DataStore
OUT = Path(__file__).resolve().parents[1] / "vendor" / "data" / "modeling" / "matchteam_dataset.parquet"
BLOCKS = {"Build Up against High Block": "H", "Build Up against Medium Block": "M",
"Build Up against Low Block": "L"}
SHOTS = {"Goal", "SavedShot", "MissedShots", "ShotOnPost"}
LANES = ["Der", "Centro", "Izq"] # y<33.3 Der, <66.7 Centro, resto Izq (convención multitag)
USECOLS = ["matchId", "teamId", "TeamName", "Competencia", "Temporada", "fecha", "home_team_id",
"period_id", "sequenceId", "phaseLabel", "x", "y", "endX", "endY", "event_name",
"xT", "xThreat", "xG_corr", "xG", "id_formation", "distanceTravelledByBall",
"lastLineBroken", "isGoal", "outcome_type"]
def _lane(y):
return pd.cut(y, [-1, 33.333, 66.667, 1e9], labels=LANES)
def _extract(df: pd.DataFrame, league: str, season: str) -> pd.DataFrame:
df = df[[c for c in USECOLS if c in df.columns]].copy()
if not {"matchId", "teamId", "x", "y", "event_name", "sequenceId", "phaseLabel"} <= set(df.columns):
return pd.DataFrame()
for c in ("x", "y", "endX", "endY", "period_id", "sequenceId", "distanceTravelledByBall"):
if c in df.columns:
df[c] = pd.to_numeric(df[c], errors="coerce")
for c in ("TeamName", "fecha", "home_team_id", "id_formation", "lastLineBroken"):
if c not in df.columns:
df[c] = pd.NA
df["matchId"] = df["matchId"].astype(str)
df["teamId"] = df["teamId"].astype(str)
df["xt"] = pd.to_numeric(df.get("xT"), errors="coerce")
if df["xt"].isna().all() and "xThreat" in df.columns:
df["xt"] = pd.to_numeric(df["xThreat"], errors="coerce")
df["xt"] = df["xt"].fillna(0.0).clip(lower=0)
df["xg"] = pd.to_numeric(df.get("xG_corr", df.get("xG")), errors="coerce").fillna(0.0)
df["is_pass"] = df["event_name"].eq("Pass")
df["is_shot"] = df["event_name"].isin(SHOTS)
df["is_goal"] = df["event_name"].eq("Goal")
df["blk"] = df["phaseLabel"].map(BLOCKS)
df["lane"] = _lane(df["y"])
# centro (geom): pase desde zona ancha terminando en área central
ex, ey = df.get("endX"), df.get("endY")
df["is_cross"] = (df["is_pass"] & ex.notna() & ey.notna() & (ex >= 83)
& ey.between(21, 79) & ((df["y"] < 21) | (df["y"] > 79) | (df["x"] >= 83)))
# pase largo / corto por distancia recorrida del balón (fallback a |Δ|)
dist = pd.to_numeric(df.get("distanceTravelledByBall"), errors="coerce")
if dist.isna().all():
dist = ((ex - df["x"]) ** 2 + (ey - df["y"]) ** 2) ** 0.5 if ex is not None else pd.Series(np.nan, index=df.index)
df["pass_long"] = df["is_pass"] & (dist >= 30)
df["pass_short"] = df["is_pass"] & (dist < 15)
df["pass_ok"] = df["is_pass"] & df.get("outcome_type", pd.Series("", index=df.index)).astype(str).str.contains("uccess", na=False)
df["progressive"] = df["is_pass"] & df.get("lastLineBroken", pd.Series("", index=df.index)).astype(str).str.strip().isin(["last", "True", "true"])
df["opp_half"] = df["x"] > 50
# equipos / rival / local
teams = df.dropna(subset=["teamId"]).groupby("matchId")["teamId"].agg(lambda s: list(dict.fromkeys(s)))
opp = {}
for mid, ts in teams.items():
if len(ts) == 2:
opp[(mid, ts[0])] = ts[1]; opp[(mid, ts[1])] = ts[0]
name = df.dropna(subset=["teamId"]).drop_duplicates("teamId").set_index("teamId")["TeamName"].to_dict()
# ── secuencias (1 fila por matchId,period,sequenceId) ──
seq = df.groupby(["matchId", "period_id", "sequenceId"], sort=False).agg(
poss=("teamId", "first"), phase=("phaseLabel", "first"), blk=("blk", "first"),
npass=("is_pass", "sum"), maxx=("x", "max"), has_cross=("is_cross", "any"),
has_long=("pass_long", "any"), xt=("xt", "sum"), xg=("xg", "sum")).reset_index()
seq["is_buildup"] = seq["phase"].astype(str).str.startswith("Build Up against")
seq["is_counter"] = seq["phase"].eq("Counter Attack")
seq["is_setpiece"] = seq["phase"].eq("Set Piece")
seq["is_recovery"] = seq["phase"].eq("Recovery")
seq["is_progr"] = seq["phase"].eq("Progressive Play")
seq["is_direct"] = (seq["npass"] <= 4) & seq["has_long"] & ~seq["is_setpiece"]
seq["reached_ft"] = seq["maxx"] >= 66
def _team_seq_feats(poss_team_col):
g = seq.groupby(["matchId", poss_team_col])
f = g.agg(n_seq=("phase", "size"),
sh_counter=("is_counter", "mean"), sh_buildup=("is_buildup", "mean"),
sh_direct=("is_direct", "mean"), sh_setpiece=("is_setpiece", "mean"),
sh_recovery=("is_recovery", "mean"), sh_progr=("is_progr", "mean"),
cross_pct=("has_cross", "mean"), n_attacks=("reached_ft", "sum"),
xt_total=("xt", "sum"), xg_total=("xg", "sum")).reset_index()
f["xt_per_attack"] = f["xt_total"] / f["n_attacks"].clip(lower=1)
return f.rename(columns={poss_team_col: "teamId"})
atk_feats = _team_seq_feats("poss")
# ── bloque defensivo enfrentado (el rival construye → este equipo defiende) ──
faced = (seq[seq["blk"].notna()].groupby(["matchId", "poss", "blk"]).size()
.unstack("blk", fill_value=0).reset_index())
for b in ("H", "M", "L"):
if b not in faced.columns:
faced[b] = 0
faced["def_team"] = [opp.get((m, t)) for m, t in zip(faced["matchId"], faced["poss"])]
defb = faced.dropna(subset=["def_team"]).rename(columns={"H": "def_H", "M": "def_M", "L": "def_L",
"def_team": "teamId"})[["matchId", "teamId", "def_H", "def_M", "def_L"]]
# ── pasillos de ataque (pases del equipo en campo rival) ──
atk = df[df["is_pass"] & (df["x"] > 50) & df["y"].notna()]
lanes = atk.groupby(["matchId", "teamId", "lane"], observed=True).size().unstack("lane", fill_value=0).reset_index()
for ln in LANES:
if ln not in lanes.columns:
lanes[ln] = 0
lanes = lanes.rename(columns={ln: f"atk_{ln}" for ln in LANES})
# ── xT por (bloque × pasillo): ataque y defensa concedida ──
# Mergeamos seq SOLO sobre los eventos con xT>0 (una fracción) para no inflar memoria.
ev = df.loc[(df["xt"] > 0) & df["lane"].notna(),
["matchId", "period_id", "sequenceId", "lane", "xt"]].merge(
seq[["matchId", "period_id", "sequenceId", "poss", "blk"]].rename(columns={"blk": "seq_blk"}),
on=["matchId", "period_id", "sequenceId"], how="left")
ev = ev[ev["seq_blk"].notna()].copy()
# ataque: eventos del equipo en posesión, por (bloque, pasillo)
off = (ev.groupby(["matchId", "poss", "seq_blk", "lane"], observed=True)["xt"].sum()
.unstack(["seq_blk", "lane"], fill_value=0.0))
off.columns = [f"offxt_{b}_{l}" for b, l in off.columns]
off = off.reset_index().rename(columns={"poss": "teamId"})
# defensa: el rival ataca → este equipo (def) concede; blk = el que impuso el defensor
ev["def_team"] = [opp.get((m, t)) for m, t in zip(ev["matchId"], ev["poss"])]
deff = (ev.dropna(subset=["def_team"]).groupby(["matchId", "def_team", "seq_blk", "lane"], observed=True)["xt"].sum()
.unstack(["seq_blk", "lane"], fill_value=0.0))
deff.columns = [f"defxt_{b}_{l}" for b, l in deff.columns]
deff = deff.reset_index().rename(columns={"def_team": "teamId"})
# ── resultado, formación, posición/presión ──
df["formk"] = df["id_formation"].astype(str)
base = df.groupby(["matchId", "teamId"]).agg(
TeamName=("TeamName", "first"), fecha=("fecha", "first"), home_team_id=("home_team_id", "first"),
n_events=("event_name", "size"), n_pass=("is_pass", "sum"), n_shots=("is_shot", "sum"),
goals_for=("is_goal", "sum"), avg_x=("x", "mean"), avg_y=("y", "mean"),
share_opp_half=("opp_half", "mean"), n_long=("pass_long", "sum"), n_short=("pass_short", "sum"),
n_passok=("pass_ok", "sum"), n_progr=("progressive", "sum"),
formation=("formk", lambda s: s.mode().iloc[0] if len(s.mode()) else "0")).reset_index()
base["pct_long"] = base["n_long"] / base["n_pass"].clip(lower=1)
base["pct_short"] = base["n_short"] / base["n_pass"].clip(lower=1)
base["pct_pass_ok"] = base["n_passok"] / base["n_pass"].clip(lower=1)
# altura media de recuperación (eventos defensivos)
rec = df[df["event_name"].isin(["BallRecovery", "Interception", "Tackle"])].groupby(["matchId", "teamId"])["x"].mean().rename("recovery_height").reset_index()
base["Competencia"] = league; base["Temporada"] = season
out = base
for t in (atk_feats, defb, lanes, off, deff, rec):
out = out.merge(t, on=["matchId", "teamId"], how="left")
out["rival_teamId"] = [opp.get((m, t)) for m, t in zip(out["matchId"], out["teamId"])]
out["rival_name"] = out["rival_teamId"].map(name)
out["is_home"] = out["teamId"].astype(str) == out["home_team_id"].astype(str)
# goles en contra = goles del rival
gf = out.set_index(["matchId", "teamId"])["goals_for"]
out["goals_against"] = [gf.get((m, r), np.nan) if r is not None else np.nan
for m, r in zip(out["matchId"], out["rival_teamId"])]
fill0 = [c for c in out.columns if c.startswith(("def_", "atk_", "offxt_", "defxt_"))]
out[fill0] = out[fill0].fillna(0)
return out
def _processed_keys() -> set:
if not OUT.exists():
return set()
d = pd.read_parquet(OUT, columns=["Competencia", "Temporada"])
return set(zip(d["Competencia"].astype(str), d["Temporada"].astype(str)))
def _league_season_files(ds: DataStore):
fs = ds._filesystem_client()
root = ds.settings.azure_preprocessed_root.strip("/")
out = []
for p in fs.get_paths(path=root, recursive=True):
if getattr(p, "is_directory", False):
continue
name = getattr(p, "name", "") or ""
fn = name.split("/")[-1]
if fn.startswith("preprocessed_") and fn.endswith(".csv") and "etiquetado" not in fn:
parts = name[len(root):].strip("/").split("/")
if len(parts) >= 3:
out.append((parts[0], parts[1], fn, getattr(p, "content_length", 0) or 0))
return out
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--leagues", nargs="*", default=None)
ap.add_argument("--smallest", type=int, default=0)
ap.add_argument("--all", action="store_true")
ap.add_argument("--force", action="store_true")
args = ap.parse_args()
ds = DataStore()
files = _league_season_files(ds)
if args.leagues:
files = [f for f in files if f[0] in set(args.leagues)]
elif args.smallest:
files = sorted(files, key=lambda f: f[3])[:args.smallest]
elif not args.all:
ap.error("Pasá --leagues, --smallest N o --all")
done = set() if args.force else _processed_keys()
OUT.parent.mkdir(parents=True, exist_ok=True)
for lg, se, fn, sz in files:
if (lg, se) in done:
print(f"skip {lg}/{se}", flush=True); continue
print(f">>> {lg}/{se} ({sz/1e6:.0f} MB) — bajando…", flush=True)
fc = ds._filesystem_client().get_file_client(f"{ds.settings.azure_preprocessed_root}/{lg}/{se}/{fn}")
with tempfile.TemporaryDirectory() as td:
csv = Path(td) / fn
with open(csv, "wb") as fh:
fc.download_file(max_concurrency=8).readinto(fh) # descarga en paralelo
df = pd.read_csv(csv, usecols=lambda c: c in USECOLS, low_memory=False)
rows = _extract(df, lg, se)
if rows.empty:
print(f" {lg}/{se}: schema insuficiente, salteada", flush=True); continue
existing = [pd.read_parquet(OUT)] if OUT.exists() else []
pd.concat(existing + [rows], ignore_index=True).to_parquet(OUT, index=False)
print(f" {len(rows)} filas | {rows.shape[1]} cols | total {len(pd.read_parquet(OUT))}", flush=True)
print("OK")
if __name__ == "__main__":
main()
|