Spaces:
Running
Running
Commit ·
01fdb49
1
Parent(s): d00f1ea
Predicción de cruce en reporte pre-partido: bloque defensivo + pasillo de ataque + xT
Browse filesAgrega modelos LightGBM (bloque defensivo, pasillo de ataque, xT por pasillo) al
reporte pre-partido: para A y B dibuja la cancha de ataque por pasillos y la de
bloque defensivo (bajo/medio/alto) + un insight del cruce. Inferencia con features
rolling 'a la fecha' precalculadas. No rompe el reporte si faltan datos.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- .gitignore +6 -0
- scripts/build_matchup_features.py +51 -0
- scripts/train_models.py +206 -0
- src/racing_reports/reports/matchup_models.py +129 -0
- src/racing_reports/reports/matchup_render.py +95 -0
- src/racing_reports/reports/pre_match.py +47 -0
- vendor/data/modeling/matchup_feat_cols.json +1 -0
- vendor/data/modeling/model_bloques__def_H.txt +0 -0
- vendor/data/modeling/model_bloques__def_L.txt +0 -0
- vendor/data/modeling/model_bloques__def_M.txt +0 -0
- vendor/data/modeling/model_pasillos__atk_Centro.txt +0 -0
- vendor/data/modeling/model_pasillos__atk_Der.txt +0 -0
- vendor/data/modeling/model_pasillos__atk_Izq.txt +0 -0
- vendor/data/modeling/model_peligro_pasillo__xt_lane_Centro.txt +0 -0
- vendor/data/modeling/model_peligro_pasillo__xt_lane_Der.txt +0 -0
- vendor/data/modeling/model_peligro_pasillo__xt_lane_Izq.txt +0 -0
- vendor/data/modeling/team_features_latest.parquet +3 -0
.gitignore
CHANGED
|
@@ -34,3 +34,9 @@ vendor/data/processed/
|
|
| 34 |
!vendor/data/modeling/attack_prediction_dataset.parquet
|
| 35 |
!vendor/data/modeling/attack_matchup_gnn_bundle.pt
|
| 36 |
!vendor/data/modeling/pv_distribution_gnn_bundle.pt
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
!vendor/data/modeling/attack_prediction_dataset.parquet
|
| 35 |
!vendor/data/modeling/attack_matchup_gnn_bundle.pt
|
| 36 |
!vendor/data/modeling/pv_distribution_gnn_bundle.pt
|
| 37 |
+
# Modelos de cruce (bloque defensivo · pasillo de ataque · xT) + features para inferencia
|
| 38 |
+
!vendor/data/modeling/model_bloques__*.txt
|
| 39 |
+
!vendor/data/modeling/model_pasillos__*.txt
|
| 40 |
+
!vendor/data/modeling/model_peligro_pasillo__*.txt
|
| 41 |
+
!vendor/data/modeling/team_features_latest.parquet
|
| 42 |
+
!vendor/data/modeling/matchup_feat_cols.json
|
scripts/build_matchup_features.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Precalcula, por equipo (liga, temporada), su vector de features rolling 'a la fecha'
|
| 2 |
+
(la última fila disponible) para alimentar la predicción de cruce A-vs-B en el reporte
|
| 3 |
+
pre-partido, sin tener que re-correr todo el pipeline en runtime.
|
| 4 |
+
|
| 5 |
+
Sale:
|
| 6 |
+
vendor/data/modeling/team_features_latest.parquet (1 fila por equipo-temporada)
|
| 7 |
+
vendor/data/modeling/matchup_feat_cols.json (orden exacto de features del modelo)
|
| 8 |
+
|
| 9 |
+
Uso: python scripts/build_matchup_features.py
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import importlib.util
|
| 14 |
+
import json
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
import pandas as pd
|
| 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 |
+
OUT = ROOT / "vendor" / "data" / "modeling"
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def main() -> None:
|
| 26 |
+
df = pd.read_parquet(tm.DATA)
|
| 27 |
+
blk = df.assign(_b=df[tm.DEF].sum(axis=1) > 0).groupby("Competencia")["_b"].mean()
|
| 28 |
+
df = df[df["Competencia"].isin(blk[blk > 0.5].index)].copy()
|
| 29 |
+
m, feat = tm._assemble(df)
|
| 30 |
+
m = m.merge(df[["matchId", "teamId", "fecha", "TeamName"]].drop_duplicates(),
|
| 31 |
+
on=["matchId", "teamId"], how="left")
|
| 32 |
+
|
| 33 |
+
# columnas que definen el "estado a la fecha" de un equipo (lado propio)
|
| 34 |
+
self_cols = [c for c in m.columns if c.startswith(("self_", "selfH_", "selfA_"))]
|
| 35 |
+
keep = ["Competencia", "Temporada", "teamId", "TeamName", "fecha", "formation"] + self_cols
|
| 36 |
+
keep = [c for c in keep if c in m.columns]
|
| 37 |
+
|
| 38 |
+
# última fila por (liga, temporada, equipo) — su forma más reciente
|
| 39 |
+
m = m.sort_values(["Competencia", "Temporada", "teamId", "fecha", "matchId"])
|
| 40 |
+
latest = m[keep].dropna(subset=["self_def_H"]).groupby(
|
| 41 |
+
["Competencia", "Temporada", "teamId"], as_index=False).last()
|
| 42 |
+
|
| 43 |
+
latest.to_parquet(OUT / "team_features_latest.parquet", index=False)
|
| 44 |
+
(OUT / "matchup_feat_cols.json").write_text(json.dumps(feat, ensure_ascii=False))
|
| 45 |
+
print(f"team_features_latest: {len(latest)} equipos-temporada | self_cols={len(self_cols)} | feat={len(feat)}")
|
| 46 |
+
print("ligas:", latest['Competencia'].nunique(), "| ej:",
|
| 47 |
+
latest[latest.Competencia.eq('Liga Profesional Argentina')]['TeamName'].dropna().unique()[:6].tolist())
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
if __name__ == "__main__":
|
| 51 |
+
main()
|
scripts/train_models.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Entrena los dos modelos (bloque defensivo y pasillos) sobre el dataset RICO
|
| 2 |
+
matchteam_dataset.parquet. Arma features SIN leakage:
|
| 3 |
+
- ELO propio por (liga,temporada), pre-partido (de los resultados).
|
| 4 |
+
- Rolling "hasta la fecha" (promedio expandido de partidos PREVIOS) del equipo y del
|
| 5 |
+
rival, y splits LOCAL / VISITA (cómo juega/resulta de local vs visitante).
|
| 6 |
+
- Resultados rolling (win%, goles a favor/en contra) all/home/away.
|
| 7 |
+
- One-hot de la formación del partido (info pre-partido).
|
| 8 |
+
LightGBM por componente, normalizado a simplex; baseline = promedio propio.
|
| 9 |
+
|
| 10 |
+
Uso: python scripts/train_models.py
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
import sys
|
| 17 |
+
|
| 18 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
| 19 |
+
|
| 20 |
+
import lightgbm as lgb
|
| 21 |
+
import numpy as np
|
| 22 |
+
import pandas as pd
|
| 23 |
+
|
| 24 |
+
DATA = Path(__file__).resolve().parents[1] / "vendor" / "data" / "modeling" / "matchteam_dataset.parquet"
|
| 25 |
+
OUTDIR = DATA.parent
|
| 26 |
+
DEF = ["def_H", "def_M", "def_L"]
|
| 27 |
+
ATK = ["atk_Der", "atk_Centro", "atk_Izq"]
|
| 28 |
+
OFFXT = [f"offxt_{b}_{l}" for b in "HML" for l in ["Der", "Centro", "Izq"]]
|
| 29 |
+
DEFXT = [f"defxt_{b}_{l}" for b in "HML" for l in ["Der", "Centro", "Izq"]]
|
| 30 |
+
XTLANE = ["xt_lane_Der", "xt_lane_Centro", "xt_lane_Izq"] # xT absoluto por pasillo (objetivo Modelo 2-b)
|
| 31 |
+
DIST_GROUPS = {"def": DEF, "atk": ATK, "offxt": OFFXT, "defxt": DEFXT}
|
| 32 |
+
SCALARS = ["sh_buildup", "sh_counter", "sh_direct", "sh_setpiece", "sh_recovery", "sh_progr",
|
| 33 |
+
"cross_pct", "pct_long", "pct_short", "pct_pass_ok", "n_attacks", "xt_total",
|
| 34 |
+
"xg_total", "xt_per_attack", "n_seq", "n_pass", "n_shots", "avg_x", "avg_y",
|
| 35 |
+
"share_opp_half", "recovery_height", "n_progr", "goals_for", "goals_against", "win"] + XTLANE
|
| 36 |
+
RNG = np.random.default_rng(7)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _norm_df(df, cols):
|
| 40 |
+
s = df[cols].sum(axis=1)
|
| 41 |
+
out = df[cols].div(s.where(s > 0), axis=0)
|
| 42 |
+
return out, s
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _elo(df: pd.DataFrame) -> pd.Series:
|
| 46 |
+
"""ELO pre-partido por (liga,temporada). Devuelve serie alineada al índice de df."""
|
| 47 |
+
K, HOME = 24.0, 60.0
|
| 48 |
+
elo = pd.Series(np.nan, index=df.index)
|
| 49 |
+
for _, g in df.groupby(["Competencia", "Temporada"], sort=False):
|
| 50 |
+
rating: dict = {}
|
| 51 |
+
# ordenar por fecha y partido; procesar cada partido (2 filas) una vez
|
| 52 |
+
g = g.sort_values(["fecha", "matchId"])
|
| 53 |
+
for mid, mm in g.groupby("matchId", sort=False):
|
| 54 |
+
if len(mm) != 2:
|
| 55 |
+
for i in mm.index:
|
| 56 |
+
elo.loc[i] = rating.get(mm.loc[i, "teamId"], 1500.0)
|
| 57 |
+
continue
|
| 58 |
+
i1, i2 = mm.index
|
| 59 |
+
t1, t2 = mm.loc[i1, "teamId"], mm.loc[i2, "teamId"]
|
| 60 |
+
r1, r2 = rating.get(t1, 1500.0), rating.get(t2, 1500.0)
|
| 61 |
+
elo.loc[i1], elo.loc[i2] = r1, r2 # pre-partido
|
| 62 |
+
h1 = HOME if mm.loc[i1, "is_home"] else 0.0
|
| 63 |
+
h2 = HOME if mm.loc[i2, "is_home"] else 0.0
|
| 64 |
+
e1 = 1.0 / (1.0 + 10 ** ((r2 - r1 - h1 + h2) / 400.0))
|
| 65 |
+
gf1, ga1 = mm.loc[i1, "goals_for"], mm.loc[i1, "goals_against"]
|
| 66 |
+
s1 = 0.5 if pd.isna(gf1) or pd.isna(ga1) or gf1 == ga1 else (1.0 if gf1 > ga1 else 0.0)
|
| 67 |
+
gd = 1.0 if pd.isna(gf1) or pd.isna(ga1) else max(1.0, abs(gf1 - ga1)) ** 0.5
|
| 68 |
+
rating[t1] = r1 + K * gd * (s1 - e1)
|
| 69 |
+
rating[t2] = r2 + K * gd * ((1 - s1) - (1 - e1))
|
| 70 |
+
return elo
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _prep(df: pd.DataFrame) -> pd.DataFrame:
|
| 74 |
+
df = df.copy()
|
| 75 |
+
df["win"] = np.where(df["goals_for"] > df["goals_against"], 1.0,
|
| 76 |
+
np.where(df["goals_for"] < df["goals_against"], 0.0, 0.5))
|
| 77 |
+
# xT absoluto por pasillo = suma del xT por bloque en cada pasillo
|
| 78 |
+
for ln in ["Der", "Centro", "Izq"]:
|
| 79 |
+
df[f"xt_lane_{ln}"] = df[[f"offxt_{b}_{ln}" for b in "HML"]].sum(axis=1)
|
| 80 |
+
# normalizar grupos distribucionales a shares (para features de estilo/peligro)
|
| 81 |
+
feat = {}
|
| 82 |
+
for name, cols in DIST_GROUPS.items():
|
| 83 |
+
nd, _ = _norm_df(df, cols)
|
| 84 |
+
for c in cols:
|
| 85 |
+
feat["f_" + c] = nd[c]
|
| 86 |
+
for c in SCALARS:
|
| 87 |
+
feat["f_" + c] = pd.to_numeric(df[c], errors="coerce")
|
| 88 |
+
F = pd.DataFrame(feat, index=df.index)
|
| 89 |
+
df = pd.concat([df, F], axis=1)
|
| 90 |
+
df["elo"] = _elo(df)
|
| 91 |
+
return df
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _rolling(df: pd.DataFrame) -> pd.DataFrame:
|
| 95 |
+
fcols = [c for c in df.columns if c.startswith("f_")]
|
| 96 |
+
df = df.sort_values(["Competencia", "Temporada", "teamId", "fecha", "matchId"]).copy()
|
| 97 |
+
keys = ["Competencia", "Temporada", "teamId"]
|
| 98 |
+
|
| 99 |
+
def _prior(frame): # expanding mean de partidos previos
|
| 100 |
+
return frame.groupby([df[k] for k in keys], group_keys=False).apply(lambda x: x.shift(1).expanding().mean())
|
| 101 |
+
|
| 102 |
+
roll_all = _prior(df[fcols]); roll_all.columns = ["self_" + c[2:] for c in fcols]
|
| 103 |
+
# splits local/visita: expanding sobre el subconjunto, luego ffill al resto
|
| 104 |
+
def _split(is_home_val):
|
| 105 |
+
sub = df[fcols].where(df["is_home"] == is_home_val)
|
| 106 |
+
r = sub.groupby([df[k] for k in keys], group_keys=False).apply(lambda x: x.shift(1).expanding().mean())
|
| 107 |
+
r = r.groupby([df[k] for k in keys], group_keys=False).ffill()
|
| 108 |
+
return r
|
| 109 |
+
rh = _split(True); rh.columns = ["selfH_" + c[2:] for c in fcols]
|
| 110 |
+
ra = _split(False); ra.columns = ["selfA_" + c[2:] for c in fcols]
|
| 111 |
+
eloprior = df.groupby(keys, group_keys=False)["elo"].apply(lambda x: x) # ya es pre-partido
|
| 112 |
+
out = pd.concat([df[keys + ["matchId", "rival_teamId", "is_home", "formation"] + DEF + ATK + XTLANE],
|
| 113 |
+
df["elo"].rename("self_elo"), roll_all, rh, ra], axis=1)
|
| 114 |
+
return out
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _assemble(df: pd.DataFrame):
|
| 118 |
+
df = _prep(df)
|
| 119 |
+
roll = _rolling(df)
|
| 120 |
+
selfcols = [c for c in roll.columns if c.startswith(("self_", "selfH_", "selfA_"))]
|
| 121 |
+
oppmap = roll[["matchId", "teamId"] + selfcols].rename(
|
| 122 |
+
columns={"teamId": "rival_teamId", **{c: "opp_" + c for c in selfcols}})
|
| 123 |
+
m = roll.merge(oppmap, on=["matchId", "rival_teamId"], how="left")
|
| 124 |
+
m["is_home"] = m["is_home"].astype(float)
|
| 125 |
+
m["elo_diff"] = m["self_elo"] - m["opp_self_elo"]
|
| 126 |
+
# one-hot formación (top 12 + otras)
|
| 127 |
+
top = m["formation"].astype(str).value_counts().head(12).index
|
| 128 |
+
for f in top:
|
| 129 |
+
m[f"form_{f}"] = (m["formation"].astype(str) == f).astype(float)
|
| 130 |
+
feat_cols = (selfcols + [c for c in m.columns if c.startswith("opp_")]
|
| 131 |
+
+ ["is_home", "elo_diff"] + [f"form_{f}" for f in top])
|
| 132 |
+
return m, feat_cols
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def _kl(a, b):
|
| 136 |
+
a = np.clip(a, 1e-9, None); b = np.clip(b, 1e-9, None)
|
| 137 |
+
return float(np.mean(np.sum(a * (np.log(a) - np.log(b)), 1)))
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def _train_one(m, feat_cols, target_cols, name):
|
| 141 |
+
sub = m.dropna(subset=["self_" + c for c in target_cols]).copy() # con historial rolling
|
| 142 |
+
tgt, tot = _norm_df(sub, target_cols)
|
| 143 |
+
keep = tot.values > 0
|
| 144 |
+
sub = sub[keep]; Y = tgt[keep].to_numpy(dtype=np.float32)
|
| 145 |
+
base = sub[["self_" + c for c in target_cols]].to_numpy(dtype=np.float32)
|
| 146 |
+
base = base / np.clip(base.sum(1, keepdims=True), 1e-9, None)
|
| 147 |
+
n = len(sub); idx = RNG.permutation(n); cut = int(n * 0.8); tr, te = idx[:cut], idx[cut:]
|
| 148 |
+
Xtr, Xte = sub[feat_cols].iloc[tr], sub[feat_cols].iloc[te]
|
| 149 |
+
vm = np.zeros(len(tr), bool); vm[RNG.permutation(len(tr))[:int(len(tr) * 0.12)]] = True
|
| 150 |
+
print(f"\n=== Modelo {name}: {n} (test {len(te)}) | {len(feat_cols)} features ===")
|
| 151 |
+
preds = []
|
| 152 |
+
for j in range(Y.shape[1]):
|
| 153 |
+
mdl = lgb.LGBMRegressor(n_estimators=700, learning_rate=0.025, num_leaves=31,
|
| 154 |
+
subsample=0.8, colsample_bytree=0.5, min_child_samples=40, verbose=-1)
|
| 155 |
+
mdl.fit(Xtr[~vm], Y[tr][~vm, j], eval_set=[(Xtr[vm], Y[tr][vm, j])],
|
| 156 |
+
callbacks=[lgb.early_stopping(50, verbose=False)])
|
| 157 |
+
preds.append(np.clip(mdl.predict(Xte), 0, None))
|
| 158 |
+
mdl.booster_.save_model(str(OUTDIR / f"model_{name}__{target_cols[j]}.txt"))
|
| 159 |
+
P = np.vstack(preds).T; P = P / np.clip(P.sum(1, keepdims=True), 1e-9, None)
|
| 160 |
+
mae = lambda a, b: float(np.mean(np.abs(a - b)))
|
| 161 |
+
print(f" modelo : MAE={mae(Y[te], P):.4f} KL={_kl(Y[te], P):.4f}")
|
| 162 |
+
print(f" baseline: MAE={mae(Y[te], base[te]):.4f} KL={_kl(Y[te], base[te]):.4f}")
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def _train_abs(m, feat_cols, target_cols, name):
|
| 166 |
+
"""Regresión de VALORES ABSOLUTOS (no shares). Eval MAE/RMSE vs promedio propio."""
|
| 167 |
+
sub = m.dropna(subset=["self_" + c for c in target_cols]).copy()
|
| 168 |
+
Y = sub[target_cols].to_numpy(dtype=np.float32)
|
| 169 |
+
base = sub[["self_" + c for c in target_cols]].to_numpy(dtype=np.float32)
|
| 170 |
+
n = len(sub); idx = RNG.permutation(n); cut = int(n * 0.8); tr, te = idx[:cut], idx[cut:]
|
| 171 |
+
Xtr, Xte = sub[feat_cols].iloc[tr], sub[feat_cols].iloc[te]
|
| 172 |
+
vm = np.zeros(len(tr), bool); vm[RNG.permutation(len(tr))[:int(len(tr) * 0.12)]] = True
|
| 173 |
+
print(f"\n=== Modelo {name} (xT absoluto): {n} (test {len(te)}) ===")
|
| 174 |
+
preds = []
|
| 175 |
+
for j in range(Y.shape[1]):
|
| 176 |
+
mdl = lgb.LGBMRegressor(n_estimators=700, learning_rate=0.025, num_leaves=31,
|
| 177 |
+
subsample=0.8, colsample_bytree=0.5, min_child_samples=40, verbose=-1)
|
| 178 |
+
mdl.fit(Xtr[~vm], Y[tr][~vm, j], eval_set=[(Xtr[vm], Y[tr][vm, j])],
|
| 179 |
+
callbacks=[lgb.early_stopping(50, verbose=False)])
|
| 180 |
+
preds.append(np.clip(mdl.predict(Xte), 0, None))
|
| 181 |
+
mdl.booster_.save_model(str(OUTDIR / f"model_{name}__{target_cols[j]}.txt"))
|
| 182 |
+
P = np.vstack(preds).T
|
| 183 |
+
mae = lambda a, b: float(np.mean(np.abs(a - b)))
|
| 184 |
+
rmse = lambda a, b: float(np.sqrt(np.mean((a - b) ** 2)))
|
| 185 |
+
print(f" modelo : MAE={mae(Y[te], P):.4f} RMSE={rmse(Y[te], P):.4f}")
|
| 186 |
+
print(f" baseline: MAE={mae(Y[te], base[te]):.4f} RMSE={rmse(Y[te], base[te]):.4f}")
|
| 187 |
+
print(f" (xT medio real por pasillo: {np.round(Y[te].mean(0), 3)})")
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def main():
|
| 191 |
+
import argparse
|
| 192 |
+
ap = argparse.ArgumentParser(); ap.add_argument("--leagues", nargs="*", default=None); a = ap.parse_args()
|
| 193 |
+
df = pd.read_parquet(DATA)
|
| 194 |
+
if a.leagues:
|
| 195 |
+
df = df[df["Competencia"].isin(a.leagues)].copy()
|
| 196 |
+
blk = (df.assign(_b=df[DEF].sum(axis=1) > 0).groupby("Competencia")["_b"].mean())
|
| 197 |
+
df = df[df["Competencia"].isin(blk[blk > 0.5].index)].copy()
|
| 198 |
+
print("dataset:", len(df), "filas |", df["Competencia"].nunique(), "ligas |", df["matchId"].nunique(), "partidos")
|
| 199 |
+
m, feat = _assemble(df)
|
| 200 |
+
_train_one(m, feat, DEF, "bloques")
|
| 201 |
+
_train_one(m, feat, ATK, "pasillos")
|
| 202 |
+
_train_abs(m, feat, XTLANE, "peligro_pasillo")
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
if __name__ == "__main__":
|
| 206 |
+
main()
|
src/racing_reports/reports/matchup_models.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Predicción de cruce A-vs-B a partir de los modelos LightGBM entrenados
|
| 2 |
+
(bloque defensivo, pasillo de ataque y xT por pasillo).
|
| 3 |
+
|
| 4 |
+
Para cada equipo usa su vector de features rolling 'a la fecha' (precalculado en
|
| 5 |
+
``team_features_latest.parquet``) y arma la fila del enfrentamiento: lado propio del
|
| 6 |
+
equipo + lado rival del oponente. Devuelve, para A y para B:
|
| 7 |
+
- dist. de bloque defensivo (alto / medio / bajo) — cómo va a defender
|
| 8 |
+
- dist. de ataque por pasillo (izq / centro / der) — por dónde va a atacar
|
| 9 |
+
- xT absoluto por pasillo (izq / centro / der) — peligro esperado por pasillo
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import json
|
| 14 |
+
import threading
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
import numpy as np
|
| 18 |
+
import pandas as pd
|
| 19 |
+
|
| 20 |
+
from racing_reports import vendor_env
|
| 21 |
+
|
| 22 |
+
_MODELDIR = vendor_env.DATA_DIR / "modeling"
|
| 23 |
+
_FEATS = _MODELDIR / "team_features_latest.parquet"
|
| 24 |
+
_FEATCOLS = _MODELDIR / "matchup_feat_cols.json"
|
| 25 |
+
|
| 26 |
+
DEF = ["def_H", "def_M", "def_L"] # alto, medio, bajo
|
| 27 |
+
ATK = ["atk_Der", "atk_Centro", "atk_Izq"] # derecha, centro, izquierda
|
| 28 |
+
XT = ["xt_lane_Der", "xt_lane_Centro", "xt_lane_Izq"]
|
| 29 |
+
LANES = ["Izq", "Centro", "Der"]
|
| 30 |
+
|
| 31 |
+
_LOCK = threading.Lock()
|
| 32 |
+
_CACHE: dict = {}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _load():
|
| 36 |
+
with _LOCK:
|
| 37 |
+
if _CACHE:
|
| 38 |
+
return _CACHE
|
| 39 |
+
if not _FEATS.exists() or not _FEATCOLS.exists():
|
| 40 |
+
_CACHE["ok"] = False
|
| 41 |
+
return _CACHE
|
| 42 |
+
import lightgbm as lgb
|
| 43 |
+
feats = pd.read_parquet(_FEATS)
|
| 44 |
+
feat_cols = json.loads(_FEATCOLS.read_text(encoding="utf-8"))
|
| 45 |
+
boosters = {}
|
| 46 |
+
for name, cols in (("bloques", DEF), ("pasillos", ATK), ("peligro_pasillo", XT)):
|
| 47 |
+
paths = [_MODELDIR / f"model_{name}__{c}.txt" for c in cols]
|
| 48 |
+
if all(p.exists() for p in paths):
|
| 49 |
+
boosters[name] = [lgb.Booster(model_file=str(p)) for p in paths]
|
| 50 |
+
_CACHE.update(ok=bool(boosters) and len(feats) > 0, feats=feats,
|
| 51 |
+
feat_cols=feat_cols, boosters=boosters)
|
| 52 |
+
return _CACHE
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def available() -> bool:
|
| 56 |
+
return bool(_load().get("ok"))
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _row_for(feats: pd.DataFrame, league: str, season: str | None, team: str):
|
| 60 |
+
"""Última fila de features del equipo (por nombre) en la liga/temporada."""
|
| 61 |
+
q = feats[feats["Competencia"].astype(str) == str(league)]
|
| 62 |
+
q = q[q["TeamName"].astype(str).str.casefold() == str(team).casefold()]
|
| 63 |
+
if season:
|
| 64 |
+
qs = q[q["Temporada"].astype(str) == str(season)]
|
| 65 |
+
q = qs if not qs.empty else q
|
| 66 |
+
if q.empty:
|
| 67 |
+
return None
|
| 68 |
+
return q.sort_values("fecha").iloc[-1]
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _build_feature_row(self_r, opp_r, is_home: bool, feat_cols: list[str]) -> np.ndarray:
|
| 72 |
+
"""Arma el vector en el orden exacto de feat_cols para un equipo (self) vs su rival (opp)."""
|
| 73 |
+
vals = {}
|
| 74 |
+
for c in feat_cols:
|
| 75 |
+
if c == "is_home":
|
| 76 |
+
vals[c] = 1.0 if is_home else 0.0
|
| 77 |
+
elif c == "elo_diff":
|
| 78 |
+
vals[c] = float(self_r.get("self_elo", 1500.0)) - float(opp_r.get("self_elo", 1500.0))
|
| 79 |
+
elif c.startswith("form_"):
|
| 80 |
+
vals[c] = 1.0 if str(self_r.get("formation")) == c[len("form_"):] else 0.0
|
| 81 |
+
elif c.startswith("opp_"):
|
| 82 |
+
base = c[len("opp_"):]
|
| 83 |
+
vals[c] = float(opp_r.get(base)) if pd.notna(opp_r.get(base)) else np.nan
|
| 84 |
+
else: # self / selfH / selfA
|
| 85 |
+
vals[c] = float(self_r.get(c)) if pd.notna(self_r.get(c)) else np.nan
|
| 86 |
+
return np.array([vals[c] for c in feat_cols], dtype=np.float64).reshape(1, -1)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _predict_side(boosters, X, dist: bool):
|
| 90 |
+
p = np.array([b.predict(X)[0] for b in boosters], dtype=float)
|
| 91 |
+
p = np.clip(p, 0, None)
|
| 92 |
+
if dist:
|
| 93 |
+
s = p.sum()
|
| 94 |
+
p = p / s if s > 0 else np.full_like(p, 1 / len(p))
|
| 95 |
+
return p
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def predict_matchup(league: str, season: str | None, team_a: str, team_b: str,
|
| 99 |
+
a_is_home: bool = True) -> dict | None:
|
| 100 |
+
"""Predicción del enfrentamiento. Devuelve dict con A y B, o None si faltan modelos
|
| 101 |
+
o no se encuentra alguno de los equipos."""
|
| 102 |
+
c = _load()
|
| 103 |
+
if not c.get("ok"):
|
| 104 |
+
return None
|
| 105 |
+
feats, feat_cols, B = c["feats"], c["feat_cols"], c["boosters"]
|
| 106 |
+
if not all(k in B for k in ("bloques", "pasillos", "peligro_pasillo")):
|
| 107 |
+
return None
|
| 108 |
+
ra = _row_for(feats, league, season, team_a)
|
| 109 |
+
rb = _row_for(feats, league, season, team_b)
|
| 110 |
+
if ra is None or rb is None:
|
| 111 |
+
return {"error": f"Sin datos del modelo para "
|
| 112 |
+
+ (team_a if ra is None else team_b) + f" en {league} {season or ''}".strip()}
|
| 113 |
+
|
| 114 |
+
def side(self_r, opp_r, is_home):
|
| 115 |
+
X = _build_feature_row(self_r, opp_r, is_home, feat_cols)
|
| 116 |
+
defb = _predict_side(B["bloques"], X, dist=True) # H, M, L
|
| 117 |
+
atk = _predict_side(B["pasillos"], X, dist=True) # Der, Centro, Izq
|
| 118 |
+
xt = _predict_side(B["peligro_pasillo"], X, dist=False)
|
| 119 |
+
return {
|
| 120 |
+
"bloque": {"alto": float(defb[0]), "medio": float(defb[1]), "bajo": float(defb[2])},
|
| 121 |
+
"pasillo": {"Der": float(atk[0]), "Centro": float(atk[1]), "Izq": float(atk[2])},
|
| 122 |
+
"xt": {"Der": float(xt[0]), "Centro": float(xt[1]), "Izq": float(xt[2])},
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
return {
|
| 126 |
+
"league": league, "season": season, "team_a": team_a, "team_b": team_b,
|
| 127 |
+
"a": side(ra, rb, a_is_home),
|
| 128 |
+
"b": side(rb, ra, not a_is_home),
|
| 129 |
+
}
|
src/racing_reports/reports/matchup_render.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Dibujo de la predicción de cruce sobre canchas:
|
| 2 |
+
- Ataque por pasillo: media cancha con 3 pasillos (Izq/Centro/Der), % y xT.
|
| 3 |
+
- Bloque defensivo: el 75% defensivo dividido en bajo (verde, cerca del arco propio),
|
| 4 |
+
medio (naranja) y alto (rojo), sombreado según el % previsto.
|
| 5 |
+
Devuelve figuras de matplotlib (una por cancha)."""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import matplotlib
|
| 9 |
+
matplotlib.use("Agg")
|
| 10 |
+
import matplotlib.pyplot as plt
|
| 11 |
+
from mplsoccer import VerticalPitch
|
| 12 |
+
|
| 13 |
+
PITCH = dict(pitch_type="opta", line_color="#cdd6e6", linewidth=1.2)
|
| 14 |
+
GREEN, ORANGE, RED = "#2bb673", "#f5912b", "#e0392b"
|
| 15 |
+
LANE_C = "#2E74D6"
|
| 16 |
+
TEXT = "#10243f"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _pitch(ax, half=False):
|
| 20 |
+
p = VerticalPitch(half=half, pad_top=2, pitch_color="none", **PITCH)
|
| 21 |
+
p.draw(ax=ax)
|
| 22 |
+
return p
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def attack_fig(side: dict, team: str) -> plt.Figure:
|
| 26 |
+
"""Media cancha de ataque con los 3 pasillos sombreados por % y rotulados con % y xT."""
|
| 27 |
+
fig, ax = plt.subplots(figsize=(4.2, 5.2))
|
| 28 |
+
fig.patch.set_facecolor("white")
|
| 29 |
+
p = _pitch(ax, half=True)
|
| 30 |
+
pas, xt = side["pasillo"], side["xt"]
|
| 31 |
+
mx = max(pas.values()) or 1.0
|
| 32 |
+
# x = ancho (0-100). Izquierda a la izquierda del espectador.
|
| 33 |
+
bounds = [(0, 33.33, "Der"), (33.33, 66.67, "Centro"), (66.67, 100, "Izq")]
|
| 34 |
+
for x0, x1, lane in bounds:
|
| 35 |
+
frac = pas[lane]
|
| 36 |
+
ax.fill_betweenx([50, 100], x0, x1, color=LANE_C, alpha=0.12 + 0.55 * frac / mx, zorder=0.5)
|
| 37 |
+
ax.plot([x0, x0], [50, 100], color="#9fb0c9", lw=1, ls="--", zorder=1)
|
| 38 |
+
ax.text((x0 + x1) / 2, 88, f"{frac*100:.0f}%", ha="center", va="center",
|
| 39 |
+
fontsize=17, fontweight="bold", color=TEXT, zorder=3)
|
| 40 |
+
ax.text((x0 + x1) / 2, 79, lane, ha="center", va="center", fontsize=10.5, color="#42546e", zorder=3)
|
| 41 |
+
ax.text((x0 + x1) / 2, 73, f"xT {xt[lane]:.2f}", ha="center", va="center",
|
| 42 |
+
fontsize=9, color="#5a6b85", zorder=3)
|
| 43 |
+
ax.set_title(f"{team}\nPor dónde ataca", fontsize=12.5, fontweight="bold", color=TEXT, pad=6)
|
| 44 |
+
fig.tight_layout()
|
| 45 |
+
return fig
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def defense_fig(side: dict, team: str) -> plt.Figure:
|
| 49 |
+
"""Cancha con el 75% defensivo en 3 bandas: bajo (verde) cerca del arco propio,
|
| 50 |
+
medio (naranja) y alto (rojo); sombreado por % previsto."""
|
| 51 |
+
fig, ax = plt.subplots(figsize=(4.2, 5.2))
|
| 52 |
+
fig.patch.set_facecolor("white")
|
| 53 |
+
_pitch(ax, half=False)
|
| 54 |
+
blo = side["bloque"]
|
| 55 |
+
mx = max(blo.values()) or 1.0
|
| 56 |
+
# arco propio abajo (y=0). Bandas a lo largo: bajo [0-25], medio [25-50], alto [50-75].
|
| 57 |
+
bands = [(0, 25, "bajo", GREEN), (25, 50, "medio", ORANGE), (50, 75, "alto", RED)]
|
| 58 |
+
for y0, y1, name, col in bands:
|
| 59 |
+
frac = blo[name]
|
| 60 |
+
ax.fill_between([0, 100], y0, y1, color=col, alpha=0.18 + 0.6 * frac / mx, zorder=0.5)
|
| 61 |
+
ax.plot([0, 100], [y1, y1], color="#9fb0c9", lw=0.8, ls="--", zorder=1)
|
| 62 |
+
ax.text(50, (y0 + y1) / 2 + 3.5, f"{frac*100:.0f}%", ha="center", va="center",
|
| 63 |
+
fontsize=16, fontweight="bold", color=TEXT, zorder=3)
|
| 64 |
+
ax.text(50, (y0 + y1) / 2 - 4, f"Bloque {name}", ha="center", va="center",
|
| 65 |
+
fontsize=10.5, color="#33455f", zorder=3)
|
| 66 |
+
ax.annotate("arco propio", (50, 1.5), ha="center", va="bottom", fontsize=8.5,
|
| 67 |
+
color="#7a889e", zorder=3)
|
| 68 |
+
ax.set_title(f"{team}\nCómo defiende", fontsize=12.5, fontweight="bold", color=TEXT, pad=6)
|
| 69 |
+
fig.tight_layout()
|
| 70 |
+
return fig
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def insight(pred: dict) -> str:
|
| 74 |
+
"""Lectura breve del cruce a partir de las predicciones de A y B."""
|
| 75 |
+
a, b = pred["a"], pred["b"]
|
| 76 |
+
ta, tb = pred["team_a"], pred["team_b"]
|
| 77 |
+
lane_es = {"Izq": "izquierda", "Centro": "el centro", "Der": "derecha"}
|
| 78 |
+
blo_max = lambda s: max(s["bloque"], key=s["bloque"].get)
|
| 79 |
+
xt_max = lambda s: max(s["xt"], key=s["xt"].get)
|
| 80 |
+
out = []
|
| 81 |
+
# quién genera más peligro
|
| 82 |
+
xa, xb = sum(a["xt"].values()), sum(b["xt"].values())
|
| 83 |
+
mas, men = (ta, tb) if xa >= xb else (tb, ta)
|
| 84 |
+
out.append(f"El modelo espera más peligro total de <b>{mas}</b> ({max(xa,xb):.2f} vs {min(xa,xb):.2f} de xT).")
|
| 85 |
+
# pasillo principal de cada uno
|
| 86 |
+
out.append(f"<b>{ta}</b> apuntaría sobre todo por {lane_es[xt_max(a)]} "
|
| 87 |
+
f"(xT {a['xt'][xt_max(a)]:.2f}); <b>{tb}</b>, por {lane_es[xt_max(b)]} "
|
| 88 |
+
f"(xT {b['xt'][xt_max(b)]:.2f}).")
|
| 89 |
+
# bloque defensivo de cada uno
|
| 90 |
+
out.append(f"Defensivamente, <b>{ta}</b> se pararía mayormente en bloque {blo_max(a)} "
|
| 91 |
+
f"y <b>{tb}</b> en bloque {blo_max(b)}.")
|
| 92 |
+
# coincidencia de pasillo (ambos cargan el mismo lado)
|
| 93 |
+
if xt_max(a) == xt_max(b) and xt_max(a) != "Centro":
|
| 94 |
+
out.append(f"Ojo: ambos cargan el mismo sector ({lane_es[xt_max(a)]}), va a ser una zona disputada.")
|
| 95 |
+
return " ".join(out)
|
src/racing_reports/reports/pre_match.py
CHANGED
|
@@ -191,6 +191,53 @@ def generate(
|
|
| 191 |
output_path=out_path,
|
| 192 |
)
|
| 193 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
return ReportBundle(
|
| 195 |
report_type="pre_match",
|
| 196 |
html_path=Path(result_path),
|
|
|
|
| 191 |
output_path=out_path,
|
| 192 |
)
|
| 193 |
|
| 194 |
+
# --- Predicción de cruce (modelos LightGBM): canchas de ataque/defensa + insight ---
|
| 195 |
+
try:
|
| 196 |
+
from racing_reports.reports import matchup_models, matchup_render
|
| 197 |
+
from racing_reports.reports.common import save_figure
|
| 198 |
+
|
| 199 |
+
if matchup_models.available():
|
| 200 |
+
pred = matchup_models.predict_matchup(league, season, home_name, away_name, a_is_home=True)
|
| 201 |
+
if pred and "error" not in pred:
|
| 202 |
+
sec = "Predicción del modelo (cruce)"
|
| 203 |
+
fig_specs = [
|
| 204 |
+
("modelo_ataque_home", f"Por dónde ataca — {home_name}", matchup_render.attack_fig(pred["a"], home_name)),
|
| 205 |
+
("modelo_defensa_home", f"Cómo defiende — {home_name}", matchup_render.defense_fig(pred["a"], home_name)),
|
| 206 |
+
("modelo_ataque_away", f"Por dónde ataca — {away_name}", matchup_render.attack_fig(pred["b"], away_name)),
|
| 207 |
+
("modelo_defensa_away", f"Cómo defiende — {away_name}", matchup_render.defense_fig(pred["b"], away_name)),
|
| 208 |
+
]
|
| 209 |
+
for nm, ttl, fig in fig_specs:
|
| 210 |
+
png_path, svg_path = save_figure(fig, out_path.parent, nm)
|
| 211 |
+
figures.append(FigureArtifact(name=nm, title=ttl, section=sec,
|
| 212 |
+
png_path=png_path, svg_path=svg_path))
|
| 213 |
+
# tabla resumen con números + insight
|
| 214 |
+
rows = []
|
| 215 |
+
for side, team in ((pred["a"], home_name), (pred["b"], away_name)):
|
| 216 |
+
rows.append({
|
| 217 |
+
"Equipo": team,
|
| 218 |
+
"Bloque alto %": round(side["bloque"]["alto"] * 100),
|
| 219 |
+
"Bloque medio %": round(side["bloque"]["medio"] * 100),
|
| 220 |
+
"Bloque bajo %": round(side["bloque"]["bajo"] * 100),
|
| 221 |
+
"Ataque Izq %": round(side["pasillo"]["Izq"] * 100),
|
| 222 |
+
"Ataque Centro %": round(side["pasillo"]["Centro"] * 100),
|
| 223 |
+
"Ataque Der %": round(side["pasillo"]["Der"] * 100),
|
| 224 |
+
"xT Izq": round(side["xt"]["Izq"], 3),
|
| 225 |
+
"xT Centro": round(side["xt"]["Centro"], 3),
|
| 226 |
+
"xT Der": round(side["xt"]["Der"], 3),
|
| 227 |
+
})
|
| 228 |
+
df_pred = pd.DataFrame(rows)
|
| 229 |
+
csv_path = out_path.parent / "modelo_cruce.csv"
|
| 230 |
+
df_pred.to_csv(csv_path, index=False)
|
| 231 |
+
preview = (f"<p style='margin-bottom:.6rem'>{matchup_render.insight(pred)}</p>"
|
| 232 |
+
+ df_pred.to_html(index=False, escape=True))
|
| 233 |
+
tables.append(TableArtifact(
|
| 234 |
+
name="modelo_cruce", title="Predicción del modelo — resumen del cruce",
|
| 235 |
+
section=sec, csv_path=csv_path, preview_html=preview))
|
| 236 |
+
elif pred and "error" in pred:
|
| 237 |
+
warnings.append("Modelo de cruce: " + str(pred["error"]))
|
| 238 |
+
except Exception as exc: # nunca romper el reporte por la predicción
|
| 239 |
+
warnings.append(f"Predicción del modelo no disponible: {exc}")
|
| 240 |
+
|
| 241 |
return ReportBundle(
|
| 242 |
report_type="pre_match",
|
| 243 |
html_path=Path(result_path),
|
vendor/data/modeling/matchup_feat_cols.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
["self_elo", "self_def_H", "self_def_M", "self_def_L", "self_atk_Der", "self_atk_Centro", "self_atk_Izq", "self_offxt_H_Der", "self_offxt_H_Centro", "self_offxt_H_Izq", "self_offxt_M_Der", "self_offxt_M_Centro", "self_offxt_M_Izq", "self_offxt_L_Der", "self_offxt_L_Centro", "self_offxt_L_Izq", "self_defxt_H_Der", "self_defxt_H_Centro", "self_defxt_H_Izq", "self_defxt_M_Der", "self_defxt_M_Centro", "self_defxt_M_Izq", "self_defxt_L_Der", "self_defxt_L_Centro", "self_defxt_L_Izq", "self_sh_buildup", "self_sh_counter", "self_sh_direct", "self_sh_setpiece", "self_sh_recovery", "self_sh_progr", "self_cross_pct", "self_pct_long", "self_pct_short", "self_pct_pass_ok", "self_n_attacks", "self_xt_total", "self_xg_total", "self_xt_per_attack", "self_n_seq", "self_n_pass", "self_n_shots", "self_avg_x", "self_avg_y", "self_share_opp_half", "self_recovery_height", "self_n_progr", "self_goals_for", "self_goals_against", "self_win", "self_xt_lane_Der", "self_xt_lane_Centro", "self_xt_lane_Izq", "selfH_def_H", "selfH_def_M", "selfH_def_L", "selfH_atk_Der", "selfH_atk_Centro", "selfH_atk_Izq", "selfH_offxt_H_Der", "selfH_offxt_H_Centro", "selfH_offxt_H_Izq", "selfH_offxt_M_Der", "selfH_offxt_M_Centro", "selfH_offxt_M_Izq", "selfH_offxt_L_Der", "selfH_offxt_L_Centro", "selfH_offxt_L_Izq", "selfH_defxt_H_Der", "selfH_defxt_H_Centro", "selfH_defxt_H_Izq", "selfH_defxt_M_Der", "selfH_defxt_M_Centro", "selfH_defxt_M_Izq", "selfH_defxt_L_Der", "selfH_defxt_L_Centro", "selfH_defxt_L_Izq", "selfH_sh_buildup", "selfH_sh_counter", "selfH_sh_direct", "selfH_sh_setpiece", "selfH_sh_recovery", "selfH_sh_progr", "selfH_cross_pct", "selfH_pct_long", "selfH_pct_short", "selfH_pct_pass_ok", "selfH_n_attacks", "selfH_xt_total", "selfH_xg_total", "selfH_xt_per_attack", "selfH_n_seq", "selfH_n_pass", "selfH_n_shots", "selfH_avg_x", "selfH_avg_y", "selfH_share_opp_half", "selfH_recovery_height", "selfH_n_progr", "selfH_goals_for", "selfH_goals_against", "selfH_win", "selfH_xt_lane_Der", "selfH_xt_lane_Centro", "selfH_xt_lane_Izq", "selfA_def_H", "selfA_def_M", "selfA_def_L", "selfA_atk_Der", "selfA_atk_Centro", "selfA_atk_Izq", "selfA_offxt_H_Der", "selfA_offxt_H_Centro", "selfA_offxt_H_Izq", "selfA_offxt_M_Der", "selfA_offxt_M_Centro", "selfA_offxt_M_Izq", "selfA_offxt_L_Der", "selfA_offxt_L_Centro", "selfA_offxt_L_Izq", "selfA_defxt_H_Der", "selfA_defxt_H_Centro", "selfA_defxt_H_Izq", "selfA_defxt_M_Der", "selfA_defxt_M_Centro", "selfA_defxt_M_Izq", "selfA_defxt_L_Der", "selfA_defxt_L_Centro", "selfA_defxt_L_Izq", "selfA_sh_buildup", "selfA_sh_counter", "selfA_sh_direct", "selfA_sh_setpiece", "selfA_sh_recovery", "selfA_sh_progr", "selfA_cross_pct", "selfA_pct_long", "selfA_pct_short", "selfA_pct_pass_ok", "selfA_n_attacks", "selfA_xt_total", "selfA_xg_total", "selfA_xt_per_attack", "selfA_n_seq", "selfA_n_pass", "selfA_n_shots", "selfA_avg_x", "selfA_avg_y", "selfA_share_opp_half", "selfA_recovery_height", "selfA_n_progr", "selfA_goals_for", "selfA_goals_against", "selfA_win", "selfA_xt_lane_Der", "selfA_xt_lane_Centro", "selfA_xt_lane_Izq", "opp_self_elo", "opp_self_def_H", "opp_self_def_M", "opp_self_def_L", "opp_self_atk_Der", "opp_self_atk_Centro", "opp_self_atk_Izq", "opp_self_offxt_H_Der", "opp_self_offxt_H_Centro", "opp_self_offxt_H_Izq", "opp_self_offxt_M_Der", "opp_self_offxt_M_Centro", "opp_self_offxt_M_Izq", "opp_self_offxt_L_Der", "opp_self_offxt_L_Centro", "opp_self_offxt_L_Izq", "opp_self_defxt_H_Der", "opp_self_defxt_H_Centro", "opp_self_defxt_H_Izq", "opp_self_defxt_M_Der", "opp_self_defxt_M_Centro", "opp_self_defxt_M_Izq", "opp_self_defxt_L_Der", "opp_self_defxt_L_Centro", "opp_self_defxt_L_Izq", "opp_self_sh_buildup", "opp_self_sh_counter", "opp_self_sh_direct", "opp_self_sh_setpiece", "opp_self_sh_recovery", "opp_self_sh_progr", "opp_self_cross_pct", "opp_self_pct_long", "opp_self_pct_short", "opp_self_pct_pass_ok", "opp_self_n_attacks", "opp_self_xt_total", "opp_self_xg_total", "opp_self_xt_per_attack", "opp_self_n_seq", "opp_self_n_pass", "opp_self_n_shots", "opp_self_avg_x", "opp_self_avg_y", "opp_self_share_opp_half", "opp_self_recovery_height", "opp_self_n_progr", "opp_self_goals_for", "opp_self_goals_against", "opp_self_win", "opp_self_xt_lane_Der", "opp_self_xt_lane_Centro", "opp_self_xt_lane_Izq", "opp_selfH_def_H", "opp_selfH_def_M", "opp_selfH_def_L", "opp_selfH_atk_Der", "opp_selfH_atk_Centro", "opp_selfH_atk_Izq", "opp_selfH_offxt_H_Der", "opp_selfH_offxt_H_Centro", "opp_selfH_offxt_H_Izq", "opp_selfH_offxt_M_Der", "opp_selfH_offxt_M_Centro", "opp_selfH_offxt_M_Izq", "opp_selfH_offxt_L_Der", "opp_selfH_offxt_L_Centro", "opp_selfH_offxt_L_Izq", "opp_selfH_defxt_H_Der", "opp_selfH_defxt_H_Centro", "opp_selfH_defxt_H_Izq", "opp_selfH_defxt_M_Der", "opp_selfH_defxt_M_Centro", "opp_selfH_defxt_M_Izq", "opp_selfH_defxt_L_Der", "opp_selfH_defxt_L_Centro", "opp_selfH_defxt_L_Izq", "opp_selfH_sh_buildup", "opp_selfH_sh_counter", "opp_selfH_sh_direct", "opp_selfH_sh_setpiece", "opp_selfH_sh_recovery", "opp_selfH_sh_progr", "opp_selfH_cross_pct", "opp_selfH_pct_long", "opp_selfH_pct_short", "opp_selfH_pct_pass_ok", "opp_selfH_n_attacks", "opp_selfH_xt_total", "opp_selfH_xg_total", "opp_selfH_xt_per_attack", "opp_selfH_n_seq", "opp_selfH_n_pass", "opp_selfH_n_shots", "opp_selfH_avg_x", "opp_selfH_avg_y", "opp_selfH_share_opp_half", "opp_selfH_recovery_height", "opp_selfH_n_progr", "opp_selfH_goals_for", "opp_selfH_goals_against", "opp_selfH_win", "opp_selfH_xt_lane_Der", "opp_selfH_xt_lane_Centro", "opp_selfH_xt_lane_Izq", "opp_selfA_def_H", "opp_selfA_def_M", "opp_selfA_def_L", "opp_selfA_atk_Der", "opp_selfA_atk_Centro", "opp_selfA_atk_Izq", "opp_selfA_offxt_H_Der", "opp_selfA_offxt_H_Centro", "opp_selfA_offxt_H_Izq", "opp_selfA_offxt_M_Der", "opp_selfA_offxt_M_Centro", "opp_selfA_offxt_M_Izq", "opp_selfA_offxt_L_Der", "opp_selfA_offxt_L_Centro", "opp_selfA_offxt_L_Izq", "opp_selfA_defxt_H_Der", "opp_selfA_defxt_H_Centro", "opp_selfA_defxt_H_Izq", "opp_selfA_defxt_M_Der", "opp_selfA_defxt_M_Centro", "opp_selfA_defxt_M_Izq", "opp_selfA_defxt_L_Der", "opp_selfA_defxt_L_Centro", "opp_selfA_defxt_L_Izq", "opp_selfA_sh_buildup", "opp_selfA_sh_counter", "opp_selfA_sh_direct", "opp_selfA_sh_setpiece", "opp_selfA_sh_recovery", "opp_selfA_sh_progr", "opp_selfA_cross_pct", "opp_selfA_pct_long", "opp_selfA_pct_short", "opp_selfA_pct_pass_ok", "opp_selfA_n_attacks", "opp_selfA_xt_total", "opp_selfA_xg_total", "opp_selfA_xt_per_attack", "opp_selfA_n_seq", "opp_selfA_n_pass", "opp_selfA_n_shots", "opp_selfA_avg_x", "opp_selfA_avg_y", "opp_selfA_share_opp_half", "opp_selfA_recovery_height", "opp_selfA_n_progr", "opp_selfA_goals_for", "opp_selfA_goals_against", "opp_selfA_win", "opp_selfA_xt_lane_Der", "opp_selfA_xt_lane_Centro", "opp_selfA_xt_lane_Izq", "is_home", "elo_diff", "form_4231", "form_4231.0", "form_433", "form_442", "form_3421", "form_433.0", "form_442.0", "form_3421.0", "form_352", "form_4141.0", "form_343", "form_4141"]
|
vendor/data/modeling/model_bloques__def_H.txt
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
vendor/data/modeling/model_bloques__def_L.txt
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
vendor/data/modeling/model_bloques__def_M.txt
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
vendor/data/modeling/model_pasillos__atk_Centro.txt
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
vendor/data/modeling/model_pasillos__atk_Der.txt
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
vendor/data/modeling/model_pasillos__atk_Izq.txt
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
vendor/data/modeling/model_peligro_pasillo__xt_lane_Centro.txt
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
vendor/data/modeling/model_peligro_pasillo__xt_lane_Der.txt
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
vendor/data/modeling/model_peligro_pasillo__xt_lane_Izq.txt
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
vendor/data/modeling/team_features_latest.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:d5a3e60860e442bab0870bad04bebfded42d96dd9a039d64bcabc3b28f759703
|
| 3 |
+
size 1604608
|