Nota para leer desde el celular: las gráficas y tablas se ven directo. El código está oculto; toca el botón Code de cada bloque para desplegarlo.
En el fútbol se da por hecho que jugar de local ayuda: el público empuja y presiona al árbitro, el visitante llega desgastado por el viaje y el local conoce su cancha. Pero un local también puede ganar más simplemente porque resultó ser mejor. Este trabajo revisa con datos de la Premier League si la ventaja de local existe y si se explica solo por la calidad de las plantillas. Las preguntas son:
archivos <- list.files(carpeta_datos, pattern = "\\.csv$", full.names = TRUE, recursive = TRUE)
names(archivos) <- basename(archivos)
df <- archivos %>%
map_dfr(read.csv, stringsAsFactors = FALSE, .id = "archivo") %>%
select(archivo, HomeTeam, AwayTeam, FTHG, FTAG, FTR, HST, AST, HF, AF, HY, AY) %>%
drop_na() %>%
mutate(
Home_Win = as.integer(FTR == "H"),
Shot_Target_Diff = HST - AST,
Goal_Diff = FTHG - FTAG
)
n_part <- nrow(df)
n_temp <- n_distinct(df$archivo)
Se usaron los partidos de la Premier League de las temporadas 2022/23, 2023/24 y 2024/25: 1,140 partidos en 3 archivos (uno por temporada). Cada fila es un partido y se descartaron los que tenían datos faltantes. Se usaron goles, resultado, tiros a puerta, faltas y amarillas de ambos equipos. A partir de ellos se calcularon la diferencia de tiros a puerta y la de goles (local menos visitante) y una variable que vale 1 si ganó el local y 0 si empató o ganó el visitante.
Todo se hizo en R (tidyverse y caret), con nivel de significancia de 0.05.
nombres <- c(FTHG = "Goles local", FTAG = "Goles visitante",
HST = "Tiros a puerta local", AST = "Tiros a puerta visitante",
HF = "Faltas local", AF = "Faltas visitante",
HY = "Amarillas local", AY = "Amarillas visitante")
desc <- df %>%
select(all_of(names(nombres))) %>%
pivot_longer(everything(), names_to = "Variable", values_to = "v") %>%
group_by(Variable) %>%
summarise(`Mín.` = min(v), Q1 = quantile(v, 0.25), Mediana = median(v),
Media = mean(v), Q3 = quantile(v, 0.75), `Máx.` = max(v),
.groups = "drop") %>%
mutate(Variable = factor(nombres[Variable], levels = nombres)) %>%
arrange(Variable)
kable(desc, digits = 2, caption = "Tabla 1. Estadística descriptiva por partido.")
| Variable | Mín. | Q1 | Mediana | Media | Q3 | Máx. |
|---|---|---|---|---|---|---|
| Goles local | 0 | 1 | 1 | 1.65 | 2 | 9 |
| Goles visitante | 0 | 0 | 1 | 1.37 | 2 | 8 |
| Tiros a puerta local | 0 | 3 | 5 | 5.06 | 7 | 16 |
| Tiros a puerta visitante | 0 | 3 | 4 | 4.20 | 5 | 15 |
| Faltas local | 2 | 8 | 10 | 10.71 | 13 | 23 |
| Faltas visitante | 1 | 9 | 11 | 11.19 | 14 | 24 |
| Amarillas local | 0 | 1 | 2 | 1.83 | 3 | 7 |
| Amarillas visitante | 0 | 1 | 2 | 2.11 | 3 | 8 |
El local anota más (1.65 goles contra 1.37) y tira más a puerta (5.06 contra 4.20), y el visitante comete más faltas y recibe más amarillas. Ojo: las medianas de goles y de amarillas son iguales para ambos, así que la diferencia está en los promedios y no en el partido típico. En promedio el local anota 0.28 goles más por partido; en una temporada de unos 380 partidos son cerca de 105 goles (cálculo aproximado con las medias).
Resultados: el local ganó 45.1 %, empataron 23.0 % y ganó el visitante 31.9 %.
g1 <- df %>%
count(FTR) %>%
mutate(prop = n / sum(n)) %>%
ggplot(aes(FTR, prop, fill = FTR)) +
geom_col(color = "black", show.legend = FALSE) +
scale_x_discrete(labels = c(A = "Visitante", D = "Empate", H = "Local")) +
scale_fill_manual(values = c(A = "#FF6B6B", D = "#BDBDBD", H = "#4ECDC4")) +
scale_y_continuous(labels = scales::percent) +
labs(title = "Figura 1. Frecuencia de resultados", x = "Resultado", y = "Porcentaje") +
theme_minimal()
g1
g2 <- ggplot(df, aes(Goal_Diff)) +
geom_histogram(binwidth = 1, fill = "#4ECDC4", color = "black", alpha = 0.7) +
geom_vline(xintercept = 0, color = "red", linetype = "dashed", linewidth = 1) +
labs(title = "Figura 2. Diferencia de goles",
subtitle = "Valores > 0: victoria local (línea roja = empate)",
x = "Goles local - goles visitante", y = "Partidos") +
theme_minimal()
g2
test_ventaja <- prop.test(sum(df$Home_Win), n_part, p = 1/3, alternative = "greater")
test_ventaja
##
## 1-sample proportions test with continuity correction
##
## data: sum(df$Home_Win) out of n_part, null probability 1/3
## X-squared = 70.351, df = 1, p-value < 2.2e-16
## alternative hypothesis: true p is greater than 0.3333333
## 95 percent confidence interval:
## 0.4263461 1.0000000
## sample estimates:
## p
## 0.4508772
Sí. El local ganó 45.1 % de los partidos, contra el 33.3 % que saldría si los tres resultados fueran igual de probables (p < 0.001). Con tantos partidos casi cualquier diferencia sale significativa, así que importa más el tamaño (unos 12 puntos porcentuales) que el p-value.
# Cada partido genera dos filas: goles del local y goles del visitante.
# Equipo y Rival llevan el archivo (temporada) para que la fuerza no se mezcle entre años.
df_long <- bind_rows(
df %>% transmute(archivo, Equipo = paste(archivo, HomeTeam),
Rival = paste(archivo, AwayTeam), Goles = FTHG, Local = 1),
df %>% transmute(archivo, Equipo = paste(archivo, AwayTeam),
Rival = paste(archivo, HomeTeam), Goles = FTAG, Local = 0)
)
modelo_poisson <- glm(Goles ~ Local + Equipo + Rival, data = df_long, family = poisson)
efecto_local <- unname(exp(coef(modelo_poisson)["Local"]))
ic <- unname(exp(confint.default(modelo_poisson)["Local", ]))
razon_cruda <- mean(df$FTHG) / mean(df$FTAG)
c(efecto_local = efecto_local, IC_inf = ic[1], IC_sup = ic[2], sin_ajustar = razon_cruda) %>%
round(3)
## efecto_local IC_inf IC_sup sin_ajustar
## 1.201 1.123 1.285 1.201
El modelo de Poisson dio un efecto local de 1.20 (IC 95 %: 1.12 a 1.28). En palabras simples: un equipo anota alrededor de 20 % más goles cuando juega de local que cuando visita a un rival de la misma fuerza. El intervalo no incluye el 1, así que la ventaja persiste al controlar la fuerza de los equipos.
El número ajustado (1.20) es prácticamente el que sale sin ajustar (1.20, que es 1.65 entre 1.37). Tiene lógica: cada equipo juega la mitad de sus partidos en casa y la mitad de visita, así que la calidad se reparte igual entre locales y visitantes y no explica la diferencia.
Por temporada, el efecto quedó así:
efecto_por_archivo <- df_long %>%
group_by(archivo) %>%
group_modify(~ {
m <- glm(Goles ~ Local + Equipo + Rival, data = .x, family = poisson)
est <- coef(m)["Local"]
se <- sqrt(diag(vcov(m)))["Local"]
tibble(efecto = exp(est), lo = exp(est - 1.96 * se), hi = exp(est + 1.96 * se))
}) %>%
ungroup()
kable(efecto_por_archivo, digits = 3,
col.names = c("Temporada (archivo)", "Efecto local", "IC 95 % inferior", "IC 95 % superior"),
caption = "Tabla 2. Efecto local ajustado por temporada.")
| Temporada (archivo) | Efecto local | IC 95 % inferior | IC 95 % superior |
|---|---|---|---|
| E0 (1).csv | 1.217 | 1.089 | 1.361 |
| E0 (2).csv | 1.341 | 1.189 | 1.513 |
| E0.csv | 1.065 | 0.947 | 1.198 |
En 1 de 3 temporadas el intervalo incluye el 1, es decir, ahí la ventaja no se distingue de cero por sí sola. Con unos 380 partidos por temporada los intervalos son anchos, así que un intervalo que toque el 1 no contradice el resultado general.
g5 <- ggplot(efecto_por_archivo, aes(archivo, efecto)) +
geom_pointrange(aes(ymin = lo, ymax = hi), color = "blue") +
geom_hline(yintercept = 1, linetype = "dashed", color = "red") +
labs(title = "Figura 3. Efecto local ajustado por temporada",
subtitle = "Multiplicador de goles del local (línea roja = sin ventaja); barras = IC 95 %",
x = NULL, y = "Multiplicador de goles") +
theme_minimal() +
theme(axis.text.x = element_text(angle = 45, hjust = 1))
g5
test_tarjetas <- t.test(df$HY, df$AY, paired = TRUE)
dif <- unname(test_tarjetas$estimate)
test_tarjetas
##
## Paired t-test
##
## data: df$HY and df$AY
## t = -5.2997, df = 1139, p-value = 1.392e-07
## alternative hypothesis: true mean difference is not equal to 0
## 95 percent confidence interval:
## -0.3822196 -0.1756751
## sample estimates:
## mean difference
## -0.2789474
g3 <- df %>%
select(Local = HY, Visitante = AY) %>%
pivot_longer(everything(), names_to = "Equipo", values_to = "Tarjetas") %>%
ggplot(aes(Equipo, Tarjetas, fill = Equipo)) +
geom_boxplot(alpha = 0.7, show.legend = FALSE) +
stat_summary(fun = mean, geom = "point", shape = 23, size = 3, fill = "white") +
scale_fill_manual(values = c(Local = "#4ECDC4", Visitante = "#FF6B6B")) +
labs(title = "Figura 4. Tarjetas amarillas por equipo",
subtitle = "Rombo blanco = media", x = NULL, y = "Tarjetas por partido") +
theme_minimal()
g3
El visitante recibió más tarjetas: 1.83 amarillas del local contra 2.11 del visitante, una diferencia de 0.28 por partido (p < 0.001). Pero el visitante también comete más faltas (11.19 contra 10.71). Por falta, las amarillas salen en aproximadamente 0.17 para el local y 0.19 para el visitante (cálculo rápido con medias, sin prueba formal). Con esto no se puede afirmar sesgo arbitral, solo que hay una diferencia pequeña que en parte se explica por las faltas.
set.seed(123)
idx <- createDataPartition(df$Home_Win, p = 0.8, list = FALSE)
train <- df[idx, ]
test <- df[-idx, ]
modelo_logit <- glm(Home_Win ~ Shot_Target_Diff, data = train, family = binomial)
pred_class <- factor(ifelse(predict(modelo_logit, test, type = "response") > 0.5, 1, 0),
levels = c(0, 1))
matriz <- confusionMatrix(pred_class, factor(test$Home_Win, levels = c(0, 1)))
coefs <- as.data.frame(summary(modelo_logit)$coefficients)
rownames(coefs) <- c("Intercepto", "Diferencia de tiros a puerta")
coefs[, 4] <- ifelse(coefs[, 4] < 0.001, "< 0.001", sprintf("%.3f", coefs[, 4]))
kable(coefs, digits = 3,
col.names = c("Estimado", "Error est.", "z", "p"),
caption = paste0("Tabla 3. Regresión logística (", nrow(train), " partidos de entrenamiento)."))
| Estimado | Error est. | z | p | |
|---|---|---|---|---|
| Intercepto | -0.598 | 0.085 | -7.053 | < 0.001 |
| Diferencia de tiros a puerta | 0.363 | 0.027 | 13.190 | < 0.001 |
Cada tiro a puerta extra del local multiplica sus odds de ganar por 1.44 (intervalo aproximado de 1.36 a 1.52). Con tiros parejos la probabilidad de victoria local es de unos 35.5 % (no llega a 50 % porque “no ganar” incluye empates), y sube a cerca de 77.1 % con cinco tiros de ventaja. En el conjunto de prueba el modelo acertó 71.1 %, contra 54.8 % de la referencia (predecir siempre lo más común). Este modelo no mide la ventaja de local: los tiros ocurren durante el partido y en parte son consecuencia del marcador.
g4 <- df %>%
mutate(grupo = pmax(pmin(Shot_Target_Diff, 8), -8)) %>%
group_by(grupo) %>%
summarise(prop_win = mean(Home_Win), n = n()) %>%
ggplot(aes(grupo, prop_win)) +
geom_point(aes(size = n), color = "black", alpha = 0.6) +
geom_smooth(data = df, aes(Shot_Target_Diff, Home_Win),
method = "glm", method.args = list(family = "binomial"),
color = "blue", se = FALSE) +
scale_y_continuous(labels = scales::percent, limits = c(0, 1)) +
labs(title = "Figura 5. Probabilidad de victoria local según tiros a puerta",
subtitle = "Puntos = proporción observada; línea azul = modelo logístico",
x = "Tiros a puerta (local - visitante)", y = "Victorias locales", size = "Partidos") +
theme_minimal()
g4
Los resultados confirman que la ventaja existe y que no se debe a que los locales sean mejores, pero no dicen de dónde viene. La tabla relaciona cada explicación popular con lo que haría falta para medirla.
disc <- tibble(
`Explicación popular` = c("Presión del público", "Presión sobre el árbitro",
"Desgaste por viaje", "Familiaridad con la cancha"),
`Qué la mediría` = c("Comparar con partidos sin afición (ej. temporada 2020/21)",
"Penales, faltas señaladas y tarjetas por falta",
"Distancia recorrida y días de descanso",
"Efecto de cada estadio"),
`Qué mostraron estos datos` = c("No se puede evaluar: siempre hubo público.",
"Diferencia pequeña en amarillas que en parte se explica por las faltas. No concluyente.",
"No hay datos de viaje ni descanso.",
"No se midió; el modelo trata igual a todos los estadios.")
)
kable(disc, caption = "Tabla 4. Explicaciones populares de la ventaja de local y qué se pudo evaluar.")
| Explicación popular | Qué la mediría | Qué mostraron estos datos |
|---|---|---|
| Presión del público | Comparar con partidos sin afición (ej. temporada 2020/21) | No se puede evaluar: siempre hubo público. |
| Presión sobre el árbitro | Penales, faltas señaladas y tarjetas por falta | Diferencia pequeña en amarillas que en parte se explica por las faltas. No concluyente. |
| Desgaste por viaje | Distancia recorrida y días de descanso | No hay datos de viaje ni descanso. |
| Familiaridad con la cancha | Efecto de cada estadio | No se midió; el modelo trata igual a todos los estadios. |