Los Four Factors en la ACB: qué pesa y qué lo mueve¶
bop analytics · informe de análisis de datos
En síntesis. Se miden los pesos de los Four Factors de Dean Oliver en la ACB sobre 3.126 partidos de diez temporadas (2016/17 a 2025/26) y se comparan con los que él propuso. El orden se mantiene: tiro, pérdidas, rebote, tiros libres. Los pesos, no: tiro 47 % [46–47] frente a 40; pérdidas 24 % [23–24] frente a 25; rebote 20 % [20–21] frente a 20; tiros libres 9 % [9–10] frente a 15. Los intervalos son de un punto: la diferencia con Oliver en tiro y tiros libres es real; en pérdidas y rebote, coinciden. Después se baja un nivel, sobre 180 equipos-temporada: qué decisiones de estilo se asocian con cada factor. Cada factor tiene entre tres y cuatro palancas con intervalo fuera del cero. Las principales: acierto y % de canastas asistidas (r +0,38 [+0,25, +0,49]); pérdidas y cuota interior (+0,29 [+0,14, +0,42]) o, en sentido contrario, cuota de triples (−0,28 [−0,39, −0,16]); rebote ofensivo y menos cuota de media distancia (−0,29 [−0,43, −0,13]); tiros libres y ritmo (+0,29 [+0,15, +0,42]) y menos media distancia (−0,26 [−0,40, −0,10]). Dos decisiones resumen el patrón: mover el balón y no tirar de media distancia. Y dos creencias no aparecen a nivel equipo: tirar cerca del aro no se asocia con más tiros libres (+0,12 [−0,02, +0,25]) ni el rebote ofensivo con conceder transición (+0,07 [−0,14, +0,29]). El reloj de posesión importa dentro de la posesión (eFG 60,6 % en tiros rápidos, 49,5 % en elaborados) pero no como rasgo de equipo: el 37 % de los tiros rápidos nace de un rebote ofensivo propio, y esa cuota de palmeos va con menos acierto y más pérdidas; la transición limpia no mejora el acierto del conjunto. Asociación, no causa.
Contenido¶
- Los Four Factors de Dean Oliver · 2. Datos y método · 3. Los pesos en la ACB frente a Oliver ·
- Las palancas: qué mueve cada factor · 5. Reloj de posesión: eficiente dentro de la posesión, no como rasgo de equipo · 6. El patrón: dos decisiones · 7. Conclusiones y cautelas.
1 · Los Four Factors de Dean Oliver¶
En Basketball on Paper (2004), Dean Oliver propuso resumir qué decide un partido en cuatro factores que se leen en el acta:
- Tiro: eFG% (acierto de campo con el triple ponderado).
- Pérdidas: TOV% (balones perdidos por posesión).
- Rebote: OREB% (rebotes ofensivos capturados sobre los disponibles).
- Tiros libres: FTM/FGA (puntos desde la línea por tiro de campo).
Y les asignó unos pesos orientativos: 40 · 25 · 20 · 15. Esos pesos no salen de un ajuste estadístico: la revisión de Poropudas (2023) los describe como una ponderación ad hoc, y los trabajos que los han estimado por regresión sobre la NBA (Jacobs, 2017; Poropudas, 2023) dan más peso al tiro y menos a los tiros libres. Aquí no se usan esas cifras: se miden los pesos en la ACB, con datos propios y un método explícito, y se comparan con los de Oliver en orden y proporción.
La segunda pregunta va un nivel más abajo. Un factor es un resultado, no una decisión: nadie entrena "meted más". ¿Qué decisiones de estilo que un equipo controla (de dónde tira, cuánto mueve el balón) se asocian con cada factor?
2 · Datos y método¶
- Fuente: medallion ACB (
silver_equipo_partido,silver_equipo_temporada,gold_estilo_equipo,silver_tiro), diez temporadas de Liga Endesa, 2016/17 a 2025/26. - Pesos: un partido = una fila (perspectiva del local). Cada factor en diferencial local − visitante, estandarizado (z-score) por temporada. Regresión lineal del margen sobre los cuatro diferenciales; el peso de un factor es su coeficiente en valor absoluto normalizado a 100. Modelo explicativo (qué decidió el partido jugado), no predictivo.
- Palancas: grano equipo × temporada. Correlación de Pearson entre cada factor y cada palanca de
estilo. Asociación, no causa. Las palancas son cuotas, no aciertos:
- cuota de tiros por zona (aro, interior = aro + pintura, media distancia, triple): intentos de campo en esa zona sobre el total de intentos de campo del equipo en la temporada;
- % asistido: canastas metidas que llegaron tras un pase, sobre el total de canastas metidas;
- ritmo: posesiones por 40 minutos. Los factores sí son rendimiento: eFG% (acierto), TOV% (pérdidas por posesión), OREB% (rebotes ofensivos capturados sobre los disponibles) y FTr (tiros libres intentados por tiro de campo).
- Dos ventanas, dichas en cada tabla. Las palancas de zona y pase se miden igual en las diez temporadas (180 equipos-temporada). Las de reloj de posesión (tiros rápidos, elaborados) solo son comparables desde 2020/21 (109): el play-by-play anterior registra el doble de tiros en los primeros tres segundos, un artefacto de registro.
- Incertidumbre: intervalos del 95 % por bootstrap (remuestreo de partidos para los pesos y de equipos-temporada para las correlaciones; 2.000 réplicas). Un intervalo que no cruza el cero es una asociación real a este nivel; uno que lo cruza, no distinguible de cero.
- Alarma composicional: las dietas de tiro suman 100 %; se leen contrastes (más triple es menos de otra cosa), no valores absolutos.
import warnings; warnings.filterwarnings("ignore")
import sys
from pathlib import Path
from collections import defaultdict
import numpy as np, pandas as pd
import matplotlib.pyplot as plt
from sklearn.linear_model import LinearRegression
RAIZ = Path.cwd()
while not (RAIZ / "febdata").exists() and RAIZ != RAIZ.parent:
RAIZ = RAIZ.parent
sys.path.insert(0, str(RAIZ))
from analisis.marca import BG, INK, MUTED, ACCENT, GRID, POS, NEG, BLUE, TEAL, aplicar
aplicar()
SEED = 42; rng = np.random.default_rng(SEED)
B = 2000 # réplicas bootstrap
BASE = RAIZ / "analisis" / "datasets-acb"
IMG = RAIZ / "negocio" / "img" / "acb-four-factors"; IMG.mkdir(parents=True, exist_ok=True)
def guardar(fig, nombre):
"""Figura para redes (1080×1080) con la marca al pie."""
fig.text(0.985, 0.012, "bop analytics · análisis independiente", ha="right", va="bottom", color=MUTED, fontsize=9)
fig.savefig(IMG / f"{nombre}.png", dpi=100, facecolor=BG)
# --- Partidos (pesos) ---
sp = pd.read_parquet(BASE / "silver" / "silver_equipo_partido"); sp["temporada"] = sp.temporada.astype(int)
for c in ["pts","pts_rival","fga","fgm","p3m","fta","ftm","oreb","dreb","tov","posesiones",
"op_pts","op_fga","op_fgm","op_p3m","op_fta","op_oreb","op_dreb","op_tov","op_pos"]:
sp[c] = pd.to_numeric(sp[c], errors="coerce")
sp["op_ftm"] = sp.op_pts - 2 * sp.op_fgm - sp.op_p3m
h = sp[sp.condicion == "local"].copy()
h["d_efg"] = (h.fgm + 0.5*h.p3m)/h.fga*100 - (h.op_fgm + 0.5*h.op_p3m)/h.op_fga*100
h["d_tov"] = h.tov/h.posesiones*100 - h.op_tov/h.op_pos*100
h["d_oreb"] = h.oreb/(h.oreb+h.op_dreb)*100 - h.op_oreb/(h.op_oreb+h.dreb)*100
h["d_ftm"] = h.ftm/h.fga*100 - h.op_ftm/h.op_fga*100
h["margen"] = h.pts - h.pts_rival
F4 = {"d_efg":"Tiro (eFG)", "d_tov":"Pérdidas (TOV)", "d_oreb":"Rebote (OREB)", "d_ftm":"Tiros libres (FTM/FGA)"}
OLIVER = {"d_efg":40, "d_tov":25, "d_oreb":20, "d_ftm":15}
C4 = list(F4)
h = h.replace([np.inf,-np.inf], np.nan).dropna(subset=C4+["margen"])
for c in C4:
h["z_"+c] = h.groupby("temporada")[c].transform(lambda x:(x-x.mean())/x.std(ddof=0))
Z4 = h[["z_"+c for c in C4]].values; Y = h.margen.values
print("Partidos:", len(h), "| temporadas:", f"{h.temporada.min()}/{str(h.temporada.min()+1)[2:]} a {h.temporada.max()}/{str(h.temporada.max()+1)[2:]}")
print(h.groupby("temporada").size().rename("partidos").to_frame().T.to_string())
Partidos: 3126 | temporadas: 2016/17 a 2025/26 temporada 2016 2017 2018 2019 2020 2021 2022 2023 2024 2025 partidos 292 284 324 238 360 325 325 326 325 327
3 · Los pesos en la ACB frente a Oliver¶
def pesos(Z, y):
w = np.abs(LinearRegression().fit(Z, y).coef_); return w / w.sum() * 100
w_obs = pesos(Z4, Y)
boot = np.empty((B, 4))
for b in range(B):
idx = rng.integers(0, len(Y), len(Y)); boot[b] = pesos(Z4[idx], Y[idx])
lo, hi = np.percentile(boot, [2.5, 97.5], axis=0)
r2 = LinearRegression().fit(Z4, Y).score(Z4, Y)
T = pd.DataFrame({"peso ACB (%)": w_obs.round(1), "IC 95 % inf": lo.round(1), "IC 95 % sup": hi.round(1),
"Oliver (2004)": [OLIVER[c] for c in C4]}, index=[F4[c] for c in C4])
T["diferencia vs Oliver"] = (T["peso ACB (%)"] - T["Oliver (2004)"]).round(1)
print(f"Regresión del margen sobre los 4 diferenciales · R² = {r2:.3f} · n = {len(Y)} partidos · bootstrap {B} réplicas")
T
Regresión del margen sobre los 4 diferenciales · R² = 0.932 · n = 3126 partidos · bootstrap 2000 réplicas
| peso ACB (%) | IC 95 % inf | IC 95 % sup | Oliver (2004) | diferencia vs Oliver | |
|---|---|---|---|---|---|
| Tiro (eFG) | 46.8 | 46.3 | 47.3 | 40 | 6.8 |
| Pérdidas (TOV) | 23.7 | 23.3 | 24.2 | 25 | -1.3 |
| Rebote (OREB) | 20.1 | 19.6 | 20.5 | 20 | 0.1 |
| Tiros libres (FTM/FGA) | 9.4 | 8.9 | 9.9 | 15 | -5.6 |
# Figura 1 (publicación): pesos ACB con intervalo, frente a Oliver
orden = C4
fig, ax = plt.subplots(figsize=(10.8, 10.8))
y = np.arange(len(orden))[::-1]
ax.barh(y, [w_obs[i] for i in range(4)], color=ACCENT, edgecolor=BG, height=0.5, label="ACB 2016/17–2025/26 (medido)")
ax.errorbar([w_obs[i] for i in range(4)], y, xerr=[w_obs-lo, hi-w_obs], fmt="none", ecolor=INK, elinewidth=2, capsize=6)
ax.scatter([OLIVER[c] for c in orden], y, marker="D", s=140, color=BLUE, zorder=5, label="Oliver (2004), orientativo")
for i, c in enumerate(orden):
ax.text(max(hi[i], OLIVER[c]) + 1.6, y[i], f"{w_obs[i]:.0f} % [{lo[i]:.0f}–{hi[i]:.0f}]", va="center", color=INK, fontsize=16, fontweight="bold")
ax.text(OLIVER[c], y[i] - 0.4, f"Oliver {OLIVER[c]}", ha="center", color=BLUE, fontsize=12)
ax.set_yticks(y); ax.set_yticklabels([F4[c] for c in orden], fontsize=16)
ax.set_xlim(0, 66); ax.set_xlabel("peso del factor en el margen del partido (%)", fontsize=13); ax.grid(axis="y", alpha=0)
ax.set_title(f"Peso de cada factor en la ACB (intervalo del 95 %)\n{len(Y):,} partidos · diez temporadas · frente a Oliver (2004)".replace(",", "."),
color=INK, fontsize=18, loc="left", pad=16)
ax.legend(fontsize=13, loc="lower right")
plt.tight_layout(rect=(0, 0.03, 1, 1)); guardar(fig, "01_pesos_vs_oliver"); plt.show()
# Estabilidad: pesos por temporada (n≈300 por temporada; solo orientativo, sin intervalo)
filas = []
for t in sorted(h.temporada.unique()):
s = h[h.temporada == t]; w = pesos(s[["z_"+c for c in C4]].values, s.margen.values)
filas.append([f"{t}/{str(t+1)[2:]}", len(s)] + list(w.round(0).astype(int)))
filas.append(["Todas", len(h)] + list(w_obs.round(0).astype(int)))
filas.append(["Oliver (2004)", ""] + [OLIVER[c] for c in C4])
Tt = pd.DataFrame(filas, columns=["temporada", "partidos"] + [F4[c] for c in C4]).set_index("temporada")
Tt
| partidos | Tiro (eFG) | Pérdidas (TOV) | Rebote (OREB) | Tiros libres (FTM/FGA) | |
|---|---|---|---|---|---|
| temporada | |||||
| 2016/17 | 292 | 48 | 22 | 20 | 10 |
| 2017/18 | 284 | 46 | 24 | 19 | 10 |
| 2018/19 | 324 | 47 | 23 | 20 | 9 |
| 2019/20 | 238 | 45 | 24 | 19 | 12 |
| 2020/21 | 360 | 50 | 23 | 19 | 8 |
| 2021/22 | 325 | 45 | 25 | 21 | 9 |
| 2022/23 | 325 | 49 | 22 | 19 | 9 |
| 2023/24 | 326 | 46 | 26 | 19 | 9 |
| 2024/25 | 325 | 47 | 22 | 21 | 9 |
| 2025/26 | 327 | 45 | 25 | 20 | 10 |
| Todas | 3126 | 47 | 24 | 20 | 9 |
| Oliver (2004) | 40 | 25 | 20 | 15 |
Lectura. Con 3.126 partidos los intervalos son de un punto: los pesos están bien determinados. Frente a Oliver, el tiro pesa siete puntos más (47 frente a 40) y los tiros libres, seis menos (9 frente a 15); pérdidas y rebote quedan donde él los puso (24 y 20). La tabla por temporada dice lo mismo cada año: el tiro entre 45 y 50, los tiros libres entre 8 y 12, sin tendencia. Los cuatro diferenciales explican el 93 % de la varianza del margen (R² 0,932): visto desde el acta, el partido es casi por completo estos cuatro números, y por eso los pesos son tan precisos.
Que los pesos de Oliver no coincidan no es una anomalía española: son orientativos, y las estimaciones por regresión publicadas sobre la NBA también dan más al tiro y menos a los tiros libres. Se cita como contexto, no como comparación cifra a cifra: otro método y otros datos.
4 · Las palancas: qué mueve cada factor (diez temporadas)¶
ge = pd.read_parquet(BASE/"gold"/"gold_estilo_equipo"); ge["temporada"]=ge.temporada.astype(int); ge["equipo_id"]=ge.equipo_id.astype(str)
et = pd.read_parquet(BASE/"silver"/"silver_equipo_temporada"); et["temporada"]=et.temporada.astype(int); et["equipo_id"]=et.equipo_id.astype(str)
d = et.merge(ge, on=["temporada","equipo_id"], suffixes=("","_g"))
d["asist"] = d.of_pct_asist
d["aro"] = d.of_vol_aro; d["interior"] = d.of_vol_aro + d.of_vol_pintura; d["media"] = d.of_vol_media
d["triple"] = d.of_vol_esquina + d.of_vol_ala + d.of_vol_frontal
d["ftr_fta"] = d.fta / d.fga * 100
LEV = {"asist":"% asistido (de las canastas)", "aro":"cuota aro (% intentos)", "interior":"cuota interior (% intentos)",
"media":"cuota media dist. (% intentos)", "triple":"cuota triples (% intentos)", "ritmo":"ritmo (pos/40 min)"}
FACT = {"efg":"eFG", "tov_pct":"TOV%", "oreb_pct":"OREB%", "ftr_fta":"FTr"}
def corr_ic(x, y):
"""r de Pearson e IC 95 % bootstrap. Semilla fija por llamada: el mismo par da el mismo intervalo en todas las celdas."""
x = np.asarray(x, float); y = np.asarray(y, float); n = len(x)
r = np.corrcoef(x, y)[0,1]
g = np.random.default_rng(SEED); bs = np.empty(B)
for b in range(B):
i = g.integers(0, n, n); bs[b] = np.corrcoef(x[i], y[i])[0,1]
return r, *np.percentile(bs, [2.5, 97.5])
M = pd.DataFrame({FACT[f]: {LEV[l]: np.corrcoef(d[l], d[f])[0,1] for l in LEV} for f in FACT}).round(2)
print("Equipos-temporada:", len(d), "| temporadas:", sorted(d.temporada.unique()))
print("Umbral de significación (p<0,05) con n =", len(d), ": |r| ≈", round(1.96/np.sqrt(len(d)-3), 2))
print("\nCorrelación factor × palanca (diez temporadas):"); print(M.to_string())
Equipos-temporada: 180 | temporadas: [np.int64(2016), np.int64(2017), np.int64(2018), np.int64(2019), np.int64(2020), np.int64(2021), np.int64(2022), np.int64(2023), np.int64(2024), np.int64(2025)]
Umbral de significación (p<0,05) con n = 180 : |r| ≈ 0.15
Correlación factor × palanca (diez temporadas):
eFG TOV% OREB% FTr
% asistido (de las canastas) 0.38 -0.09 -0.02 -0.06
cuota aro (% intentos) -0.09 0.29 0.21 0.12
cuota interior (% intentos) -0.11 0.29 0.21 0.14
cuota media dist. (% intentos) -0.27 0.01 -0.29 -0.26
cuota triples (% intentos) 0.30 -0.28 0.01 0.06
ritmo (pos/40 min) -0.19 0.04 0.06 0.29
# Escala de marca: azul secundario (negativo) → fondo oscuro (cero) → naranja de acento (positivo).
# El color del número se elige por la luminosidad de la celda, para que siempre se lea.
from matplotlib.colors import LinearSegmentedColormap
CMAP = LinearSegmentedColormap.from_list("bop_div", [BLUE, BG, ACCENT])
def _txt(rgba):
r, g, b_ = rgba[:3]; return BG if 0.2126*r + 0.7152*g + 0.0722*b_ > 0.45 else INK
fig, ax = plt.subplots(figsize=(8.6, 5.2))
im = ax.imshow(M.values, cmap=CMAP, vmin=-0.5, vmax=0.5, aspect="auto")
ax.set_xticks(range(len(M.columns))); ax.set_xticklabels(M.columns); ax.set_yticks(range(len(M.index))); ax.set_yticklabels(M.index)
for i in range(M.shape[0]):
for j in range(M.shape[1]):
v = M.values[i,j]; ax.text(j, i, f"{v:+.2f}", ha="center", va="center", color=_txt(im.cmap(im.norm(v))), fontsize=10, fontweight="bold")
cb = fig.colorbar(im, ax=ax, fraction=0.035, pad=0.02); cb.set_label("correlación", color=MUTED); cb.ax.yaxis.set_tick_params(color=MUTED); cb.outline.set_edgecolor(GRID)
plt.setp(cb.ax.get_yticklabels(), color=MUTED)
ax.set_title("Palanca × factor · correlación (ACB, 180 equipos-temporada, 2016/17–2025/26)", color=INK); ax.grid(False)
plt.tight_layout(); plt.show()
# TODAS las palancas con asociación real (intervalo del 95 % fuera del cero), por factor y de mayor a menor |r|,
# con la comparación con la ventana 2020/21+ (109 casos). PAL guarda la principal de cada factor (para la figura 2).
d6 = d[d.temporada >= 2020]
PAL, filas = [], []
for f in FACT:
cand = []
for l in LEV:
r, a, b = corr_ic(d[l], d[f])
if a > 0 or b < 0:
cand.append((abs(r), l, r, a, b))
cand.sort(reverse=True)
PAL.append((f, cand[0][1]))
for _, l, r, a, b in cand:
r6 = np.corrcoef(d6[l], d6[f])[0,1]
filas.append([FACT[f], LEV[l], f"{r:+.2f}", f"[{a:+.2f}, {b:+.2f}]", f"{r6:+.2f}"])
P = pd.DataFrame(filas, columns=["factor", "palanca", "r (10 temporadas, n=180)", "IC 95 %", "r (2020/21+, n=109)"]).set_index(["factor", "palanca"])
print("Palancas con intervalo fuera del cero, por factor (de mayor a menor |r|):")
P
Palancas con intervalo fuera del cero, por factor (de mayor a menor |r|):
| r (10 temporadas, n=180) | IC 95 % | r (2020/21+, n=109) | ||
|---|---|---|---|---|
| factor | palanca | |||
| eFG | % asistido (de las canastas) | +0.38 | [+0.25, +0.49] | +0.40 |
| cuota triples (% intentos) | +0.30 | [+0.16, +0.42] | +0.26 | |
| cuota media dist. (% intentos) | -0.27 | [-0.41, -0.12] | -0.09 | |
| ritmo (pos/40 min) | -0.19 | [-0.31, -0.06] | -0.19 | |
| TOV% | cuota interior (% intentos) | +0.29 | [+0.14, +0.42] | +0.32 |
| cuota aro (% intentos) | +0.29 | [+0.16, +0.40] | +0.31 | |
| cuota triples (% intentos) | -0.28 | [-0.39, -0.16] | -0.31 | |
| OREB% | cuota media dist. (% intentos) | -0.29 | [-0.43, -0.13] | -0.23 |
| cuota interior (% intentos) | +0.21 | [+0.05, +0.36] | +0.16 | |
| cuota aro (% intentos) | +0.21 | [+0.06, +0.35] | +0.08 | |
| FTr | ritmo (pos/40 min) | +0.29 | [+0.15, +0.42] | +0.28 |
| cuota media dist. (% intentos) | -0.26 | [-0.40, -0.10] | -0.31 | |
| cuota interior (% intentos) | +0.14 | [+0.00, +0.26] | +0.09 |
# Figura 2 (publicación): una palanca por factor
fig, axs = plt.subplots(2, 2, figsize=(10.8, 10.8))
ETQ = {"asist":"canastas asistidas (% de las metidas)", "triple":"cuota de triples (% de los intentos)", "media":"cuota de media distancia (% de los intentos)",
"aro":"cuota de tiros al aro (% de los intentos)", "interior":"cuota interior, aro + pintura (% de los intentos)", "ritmo":"ritmo (posesiones por 40 min)"}
ETF = {"efg":"eFG% del equipo", "tov_pct":"TOV% (pérdidas por posesión)", "oreb_pct":"OREB% propio", "ftr_fta":"FTr (tiros libres por tiro de campo)"}
NOM = {"efg":"Acierto", "tov_pct":"Pérdidas", "oreb_pct":"Rebote ofensivo", "ftr_fta":"Tiros libres"}
CORTO = {"asist":"% asistido", "triple":"cuota de triples", "media":"cuota de media distancia", "aro":"cuota de aro", "interior":"cuota interior", "ritmo":"ritmo"}
def TITULO(f, l, r):
return f"{NOM[f]} ← {'más' if r > 0 else 'menos'} {CORTO[l]}"
COL = {"efg":ACCENT, "tov_pct":BLUE, "oreb_pct":TEAL, "ftr_fta":POS}
for ax, (f, l) in zip(axs.ravel(), PAL):
r, a, b = corr_ic(d[l], d[f])
ax.scatter(d[l], d[f], s=30, color=COL[f], alpha=0.8, edgecolor=BG, linewidth=0.5)
b1, b0 = np.polyfit(d[l], d[f], 1); xs = np.linspace(d[l].min(), d[l].max(), 50)
ax.plot(xs, b0 + b1*xs, color=INK, lw=1.4, ls="--", alpha=0.8)
ax.set_xlabel(ETQ[l], fontsize=11); ax.set_ylabel(ETF[f], fontsize=11)
ax.set_title(f"{TITULO(f, l, r)}\nr = {r:+.2f} [{a:+.2f}, {b:+.2f}]", color=INK, fontsize=13, loc="left")
fig.suptitle("La palanca principal de cada factor en la ACB · 180 equipos-temporada, 2016/17–2025/26", color=INK, fontsize=17, fontweight="bold", x=0.02, ha="left")
plt.tight_layout(rect=(0, 0.03, 1, 0.96)); guardar(fig, "02_palancas"); plt.show()
Lectura. Ningún factor depende de una sola decisión: cada uno tiene entre tres y cuatro palancas con intervalo fuera del cero (tabla). Por factor, de mayor a menor asociación:
- Acierto (eFG%). % asistido +0,38 [+0,25, +0,49]; cuota de triples +0,30 [+0,16, +0,42]; cuota de media distancia −0,27 [−0,41, −0,12]; ritmo −0,19 [−0,31, −0,06]. Mover el balón y elegir triple antes que media van con más acierto; correr más, con algo menos. Se usa el % asistido y no las asistencias por 100 posesiones, que correlacionan más pero en parte por aritmética (solo se asiste una canasta metida).
- Pérdidas (TOV%). Cuota interior +0,29 [+0,14, +0,42] y cuota de aro +0,29 [+0,16, +0,40], las dos caras de la misma decisión; cuota de triples −0,28 [−0,39, −0,16]. Atacar la zona arriesga más el balón que el tiro exterior.
- Rebote ofensivo (OREB%). Cuota de media distancia −0,29 [−0,43, −0,13]; cuota interior +0,21 [+0,05, +0,36] y de aro +0,21 [+0,06, +0,35]. El fallo de un tiro cercano cae bajo el aro; el de una media larga, lejos.
- Tiros libres (FTr). Ritmo +0,29 [+0,15, +0,42]; cuota de media distancia −0,26 [−0,40, −0,10]; cuota interior +0,14 [+0,00, +0,26], que roza el cero. Correr más y no conformarse con el tiro intermedio van con más faltas recibidas; tirar cerca del aro, apenas.
Ampliar de seis a diez temporadas (de 109 a 180 casos) no cambia ninguna de las palancas principales: conservan signo y magnitud (última columna). Con la muestra mayor aparecen dos que antes no llegaban al umbral: la media distancia también se asocia con menos acierto (−0,27 frente a −0,09 con seis temporadas) y la cuota de aro con más rebote ofensivo (+0,21 frente a +0,08).
5 · Reloj de posesión: eficiente dentro de la posesión, no como rasgo de equipo (seis temporadas, 2020/21+)¶
t = pd.read_parquet(BASE/"silver"/"silver_tiro", columns=["temporada","equipo_id","id_partido","tramo"])
t["temporada"] = t.temporada.astype(int); t = t[t.temporada >= 2020].copy()
t["equipo_id"] = t.equipo_id.astype(str); t["id_partido"] = t.id_partido.astype(str)
t["rapido"] = t.tramo == "1-8 s · rápido"
be = pd.read_csv(BASE/"bronze"/"bronze_boxscore_equipo.csv", dtype={"id_partido":str, "equipo_id":str})
eqs = defaultdict(list)
for r in be[["id_partido","equipo_id"]].itertuples(): eqs[r.id_partido].append(r.equipo_id)
rival = {}
for pid, e in eqs.items():
if len(e) == 2: rival[(pid,e[0])], rival[(pid,e[1])] = e[1], e[0]
t["def_team"] = [rival.get((p,e)) for p,e in zip(t.id_partido, t.equipo_id)]
offr = t.groupby(["temporada","equipo_id"]).rapido.mean().mul(100).rename("t_rapido").reset_index()
defmap = t.dropna(subset=["def_team"]).groupby(["temporada","def_team"]).rapido.mean().mul(100).to_dict()
d6 = d[d.temporada >= 2020].merge(offr, on=["temporada","equipo_id"])
d6["def_t_rapido"] = [defmap.get((tt,ee)) for tt,ee in zip(d6.temporada, d6.equipo_id)]
filas = []
for nombre, x, y_ in [("Trampa: rebote ofensivo ↔ tiros rápidos propios", "oreb_pct", "t_rapido"),
("Creencia: rebote ofensivo → transición concedida", "oreb_pct", "def_t_rapido")]:
r, a, b = corr_ic(d6[x], d6[y_]); filas.append([nombre, len(d6), round(r,2), f"[{a:+.2f}, {b:+.2f}]"])
r, a, b = corr_ic(d["aro"], d["ftr_fta"]); filas.append(["Creencia: tirar cerca del aro → tiros libres (diez temporadas)", len(d), round(r,2), f"[{a:+.2f}, {b:+.2f}]"])
R = pd.DataFrame(filas, columns=["relación", "n", "r", "IC 95 %"]).set_index("relación")
R
| n | r | IC 95 % | |
|---|---|---|---|
| relación | |||
| Trampa: rebote ofensivo ↔ tiros rápidos propios | 109 | 0.48 | [+0.27, +0.65] |
| Creencia: rebote ofensivo → transición concedida | 109 | 0.07 | [-0.14, +0.29] |
| Creencia: tirar cerca del aro → tiros libres (diez temporadas) | 180 | 0.12 | [-0.02, +0.25] |
# ¿De dónde viene el tiro rápido? Origen de la posesión, TIRO A TIRO, con el motor (febdata) sobre los partidos ACB
# convertidos (data-acb/): el medallion ACB aún no tiene capa de posesiones. Seis temporadas comparables (2020/21+).
# Incluye los tiros SIN coordenadas del feed (dos anotados: mates y palmeos), que silver_tiro deja fuera.
import glob
from febdata import pbp, posesiones as _pos
ED2T = {85: 2020, 86: 2021, 87: 2022, 88: 2023, 89: 2024, 90: 2025}
def _tramo(s): return "1-8 s" if s <= 8 else ("9-16 s" if s <= 16 else "17+ s")
ORIG = {"perdida": "tras pérdida del rival", "anotado": "tras canasta del rival", "tiro_libre": "tras canasta del rival",
"fallado": "tras rebote defensivo", "fin_periodo": "inicio de periodo"}
filas = []
for ed, temp in ED2T.items():
for kf in sorted(glob.glob(str(RAIZ / "data-acb" / str(ed) / "*_KeyFacts.json"))):
try:
part = pbp.cargar(kf, kf.replace("_KeyFacts", "_BoxScore"))
except Exception:
continue
ps = _pos.posesiones(part); E = part.eventos; vistos = set()
ids = {"A": str(part.equipo_a.id), "B": str(part.equipo_b.id)}
prev = None
for p in ps:
base = ORIG.get(prev, "otro"); clock0 = p.inicio; oreb = False
for i, e in enumerate(E):
if e.seg_partido < p.inicio or e.lado != p.lado:
continue
if e.seg_partido > p.fin:
break
if e.accion == "rebound" and e.seg_partido > p.inicio: # rebote ofensivo: reinicia el reloj
oreb = True; clock0 = e.seg_partido
elif e.accion == "shoot" and i not in vistos: # un tiro en el segundo de cambio no se cuenta dos veces
vistos.add(i)
filas.append((temp, ids[p.lado], _tramo(max(e.seg_partido - clock0, 0)),
"tras rebote ofensivo propio" if oreb else base, bool(e.anotado), "DE 3" in (e.texto or ""), e.x is None))
prev = p.final
ti = pd.DataFrame(filas, columns=["temporada","equipo_id","tramo","origen","anotado","triple","sin_xy"])
ti["efgp"] = np.where(ti.anotado, np.where(ti.triple, 1.5, 1.0), 0.0)
ORD = ["1-8 s","9-16 s","17+ s"]
print(f"Tiros: {len(ti):,} · sin coordenadas en el feed: {ti.sin_xy.sum():,} ({ti.sin_xy.mean()*100:.1f} %), "
f"anotados el {ti[ti.sin_xy].anotado.mean()*100:.1f} %; el {(ti[ti.sin_xy].tramo=='1-8 s').mean()*100:.0f} % en 1-8 s".replace(",", "."))
print("eFG de la liga por tramo, TODOS los tiros (%):", ti.groupby("tramo").efgp.mean().mul(100).reindex(ORD).round(1).to_dict())
print("eFG por tramo solo con coordenadas (lo que ve silver_tiro):", ti[~ti.sin_xy].groupby("tramo").efgp.mean().mul(100).reindex(ORD).round(1).to_dict())
G = ti.groupby(["tramo","origen"]).agg(n=("efgp","size"), efg=("efgp","mean")).reset_index()
G["cuota"] = G.n / G.groupby("tramo").n.transform("sum") * 100; G["efg"] *= 100
O = ["tras rebote ofensivo propio","tras pérdida del rival","tras rebote defensivo","tras canasta del rival"]
print("\nDe dónde viene cada tiro: cuota de cada origen dentro del tramo (%)")
print(G.pivot(index="origen", columns="tramo", values="cuota").reindex(index=O, columns=ORD).round(1).to_string())
print("\neFG por origen y tramo (%)")
print(G.pivot(index="origen", columns="tramo", values="efg").reindex(index=O, columns=ORD).round(1).to_string())
Tiros: 252.370 · sin coordenadas en el feed: 10.028 (4.0 %). anotados el 99.9 %; el 41 % en 1-8 s
eFG de la liga por tramo, TODOS los tiros (%): {'1-8 s': 60.6, '9-16 s': 55.0, '17+ s': 49.5}
eFG por tramo solo con coordenadas (lo que ve silver_tiro): {'1-8 s': 57.7, '9-16 s': 53.3, '17+ s': 48.2}
De dónde viene cada tiro: cuota de cada origen dentro del tramo (%)
tramo 1-8 s 9-16 s 17+ s
origen
tras rebote ofensivo propio 37.4 9.2 1.4
tras pérdida del rival 23.3 14.4 8.7
tras rebote defensivo 26.6 34.0 26.4
tras canasta del rival 12.3 41.6 62.9
eFG por origen y tramo (%)
tramo 1-8 s 9-16 s 17+ s
origen
tras rebote ofensivo propio 58.3 50.5 41.1
tras pérdida del rival 66.7 54.3 48.5
tras rebote defensivo 63.2 55.8 49.8
tras canasta del rival 50.1 55.7 49.8
# ¿Los tramos mueven los factores? Cuota de tiros por tramo y, dentro de los rápidos, POR ORIGEN (equipo-temporada) frente a los cuatro factores
tot = ti.groupby(["temporada","equipo_id"]).size()
def _cuota(m, nombre):
return (ti[m].groupby(["temporada","equipo_id"]).size() / tot * 100).fillna(0).rename(nombre)
r8 = ti.tramo == "1-8 s"; po = ti.origen == "tras rebote ofensivo propio"
Qo = pd.concat([_cuota(ti.tramo == "1-8 s", "tiros en 1-8 s (rápidos), total"),
_cuota(ti.tramo == "9-16 s", "tiros en 9-16 s (medios)"),
_cuota(ti.tramo == "17+ s", "tiros en 17+ s (elaborados)"),
_cuota(r8 & ~po, "rápidos sin rebote ofensivo (transición y saque)"),
_cuota(r8 & (ti.origen == "tras pérdida del rival"), "rápidos tras pérdida del rival"),
_cuota(r8 & po, "rápidos tras rebote ofensivo (palmeos)")], axis=1).reset_index()
dq = d6.merge(Qo, on=["temporada","equipo_id"])
filas = []
for c in Qo.columns[2:]:
fila = [c, round(dq[c].mean(), 1)]
for f in FACT:
r, a, b = corr_ic(dq[c], dq[f]); fila.append(f"{r:+.2f} [{a:+.2f}, {b:+.2f}]")
filas.append(fila)
QF = pd.DataFrame(filas, columns=["cuota de tiros (% de los intentos)", "media"] + list(FACT.values())).set_index("cuota de tiros (% de los intentos)")
print(f"n = {len(dq)} equipos-temporada · r e IC 95 %")
QF
n = 109 equipos-temporada · r e IC 95 %
| media | eFG | TOV% | OREB% | FTr | |
|---|---|---|---|---|---|
| cuota de tiros (% de los intentos) | |||||
| tiros en 1-8 s (rápidos), total | 24.1 | -0.25 [-0.42, -0.06] | +0.28 [+0.07, +0.46] | +0.50 [+0.30, +0.66] | -0.12 [-0.27, +0.02] |
| tiros en 9-16 s (medios) | 35.2 | -0.01 [-0.22, +0.19] | +0.07 [-0.11, +0.24] | +0.24 [+0.03, +0.43] | +0.06 [-0.14, +0.25] |
| tiros en 17+ s (elaborados) | 40.7 | +0.16 [-0.05, +0.36] | -0.21 [-0.40, -0.01] | -0.45 [-0.61, -0.24] | +0.04 [-0.12, +0.21] |
| rápidos sin rebote ofensivo (transición y saque) | 15.1 | -0.13 [-0.32, +0.07] | +0.21 [-0.00, +0.39] | +0.34 [+0.09, +0.56] | -0.05 [-0.20, +0.09] |
| rápidos tras pérdida del rival | 5.6 | -0.17 [-0.32, -0.01] | +0.10 [-0.11, +0.30] | +0.20 [+0.00, +0.38] | +0.06 [-0.09, +0.19] |
| rápidos tras rebote ofensivo (palmeos) | 9.0 | -0.45 [-0.60, -0.28] | +0.33 [+0.16, +0.48] | +0.69 [+0.56, +0.78] | -0.25 [-0.39, -0.09] |
# Figura 4 (publicación): de dónde viene el tiro rápido y qué pasa a nivel equipo
from analisis.marca import INK2, GREEN
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10.8, 10.8), gridspec_kw={"height_ratios": [1.05, 1]})
g8 = G[G.tramo == "1-8 s"].set_index("origen").reindex(O)
ETO = {"tras rebote ofensivo propio": "rebote ofensivo propio (palmeos)", "tras pérdida del rival": "pérdida del rival",
"tras rebote defensivo": "rebote defensivo", "tras canasta del rival": "saque tras canasta del rival"}
yy = np.arange(len(O))[::-1]
cols = [ACCENT, GREEN, TEAL, MUTED]
ax1.barh(yy, g8.cuota.values, color=cols, edgecolor=BG, height=0.62)
for y_, c_, e_ in zip(yy, g8.cuota.values, g8.efg.values):
ax1.text(c_ + 0.8, y_, f"{c_:.0f} de cada 100 · eFG {e_:.1f} %".replace(".", ","), va="center", color=INK, fontsize=13, fontweight="bold")
ax1.set_yticks(yy); ax1.set_yticklabels([ETO[o] for o in O], fontsize=13); ax1.set_xlim(0, 72); ax1.grid(axis="y", alpha=0)
ax1.set_xlabel("de cada 100 tiros en 1-8 s de reloj de posesión", fontsize=12)
ax1.set_title(f"De dónde viene el tiro rápido y cuánto vale cada uno\n{len(ti):,} tiros · 2020/21 a 2025/26 · cuota y eFG de cada origen".replace(",", "."), color=INK, fontsize=15, x=-0.5, ha="left", pad=12)
# abajo: cuota de rápidos por origen frente al eFG del equipo, con intervalo
filas2 = [("cuota de tiros rápidos, total", "tiros en 1-8 s (rápidos), total"),
("solo palmeos (tras rebote ofensivo)", "rápidos tras rebote ofensivo (palmeos)"),
("solo tras pérdida del rival", "rápidos tras pérdida del rival"),
("sin rebote ofensivo (transición y saque)", "rápidos sin rebote ofensivo (transición y saque)")]
y2 = np.arange(len(filas2))[::-1]
for y_, (etq, col) in zip(y2, filas2):
r, a_, b_ = corr_ic(dq[col], dq["efg"])
c_ = NEG if b_ < 0 else INK2
ax2.plot([a_, b_], [y_, y_], color=c_, lw=4, solid_capstyle="round"); ax2.scatter([r], [y_], color=c_, s=110, zorder=5, edgecolor=BG)
ax2.text(0.22, y_, f"{r:+.2f} [{a_:+.2f}, {b_:+.2f}]".replace(".", ","), va="center", color=INK, fontsize=12, fontweight="bold")
ax2.axvline(0, color=INK, lw=1, ls="--", alpha=0.7)
ax2.set_yticks(y2); ax2.set_yticklabels([f[0] for f in filas2], fontsize=12); ax2.set_xlim(-0.72, 0.72); ax2.set_ylim(-0.7, len(filas2) - 0.3)
ax2.set_xlabel("correlación con el eFG% del equipo · intervalo del 95 % · 109 equipos-temporada", fontsize=11)
ax2.set_title("Como rasgo de equipo: los palmeos arrastran la relación con el acierto", color=INK, fontsize=14, x=-0.5, ha="left", pad=10)
plt.tight_layout(rect=(0, 0.03, 1, 1)); guardar(fig, "04_tiro_rapido_origen"); plt.show()
fig, axs = plt.subplots(1, 3, figsize=(13.5, 4.4))
for ax, (x, y_, xl, yl, tit, col) in zip(axs, [
("oreb_pct","t_rapido","OREB% propio","cuota de tiros rápidos propios (% intentos, 1-8 s)","Trampa aritmética (putbacks)", MUTED),
("oreb_pct","def_t_rapido","OREB% propio","cuota de tiros rápidos del rival (% de sus intentos)","¿Rebotear concede transición?", NEG),
("aro","ftr_fta","cuota de tiros al aro (% intentos)","FTr","¿Tirar al aro da tiros libres?", NEG)]):
src = d if x == "aro" else d6
r, a, b = corr_ic(src[x], src[y_])
ax.scatter(src[x], src[y_], s=26, color=col, alpha=0.75, edgecolor=BG, linewidth=0.5)
b1, b0 = np.polyfit(src[x], src[y_], 1); xs = np.linspace(src[x].min(), src[x].max(), 50)
ax.plot(xs, b0 + b1*xs, color=INK, lw=1.3, ls="--", alpha=0.8)
ax.set_xlabel(xl); ax.set_ylabel(yl); ax.set_title(f"{tit}\nr = {r:+.2f} [{a:+.2f}, {b:+.2f}] · n = {len(src)}", color=INK, fontsize=11, loc="left")
plt.tight_layout(); plt.show()
Lectura. Dentro de la posesión el reloj manda: eFG del 60,6 % en 1-8 s, 55,0 % en 9-16 s y 49,5 % en 17+ s, once puntos entre el primero y el último. Pero como rasgo de equipo el signo se invierte: más cuota de tiros rápidos va con menos acierto (−0,25 [−0,42, −0,06]) y más pérdidas (+0,28 [+0,07, +0,46]).
De dónde viene el tiro rápido lo explica. De cada 100 tiros en 1-8 s, 37 nacen de un rebote ofensivo propio (palmeos y segundas opciones), 23 de una pérdida del rival, 27 de un rebote defensivo y 12 de un saque tras canasta del rival. Y no valen lo mismo: eFG del 66,7 % tras pérdida del rival y 63,2 % tras rebote defensivo, la transición real; 58,3 % tras rebote ofensivo; 50,1 % tras canasta del rival, el saque rápido.
Separando el origen, a nivel equipo:
- Los palmeos arrastran la relación. Su cuota va con menos acierto (−0,45 [−0,60, −0,28]), más pérdidas (+0,33 [+0,16, +0,48]) y, por construcción, más rebote ofensivo (+0,69).
- La transición limpia no mueve el acierto del conjunto. La cuota de tiros rápidos sin rebote ofensivo, transición y saque, no se asocia con el eFG del equipo (−0,13 [−0,32, +0,07]) y roza el cero con las pérdidas (+0,21 [−0,00, +0,39]). La cuota tras pérdida del rival, la transición más pura, tampoco (−0,17 [−0,32, −0,01]).
El tiro rápido es eficiente cuando ocurre, pero ningún equipo acierta más por tomar más. Lo que distingue a los equipos con más cuota de rápidos es que rebotean y palmean más, y eso va con peor acierto de conjunto. El tramo no es una palanca de estilo; el origen de la posesión sí explica lo que se ve.
Nota de datos. El 4,0 % de los tiros (10.028) llega sin coordenadas en el feed de la ACB: casi todos dos
anotados (mates y palmeos) y el 41 % en 1-8 s. silver_tiro los deja fuera, y por eso el eFG de los tiros
rápidos medido solo con coordenadas baja al 57,7 %. Aquí se cuentan todos. Las cuotas de zona del apartado 4
sí excluyen ese 4 %, que en su mayoría es tiro al aro.
- Trampa aritmética (r +0,48 [+0,27, +0,65]). Rebote ofensivo y tiros rápidos propios correlacionan fuerte, pero no es palanca: el rebote reinicia el reloj y el palmeo cuenta como tiro rápido. El rebote genera el tiro rápido, no al revés.
- Rebotear no concede transición (r +0,07 [−0,14, +0,29]). A nivel equipo-temporada, cargar el rebote ofensivo no se asocia con más tiros rápidos del rival; el intervalo incluye el cero. Cautela: el efecto podría existir posesión a posesión y diluirse al promediar la temporada.
- Tirar cerca del aro no da tiros libres (r +0,12 [−0,02, +0,25]). Asociación débil, con el intervalo tocando el cero. Muchas faltas que dan tiros libres ocurren sin tiro registrado (falta en la penetración): la FTr mide contacto, no localización.
6 · El patrón: dos decisiones¶
CLAVE = {"% asistido (de las canastas)":"asist", "cuota media dist. (% intentos)":"media", "cuota triples (% intentos)":"triple", "cuota interior (% intentos)":"interior", "ritmo (pos/40 min)":"ritmo"}
filas = []
for nm, l in CLAVE.items():
fila = [nm]
for f in FACT:
r, a, b = corr_ic(d[l], d[f]); fila.append(f"{r:+.2f} [{a:+.2f}, {b:+.2f}]")
filas.append(fila)
D = pd.DataFrame(filas, columns=["palanca"] + list(FACT.values())).set_index("palanca")
print("Correlación de cada palanca clave con los cuatro factores (IC 95 %), diez temporadas, n = 180:")
D
Correlación de cada palanca clave con los cuatro factores (IC 95 %), diez temporadas, n = 180:
| eFG | TOV% | OREB% | FTr | |
|---|---|---|---|---|
| palanca | ||||
| % asistido (de las canastas) | +0.38 [+0.25, +0.49] | -0.09 [-0.22, +0.06] | -0.02 [-0.16, +0.13] | -0.06 [-0.20, +0.09] |
| cuota media dist. (% intentos) | -0.27 [-0.41, -0.12] | +0.01 [-0.12, +0.14] | -0.29 [-0.43, -0.13] | -0.26 [-0.40, -0.10] |
| cuota triples (% intentos) | +0.30 [+0.16, +0.42] | -0.28 [-0.39, -0.16] | +0.01 [-0.17, +0.17] | +0.06 [-0.07, +0.19] |
| cuota interior (% intentos) | -0.11 [-0.25, +0.03] | +0.29 [+0.14, +0.42] | +0.21 [+0.05, +0.36] | +0.14 [+0.00, +0.26] |
| ritmo (pos/40 min) | -0.19 [-0.31, -0.06] | +0.04 [-0.10, +0.17] | +0.06 [-0.10, +0.23] | +0.29 [+0.15, +0.42] |
# Figura 3 (publicación): las cuatro palancas frente a los cuatro factores, con intervalo
fig, ax = plt.subplots(figsize=(10.8, 10.8))
pal = list(CLAVE); fac = list(FACT)
from analisis.marca import INK2, GREEN
colf = {"efg":ACCENT, "tov_pct":INK2, "oreb_pct":TEAL, "ftr_fta":GREEN}
yb = np.arange(len(pal))[::-1] * 1.0
for j, f in enumerate(fac):
off = (j - 1.5) * 0.18
for i, nm in enumerate(pal):
r, a, b = corr_ic(d[CLAVE[nm]], d[f])
ax.plot([a, b], [yb[i]+off]*2, color=colf[f], lw=3, alpha=0.9, solid_capstyle="round")
ax.scatter([r], [yb[i]+off], color=colf[f], s=70, zorder=5, edgecolor=BG, label=FACT[f] if i == 0 else None)
ax.axvline(0, color=INK, lw=1, ls="--", alpha=0.7)
ax.set_yticks(yb); ax.set_yticklabels(pal, fontsize=13); ax.set_xlim(-0.6, 0.6); ax.set_ylim(-0.6, len(pal) + 0.15)
ax.set_xlabel("correlación con el factor (punto) e intervalo del 95 % (línea)", fontsize=12)
ax.set_title("Qué palancas mueven qué factores en la ACB\n180 equipos-temporada · línea = intervalo del 95 %",
color=INK, fontsize=17, loc="left", pad=14)
ax.legend(fontsize=13, loc="upper center", title="factor", title_fontsize=12, ncol=4)
plt.tight_layout(rect=(0, 0.03, 1, 1)); guardar(fig, "03_patron"); plt.show()
Lectura. Dos decisiones concentran las asociaciones con intervalo fuera del cero:
- Mover el balón sube el acierto (+0,38), el factor que más pesa (47 %). No se asocia con los otros tres.
- No tirar de media distancia mejora tres factores a la vez: acierto (−0,27), rebote ofensivo (−0,29) y tiros libres (−0,26). Es la única palanca que toca tres de los cuatro.
La cuota de triples y la interior son las dos caras de una misma decisión: más triple, menos pérdidas y más acierto; más interior, más pérdidas, algo más de rebote (+0,21) y de tiros libres (+0,14, intervalo que roza el cero). El ritmo no es una decisión de zona, pero también mueve dos factores: más tiros libres (+0,29) y algo menos de acierto (−0,19).
7 · Conclusiones y cautelas¶
Lo medido. 3.126 partidos y 180 equipos-temporada de la ACB, diez temporadas, con intervalos del 95 % por bootstrap.
- El orden de Oliver se cumple; los pesos, no. Tiro 47 %, pérdidas 24 %, rebote 20 %, tiros libres 9 %. Frente a 40 · 25 · 20 · 15: el tiro pesa siete puntos más y los tiros libres seis menos, con intervalos de un punto. Pérdidas y rebote coinciden.
- Los pesos son estables temporada a temporada. Sin tendencia en diez años.
- Cada factor tiene varias palancas de estilo con asociación real, entre tres y cuatro. Las principales: % asistido y acierto; cuota interior y más pérdidas, cuota de triples y menos; menos cuota de media distancia y más rebote ofensivo y más tiros libres; ritmo y más tiros libres. Son cuotas de tiro y de canastas asistidas, no aciertos.
- Dos decisiones resumen el patrón: mover el balón y no tirar de media distancia. La segunda toca tres factores.
- Dos creencias no aparecen a nivel equipo: tirar cerca del aro no se asocia con más tiros libres, y cargar el rebote ofensivo no se asocia con conceder transición. Y una correlación fuerte, rebote y tiros rápidos (+0,48), es aritmética, no palanca.
- El reloj de posesión importa dentro de la posesión, no como rasgo de equipo. Once puntos de eFG entre un tiro rápido y uno elaborado; pero los equipos con más cuota de tiros rápidos aciertan menos y pierden más. Separando el origen de la posesión: son los palmeos tras rebote ofensivo (37 % de los tiros rápidos) los que arrastran la relación; la transición limpia no se asocia con el acierto del conjunto.
Cautelas¶
- Asociación, no causa. Las palancas son correlaciones a nivel equipo-temporada. Un r de 0,38 explica el 14 % de la varianza; el mensaje es el signo y el orden, no el decimal.
- Dos ventanas. Zona y pase, diez temporadas (180 casos); reloj de posesión, seis (109), por el cambio de registro del play-by-play en 2020/21.
- Cobertura de coordenadas. El 4 % de los tiros (dos anotados sin coordenadas en el feed, mates y palmeos)
no tiene zona: queda fuera de las cuotas de zona del apartado 4 y de
silver_tiro, pero entra en los factores y en el análisis por origen del apartado 5, que recorre los partidos con el motor porque el medallion ACB aún no tiene capa de posesiones. - Composicional. Las dietas de tiro suman 100 %: más triple es, necesariamente, menos de otra zona.
- Pesos, no palancas. El peso de un factor dice cuánto decidió el partido jugado, no cómo se mejora. Modelo explicativo, no predictivo.
- Oliver. Sus pesos son orientativos (Poropudas, 2023, los describe como ad hoc); la comparación es de orden y proporción. Las estimaciones publicadas sobre la NBA se citan como contexto, con otro método y otros datos.
Referencias. Oliver, D. (2004). Basketball on Paper. Potomac Books. · Poropudas, J. (2023). Dean Oliver's Four Factors Revisited. arXiv:2305.13032. · Jacobs (2017) y Teramoto y Cross (2010), citados en Poropudas (2023).