RRC / vendor /scripts /compare_block_methods_seq.py
pablogrois's picture
Deploy MVP: API JSON + SPA + bundle/cache de artifacts CORE
e58615a
Raw
History Blame Contribute Delete
23.4 kB
"""
compare_block_methods_seq.py
-----------------------------
Igual que compare_block_methods.py pero usando sequenceId (Opta) como unidad base
en lugar de possessionId. Una sequence no cruza interrupciones (faltas, saques, etc.),
por lo que es una unidad más limpia para clasificar el bloque defensivo.
Todo se computa desde el preprocessed — no se necesitan los xlsx de raw events.
"""
import ast
import warnings
from pathlib import Path
import numpy as np
import pandas as pd
import plotly.graph_objects as go
from plotly.subplots import make_subplots
warnings.filterwarnings("ignore")
PREPROCESSED = Path("/Users/pagrois/Documents/Racing/preprocessed_SSD_25-26.csv")
REPORTS_DIR = Path("/Users/pagrois/Racing/reports")
REPORTS_DIR.mkdir(parents=True, exist_ok=True)
DARK_BG = "#1A1F2E"
CARD_BG = "#232A3B"
TEXT_COLOR = "#E8EDF5"
GRID_COLOR = "#2E3650"
COLORS = {"High": "#E06C5F", "Medium": "#F0A500", "Low": "#00A86B"}
BLOCK_LBL = {"High": "Bloque Alto", "Medium": "Bloque Medio", "Low": "Bloque Bajo"}
BLOCKS = ["High", "Medium", "Low"]
PHASE_TO_BLOCK = {
"Build Up against High Block": "High",
"Build Up against Medium Block": "Medium",
"Build Up against Low Block": "Low",
}
SHOT_EVENTS = {"SavedShot","MissedShots","Goal","ShotOnPost","KeeperSweeper"}
PERIOD_COL = "period_id"
def base_layout(**extra):
d = dict(paper_bgcolor=CARD_BG, plot_bgcolor=DARK_BG,
font=dict(color=TEXT_COLOR, size=11),
margin=dict(l=60, r=20, t=55, b=50),
legend=dict(bgcolor=CARD_BG, bordercolor=GRID_COLOR, borderwidth=1))
d.update(extra); return d
def axis_style(**kw):
d = dict(gridcolor=GRID_COLOR, zerolinecolor=GRID_COLOR, linecolor=GRID_COLOR)
d.update(kw); return d
def fig2html(fig):
return fig.to_html(full_html=False, include_plotlyjs=False)
def n_opts(v):
if pd.isna(v): return 0
try:
d = ast.literal_eval(str(v))
return len(d.get("player", [])) if isinstance(d, dict) else 0
except: return 0
# ---------------------------------------------------------------------------
# 1. Cargar preprocessed
# ---------------------------------------------------------------------------
print("Cargando preprocessed...", flush=True)
load_cols = ["matchId", "sequenceId", "phaseLabel", "TeamName", "TeamRival",
"fecha", PERIOD_COL, "minute", "second", "x", "pressure",
"passOption", "event_name"]
df_pre = pd.read_csv(PREPROCESSED, usecols=load_cols, low_memory=False)
df_pre["matchId"] = df_pre["matchId"].astype(str)
df_pre["sequenceId"] = df_pre["sequenceId"].astype(float).astype(str)
df_pre["block_raw"] = df_pre["phaseLabel"].map(PHASE_TO_BLOCK)
df_pre[PERIOD_COL] = pd.to_numeric(df_pre[PERIOD_COL], errors="coerce")
df_pre = df_pre[df_pre[PERIOD_COL].isin([1, 2])]
df_pre["x_val"] = pd.to_numeric(df_pre["x"], errors="coerce")
df_pre["is_pass"] = df_pre["event_name"] == "Pass"
df_pre["is_key"] = df_pre["event_name"].isin({"Pass"} | SHOT_EVENTS)
df_pre["has_press"] = df_pre["pressure"].notna()
df_pre["n_opt"] = df_pre["passOption"].apply(n_opts)
df_pre["fecha_str"] = df_pre["fecha"].str[:10]
print(f" {df_pre['matchId'].nunique()} partidos, "
f"{df_pre.groupby(['matchId','sequenceId']).ngroups:,} sequences totales")
# Subconjunto Racing
racing_mask = (df_pre["TeamName"].str.contains("Racing de Santander", na=False) |
df_pre["TeamRival"].str.contains("Racing de Santander", na=False))
df_racing = df_pre[racing_mask].copy()
has_label_mids = set(df_racing.dropna(subset=["block_raw"])["matchId"])
match_dates = (df_racing[df_racing["matchId"].isin(has_label_mids)]
.groupby("matchId")["fecha_str"].first().sort_values())
all_racing_ids = match_dates.index.tolist()
match_info = {}
for mid in all_racing_ids:
r = df_racing[df_racing["matchId"] == mid].iloc[0]
home = r["TeamName"]; away = r["TeamRival"]
is_racing_home = "Racing" in home
rival = away if is_racing_home else home
match_info[mid] = {
"fecha": r["fecha_str"],
"home": home, "away": away,
"racing_local": is_racing_home,
"rival": rival.replace("Racing de Santander", "Racing"),
"label": r["fecha_str"] + " " + rival.replace("Racing de Santander","Racing")[:16],
}
print(f" {len(all_racing_ids)} partidos Racing con etiquetas phaseLabel")
df_ar = df_racing[df_racing["matchId"].isin(all_racing_ids)].copy()
# Equipo y minuto de inicio de cada sequence
seq_info = (df_ar.dropna(subset=["sequenceId"])
.sort_values(["matchId","sequenceId","minute","second"])
.groupby(["matchId","sequenceId"])[["TeamName", PERIOD_COL,"minute","second"]]
.first().reset_index())
seq_info["is_racing_att"] = seq_info["TeamName"].str.contains("Racing de Santander", na=False)
# ---------------------------------------------------------------------------
# 2. Raw stats por sequence — desde preprocessed
# ---------------------------------------------------------------------------
print("\nComputando stats por sequence...", flush=True)
raw_stats = {}
for (mid, sid), grp in df_ar.dropna(subset=["sequenceId"]).groupby(["matchId","sequenceId"]):
press_grp = grp[grp["has_press"] & grp["x_val"].notna()]
n_press_own = int((press_grp["x_val"] < 50).sum())
pass_rival = grp[grp["is_pass"] & grp["x_val"].notna() & (grp["x_val"] > 50)]
pass_7opt = int((pass_rival["n_opt"] >= 7).sum())
pass_own = grp[grp["is_pass"] & grp["x_val"].notna() & (grp["x_val"] < 50)]
max_opts_own = int(pass_own["n_opt"].max()) if len(pass_own) > 0 else 0
raw_stats[(mid, sid)] = {
"n_passes": int(grp["is_pass"].sum()),
"pass_7opt_rival": pass_7opt,
"max_opts_own": max_opts_own,
"n_press_own": n_press_own,
"n_key_events": int(grp["is_key"].sum()),
}
key_ok = {(m, s) for (m, s), v in raw_stats.items() if v.get("n_key_events", 0) >= 3}
print(f" {len(raw_stats):,} sequences Racing con stats | {len(key_ok):,} con ≥3 eventos clave")
# ---------------------------------------------------------------------------
# 3. Eventos de bloque por sequence
# ---------------------------------------------------------------------------
block_ev_racing = (df_ar.dropna(subset=["block_raw","sequenceId"])
.groupby(["matchId","sequenceId","block_raw"])
.size().reset_index(name="n_ev"))
# SIMPLE global (toda la liga, ≥3 eventos de bloque totales)
block_ev_global = (df_pre.dropna(subset=["block_raw","sequenceId"])
.groupby(["matchId","sequenceId","block_raw"])
.size().reset_index(name="n_ev"))
# ---------------------------------------------------------------------------
# 4. MÉTODO SIMPLE — toda la liga
# ---------------------------------------------------------------------------
print("\nMétodo SIMPLE (toda la liga)...", flush=True)
simple_global_rows = []
for (mid, sid), grp in block_ev_global.groupby(["matchId","sequenceId"]):
if grp["n_ev"].sum() < 3: continue
dominant = grp.loc[grp["n_ev"].idxmax(), "block_raw"]
simple_global_rows.append({"matchId": mid, "sequenceId": sid,
"block": dominant, "weight": 1.0})
df_simple_global = pd.DataFrame(simple_global_rows) if simple_global_rows else pd.DataFrame(
columns=["matchId","sequenceId","block","weight"])
print(f" {len(df_simple_global):,} sequences (liga completa)")
# SIMPLE Racing
simple_rows = []
for (mid, sid), grp in block_ev_racing.groupby(["matchId","sequenceId"]):
if (mid, sid) not in key_ok: continue
dominant = grp.loc[grp["n_ev"].idxmax(), "block_raw"]
simple_rows.append({"matchId": mid, "sequenceId": sid,
"block": dominant, "weight": 1.0})
df_simple = pd.DataFrame(simple_rows) if simple_rows else pd.DataFrame(
columns=["matchId","sequenceId","block","weight"])
df_simple = df_simple.merge(seq_info, on=["matchId","sequenceId"], how="left")
print(f" {len(df_simple):,} sequences Racing")
# ---------------------------------------------------------------------------
# 5. MÉTODO FILTRADO — Racing, con multi-bloque para sequences largas (≥7 pases)
# ---------------------------------------------------------------------------
print("\nMétodo FILTRADO (Racing)...", flush=True)
def resolve_blocks(grp):
max_n = grp["n_ev"].max()
tied = set(grp[grp["n_ev"] == max_n]["block_raw"])
if len(tied) == 1: return list(tied)
valid = set(grp[(grp["n_ev"] >= 3) & grp["block_raw"].isin(tied)]["block_raw"])
blocks = list(valid) if valid else list(tied)
if len(blocks) > 1 and "Medium" in blocks:
nm = [b for b in blocks if b != "Medium"]
if nm: blocks = nm
return blocks
filt_rows = []
labeled_seqs = set()
for (mid, sid), grp in block_ev_racing.groupby(["matchId","sequenceId"]):
if (mid, sid) not in key_ok: continue
labeled_seqs.add((mid, sid))
n_passes = raw_stats.get((mid, sid), {}).get("n_passes", 0)
total_block_ev = grp["n_ev"].sum()
# Multi-bloque: ≥7 pases, y cada bloque debe tener ≥3 eventos
grp_multi = grp[grp["n_ev"] >= 3]
if n_passes >= 7 and len(grp_multi) > 1:
total_multi_ev = grp_multi["n_ev"].sum()
for _, row in grp_multi.iterrows():
filt_rows.append({"matchId": mid, "sequenceId": sid,
"block": row["block_raw"],
"weight": row["n_ev"] / total_multi_ev})
else:
blocks = resolve_blocks(grp)
w = 1.0 / len(blocks)
for b in blocks:
filt_rows.append({"matchId": mid, "sequenceId": sid, "block": b, "weight": w})
# Heurística para sequences sin etiqueta phaseLabel
for (mid, sid), s in raw_stats.items():
if (mid, sid) in labeled_seqs: continue
if s.get("n_passes", 0) <= 3: continue
if (mid, sid) not in key_ok: continue
is_high = s["n_press_own"] > 3 or s.get("max_opts_own", 0) >= 5
b = "Low" if s["pass_7opt_rival"] >= 2 else ("High" if is_high else "Medium")
filt_rows.append({"matchId": mid, "sequenceId": sid, "block": b, "weight": 1.0})
df_fx_all = pd.DataFrame(filt_rows) if filt_rows else pd.DataFrame(
columns=["matchId","sequenceId","block","weight"])
df_fx_all = df_fx_all.merge(seq_info, on=["matchId","sequenceId"], how="left")
# Filtro ≥3 eventos clave
df_fx_all = df_fx_all[df_fx_all.apply(
lambda r: (r["matchId"], r["sequenceId"]) in key_ok, axis=1)].copy()
# Reclasificación High→Med
df_fx_all["n_press_own"] = df_fx_all.apply(
lambda r: raw_stats.get((r["matchId"],r["sequenceId"]),{}).get("n_press_own",0), axis=1)
df_fx_all["max_opts_own"] = df_fx_all.apply(
lambda r: raw_stats.get((r["matchId"],r["sequenceId"]),{}).get("max_opts_own",0), axis=1)
matches_with_press = {m for (m,s), v in raw_stats.items() if v.get("n_press_own",0) > 0}
mask_am = ((df_fx_all["block"] == "High") &
(df_fx_all["n_press_own"] < 1) &
(df_fx_all["max_opts_own"] < 5) &
(df_fx_all["matchId"].isin(matches_with_press)))
df_fx_all.loc[mask_am, "block"] = "Medium"
reclassif_am = int(mask_am.sum())
n_seq_fx = df_fx_all[["matchId","sequenceId"]].drop_duplicates().shape[0]
print(f" Reclasificados High→Med: {reclassif_am}")
print(f" {n_seq_fx:,} sequences únicas Racing clasificadas ({len(df_fx_all):,} filas)")
# ---------------------------------------------------------------------------
# 6. Distribuciones
# ---------------------------------------------------------------------------
def weighted_dist(df):
total_w = df["weight"].sum()
if total_w == 0: return {b: 0.0 for b in BLOCKS}, 0
dist = {b: 100 * df[df["block"]==b]["weight"].sum() / total_w for b in BLOCKS}
n = df[["matchId","sequenceId"]].drop_duplicates().shape[0]
return dist, n
def global_dist(df, role_val):
return weighted_dist(df[df["is_racing_att"] == role_val])
def global_dist_all(df):
return weighted_dist(df)
def dist_by_match(df, role_val):
sub = df[df["is_racing_att"] == role_val].copy()
rows = []
for mid in all_racing_ids:
m_sub = sub[sub["matchId"] == mid]
total_w = max(m_sub["weight"].sum(), 1e-9)
rows.append({
"matchId": mid,
"label": match_info[mid]["label"],
"total": m_sub[["matchId","sequenceId"]].drop_duplicates().shape[0],
**{b: 100 * m_sub[m_sub["block"]==b]["weight"].sum() / total_w for b in BLOCKS}
})
return pd.DataFrame(rows)
simple_global_dist, simple_global_n = global_dist_all(df_simple_global)
simple_att_dist, simple_att_n = global_dist(df_simple, True)
simple_def_dist, simple_def_n = global_dist(df_simple, False)
fx_att_dist, fx_att_n = global_dist(df_fx_all, True)
fx_def_dist, fx_def_n = global_dist(df_fx_all, False)
def dominant_block(grp):
return grp.loc[grp["weight"].idxmax(), "block"]
simple_dom = (df_simple.groupby(["matchId","sequenceId"])
.apply(dominant_block).reset_index(name="block_simple"))
fx_dom = (df_fx_all.groupby(["matchId","sequenceId"])
.apply(dominant_block).reset_index(name="block_fx"))
merged = simple_dom.merge(fx_dom, on=["matchId","sequenceId"], how="inner")
differ = (merged["block_simple"] != merged["block_fx"]).sum()
total_shared = len(merged)
print(f"\n Sequences en común: {total_shared:,} | Difieren: {differ:,} ({100*differ/max(total_shared,1):.1f}%)")
# ---------------------------------------------------------------------------
# 7. Gráficos
# ---------------------------------------------------------------------------
print("\nGenerando gráficos...", flush=True)
# ── Fig 1: Donuts globales ───────────────────────────────────────────────────
fig1 = make_subplots(rows=1, cols=5, specs=[[{"type":"domain"}]*5],
subplot_titles=["SIMPLE — Toda la liga",
"SIMPLE — Racing ataca","SIMPLE — Racing defiende",
"FILTRADO — Racing ataca","FILTRADO — Racing defiende"])
for col, (dist, n) in enumerate([
(simple_global_dist, simple_global_n),
(simple_att_dist, simple_att_n),
(simple_def_dist, simple_def_n),
(fx_att_dist, fx_att_n),
(fx_def_dist, fx_def_n),
], 1):
fig1.add_trace(go.Pie(
labels=[BLOCK_LBL[b] for b in BLOCKS],
values=[round(dist[b], 1) for b in BLOCKS],
marker_colors=[COLORS[b] for b in BLOCKS],
hole=0.55, textinfo="label+percent",
textfont=dict(size=9, color=TEXT_COLOR),
hovertemplate="%{label}: %{value:.1f}%<extra></extra>",
name=f"n={n:,}",
), row=1, col=col)
col_sfx = "" if col == 1 else str(col)
fig1.add_annotation(
text=f"n={n:,}", showarrow=False, font=dict(size=8, color=TEXT_COLOR),
xref=f"x{col_sfx} domain", yref=f"y{col_sfx} domain", x=0.5, y=0.5,
)
fig1.update_layout(**base_layout(
title=dict(text="Distribución global por SEQUENCE — SIMPLE (toda la liga) vs FILTRADO (Racing)",
font=dict(color=TEXT_COLOR, size=13), x=0.01),
height=380,
))
# ── Fig 2 & 3: Por partido ───────────────────────────────────────────────────
def all_match_bar_fig(df_s, df_f, role_val, title):
labels = []; vals_s = {b: [] for b in BLOCKS}; vals_f = {b: [] for b in BLOCKS}
for mid in all_racing_ids:
short = match_info[mid]["label"]
labels.append(f"{short[:5]} {short[11:]}")
for df_, vals in [(df_s, vals_s), (df_f, vals_f)]:
m_sub = df_[(df_["matchId"] == mid) & (df_["is_racing_att"] == role_val)]
total_w = max(m_sub["weight"].sum(), 1e-9)
for b in BLOCKS:
vals[b].append(100 * m_sub[m_sub["block"]==b]["weight"].sum() / total_w)
fig = make_subplots(rows=2, cols=1, shared_xaxes=True,
subplot_titles=["SIMPLE","FILTRADO"], vertical_spacing=0.06)
for b in BLOCKS:
for row, vals in [(1, vals_s), (2, vals_f)]:
texts = [f"{v:.0f}%" if v >= 8 else "" for v in vals[b]]
fig.add_trace(go.Bar(
name=BLOCK_LBL[b], x=labels, y=vals[b], marker_color=COLORS[b],
text=texts, textposition="inside", textfont=dict(size=8, color="white"),
showlegend=(row == 1), legendgroup=b,
), row=row, col=1)
fig.update_layout(**base_layout(
title=dict(text=title, font=dict(color=TEXT_COLOR, size=13), x=0.01),
barmode="stack", height=700,
xaxis2=dict(**axis_style(tickangle=-45, tickfont=dict(size=8))),
yaxis=dict(**axis_style(title="% sequences", range=[0,100])),
yaxis2=dict(**axis_style(title="% sequences", range=[0,100])),
))
return fig
fig2 = all_match_bar_fig(df_simple, df_fx_all, True, "Por partido — Racing ATACA")
fig3 = all_match_bar_fig(df_simple, df_fx_all, False, "Por partido — Racing DEFIENDE")
# ── Fig 4: % que difieren por partido ────────────────────────────────────────
merged_m = merged.merge(seq_info[["matchId","sequenceId","TeamName"]],
on=["matchId","sequenceId"], how="left")
diff_pm = (merged_m.groupby("matchId")
.apply(lambda g: pd.Series({
"total": len(g),
"difieren": (g["block_simple"] != g["block_fx"]).sum(),
})).reset_index())
diff_pm["pct"] = 100 * diff_pm["difieren"] / diff_pm["total"].clip(lower=1)
diff_pm["label"] = diff_pm["matchId"].map(lambda m: match_info.get(m,{}).get("label","?"))
diff_pm = diff_pm.sort_values("matchId")
fig4 = go.Figure()
fig4.add_trace(go.Bar(
x=diff_pm["label"], y=diff_pm["pct"],
marker_color=COLORS["Medium"],
text=[f"{v:.0f}%" for v in diff_pm["pct"]],
textposition="outside", textfont=dict(color=TEXT_COLOR, size=9),
))
fig4.update_layout(**base_layout(
title=dict(text="% sequences con distinta clasificación entre Simple y Filtrado (Racing)",
font=dict(color=TEXT_COLOR, size=13), x=0.01),
height=380,
xaxis=dict(**axis_style(tickangle=-45, tickfont=dict(size=8))),
yaxis=dict(**axis_style(title="% difieren", range=[0,100])),
))
# ── Fig 5: Matriz de transición ──────────────────────────────────────────────
trans = merged.groupby(["block_simple","block_fx"]).size().reset_index(name="n")
fig5 = go.Figure(go.Heatmap(
x=[BLOCK_LBL[b] for b in BLOCKS],
y=[BLOCK_LBL[b] for b in BLOCKS],
z=[[trans[(trans["block_simple"]==bs)&(trans["block_fx"]==bc)]["n"].sum()
for bc in BLOCKS] for bs in BLOCKS],
colorscale="Blues",
text=[[str(int(trans[(trans["block_simple"]==bs)&(trans["block_fx"]==bc)]["n"].sum()))
for bc in BLOCKS] for bs in BLOCKS],
texttemplate="%{text}", textfont=dict(size=12, color=TEXT_COLOR),
hovertemplate="Simple=%{y}, Filtrado=%{x}: %{text}<extra></extra>",
xgap=2, ygap=2,
))
fig5.update_layout(**base_layout(
title=dict(text="Transición Simple → Filtrado (Racing)",
font=dict(color=TEXT_COLOR, size=13), x=0.01),
height=320,
xaxis=dict(**axis_style(title="Filtrado")),
yaxis=dict(**axis_style(title="Simple")),
))
# ---------------------------------------------------------------------------
# 8. HTML
# ---------------------------------------------------------------------------
print("Generando HTML...", flush=True)
def stat_box(label, val, color="#F0A500"):
return (f'<div class="stat"><span class="stat-val" style="color:{color}">{val}</span>'
f'<span class="stat-lbl">{label}</span></div>')
html = f"""<!DOCTYPE html>
<html lang="es"><head>
<meta charset="UTF-8">
<title>Comparación métodos bloque (sequenceId) — Racing</title>
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
<style>
*{{box-sizing:border-box;margin:0;padding:0}}
body{{background:{DARK_BG};color:{TEXT_COLOR};font-family:'Segoe UI',sans-serif;padding:28px}}
h1{{font-size:1.5rem;margin-bottom:6px}}
h2{{font-size:1rem;color:#8A97A5;margin:28px 0 10px;text-transform:uppercase;
letter-spacing:.5px;border-bottom:1px solid {GRID_COLOR};padding-bottom:5px}}
.subtitle{{color:#8A97A5;font-size:.85rem;margin-bottom:24px;line-height:1.7}}
.card{{background:{CARD_BG};border-radius:10px;padding:16px;margin-bottom:18px}}
.note{{color:#8A97A5;font-size:.78rem;margin:0 0 8px;line-height:1.6}}
.stats{{display:flex;gap:16px;flex-wrap:wrap;margin-bottom:22px}}
.stat{{background:{CARD_BG};border-radius:8px;padding:12px 20px;text-align:center;min-width:130px}}
.stat-val{{display:block;font-size:1.6rem;font-weight:700}}
.stat-lbl{{display:block;font-size:.75rem;color:#8A97A5;margin-top:2px}}
</style></head>
<body>
<h1>Comparación de métodos de bloque — unidad: <em>sequenceId</em> — Racing de Santander</h1>
<div class="subtitle">
Unidad de análisis: <b>sequenceId de Opta</b> (más granular que possessionId —
no cruza interrupciones como faltas o saques de banda).<br>
<b>SIMPLE:</b> phaseLabel dominante · filtro ≥3 eventos de bloque · sin reclasificaciones ·
<em>Global = toda la liga ({df_pre['matchId'].nunique()} partidos)</em><br>
<b>FILTRADO:</b> phaseLabel con desempate · filtro ≥3 eventos clave ·
multi-bloque ponderado para sequences largas (≥7 pases) ·
High→Med si 0 presiones Y defensor sin ≥5 jugadores en campo Racing ·
heurística para sequences sin etiqueta · <em>Solo Racing ({len(all_racing_ids)} partidos)</em>
</div>
<div class="stats">
{stat_box("SIMPLE — toda la liga", f"{simple_global_n:,}")}
{stat_box("SIMPLE — Racing ataca", f"{simple_att_n:,}")}
{stat_box("SIMPLE — Racing defiende", f"{simple_def_n:,}")}
{stat_box("FILTRADO — Racing ataca", f"{fx_att_n:,}")}
{stat_box("FILTRADO — Racing defiende", f"{fx_def_n:,}")}
{stat_box("En común (Racing)", f"{total_shared:,}")}
{stat_box("Difieren", f"{differ:,} ({100*differ/max(total_shared,1):.1f}%)", "#E06C5F")}
{stat_box("Reclasif. High→Med", f"{reclassif_am:,}")}
</div>
<h2>1. Distribución global — donuts</h2>
<div class="card">{fig2html(fig1)}</div>
<h2>2. Por partido — Racing ataca (Simple arriba · Filtrado abajo)</h2>
<div class="card">{fig2html(fig2)}</div>
<h2>3. Por partido — Racing defiende (Simple arriba · Filtrado abajo)</h2>
<div class="card">{fig2html(fig3)}</div>
<h2>4. % sequences con clasificación distinta entre métodos (Racing)</h2>
<p class="note">Calculado sobre sequences presentes en ambos métodos.</p>
<div class="card">{fig2html(fig4)}</div>
<h2>5. Matriz de transición Simple → Filtrado (Racing)</h2>
<p class="note">Fila = Simple · Columna = Filtrado · valores = n sequences</p>
<div class="card">{fig2html(fig5)}</div>
</body></html>"""
out = REPORTS_DIR / "comparacion_metodos_bloque_seq.html"
out.write_text(html, encoding="utf-8")
print(f"\nReporte: {out}")