pablogrois Claude Fable 5 commited on
Commit
b3df273
·
1 Parent(s): c79fec9

Sección 'Recursos bloque bajo': scatter interactivo + tablas por equipo

Browse files

Scatter 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 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; }