Spaces:
Running
Running
Commit ·
b3df273
1
Parent(s): c79fec9
Sección 'Recursos bloque bajo': scatter interactivo + tablas por equipo
Browse filesScatter con liga (opción todas), ejes configurables (lado of/def × recurso ×
métrica: pv/jugada, tiros/jugada, xG acum/por jugada/por exitosa, goles, %goles,
%éxito, intentos), hover con equipo, click fija etiqueta. Abajo: tablas ofensiva
y defensiva por equipo con media de liga. Artefacto vendor/data/lowblock/
(mín 10 intentos por celda en scatter).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- scripts/build_model_dataset.py +250 -0
- scripts/cmp_nn.py +95 -0
- scripts/cmp_prep.py +88 -0
- scripts/gen_sl_def_profile.py +75 -0
- scripts/train_compare.py +149 -0
- src/racing_reports/api/routes_lowblock.py +31 -0
- src/racing_reports/lowblock_data.py +90 -0
- src/racing_reports/web/app.py +2 -0
- src/racing_reports/web/templates/home.html +204 -0
scripts/build_model_dataset.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Construye el dataset por equipo-partido (features RICAS) para los modelos de
|
| 2 |
+
bloque defensivo y pasillos, procesando los preprocessed liga por liga EN STREAMING
|
| 3 |
+
(baja → extrae 1 fila por (matchId,teamId) → borra el CSV). Resumable.
|
| 4 |
+
|
| 5 |
+
Por equipo-partido extrae (todo desde los eventos; NO carga la columna qualifiers —
|
| 6 |
+
centros/largos se derivan por geometría/distancia):
|
| 7 |
+
- Resultado: goles a favor/en contra (para ELO y splits local/visita).
|
| 8 |
+
- Estilo: % fast break / build up / juego directo / set piece / recovery /
|
| 9 |
+
progressive; % ataques que terminan en centro; % pases cortos/largos/exitosos;
|
| 10 |
+
pases progresivos; nº de ataques (llegan a último tercio); xT y xG por partido.
|
| 11 |
+
- Formación más usada (id_formation) → one-hot en el entrenamiento.
|
| 12 |
+
- Posición/presión: avg x/y, % campo rival, altura media de recuperación.
|
| 13 |
+
- Distribución de BLOQUE DEFENSIVO enfrentado (alto/medio/bajo).
|
| 14 |
+
- Distribución de PASILLOS de ataque (Izq/Centro/Der).
|
| 15 |
+
- Peligro (xT) por (bloque × pasillo) en ATAQUE y en DEFENSA (concedido).
|
| 16 |
+
El ELO, el rolling "hasta la fecha" y los splits local/visita se arman en el
|
| 17 |
+
entrenamiento (necesitan orden cronológico entre partidos).
|
| 18 |
+
|
| 19 |
+
Uso: python scripts/build_model_dataset.py --leagues "Spanish La Liga" ...
|
| 20 |
+
python scripts/build_model_dataset.py --all
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
from __future__ import annotations
|
| 24 |
+
|
| 25 |
+
import argparse
|
| 26 |
+
import sys
|
| 27 |
+
import tempfile
|
| 28 |
+
from pathlib import Path
|
| 29 |
+
|
| 30 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
| 31 |
+
|
| 32 |
+
import numpy as np
|
| 33 |
+
import pandas as pd
|
| 34 |
+
|
| 35 |
+
from racing_reports.datastore import DataStore
|
| 36 |
+
|
| 37 |
+
OUT = Path(__file__).resolve().parents[1] / "vendor" / "data" / "modeling" / "matchteam_dataset.parquet"
|
| 38 |
+
BLOCKS = {"Build Up against High Block": "H", "Build Up against Medium Block": "M",
|
| 39 |
+
"Build Up against Low Block": "L"}
|
| 40 |
+
SHOTS = {"Goal", "SavedShot", "MissedShots", "ShotOnPost"}
|
| 41 |
+
LANES = ["Der", "Centro", "Izq"] # y<33.3 Der, <66.7 Centro, resto Izq (convención multitag)
|
| 42 |
+
USECOLS = ["matchId", "teamId", "TeamName", "Competencia", "Temporada", "fecha", "home_team_id",
|
| 43 |
+
"period_id", "sequenceId", "phaseLabel", "x", "y", "endX", "endY", "event_name",
|
| 44 |
+
"xT", "xThreat", "xG_corr", "xG", "id_formation", "distanceTravelledByBall",
|
| 45 |
+
"lastLineBroken", "isGoal", "outcome_type"]
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _lane(y):
|
| 49 |
+
return pd.cut(y, [-1, 33.333, 66.667, 1e9], labels=LANES)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _extract(df: pd.DataFrame, league: str, season: str) -> pd.DataFrame:
|
| 53 |
+
df = df[[c for c in USECOLS if c in df.columns]].copy()
|
| 54 |
+
if not {"matchId", "teamId", "x", "y", "event_name", "sequenceId", "phaseLabel"} <= set(df.columns):
|
| 55 |
+
return pd.DataFrame()
|
| 56 |
+
for c in ("x", "y", "endX", "endY", "period_id", "sequenceId", "distanceTravelledByBall"):
|
| 57 |
+
if c in df.columns:
|
| 58 |
+
df[c] = pd.to_numeric(df[c], errors="coerce")
|
| 59 |
+
for c in ("TeamName", "fecha", "home_team_id", "id_formation", "lastLineBroken"):
|
| 60 |
+
if c not in df.columns:
|
| 61 |
+
df[c] = pd.NA
|
| 62 |
+
df["matchId"] = df["matchId"].astype(str)
|
| 63 |
+
df["teamId"] = df["teamId"].astype(str)
|
| 64 |
+
df["xt"] = pd.to_numeric(df.get("xT"), errors="coerce")
|
| 65 |
+
if df["xt"].isna().all() and "xThreat" in df.columns:
|
| 66 |
+
df["xt"] = pd.to_numeric(df["xThreat"], errors="coerce")
|
| 67 |
+
df["xt"] = df["xt"].fillna(0.0).clip(lower=0)
|
| 68 |
+
df["xg"] = pd.to_numeric(df.get("xG_corr", df.get("xG")), errors="coerce").fillna(0.0)
|
| 69 |
+
df["is_pass"] = df["event_name"].eq("Pass")
|
| 70 |
+
df["is_shot"] = df["event_name"].isin(SHOTS)
|
| 71 |
+
df["is_goal"] = df["event_name"].eq("Goal")
|
| 72 |
+
df["blk"] = df["phaseLabel"].map(BLOCKS)
|
| 73 |
+
df["lane"] = _lane(df["y"])
|
| 74 |
+
# centro (geom): pase desde zona ancha terminando en área central
|
| 75 |
+
ex, ey = df.get("endX"), df.get("endY")
|
| 76 |
+
df["is_cross"] = (df["is_pass"] & ex.notna() & ey.notna() & (ex >= 83)
|
| 77 |
+
& ey.between(21, 79) & ((df["y"] < 21) | (df["y"] > 79) | (df["x"] >= 83)))
|
| 78 |
+
# pase largo / corto por distancia recorrida del balón (fallback a |Δ|)
|
| 79 |
+
dist = pd.to_numeric(df.get("distanceTravelledByBall"), errors="coerce")
|
| 80 |
+
if dist.isna().all():
|
| 81 |
+
dist = ((ex - df["x"]) ** 2 + (ey - df["y"]) ** 2) ** 0.5 if ex is not None else pd.Series(np.nan, index=df.index)
|
| 82 |
+
df["pass_long"] = df["is_pass"] & (dist >= 30)
|
| 83 |
+
df["pass_short"] = df["is_pass"] & (dist < 15)
|
| 84 |
+
df["pass_ok"] = df["is_pass"] & df.get("outcome_type", pd.Series("", index=df.index)).astype(str).str.contains("uccess", na=False)
|
| 85 |
+
df["progressive"] = df["is_pass"] & df.get("lastLineBroken", pd.Series("", index=df.index)).astype(str).str.strip().isin(["last", "True", "true"])
|
| 86 |
+
df["opp_half"] = df["x"] > 50
|
| 87 |
+
|
| 88 |
+
# equipos / rival / local
|
| 89 |
+
teams = df.dropna(subset=["teamId"]).groupby("matchId")["teamId"].agg(lambda s: list(dict.fromkeys(s)))
|
| 90 |
+
opp = {}
|
| 91 |
+
for mid, ts in teams.items():
|
| 92 |
+
if len(ts) == 2:
|
| 93 |
+
opp[(mid, ts[0])] = ts[1]; opp[(mid, ts[1])] = ts[0]
|
| 94 |
+
name = df.dropna(subset=["teamId"]).drop_duplicates("teamId").set_index("teamId")["TeamName"].to_dict()
|
| 95 |
+
|
| 96 |
+
# ── secuencias (1 fila por matchId,period,sequenceId) ──
|
| 97 |
+
seq = df.groupby(["matchId", "period_id", "sequenceId"], sort=False).agg(
|
| 98 |
+
poss=("teamId", "first"), phase=("phaseLabel", "first"), blk=("blk", "first"),
|
| 99 |
+
npass=("is_pass", "sum"), maxx=("x", "max"), has_cross=("is_cross", "any"),
|
| 100 |
+
has_long=("pass_long", "any"), xt=("xt", "sum"), xg=("xg", "sum")).reset_index()
|
| 101 |
+
seq["is_buildup"] = seq["phase"].astype(str).str.startswith("Build Up against")
|
| 102 |
+
seq["is_counter"] = seq["phase"].eq("Counter Attack")
|
| 103 |
+
seq["is_setpiece"] = seq["phase"].eq("Set Piece")
|
| 104 |
+
seq["is_recovery"] = seq["phase"].eq("Recovery")
|
| 105 |
+
seq["is_progr"] = seq["phase"].eq("Progressive Play")
|
| 106 |
+
seq["is_direct"] = (seq["npass"] <= 4) & seq["has_long"] & ~seq["is_setpiece"]
|
| 107 |
+
seq["reached_ft"] = seq["maxx"] >= 66
|
| 108 |
+
|
| 109 |
+
def _team_seq_feats(poss_team_col):
|
| 110 |
+
g = seq.groupby(["matchId", poss_team_col])
|
| 111 |
+
f = g.agg(n_seq=("phase", "size"),
|
| 112 |
+
sh_counter=("is_counter", "mean"), sh_buildup=("is_buildup", "mean"),
|
| 113 |
+
sh_direct=("is_direct", "mean"), sh_setpiece=("is_setpiece", "mean"),
|
| 114 |
+
sh_recovery=("is_recovery", "mean"), sh_progr=("is_progr", "mean"),
|
| 115 |
+
cross_pct=("has_cross", "mean"), n_attacks=("reached_ft", "sum"),
|
| 116 |
+
xt_total=("xt", "sum"), xg_total=("xg", "sum")).reset_index()
|
| 117 |
+
f["xt_per_attack"] = f["xt_total"] / f["n_attacks"].clip(lower=1)
|
| 118 |
+
return f.rename(columns={poss_team_col: "teamId"})
|
| 119 |
+
|
| 120 |
+
atk_feats = _team_seq_feats("poss")
|
| 121 |
+
|
| 122 |
+
# ── bloque defensivo enfrentado (el rival construye → este equipo defiende) ──
|
| 123 |
+
faced = (seq[seq["blk"].notna()].groupby(["matchId", "poss", "blk"]).size()
|
| 124 |
+
.unstack("blk", fill_value=0).reset_index())
|
| 125 |
+
for b in ("H", "M", "L"):
|
| 126 |
+
if b not in faced.columns:
|
| 127 |
+
faced[b] = 0
|
| 128 |
+
faced["def_team"] = [opp.get((m, t)) for m, t in zip(faced["matchId"], faced["poss"])]
|
| 129 |
+
defb = faced.dropna(subset=["def_team"]).rename(columns={"H": "def_H", "M": "def_M", "L": "def_L",
|
| 130 |
+
"def_team": "teamId"})[["matchId", "teamId", "def_H", "def_M", "def_L"]]
|
| 131 |
+
|
| 132 |
+
# ── pasillos de ataque (pases del equipo en campo rival) ──
|
| 133 |
+
atk = df[df["is_pass"] & (df["x"] > 50) & df["y"].notna()]
|
| 134 |
+
lanes = atk.groupby(["matchId", "teamId", "lane"], observed=True).size().unstack("lane", fill_value=0).reset_index()
|
| 135 |
+
for ln in LANES:
|
| 136 |
+
if ln not in lanes.columns:
|
| 137 |
+
lanes[ln] = 0
|
| 138 |
+
lanes = lanes.rename(columns={ln: f"atk_{ln}" for ln in LANES})
|
| 139 |
+
|
| 140 |
+
# ── xT por (bloque × pasillo): ataque y defensa concedida ──
|
| 141 |
+
# Mergeamos seq SOLO sobre los eventos con xT>0 (una fracción) para no inflar memoria.
|
| 142 |
+
ev = df.loc[(df["xt"] > 0) & df["lane"].notna(),
|
| 143 |
+
["matchId", "period_id", "sequenceId", "lane", "xt"]].merge(
|
| 144 |
+
seq[["matchId", "period_id", "sequenceId", "poss", "blk"]].rename(columns={"blk": "seq_blk"}),
|
| 145 |
+
on=["matchId", "period_id", "sequenceId"], how="left")
|
| 146 |
+
ev = ev[ev["seq_blk"].notna()].copy()
|
| 147 |
+
# ataque: eventos del equipo en posesión, por (bloque, pasillo)
|
| 148 |
+
off = (ev.groupby(["matchId", "poss", "seq_blk", "lane"], observed=True)["xt"].sum()
|
| 149 |
+
.unstack(["seq_blk", "lane"], fill_value=0.0))
|
| 150 |
+
off.columns = [f"offxt_{b}_{l}" for b, l in off.columns]
|
| 151 |
+
off = off.reset_index().rename(columns={"poss": "teamId"})
|
| 152 |
+
# defensa: el rival ataca → este equipo (def) concede; blk = el que impuso el defensor
|
| 153 |
+
ev["def_team"] = [opp.get((m, t)) for m, t in zip(ev["matchId"], ev["poss"])]
|
| 154 |
+
deff = (ev.dropna(subset=["def_team"]).groupby(["matchId", "def_team", "seq_blk", "lane"], observed=True)["xt"].sum()
|
| 155 |
+
.unstack(["seq_blk", "lane"], fill_value=0.0))
|
| 156 |
+
deff.columns = [f"defxt_{b}_{l}" for b, l in deff.columns]
|
| 157 |
+
deff = deff.reset_index().rename(columns={"def_team": "teamId"})
|
| 158 |
+
|
| 159 |
+
# ── resultado, formación, posición/presión ──
|
| 160 |
+
df["formk"] = df["id_formation"].astype(str)
|
| 161 |
+
base = df.groupby(["matchId", "teamId"]).agg(
|
| 162 |
+
TeamName=("TeamName", "first"), fecha=("fecha", "first"), home_team_id=("home_team_id", "first"),
|
| 163 |
+
n_events=("event_name", "size"), n_pass=("is_pass", "sum"), n_shots=("is_shot", "sum"),
|
| 164 |
+
goals_for=("is_goal", "sum"), avg_x=("x", "mean"), avg_y=("y", "mean"),
|
| 165 |
+
share_opp_half=("opp_half", "mean"), n_long=("pass_long", "sum"), n_short=("pass_short", "sum"),
|
| 166 |
+
n_passok=("pass_ok", "sum"), n_progr=("progressive", "sum"),
|
| 167 |
+
formation=("formk", lambda s: s.mode().iloc[0] if len(s.mode()) else "0")).reset_index()
|
| 168 |
+
base["pct_long"] = base["n_long"] / base["n_pass"].clip(lower=1)
|
| 169 |
+
base["pct_short"] = base["n_short"] / base["n_pass"].clip(lower=1)
|
| 170 |
+
base["pct_pass_ok"] = base["n_passok"] / base["n_pass"].clip(lower=1)
|
| 171 |
+
# altura media de recuperación (eventos defensivos)
|
| 172 |
+
rec = df[df["event_name"].isin(["BallRecovery", "Interception", "Tackle"])].groupby(["matchId", "teamId"])["x"].mean().rename("recovery_height").reset_index()
|
| 173 |
+
|
| 174 |
+
base["Competencia"] = league; base["Temporada"] = season
|
| 175 |
+
out = base
|
| 176 |
+
for t in (atk_feats, defb, lanes, off, deff, rec):
|
| 177 |
+
out = out.merge(t, on=["matchId", "teamId"], how="left")
|
| 178 |
+
out["rival_teamId"] = [opp.get((m, t)) for m, t in zip(out["matchId"], out["teamId"])]
|
| 179 |
+
out["rival_name"] = out["rival_teamId"].map(name)
|
| 180 |
+
out["is_home"] = out["teamId"].astype(str) == out["home_team_id"].astype(str)
|
| 181 |
+
# goles en contra = goles del rival
|
| 182 |
+
gf = out.set_index(["matchId", "teamId"])["goals_for"]
|
| 183 |
+
out["goals_against"] = [gf.get((m, r), np.nan) if r is not None else np.nan
|
| 184 |
+
for m, r in zip(out["matchId"], out["rival_teamId"])]
|
| 185 |
+
fill0 = [c for c in out.columns if c.startswith(("def_", "atk_", "offxt_", "defxt_"))]
|
| 186 |
+
out[fill0] = out[fill0].fillna(0)
|
| 187 |
+
return out
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def _processed_keys() -> set:
|
| 191 |
+
if not OUT.exists():
|
| 192 |
+
return set()
|
| 193 |
+
d = pd.read_parquet(OUT, columns=["Competencia", "Temporada"])
|
| 194 |
+
return set(zip(d["Competencia"].astype(str), d["Temporada"].astype(str)))
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def _league_season_files(ds: DataStore):
|
| 198 |
+
fs = ds._filesystem_client()
|
| 199 |
+
root = ds.settings.azure_preprocessed_root.strip("/")
|
| 200 |
+
out = []
|
| 201 |
+
for p in fs.get_paths(path=root, recursive=True):
|
| 202 |
+
if getattr(p, "is_directory", False):
|
| 203 |
+
continue
|
| 204 |
+
name = getattr(p, "name", "") or ""
|
| 205 |
+
fn = name.split("/")[-1]
|
| 206 |
+
if fn.startswith("preprocessed_") and fn.endswith(".csv") and "etiquetado" not in fn:
|
| 207 |
+
parts = name[len(root):].strip("/").split("/")
|
| 208 |
+
if len(parts) >= 3:
|
| 209 |
+
out.append((parts[0], parts[1], fn, getattr(p, "content_length", 0) or 0))
|
| 210 |
+
return out
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def main() -> None:
|
| 214 |
+
ap = argparse.ArgumentParser()
|
| 215 |
+
ap.add_argument("--leagues", nargs="*", default=None)
|
| 216 |
+
ap.add_argument("--smallest", type=int, default=0)
|
| 217 |
+
ap.add_argument("--all", action="store_true")
|
| 218 |
+
ap.add_argument("--force", action="store_true")
|
| 219 |
+
args = ap.parse_args()
|
| 220 |
+
ds = DataStore()
|
| 221 |
+
files = _league_season_files(ds)
|
| 222 |
+
if args.leagues:
|
| 223 |
+
files = [f for f in files if f[0] in set(args.leagues)]
|
| 224 |
+
elif args.smallest:
|
| 225 |
+
files = sorted(files, key=lambda f: f[3])[:args.smallest]
|
| 226 |
+
elif not args.all:
|
| 227 |
+
ap.error("Pasá --leagues, --smallest N o --all")
|
| 228 |
+
done = set() if args.force else _processed_keys()
|
| 229 |
+
OUT.parent.mkdir(parents=True, exist_ok=True)
|
| 230 |
+
for lg, se, fn, sz in files:
|
| 231 |
+
if (lg, se) in done:
|
| 232 |
+
print(f"skip {lg}/{se}", flush=True); continue
|
| 233 |
+
print(f">>> {lg}/{se} ({sz/1e6:.0f} MB) — bajando…", flush=True)
|
| 234 |
+
fc = ds._filesystem_client().get_file_client(f"{ds.settings.azure_preprocessed_root}/{lg}/{se}/{fn}")
|
| 235 |
+
with tempfile.TemporaryDirectory() as td:
|
| 236 |
+
csv = Path(td) / fn
|
| 237 |
+
with open(csv, "wb") as fh:
|
| 238 |
+
fc.download_file(max_concurrency=8).readinto(fh) # descarga en paralelo
|
| 239 |
+
df = pd.read_csv(csv, usecols=lambda c: c in USECOLS, low_memory=False)
|
| 240 |
+
rows = _extract(df, lg, se)
|
| 241 |
+
if rows.empty:
|
| 242 |
+
print(f" {lg}/{se}: schema insuficiente, salteada", flush=True); continue
|
| 243 |
+
existing = [pd.read_parquet(OUT)] if OUT.exists() else []
|
| 244 |
+
pd.concat(existing + [rows], ignore_index=True).to_parquet(OUT, index=False)
|
| 245 |
+
print(f" {len(rows)} filas | {rows.shape[1]} cols | total {len(pd.read_parquet(OUT))}", flush=True)
|
| 246 |
+
print("OK")
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
if __name__ == "__main__":
|
| 250 |
+
main()
|
scripts/cmp_nn.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Etapa 2 de la comparación: lee /tmp/cmp.npz (arrays + preds LightGBM + baseline),
|
| 2 |
+
entrena la red neuronal multi-tarea (embeddings + softmax/KL para bloques y pasillos,
|
| 3 |
+
MSE para xT absoluto) e imprime la tabla final baseline / LightGBM / NN.
|
| 4 |
+
|
| 5 |
+
Corre en proceso APARTE de lightgbm (solo importa torch) para evitar el choque de
|
| 6 |
+
OpenMP. Uso: python scripts/cmp_nn.py
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
import torch
|
| 13 |
+
import torch.nn as nn
|
| 14 |
+
|
| 15 |
+
torch.manual_seed(7)
|
| 16 |
+
NPZ = "/tmp/cmp.npz"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _kl(a, b):
|
| 20 |
+
a = np.clip(a, 1e-9, None); b = np.clip(b, 1e-9, None)
|
| 21 |
+
return float(np.mean(np.sum(a * (np.log(a) - np.log(b)), 1)))
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _metrics(y, p, dist=True):
|
| 25 |
+
mae = float(np.mean(np.abs(y - p)))
|
| 26 |
+
if dist:
|
| 27 |
+
return {"MAE": round(mae, 4), "KL": round(_kl(y, p), 4)}
|
| 28 |
+
return {"MAE": round(mae, 4), "RMSE": round(float(np.sqrt(np.mean((y - p) ** 2))), 4)}
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class MTNet(nn.Module):
|
| 32 |
+
def __init__(self, n_num, n_team, n_lg, n_form):
|
| 33 |
+
super().__init__()
|
| 34 |
+
self.te = nn.Embedding(n_team, 16); self.le = nn.Embedding(n_lg, 4); self.fe = nn.Embedding(n_form, 4)
|
| 35 |
+
d = n_num + 16 + 16 + 4 + 4
|
| 36 |
+
self.trunk = nn.Sequential(nn.Linear(d, 128), nn.ReLU(), nn.Dropout(0.3),
|
| 37 |
+
nn.Linear(128, 64), nn.ReLU(), nn.Dropout(0.2))
|
| 38 |
+
self.h_blo = nn.Linear(64, 3); self.h_pas = nn.Linear(64, 3); self.h_xt = nn.Linear(64, 3)
|
| 39 |
+
|
| 40 |
+
def forward(self, x, ts, to, lg, fm):
|
| 41 |
+
z = torch.cat([x, self.te(ts), self.te(to), self.le(lg), self.fe(fm)], 1)
|
| 42 |
+
z = self.trunk(z)
|
| 43 |
+
return self.h_blo(z), self.h_pas(z), self.h_xt(z)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def main():
|
| 47 |
+
d = np.load(NPZ)
|
| 48 |
+
Xn = d["Xn"]; Yb, Yp, Yx = d["Yb"], d["Yp"], d["Yx"]
|
| 49 |
+
Bb, Bp, Bx = d["Bb"], d["Bp"], d["Bx"]
|
| 50 |
+
tr, va, te = d["tr"], d["va"], d["te"]
|
| 51 |
+
pb, pp, px = d["pb"], d["pp"], d["px"]
|
| 52 |
+
ts, to, lg, fm = d["ts"], d["to"], d["lg"], d["fm"]
|
| 53 |
+
nfeat = int(d["nfeat"])
|
| 54 |
+
|
| 55 |
+
print("--- Red neuronal (embeddings + multi-tarea) ---", flush=True)
|
| 56 |
+
T = lambda a: torch.tensor(a)
|
| 57 |
+
net = MTNet(nfeat, int(d["n_team"]), int(d["n_lg"]), int(d["n_form"]))
|
| 58 |
+
opt = torch.optim.Adam(net.parameters(), lr=2e-3, weight_decay=1e-4)
|
| 59 |
+
klf = nn.KLDivLoss(reduction="batchmean"); msef = nn.MSELoss()
|
| 60 |
+
Xt = T(Xn); tsT, toT, lgT, fmT = T(ts), T(to), T(lg), T(fm)
|
| 61 |
+
Yb_t, Yp_t, Yx_t = T(Yb), T(Yp), T(Yx)
|
| 62 |
+
tr_t, va_t, te_t = T(np.where(tr)[0]), T(np.where(va)[0]), T(np.where(te)[0])
|
| 63 |
+
best = (1e9, None)
|
| 64 |
+
for ep in range(300):
|
| 65 |
+
net.train(); opt.zero_grad()
|
| 66 |
+
b, p, x = net(Xt[tr_t], tsT[tr_t], toT[tr_t], lgT[tr_t], fmT[tr_t])
|
| 67 |
+
loss = (klf(torch.log_softmax(b, 1), Yb_t[tr_t]) + klf(torch.log_softmax(p, 1), Yp_t[tr_t])
|
| 68 |
+
+ 0.5 * msef(x, Yx_t[tr_t]))
|
| 69 |
+
loss.backward(); opt.step()
|
| 70 |
+
if ep % 10 == 0:
|
| 71 |
+
net.eval()
|
| 72 |
+
with torch.no_grad():
|
| 73 |
+
b, p, x = net(Xt[va_t], tsT[va_t], toT[va_t], lgT[va_t], fmT[va_t])
|
| 74 |
+
v = (klf(torch.log_softmax(b, 1), Yb_t[va_t]) + klf(torch.log_softmax(p, 1), Yp_t[va_t])).item()
|
| 75 |
+
if v < best[0]:
|
| 76 |
+
best = (v, {k: val.clone() for k, val in net.state_dict().items()})
|
| 77 |
+
net.load_state_dict(best[1]); net.eval()
|
| 78 |
+
with torch.no_grad():
|
| 79 |
+
b, p, x = net(Xt[te_t], tsT[te_t], toT[te_t], lgT[te_t], fmT[te_t])
|
| 80 |
+
nb = torch.softmax(b, 1).numpy(); npp = torch.softmax(p, 1).numpy(); nx = np.clip(x.numpy(), 0, None)
|
| 81 |
+
|
| 82 |
+
base = {"bloques": _metrics(Yb[te], Bb[te]), "pasillos": _metrics(Yp[te], Bp[te]), "xt": _metrics(Yx[te], Bx[te], dist=False)}
|
| 83 |
+
lgb_res = {"bloques": _metrics(Yb[te], pb), "pasillos": _metrics(Yp[te], pp), "xt": _metrics(Yx[te], px, dist=False)}
|
| 84 |
+
nn_res = {"bloques": _metrics(Yb[te], nb), "pasillos": _metrics(Yp[te], npp), "xt": _metrics(Yx[te], nx, dist=False)}
|
| 85 |
+
|
| 86 |
+
print("\n================ COMPARACIÓN (test temporal) ================", flush=True)
|
| 87 |
+
for k in ("bloques", "pasillos", "xt"):
|
| 88 |
+
print(f"\n{k}:")
|
| 89 |
+
print(f" baseline : {base[k]}")
|
| 90 |
+
print(f" LightGBM : {lgb_res[k]}")
|
| 91 |
+
print(f" NN : {nn_res[k]}")
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
if __name__ == "__main__":
|
| 95 |
+
main()
|
scripts/cmp_prep.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Etapa 1 de la comparación: arma el split TEMPORAL, entrena LightGBM y guarda
|
| 2 |
+
TODO lo necesario (arrays + predicciones LGB + baseline) en /tmp/cmp.npz para que
|
| 3 |
+
la red neuronal corra en un proceso APARTE (evita el deadlock/segfault de OpenMP
|
| 4 |
+
entre lightgbm y torch en el mismo proceso).
|
| 5 |
+
|
| 6 |
+
Uso: python scripts/cmp_prep.py
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import importlib.util
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
import lightgbm as lgb
|
| 15 |
+
import numpy as np
|
| 16 |
+
import pandas as pd
|
| 17 |
+
|
| 18 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 19 |
+
spec = importlib.util.spec_from_file_location("tm", ROOT / "scripts" / "train_models.py")
|
| 20 |
+
tm = importlib.util.module_from_spec(spec); spec.loader.exec_module(tm)
|
| 21 |
+
RNG = np.random.default_rng(7)
|
| 22 |
+
NPZ = Path("/tmp/cmp.npz")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _lgb_dist(Xtr, Ytr, Xva, Yva, Xte):
|
| 26 |
+
preds = []
|
| 27 |
+
for j in range(Ytr.shape[1]):
|
| 28 |
+
m = lgb.LGBMRegressor(n_estimators=700, learning_rate=0.025, num_leaves=31,
|
| 29 |
+
subsample=0.8, colsample_bytree=0.5, min_child_samples=40, verbose=-1)
|
| 30 |
+
m.fit(Xtr, Ytr[:, j], eval_set=[(Xva, Yva[:, j])], callbacks=[lgb.early_stopping(50, verbose=False)])
|
| 31 |
+
preds.append(np.clip(m.predict(Xte), 0, None))
|
| 32 |
+
return np.vstack(preds).T
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def main():
|
| 36 |
+
df = pd.read_parquet(tm.DATA)
|
| 37 |
+
blk = df.assign(_b=df[tm.DEF].sum(axis=1) > 0).groupby("Competencia")["_b"].mean()
|
| 38 |
+
df = df[df["Competencia"].isin(blk[blk > 0.5].index)].copy()
|
| 39 |
+
m, feat = tm._assemble(df)
|
| 40 |
+
m = m.merge(df[["matchId", "teamId", "fecha"]].drop_duplicates(), on=["matchId", "teamId"], how="left")
|
| 41 |
+
m["fecha"] = pd.to_datetime(m["fecha"].astype(str).str[:10], errors="coerce") # quita sufijo 'Z'
|
| 42 |
+
thr = m.groupby(["Competencia", "Temporada"])["fecha"].transform(lambda s: s.quantile(0.8))
|
| 43 |
+
m["is_test"] = (m["fecha"] > thr).fillna(False)
|
| 44 |
+
|
| 45 |
+
m = m.dropna(subset=["self_" + c for c in tm.DEF]).copy()
|
| 46 |
+
valid = (m[tm.DEF].sum(1) > 0) & (m[tm.ATK].sum(1) > 0)
|
| 47 |
+
m = m[valid].copy()
|
| 48 |
+
|
| 49 |
+
Yb, _ = tm._norm_df(m, tm.DEF); Yb = Yb.to_numpy(np.float32)
|
| 50 |
+
Yp, _ = tm._norm_df(m, tm.ATK); Yp = Yp.to_numpy(np.float32)
|
| 51 |
+
Yx = m[tm.XTLANE].to_numpy(np.float32)
|
| 52 |
+
Bb = m[["self_" + c for c in tm.DEF]].to_numpy(np.float32); Bb = Bb / np.clip(Bb.sum(1, keepdims=True), 1e-9, None)
|
| 53 |
+
Bp = m[["self_" + c for c in tm.ATK]].to_numpy(np.float32); Bp = Bp / np.clip(Bp.sum(1, keepdims=True), 1e-9, None)
|
| 54 |
+
Bx = m[["self_" + c for c in tm.XTLANE]].to_numpy(np.float32)
|
| 55 |
+
|
| 56 |
+
X = m[feat].to_numpy(np.float32); X = np.nan_to_num(X)
|
| 57 |
+
te = m["is_test"].to_numpy()
|
| 58 |
+
tr_all = ~te
|
| 59 |
+
mu, sd = X[tr_all].mean(0), X[tr_all].std(0) + 1e-6
|
| 60 |
+
Xn = ((X - mu) / sd).astype(np.float32)
|
| 61 |
+
tr_idx = np.where(tr_all)[0]
|
| 62 |
+
va = np.zeros(len(m), bool); va[RNG.choice(tr_idx, int(len(tr_idx) * 0.12), replace=False)] = True
|
| 63 |
+
tr = tr_all & ~va
|
| 64 |
+
print(f"filas: {len(m)} | train {tr.sum()} val {va.sum()} test {te.sum()} | features {len(feat)}", flush=True)
|
| 65 |
+
|
| 66 |
+
print("--- LightGBM (split temporal) ---", flush=True)
|
| 67 |
+
pb = _lgb_dist(Xn[tr], Yb[tr], Xn[va], Yb[va], Xn[te]); pb = pb / np.clip(pb.sum(1, keepdims=True), 1e-9, None)
|
| 68 |
+
pp = _lgb_dist(Xn[tr], Yp[tr], Xn[va], Yp[va], Xn[te]); pp = pp / np.clip(pp.sum(1, keepdims=True), 1e-9, None)
|
| 69 |
+
px = _lgb_dist(Xn[tr], Yx[tr], Xn[va], Yx[va], Xn[te])
|
| 70 |
+
print("LightGBM listo, guardando arrays...", flush=True)
|
| 71 |
+
|
| 72 |
+
# embeddings para la red
|
| 73 |
+
tcodes = pd.factorize(pd.concat([m["teamId"], m["rival_teamId"]]).astype(str))[1]
|
| 74 |
+
tmap = {v: i + 1 for i, v in enumerate(tcodes)}
|
| 75 |
+
ts = m["teamId"].astype(str).map(tmap).fillna(0).astype(int).to_numpy()
|
| 76 |
+
to = m["rival_teamId"].astype(str).map(tmap).fillna(0).astype(int).to_numpy()
|
| 77 |
+
lg = pd.factorize(m["Competencia"])[0] + 1
|
| 78 |
+
fm = pd.factorize(m["formation"].astype(str))[0] + 1
|
| 79 |
+
|
| 80 |
+
np.savez(NPZ, Xn=Xn, Yb=Yb, Yp=Yp, Yx=Yx, Bb=Bb, Bp=Bp, Bx=Bx,
|
| 81 |
+
tr=tr, va=va, te=te, pb=pb, pp=pp, px=px,
|
| 82 |
+
ts=ts, to=to, lg=lg, fm=fm, nfeat=len(feat),
|
| 83 |
+
n_team=len(tmap) + 1, n_lg=int(lg.max()) + 1, n_form=int(fm.max()) + 1)
|
| 84 |
+
print("guardado →", NPZ, flush=True)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
if __name__ == "__main__":
|
| 88 |
+
main()
|
scripts/gen_sl_def_profile.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Genera el lollipop de PERFIL DEFENSIVO de San Lorenzo (idéntico al de la sección
|
| 2 |
+
'Equipos y variables' del Space) y lo guarda como PNG para la presentación.
|
| 3 |
+
Verde = z del equipo; diamante amarillo = mejor de la liga; columnas z y ranking."""
|
| 4 |
+
import sys
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
| 8 |
+
import matplotlib
|
| 9 |
+
matplotlib.use("Agg")
|
| 10 |
+
import matplotlib.pyplot as plt
|
| 11 |
+
from matplotlib.lines import Line2D
|
| 12 |
+
from matplotlib.markers import MarkerStyle
|
| 13 |
+
|
| 14 |
+
from racing_reports import team_vars as tv
|
| 15 |
+
|
| 16 |
+
LEAGUE, SEASON, TEAM = "Liga Profesional Argentina", "26", "San Lorenzo"
|
| 17 |
+
VARS = [
|
| 18 |
+
"goles_transicion_rival", "situacion_fast_break_rival", "acciones_defensivas_en_ofensiva",
|
| 19 |
+
"agresividad_offside", "altura_media_recuperacion", "avg_altura_offside", "ballrecovery",
|
| 20 |
+
"big_chances_generadas_rival", "clearance_totales", "duelos_aereos_ganados",
|
| 21 |
+
"goles_regular_play_rival", "goles_tempranos_rival", "goles_totales_rival", "intercepciones",
|
| 22 |
+
"pases_peligrosos_exitosos_con_centros_rival", "pases_progresivos_exitosos_rival",
|
| 23 |
+
"pct_centros_pases_totales_ultimo_tercio_rival", "pct_duelos_aereos_ganados",
|
| 24 |
+
"pct_pases_exitosos_t3_rival", "velocidad_progresion_m_por_s_rival", "xg_suma_tiros_rival",
|
| 25 |
+
]
|
| 26 |
+
|
| 27 |
+
BG = "#1b3a32"; GREEN = "#3ddc84"; RED = "#e8584e"; YEL = "#f2c14e"; TXT = "#e8eeea"; MUT = "#9fb4ab"
|
| 28 |
+
|
| 29 |
+
prof = tv.team_profile(LEAGUE, SEASON, TEAM, VARS, scope="liga")
|
| 30 |
+
items = prof["items"]
|
| 31 |
+
n = prof["pool_size"]
|
| 32 |
+
items = items[::-1] # primera variable arriba
|
| 33 |
+
|
| 34 |
+
fig, ax = plt.subplots(figsize=(13, 9), facecolor=BG)
|
| 35 |
+
ax.set_facecolor(BG)
|
| 36 |
+
y = range(len(items))
|
| 37 |
+
for i, it in enumerate(items):
|
| 38 |
+
z = it["z"] or 0.0
|
| 39 |
+
c = GREEN if z >= 0 else RED
|
| 40 |
+
ax.plot([0, z], [i, i], color=c, lw=2.5, zorder=2, solid_capstyle="round")
|
| 41 |
+
ax.scatter([z], [i], s=130, color=c, zorder=3, edgecolors=BG, linewidths=1.5)
|
| 42 |
+
mz = it.get("mejor_liga_z")
|
| 43 |
+
if mz is not None:
|
| 44 |
+
ax.scatter([mz], [i], marker=MarkerStyle("D"), s=95, color=YEL, zorder=4,
|
| 45 |
+
edgecolors=BG, linewidths=1.2)
|
| 46 |
+
# etiqueta izquierda (con flecha ↓ si negativa)
|
| 47 |
+
lab = ("↓" if it["negativa"] else "") + it["label"]
|
| 48 |
+
if len(lab) > 32:
|
| 49 |
+
lab = lab[:31] + "…"
|
| 50 |
+
ax.text(-3.25, i, lab, ha="right", va="center", color=(YEL if it["negativa"] else TXT), fontsize=11)
|
| 51 |
+
# z y ranking a la derecha
|
| 52 |
+
ax.text(3.55, i, f"{z:+.2f}", ha="right", va="center", color=c, fontsize=11, fontweight="bold")
|
| 53 |
+
ax.text(4.35, i, f"{it['rank']}/{n}", ha="right", va="center", color=MUT, fontsize=10)
|
| 54 |
+
|
| 55 |
+
ax.axvline(0, color=MUT, lw=1, ls=(0, (4, 3)), alpha=0.6, zorder=1)
|
| 56 |
+
for xg in (-3, 3):
|
| 57 |
+
ax.axvline(xg, color="#33514a", lw=0.8, alpha=0.5, zorder=0)
|
| 58 |
+
ax.set_xlim(-3.2, 3.2); ax.set_ylim(-0.7, len(items) - 0.3)
|
| 59 |
+
ax.set_yticks([]); ax.set_xticks([-3, 0, 3]); ax.set_xticklabels(["-3", "0", "+3"], color=MUT, fontsize=10)
|
| 60 |
+
for s in ax.spines.values():
|
| 61 |
+
s.set_visible(False)
|
| 62 |
+
ax.text(3.55, len(items) - 0.1, "z", ha="right", va="center", color=MUT, fontsize=10)
|
| 63 |
+
ax.text(4.35, len(items) - 0.1, "ranking", ha="right", va="center", color=MUT, fontsize=10)
|
| 64 |
+
ax.set_title(f"San Lorenzo (Liga Profesional Argentina · 26) · vs su liga-temporada · {n} equipos",
|
| 65 |
+
color=TXT, fontsize=14, fontweight="bold", loc="left", pad=16)
|
| 66 |
+
leg = [Line2D([0], [0], marker="o", color=BG, markerfacecolor=GREEN, markersize=11, label="Equipo"),
|
| 67 |
+
Line2D([0], [0], marker="D", color=BG, markerfacecolor=YEL, markersize=10, label="Mejor de la liga")]
|
| 68 |
+
ax.legend(handles=leg, loc="lower left", fontsize=10, facecolor="#16251e",
|
| 69 |
+
edgecolor="#33514a", labelcolor=TXT, ncol=2, bbox_to_anchor=(0.0, -0.06))
|
| 70 |
+
|
| 71 |
+
out = Path(__file__).resolve().parents[1].parent / "Downloads" / "presentation-sanlorenzo" / "san_lorenzo" / "perfil_defensivo.png"
|
| 72 |
+
out = Path("/Users/pagrois/Downloads/presentation-sanlorenzo/san_lorenzo/perfil_defensivo.png")
|
| 73 |
+
fig.subplots_adjust(left=0.30, right=0.96, top=0.92, bottom=0.06)
|
| 74 |
+
fig.savefig(out, dpi=130, facecolor=BG)
|
| 75 |
+
print("guardado:", out, "| pool:", n, "| vars:", len(items))
|
scripts/train_compare.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Compara LightGBM vs una red neuronal multi-tarea (embeddings + softmax/KL) en el
|
| 2 |
+
MISMO split TEMPORAL (test = último ~20% de fechas de cada liga-temporada), sobre los
|
| 3 |
+
3 objetivos: bloque defensivo, pasillos (share) y xT absoluto por pasillo.
|
| 4 |
+
|
| 5 |
+
Uso: python scripts/train_compare.py
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import importlib.util
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
import lightgbm as lgb
|
| 14 |
+
import numpy as np
|
| 15 |
+
import pandas as pd
|
| 16 |
+
import torch
|
| 17 |
+
import torch.nn as nn
|
| 18 |
+
|
| 19 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 20 |
+
spec = importlib.util.spec_from_file_location("tm", ROOT / "scripts" / "train_models.py")
|
| 21 |
+
tm = importlib.util.module_from_spec(spec); spec.loader.exec_module(tm)
|
| 22 |
+
RNG = np.random.default_rng(7)
|
| 23 |
+
torch.manual_seed(7)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _kl(a, b):
|
| 27 |
+
a = np.clip(a, 1e-9, None); b = np.clip(b, 1e-9, None)
|
| 28 |
+
return float(np.mean(np.sum(a * (np.log(a) - np.log(b)), 1)))
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _metrics(y, p, dist=True):
|
| 32 |
+
mae = float(np.mean(np.abs(y - p)))
|
| 33 |
+
if dist:
|
| 34 |
+
return {"MAE": round(mae, 4), "KL": round(_kl(y, p), 4)}
|
| 35 |
+
return {"MAE": round(mae, 4), "RMSE": round(float(np.sqrt(np.mean((y - p) ** 2))), 4)}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _lgb_dist(Xtr, Ytr, Xva, Yva, Xte):
|
| 39 |
+
preds = []
|
| 40 |
+
for j in range(Ytr.shape[1]):
|
| 41 |
+
m = lgb.LGBMRegressor(n_estimators=700, learning_rate=0.025, num_leaves=31,
|
| 42 |
+
subsample=0.8, colsample_bytree=0.5, min_child_samples=40, verbose=-1)
|
| 43 |
+
m.fit(Xtr, Ytr[:, j], eval_set=[(Xva, Yva[:, j])], callbacks=[lgb.early_stopping(50, verbose=False)])
|
| 44 |
+
preds.append(np.clip(m.predict(Xte), 0, None))
|
| 45 |
+
return np.vstack(preds).T
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class MTNet(nn.Module):
|
| 49 |
+
def __init__(self, n_num, n_team, n_lg, n_form):
|
| 50 |
+
super().__init__()
|
| 51 |
+
self.te = nn.Embedding(n_team, 16); self.le = nn.Embedding(n_lg, 4); self.fe = nn.Embedding(n_form, 4)
|
| 52 |
+
d = n_num + 16 + 16 + 4 + 4
|
| 53 |
+
self.trunk = nn.Sequential(nn.Linear(d, 128), nn.ReLU(), nn.Dropout(0.3),
|
| 54 |
+
nn.Linear(128, 64), nn.ReLU(), nn.Dropout(0.2))
|
| 55 |
+
self.h_blo = nn.Linear(64, 3); self.h_pas = nn.Linear(64, 3); self.h_xt = nn.Linear(64, 3)
|
| 56 |
+
|
| 57 |
+
def forward(self, x, ts, to, lg, fm):
|
| 58 |
+
z = torch.cat([x, self.te(ts), self.te(to), self.le(lg), self.fe(fm)], 1)
|
| 59 |
+
z = self.trunk(z)
|
| 60 |
+
return self.h_blo(z), self.h_pas(z), self.h_xt(z)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def main():
|
| 64 |
+
df = pd.read_parquet(tm.DATA)
|
| 65 |
+
blk = df.assign(_b=df[tm.DEF].sum(axis=1) > 0).groupby("Competencia")["_b"].mean()
|
| 66 |
+
df = df[df["Competencia"].isin(blk[blk > 0.5].index)].copy()
|
| 67 |
+
m, feat = tm._assemble(df)
|
| 68 |
+
m = m.merge(df[["matchId", "teamId", "fecha"]].drop_duplicates(), on=["matchId", "teamId"], how="left")
|
| 69 |
+
m["fecha"] = pd.to_datetime(m["fecha"].astype(str).str[:10], errors="coerce") # quita sufijo 'Z'
|
| 70 |
+
thr = m.groupby(["Competencia", "Temporada"])["fecha"].transform(lambda s: s.quantile(0.8))
|
| 71 |
+
m["is_test"] = (m["fecha"] > thr).fillna(False)
|
| 72 |
+
|
| 73 |
+
# filas con historial rolling + bloque válido
|
| 74 |
+
m = m.dropna(subset=["self_" + c for c in tm.DEF]).copy()
|
| 75 |
+
valid = (m[tm.DEF].sum(1) > 0) & (m[tm.ATK].sum(1) > 0)
|
| 76 |
+
m = m[valid].copy()
|
| 77 |
+
|
| 78 |
+
# targets
|
| 79 |
+
Yb, _ = tm._norm_df(m, tm.DEF); Yb = Yb.to_numpy(np.float32)
|
| 80 |
+
Yp, _ = tm._norm_df(m, tm.ATK); Yp = Yp.to_numpy(np.float32)
|
| 81 |
+
Yx = m[tm.XTLANE].to_numpy(np.float32)
|
| 82 |
+
# baseline = promedio propio rolling
|
| 83 |
+
Bb = m[["self_" + c for c in tm.DEF]].to_numpy(np.float32); Bb = Bb / np.clip(Bb.sum(1, keepdims=True), 1e-9, None)
|
| 84 |
+
Bp = m[["self_" + c for c in tm.ATK]].to_numpy(np.float32); Bp = Bp / np.clip(Bp.sum(1, keepdims=True), 1e-9, None)
|
| 85 |
+
Bx = m[["self_" + c for c in tm.XTLANE]].to_numpy(np.float32)
|
| 86 |
+
|
| 87 |
+
X = m[feat].to_numpy(np.float32); X = np.nan_to_num(X)
|
| 88 |
+
te = m["is_test"].to_numpy()
|
| 89 |
+
tr_all = ~te
|
| 90 |
+
mu, sd = X[tr_all].mean(0), X[tr_all].std(0) + 1e-6
|
| 91 |
+
Xn = (X - mu) / sd
|
| 92 |
+
# val carve del train para early stopping
|
| 93 |
+
tr_idx = np.where(tr_all)[0]; va = np.zeros(len(m), bool); va[RNG.choice(tr_idx, int(len(tr_idx) * 0.12), replace=False)] = True
|
| 94 |
+
tr = tr_all & ~va
|
| 95 |
+
print(f"filas: {len(m)} | train {tr.sum()} val {va.sum()} test {te.sum()} | features {len(feat)}")
|
| 96 |
+
|
| 97 |
+
# ===== LightGBM =====
|
| 98 |
+
print("\n--- LightGBM (split temporal) ---")
|
| 99 |
+
pb = _lgb_dist(Xn[tr], Yb[tr], Xn[va], Yb[va], Xn[te]); pb = pb / np.clip(pb.sum(1, keepdims=True), 1e-9, None)
|
| 100 |
+
pp = _lgb_dist(Xn[tr], Yp[tr], Xn[va], Yp[va], Xn[te]); pp = pp / np.clip(pp.sum(1, keepdims=True), 1e-9, None)
|
| 101 |
+
px = _lgb_dist(Xn[tr], Yx[tr], Xn[va], Yx[va], Xn[te])
|
| 102 |
+
lgb_res = {"bloques": _metrics(Yb[te], pb), "pasillos": _metrics(Yp[te], pp), "xt": _metrics(Yx[te], px, dist=False)}
|
| 103 |
+
|
| 104 |
+
# ===== NN multi-tarea =====
|
| 105 |
+
print("--- Red neuronal (embeddings + multi-tarea) ---")
|
| 106 |
+
tcodes = pd.factorize(pd.concat([m["teamId"], m["rival_teamId"]]).astype(str))[1]
|
| 107 |
+
tmap = {v: i + 1 for i, v in enumerate(tcodes)}
|
| 108 |
+
ts = m["teamId"].astype(str).map(tmap).fillna(0).astype(int).to_numpy()
|
| 109 |
+
to = m["rival_teamId"].astype(str).map(tmap).fillna(0).astype(int).to_numpy()
|
| 110 |
+
lg = pd.factorize(m["Competencia"])[0] + 1
|
| 111 |
+
fm = pd.factorize(m["formation"].astype(str))[0] + 1
|
| 112 |
+
T = lambda a: torch.tensor(a)
|
| 113 |
+
net = MTNet(len(feat), len(tmap) + 1, lg.max() + 1, fm.max() + 1)
|
| 114 |
+
opt = torch.optim.Adam(net.parameters(), lr=2e-3, weight_decay=1e-4)
|
| 115 |
+
klf = nn.KLDivLoss(reduction="batchmean"); msef = nn.MSELoss()
|
| 116 |
+
Xt = T(Xn); tsT, toT, lgT, fmT = T(ts), T(to), T(lg), T(fm)
|
| 117 |
+
Yb_t, Yp_t, Yx_t = T(Yb), T(Yp), T(Yx)
|
| 118 |
+
tr_t, va_t, te_t = T(np.where(tr)[0]), T(np.where(va)[0]), T(np.where(te)[0])
|
| 119 |
+
best = (1e9, None)
|
| 120 |
+
for ep in range(300):
|
| 121 |
+
net.train(); opt.zero_grad()
|
| 122 |
+
b, p, x = net(Xt[tr_t], tsT[tr_t], toT[tr_t], lgT[tr_t], fmT[tr_t])
|
| 123 |
+
loss = (klf(torch.log_softmax(b, 1), Yb_t[tr_t]) + klf(torch.log_softmax(p, 1), Yp_t[tr_t])
|
| 124 |
+
+ 0.5 * msef(x, Yx_t[tr_t]))
|
| 125 |
+
loss.backward(); opt.step()
|
| 126 |
+
if ep % 10 == 0:
|
| 127 |
+
net.eval()
|
| 128 |
+
with torch.no_grad():
|
| 129 |
+
b, p, x = net(Xt[va_t], tsT[va_t], toT[va_t], lgT[va_t], fmT[va_t])
|
| 130 |
+
v = (klf(torch.log_softmax(b, 1), Yb_t[va_t]) + klf(torch.log_softmax(p, 1), Yp_t[va_t])).item()
|
| 131 |
+
if v < best[0]:
|
| 132 |
+
best = (v, {k: val.clone() for k, val in net.state_dict().items()})
|
| 133 |
+
net.load_state_dict(best[1]); net.eval()
|
| 134 |
+
with torch.no_grad():
|
| 135 |
+
b, p, x = net(Xt[te_t], tsT[te_t], toT[te_t], lgT[te_t], fmT[te_t])
|
| 136 |
+
nb = torch.softmax(b, 1).numpy(); npp = torch.softmax(p, 1).numpy(); nx = np.clip(x.numpy(), 0, None)
|
| 137 |
+
nn_res = {"bloques": _metrics(Yb[te], nb), "pasillos": _metrics(Yp[te], npp), "xt": _metrics(Yx[te], nx, dist=False)}
|
| 138 |
+
|
| 139 |
+
base = {"bloques": _metrics(Yb[te], Bb[te]), "pasillos": _metrics(Yp[te], Bp[te]), "xt": _metrics(Yx[te], Bx[te], dist=False)}
|
| 140 |
+
print("\n================ COMPARACIÓN (test temporal) ================")
|
| 141 |
+
for k in ("bloques", "pasillos", "xt"):
|
| 142 |
+
print(f"\n{k}:")
|
| 143 |
+
print(f" baseline : {base[k]}")
|
| 144 |
+
print(f" LightGBM : {lgb_res[k]}")
|
| 145 |
+
print(f" NN : {nn_res[k]}")
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
if __name__ == "__main__":
|
| 149 |
+
main()
|
src/racing_reports/api/routes_lowblock.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Endpoints de la sección "Recursos bloque bajo"."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 6 |
+
|
| 7 |
+
from racing_reports import lowblock_data
|
| 8 |
+
from racing_reports.api.deps import require_auth
|
| 9 |
+
|
| 10 |
+
router = APIRouter(prefix="/api/lowblock")
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@router.get("/options", dependencies=[Depends(require_auth)])
|
| 14 |
+
def options() -> dict:
|
| 15 |
+
try:
|
| 16 |
+
return lowblock_data.options()
|
| 17 |
+
except FileNotFoundError:
|
| 18 |
+
raise HTTPException(status_code=404, detail="Sin artefacto de recursos bloque bajo.")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@router.get("/data", dependencies=[Depends(require_auth)])
|
| 22 |
+
def data(league: str = "__all__") -> dict:
|
| 23 |
+
return {"rows": lowblock_data.scatter_rows(league)}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@router.get("/team", dependencies=[Depends(require_auth)])
|
| 27 |
+
def team(league: str, equipo: str) -> dict:
|
| 28 |
+
try:
|
| 29 |
+
return lowblock_data.team_tables(league, equipo)
|
| 30 |
+
except ValueError as exc:
|
| 31 |
+
raise HTTPException(status_code=404, detail=str(exc))
|
src/racing_reports/lowblock_data.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Datos de la sección "Recursos bloque bajo" (scatter + tablas por equipo).
|
| 2 |
+
|
| 3 |
+
Lee el artefacto bundleado ``vendor/data/lowblock/team_resources.parquet``:
|
| 4 |
+
una fila por (liga, equipo, lado of/def, recurso) con las métricas de intento
|
| 5 |
+
(n, % éxito, pv por jugada) y de resultado por secuencia (tiros/jugada, xG, goles).
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from functools import lru_cache
|
| 11 |
+
|
| 12 |
+
import pandas as pd
|
| 13 |
+
|
| 14 |
+
from racing_reports import vendor_env
|
| 15 |
+
|
| 16 |
+
PATH = vendor_env.DATA_DIR / "lowblock" / "team_resources.parquet"
|
| 17 |
+
|
| 18 |
+
RECURSO_LABELS = {
|
| 19 |
+
"pase_entre_lineas": "Pase entre líneas",
|
| 20 |
+
"cutback": "Cutback",
|
| 21 |
+
"centro_colgado": "Centro colgado",
|
| 22 |
+
"centro_inswinger": "Centro inswinger",
|
| 23 |
+
"centro_outswinger": "Centro outswinger",
|
| 24 |
+
"centro_raso": "Centro raso",
|
| 25 |
+
"rompe_lineas_ok": "Rompe-líneas (completado)",
|
| 26 |
+
"gambeta": "Gambeta (1v1)",
|
| 27 |
+
"tiro_lejano": "Tiro larga distancia",
|
| 28 |
+
}
|
| 29 |
+
METRICA_LABELS = {
|
| 30 |
+
"pv_jugada": "Peligro por jugada (pv ‰)",
|
| 31 |
+
"tiros_jugada": "Tiros por jugada",
|
| 32 |
+
"xg_acum": "xG acumulado",
|
| 33 |
+
"xg_jugada": "xG por jugada",
|
| 34 |
+
"xg_jugada_exitosa": "xG por jugada exitosa",
|
| 35 |
+
"goles": "Goles",
|
| 36 |
+
"pct_goles": "% de goles",
|
| 37 |
+
"pct_exito": "% de éxito",
|
| 38 |
+
"n_intentos": "Cantidad de intentos",
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@lru_cache(maxsize=1)
|
| 43 |
+
def _df() -> pd.DataFrame:
|
| 44 |
+
return pd.read_parquet(PATH)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def options() -> dict:
|
| 48 |
+
df = _df()
|
| 49 |
+
return {
|
| 50 |
+
"leagues": sorted(df["liga"].unique().tolist()),
|
| 51 |
+
"recursos": RECURSO_LABELS,
|
| 52 |
+
"metricas": METRICA_LABELS,
|
| 53 |
+
"lados": {"of": "Ofensivo", "def": "Defensivo"},
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def scatter_rows(league: str) -> list[dict]:
|
| 58 |
+
df = _df()
|
| 59 |
+
if league and league != "__all__":
|
| 60 |
+
df = df[df["liga"] == league]
|
| 61 |
+
cols = ["liga", "equipo", "lado", "recurso", "n_intentos", "pct_exito", "pv_jugada",
|
| 62 |
+
"tiros_jugada", "xg_acum", "xg_jugada", "xg_jugada_exitosa", "goles", "pct_goles"]
|
| 63 |
+
out = df[cols].where(pd.notna(df[cols]), None)
|
| 64 |
+
return out.to_dict("records")
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def team_tables(league: str, equipo: str) -> dict:
|
| 68 |
+
df = _df()
|
| 69 |
+
liga_df = df[df["liga"] == league]
|
| 70 |
+
if liga_df.empty:
|
| 71 |
+
raise ValueError(f"Liga sin datos: {league}")
|
| 72 |
+
# medias de la liga ponderadas por intentos (para la referencia "vs liga")
|
| 73 |
+
out = {}
|
| 74 |
+
for lado in ("of", "def"):
|
| 75 |
+
sub = liga_df[liga_df["lado"] == lado]
|
| 76 |
+
medias = {}
|
| 77 |
+
for rec, g in sub.groupby("recurso"):
|
| 78 |
+
w = g["n_intentos"].clip(lower=1)
|
| 79 |
+
medias[rec] = {
|
| 80 |
+
"pv_jugada": round(float((g["pv_jugada"] * w).sum() / w.sum()), 2),
|
| 81 |
+
"pct_exito": round(float((g["pct_exito"] * w).sum() / w.sum()), 1),
|
| 82 |
+
"xg_jugada": round(float((g["xg_jugada"].fillna(0) * w).sum() / w.sum()), 4),
|
| 83 |
+
}
|
| 84 |
+
rows = sub[sub["equipo"] == equipo].sort_values("pv_jugada", ascending=False)
|
| 85 |
+
out[lado] = {
|
| 86 |
+
"rows": rows.where(pd.notna(rows), None).to_dict("records"),
|
| 87 |
+
"liga_media": medias,
|
| 88 |
+
}
|
| 89 |
+
return {"league": league, "equipo": equipo,
|
| 90 |
+
"recursos": RECURSO_LABELS, **out}
|
src/racing_reports/web/app.py
CHANGED
|
@@ -22,6 +22,7 @@ from racing_reports.api.routes_meta import router as api_meta_router
|
|
| 22 |
from racing_reports.api.routes_reports import router as api_reports_router
|
| 23 |
from racing_reports.api.routes_team_vars import router as api_team_vars_router
|
| 24 |
from racing_reports.api.routes_ejes import router as api_ejes_router
|
|
|
|
| 25 |
from racing_reports.config import DEFAULT_SETTINGS
|
| 26 |
from racing_reports.datastore import DataStore
|
| 27 |
from racing_reports.match_index import MatchIndex
|
|
@@ -49,6 +50,7 @@ app.include_router(api_reports_router)
|
|
| 49 |
app.include_router(api_jobs_router)
|
| 50 |
app.include_router(api_team_vars_router)
|
| 51 |
app.include_router(api_ejes_router)
|
|
|
|
| 52 |
|
| 53 |
# Estado en memoria (single container en HF Space). Re-export para retrocompat.
|
| 54 |
Job = jobs_mod.Job
|
|
|
|
| 22 |
from racing_reports.api.routes_reports import router as api_reports_router
|
| 23 |
from racing_reports.api.routes_team_vars import router as api_team_vars_router
|
| 24 |
from racing_reports.api.routes_ejes import router as api_ejes_router
|
| 25 |
+
from racing_reports.api.routes_lowblock import router as api_lowblock_router
|
| 26 |
from racing_reports.config import DEFAULT_SETTINGS
|
| 27 |
from racing_reports.datastore import DataStore
|
| 28 |
from racing_reports.match_index import MatchIndex
|
|
|
|
| 50 |
app.include_router(api_jobs_router)
|
| 51 |
app.include_router(api_team_vars_router)
|
| 52 |
app.include_router(api_ejes_router)
|
| 53 |
+
app.include_router(api_lowblock_router)
|
| 54 |
|
| 55 |
# Estado en memoria (single container en HF Space). Re-export para retrocompat.
|
| 56 |
Job = jobs_mod.Job
|
src/racing_reports/web/templates/home.html
CHANGED
|
@@ -504,6 +504,104 @@
|
|
| 504 |
<div id="ejtip" style="position:absolute;display:none;pointer-events:none;background:#1f2937;border:1px solid #4b5563;border-radius:8px;padding:6px 10px;font-size:12px;max-width:230px;z-index:10"></div>
|
| 505 |
</div>
|
| 506 |
</section>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 507 |
</div>
|
| 508 |
</template>
|
| 509 |
|
|
@@ -619,6 +717,12 @@
|
|
| 619 |
ejX:'elaboracion', ejY:'verticalidad', ejLado:'gen', ejMet:'z', ejData:null, ejError:'', ejLoaded:false,
|
| 620 |
ejPostMsg:'', ejPostMsgError:false,
|
| 621 |
mlLeagues: [], ejPartidos: {},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 622 |
|
| 623 |
async init() { await this.loadLeagues(); await this.loadSeasons(false); await this.loadTeams(); this._refreshHealth(); },
|
| 624 |
async _getJSON(u){ const r=await fetch(u); if(!r.ok) throw new Error((await r.json().catch(()=>({detail:r.statusText}))).detail); return r.json(); },
|
|
@@ -649,6 +753,7 @@
|
|
| 649 |
this.tvLoaded = true;
|
| 650 |
} catch(e){ this.tvError = 'No se pudieron cargar las opciones: '+e; }
|
| 651 |
this.ejInit();
|
|
|
|
| 652 |
},
|
| 653 |
tvpPct(z){ const m=this.tvpZmax||3; const c=Math.max(-m,Math.min(m,z)); return 50 + c/m*50; },
|
| 654 |
tvpAddVar(){ this.tvpVars.push(''); },
|
|
@@ -841,6 +946,105 @@
|
|
| 841 |
},
|
| 842 |
_pmErr(m){ this.postMatchMsg=m; this.postMatchMsgError=true; },
|
| 843 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 844 |
async ejRunPost(){
|
| 845 |
this.ejPostMsg=''; this.ejPostMsgError=false;
|
| 846 |
if (Object.keys(this.ejPartidos).length && !this.ejPartidos[this.league]){ this.ejPostMsg='Esta liga no tiene artefactos de ejes.'; this.ejPostMsgError=true; return; }
|
|
|
|
| 504 |
<div id="ejtip" style="position:absolute;display:none;pointer-events:none;background:#1f2937;border:1px solid #4b5563;border-radius:8px;padding:6px 10px;font-size:12px;max-width:230px;z-index:10"></div>
|
| 505 |
</div>
|
| 506 |
</section>
|
| 507 |
+
|
| 508 |
+
<!-- ---- Recursos bloque bajo ---- -->
|
| 509 |
+
<section class="bg-ink-700 border border-ink-600 rounded-2xl p-5 mt-2">
|
| 510 |
+
<h2 class="text-lg font-semibold text-racing-500 mb-1">6. Recursos bloque bajo</h2>
|
| 511 |
+
<p class="text-xs text-gray-400 mb-3">Recursos ofensivos contra bloque bajo (y su cara defensiva) por equipo. Elegí liga, y para cada eje: lado, recurso y métrica. Hover = equipo; click = fijar etiqueta. Se muestran equipos con ≥10 intentos en ambos ejes.</p>
|
| 512 |
+
<div class="flex flex-wrap items-end gap-3 mb-2">
|
| 513 |
+
<div>
|
| 514 |
+
<label class="block text-xs text-gray-400 mb-1">Liga</label>
|
| 515 |
+
<select x-model="lbLeague" @change="lbLoad()" class="bg-ink-800 border border-ink-600 rounded-lg px-3 py-2 text-sm">
|
| 516 |
+
<option value="__all__">Todas las ligas</option>
|
| 517 |
+
<template x-for="l in lbLeagues" :key="l"><option :value="l" x-text="l"></option></template>
|
| 518 |
+
</select>
|
| 519 |
+
</div>
|
| 520 |
+
</div>
|
| 521 |
+
<div class="grid sm:grid-cols-2 gap-4 mb-2">
|
| 522 |
+
<div class="bg-ink-800/60 border border-ink-600 rounded-xl p-3">
|
| 523 |
+
<div class="text-xs uppercase tracking-wide text-gray-400 mb-2">Eje X</div>
|
| 524 |
+
<div class="flex flex-wrap gap-2">
|
| 525 |
+
<select x-model="lbX.lado" @change="lbDraw()" class="bg-ink-800 border border-ink-600 rounded-lg px-2 py-1.5 text-sm">
|
| 526 |
+
<option value="of">Ofensivo</option><option value="def">Defensivo</option>
|
| 527 |
+
</select>
|
| 528 |
+
<select x-model="lbX.recurso" @change="lbDraw()" class="bg-ink-800 border border-ink-600 rounded-lg px-2 py-1.5 text-sm">
|
| 529 |
+
<template x-for="(lbl,k) in lbRecursos" :key="k"><option :value="k" x-text="lbl"></option></template>
|
| 530 |
+
</select>
|
| 531 |
+
<select x-model="lbX.metrica" @change="lbDraw()" class="bg-ink-800 border border-ink-600 rounded-lg px-2 py-1.5 text-sm">
|
| 532 |
+
<template x-for="(lbl,k) in lbMetricas" :key="k"><option :value="k" x-text="lbl"></option></template>
|
| 533 |
+
</select>
|
| 534 |
+
</div>
|
| 535 |
+
</div>
|
| 536 |
+
<div class="bg-ink-800/60 border border-ink-600 rounded-xl p-3">
|
| 537 |
+
<div class="text-xs uppercase tracking-wide text-gray-400 mb-2">Eje Y</div>
|
| 538 |
+
<div class="flex flex-wrap gap-2">
|
| 539 |
+
<select x-model="lbY.lado" @change="lbDraw()" class="bg-ink-800 border border-ink-600 rounded-lg px-2 py-1.5 text-sm">
|
| 540 |
+
<option value="of">Ofensivo</option><option value="def">Defensivo</option>
|
| 541 |
+
</select>
|
| 542 |
+
<select x-model="lbY.recurso" @change="lbDraw()" class="bg-ink-800 border border-ink-600 rounded-lg px-2 py-1.5 text-sm">
|
| 543 |
+
<template x-for="(lbl,k) in lbRecursos" :key="k"><option :value="k" x-text="lbl"></option></template>
|
| 544 |
+
</select>
|
| 545 |
+
<select x-model="lbY.metrica" @change="lbDraw()" class="bg-ink-800 border border-ink-600 rounded-lg px-2 py-1.5 text-sm">
|
| 546 |
+
<template x-for="(lbl,k) in lbMetricas" :key="k"><option :value="k" x-text="lbl"></option></template>
|
| 547 |
+
</select>
|
| 548 |
+
</div>
|
| 549 |
+
</div>
|
| 550 |
+
</div>
|
| 551 |
+
<p class="text-xs text-red-300 mb-2" x-show="lbError" x-text="lbError"></p>
|
| 552 |
+
<div id="lbwrap" style="position:relative">
|
| 553 |
+
<svg id="lbsvg" width="100%" height="460" viewBox="0 0 980 460"></svg>
|
| 554 |
+
<div id="lbtip" style="position:absolute;display:none;pointer-events:none;background:#1f2937;border:1px solid #4b5563;border-radius:8px;padding:6px 10px;font-size:12px;max-width:260px;z-index:10"></div>
|
| 555 |
+
</div>
|
| 556 |
+
|
| 557 |
+
<div class="border-t border-ink-600 mt-4 pt-4">
|
| 558 |
+
<h3 class="font-semibold text-sm mb-2">Tablas por equipo</h3>
|
| 559 |
+
<div class="flex flex-wrap items-end gap-3 mb-3">
|
| 560 |
+
<div>
|
| 561 |
+
<label class="block text-xs text-gray-400 mb-1">Liga</label>
|
| 562 |
+
<select x-model="lbtLeague" @change="lbtLoadTeams()" class="bg-ink-800 border border-ink-600 rounded-lg px-3 py-2 text-sm">
|
| 563 |
+
<template x-for="l in lbLeagues" :key="l"><option :value="l" x-text="l"></option></template>
|
| 564 |
+
</select>
|
| 565 |
+
</div>
|
| 566 |
+
<div>
|
| 567 |
+
<label class="block text-xs text-gray-400 mb-1">Equipo</label>
|
| 568 |
+
<select x-model="lbtEquipo" class="bg-ink-800 border border-ink-600 rounded-lg px-3 py-2 text-sm">
|
| 569 |
+
<template x-for="e in lbtEquipos" :key="e"><option :value="e" x-text="e"></option></template>
|
| 570 |
+
</select>
|
| 571 |
+
</div>
|
| 572 |
+
<button @click="lbtRun()" class="bg-racing-500 hover:bg-racing-600 text-racing-900 font-semibold px-4 py-2 rounded-lg text-sm">Generar tablas</button>
|
| 573 |
+
</div>
|
| 574 |
+
<p class="text-xs text-red-300" x-show="lbtError" x-text="lbtError"></p>
|
| 575 |
+
<template x-for="lado in ['of','def']" :key="lado">
|
| 576 |
+
<div x-show="lbtResult" class="mb-4">
|
| 577 |
+
<div class="text-xs uppercase tracking-wide mb-1" :class="lado==='of' ? 'text-racing-200' : 'text-yellow-200'"
|
| 578 |
+
x-text="(lado==='of' ? 'Ofensiva — atacando bloques bajos' : 'Defensiva — su bloque bajo (lo que concede)')"></div>
|
| 579 |
+
<table class="w-full text-xs">
|
| 580 |
+
<thead><tr class="text-gray-400 text-left">
|
| 581 |
+
<th class="py-1">Recurso</th><th class="text-right">Intentos</th><th class="text-right">% éxito</th>
|
| 582 |
+
<th class="text-right">Peligro/jugada (‰)</th><th class="text-right">media liga</th>
|
| 583 |
+
<th class="text-right">Tiros/jugada</th><th class="text-right">xG/jugada</th><th class="text-right">Goles</th><th class="text-right">% goles</th>
|
| 584 |
+
</tr></thead>
|
| 585 |
+
<tbody>
|
| 586 |
+
<template x-for="r in ((lbtResult && lbtResult[lado] && lbtResult[lado].rows) || [])" :key="lado+r.recurso">
|
| 587 |
+
<tr class="border-t border-ink-600">
|
| 588 |
+
<td class="py-1" x-text="lbRecursos[r.recurso]||r.recurso"></td>
|
| 589 |
+
<td class="text-right" x-text="r.n_intentos"></td>
|
| 590 |
+
<td class="text-right" x-text="r.pct_exito!=null ? r.pct_exito+'%' : '—'"></td>
|
| 591 |
+
<td class="text-right font-semibold" x-text="r.pv_jugada!=null ? r.pv_jugada : '—'"></td>
|
| 592 |
+
<td class="text-right text-gray-400" x-text="((lbtResult[lado].liga_media[r.recurso]||{}).pv_jugada != null) ? lbtResult[lado].liga_media[r.recurso].pv_jugada : '—'"></td>
|
| 593 |
+
<td class="text-right" x-text="r.tiros_jugada ?? '—'"></td>
|
| 594 |
+
<td class="text-right" x-text="r.xg_jugada ?? '—'"></td>
|
| 595 |
+
<td class="text-right" x-text="r.goles ?? '—'"></td>
|
| 596 |
+
<td class="text-right" x-text="r.pct_goles!=null ? r.pct_goles+'%' : '—'"></td>
|
| 597 |
+
</tr>
|
| 598 |
+
</template>
|
| 599 |
+
</tbody>
|
| 600 |
+
</table>
|
| 601 |
+
</div>
|
| 602 |
+
</template>
|
| 603 |
+
</div>
|
| 604 |
+
</section>
|
| 605 |
</div>
|
| 606 |
</template>
|
| 607 |
|
|
|
|
| 717 |
ejX:'elaboracion', ejY:'verticalidad', ejLado:'gen', ejMet:'z', ejData:null, ejError:'', ejLoaded:false,
|
| 718 |
ejPostMsg:'', ejPostMsgError:false,
|
| 719 |
mlLeagues: [], ejPartidos: {},
|
| 720 |
+
// Recursos bloque bajo
|
| 721 |
+
lbLeagues: [], lbRecursos: {}, lbMetricas: {}, lbLeague: '__all__', lbRows: [], lbError: '', lbLoaded: false,
|
| 722 |
+
lbX: {lado:'of', recurso:'rompe_lineas_ok', metrica:'pv_jugada'},
|
| 723 |
+
lbY: {lado:'of', recurso:'centro_outswinger', metrica:'pv_jugada'},
|
| 724 |
+
lbPinned: {},
|
| 725 |
+
lbtLeague: '', lbtEquipo: '', lbtEquipos: [], lbtResult: null, lbtError: '',
|
| 726 |
|
| 727 |
async init() { await this.loadLeagues(); await this.loadSeasons(false); await this.loadTeams(); this._refreshHealth(); },
|
| 728 |
async _getJSON(u){ const r=await fetch(u); if(!r.ok) throw new Error((await r.json().catch(()=>({detail:r.statusText}))).detail); return r.json(); },
|
|
|
|
| 753 |
this.tvLoaded = true;
|
| 754 |
} catch(e){ this.tvError = 'No se pudieron cargar las opciones: '+e; }
|
| 755 |
this.ejInit();
|
| 756 |
+
this.lbInit();
|
| 757 |
},
|
| 758 |
tvpPct(z){ const m=this.tvpZmax||3; const c=Math.max(-m,Math.min(m,z)); return 50 + c/m*50; },
|
| 759 |
tvpAddVar(){ this.tvpVars.push(''); },
|
|
|
|
| 946 |
},
|
| 947 |
_pmErr(m){ this.postMatchMsg=m; this.postMatchMsgError=true; },
|
| 948 |
|
| 949 |
+
async lbInit(){
|
| 950 |
+
if (this.lbLoaded) return;
|
| 951 |
+
try {
|
| 952 |
+
const o = await this._getJSON('/api/lowblock/options');
|
| 953 |
+
this.lbLeagues = o.leagues||[]; this.lbRecursos = o.recursos||{}; this.lbMetricas = o.metricas||{};
|
| 954 |
+
this.lbLoaded = true;
|
| 955 |
+
await this.$nextTick();
|
| 956 |
+
this.lbX = {...this.lbX}; this.lbY = {...this.lbY};
|
| 957 |
+
this.lbtLeague = this.lbLeagues.find(l=>l.toLowerCase().includes('segunda')) || this.lbLeagues[0] || '';
|
| 958 |
+
await this.lbtLoadTeams();
|
| 959 |
+
await this.lbLoad();
|
| 960 |
+
} catch(e){ this.lbError = 'Sin datos de bloque bajo: '+e; }
|
| 961 |
+
},
|
| 962 |
+
async lbLoad(){
|
| 963 |
+
this.lbError='';
|
| 964 |
+
try {
|
| 965 |
+
const d = await this._getJSON(`/api/lowblock/data?league=${encodeURIComponent(this.lbLeague)}`);
|
| 966 |
+
this.lbRows = d.rows||[];
|
| 967 |
+
await this.$nextTick(); this.lbDraw();
|
| 968 |
+
} catch(e){ this.lbError = 'Error: '+e; }
|
| 969 |
+
},
|
| 970 |
+
lbVal(rowsByKey, eq, ax){
|
| 971 |
+
const r = rowsByKey[eq+'|'+ax.lado+'|'+ax.recurso];
|
| 972 |
+
if (!r || r.n_intentos < 10) return null;
|
| 973 |
+
const v = r[ax.metrica];
|
| 974 |
+
return (v==null || isNaN(v)) ? null : {v:+v, n:r.n_intentos};
|
| 975 |
+
},
|
| 976 |
+
lbDraw(){
|
| 977 |
+
const svg=document.getElementById('lbsvg'), tip=document.getElementById('lbtip'), wrap=document.getElementById('lbwrap');
|
| 978 |
+
if (!svg || !this.lbRows.length) return;
|
| 979 |
+
svg.innerHTML='';
|
| 980 |
+
const byKey={};
|
| 981 |
+
for (const r of this.lbRows) byKey[r.equipo+'|'+r.lado+'|'+r.recurso]=r;
|
| 982 |
+
const equipos=[...new Set(this.lbRows.map(r=>r.equipo))];
|
| 983 |
+
const pts=[];
|
| 984 |
+
for (const eq of equipos){
|
| 985 |
+
const vx=this.lbVal(byKey, eq, this.lbX), vy=this.lbVal(byKey, eq, this.lbY);
|
| 986 |
+
if (vx && vy) pts.push({eq, x:vx.v, y:vy.v, nx:vx.n, ny:vy.n});
|
| 987 |
+
}
|
| 988 |
+
if (!pts.length){ this.lbError='Sin equipos con ≥10 intentos en ambos ejes.'; return; }
|
| 989 |
+
this.lbError='';
|
| 990 |
+
const NS='http://www.w3.org/2000/svg', W=980, H=460, M={l:64,r:20,t:16,b:48};
|
| 991 |
+
const xs=pts.map(p=>p.x), ys=pts.map(p=>p.y);
|
| 992 |
+
const pad=(a,b)=>{const d=(b-a)||1; return [a-d*0.08, b+d*0.08];};
|
| 993 |
+
const [x0,x1]=pad(Math.min(...xs),Math.max(...xs)), [y0,y1]=pad(Math.min(...ys),Math.max(...ys));
|
| 994 |
+
const X=v=>M.l+(v-x0)/(x1-x0)*(W-M.l-M.r), Y=v=>H-M.b-(v-y0)/(y1-y0)*(H-M.t-M.b);
|
| 995 |
+
const el=(tag,attrs)=>{const e=document.createElementNS(NS,tag);for(const k in attrs)e.setAttribute(k,attrs[k]);svg.appendChild(e);return e;};
|
| 996 |
+
const fmt=v=>Math.abs(v)>=100?v.toFixed(0):(Math.abs(v)>=1?v.toFixed(1):v.toFixed(2));
|
| 997 |
+
for (let i=0;i<=4;i++){
|
| 998 |
+
const vx=x0+(x1-x0)*i/4, vy=y0+(y1-y0)*i/4;
|
| 999 |
+
el('line',{x1:X(vx),y1:M.t,x2:X(vx),y2:H-M.b,stroke:'#1f2937'});
|
| 1000 |
+
el('line',{x1:M.l,y1:Y(vy),x2:W-M.r,y2:Y(vy),stroke:'#1f2937'});
|
| 1001 |
+
const tx=el('text',{x:X(vx),y:H-M.b+16,'font-size':'11','text-anchor':'middle',fill:'#6b7280'}); tx.textContent=fmt(vx);
|
| 1002 |
+
const ty=el('text',{x:M.l-8,y:Y(vy)+4,'font-size':'11','text-anchor':'end',fill:'#6b7280'}); ty.textContent=fmt(vy);
|
| 1003 |
+
}
|
| 1004 |
+
const lx=el('text',{x:W-M.r,y:H-M.b+34,'font-size':'12','text-anchor':'end',fill:'#9ca3af'});
|
| 1005 |
+
lx.textContent=(this.lbX.lado==='of'?'OF · ':'DEF · ')+(this.lbRecursos[this.lbX.recurso]||'')+' — '+(this.lbMetricas[this.lbX.metrica]||'');
|
| 1006 |
+
const ly=el('text',{x:M.l,y:M.t-4,'font-size':'12',fill:'#9ca3af'});
|
| 1007 |
+
ly.textContent='↑ '+(this.lbY.lado==='of'?'OF · ':'DEF · ')+(this.lbRecursos[this.lbY.recurso]||'')+' — '+(this.lbMetricas[this.lbY.metrica]||'');
|
| 1008 |
+
const self=this;
|
| 1009 |
+
pts.forEach(pt=>{
|
| 1010 |
+
const racing = pt.eq==='Racing de Santander';
|
| 1011 |
+
const pinned = !!self.lbPinned[pt.eq];
|
| 1012 |
+
const c=el('circle',{cx:X(pt.x),cy:Y(pt.y),r:racing?8:6,
|
| 1013 |
+
fill:racing?'#f59e0b':(pinned?'#4ade80':'#9ca3af'),'fill-opacity':racing||pinned?1:0.65,
|
| 1014 |
+
stroke:'#111827','stroke-width':1});
|
| 1015 |
+
c.style.cursor='pointer';
|
| 1016 |
+
if (racing || pinned){
|
| 1017 |
+
const l=el('text',{x:X(pt.x)+10,y:Y(pt.y)+4,'font-size':'11','font-weight':'600',fill:'#e5e7eb'});
|
| 1018 |
+
l.textContent=pt.eq;
|
| 1019 |
+
}
|
| 1020 |
+
c.addEventListener('mousemove',ev=>{
|
| 1021 |
+
const rct=wrap.getBoundingClientRect();
|
| 1022 |
+
tip.style.display='block';
|
| 1023 |
+
tip.style.left=Math.min(ev.clientX-rct.left+14,rct.width-270)+'px';
|
| 1024 |
+
tip.style.top=(ev.clientY-rct.top+10)+'px';
|
| 1025 |
+
tip.innerHTML='<b>'+pt.eq+'</b><br>X: '+fmt(pt.x)+' ('+pt.nx+' int.)<br>Y: '+fmt(pt.y)+' ('+pt.ny+' int.)';
|
| 1026 |
+
c.setAttribute('r',racing?10:8);
|
| 1027 |
+
});
|
| 1028 |
+
c.addEventListener('mouseleave',()=>{ tip.style.display='none'; c.setAttribute('r',racing?8:6); });
|
| 1029 |
+
c.addEventListener('click',()=>{ self.lbPinned[pt.eq]=!self.lbPinned[pt.eq]; self.lbDraw(); });
|
| 1030 |
+
});
|
| 1031 |
+
},
|
| 1032 |
+
async lbtLoadTeams(){
|
| 1033 |
+
if (!this.lbtLeague){ this.lbtEquipos=[]; return; }
|
| 1034 |
+
try {
|
| 1035 |
+
const d = await this._getJSON(`/api/lowblock/data?league=${encodeURIComponent(this.lbtLeague)}`);
|
| 1036 |
+
this.lbtEquipos = [...new Set((d.rows||[]).map(r=>r.equipo))].sort();
|
| 1037 |
+
if (!this.lbtEquipos.includes(this.lbtEquipo))
|
| 1038 |
+
this.lbtEquipo = this.lbtEquipos.includes('Racing de Santander') ? 'Racing de Santander' : (this.lbtEquipos[0]||'');
|
| 1039 |
+
} catch(e){ this.lbtEquipos=[]; }
|
| 1040 |
+
},
|
| 1041 |
+
async lbtRun(){
|
| 1042 |
+
this.lbtError=''; this.lbtResult=null;
|
| 1043 |
+
if (!this.lbtLeague || !this.lbtEquipo){ this.lbtError='Elegí liga y equipo.'; return; }
|
| 1044 |
+
try {
|
| 1045 |
+
this.lbtResult = await this._getJSON(`/api/lowblock/team?league=${encodeURIComponent(this.lbtLeague)}&equipo=${encodeURIComponent(this.lbtEquipo)}`);
|
| 1046 |
+
} catch(e){ this.lbtError = 'Error: '+e; }
|
| 1047 |
+
},
|
| 1048 |
async ejRunPost(){
|
| 1049 |
this.ejPostMsg=''; this.ejPostMsgError=false;
|
| 1050 |
if (Object.keys(this.ejPartidos).length && !this.ejPartidos[this.league]){ this.ejPostMsg='Esta liga no tiene artefactos de ejes.'; this.ejPostMsgError=true; return; }
|