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.

1. Introducción

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:

2. Datos

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.

3. Métodos

Todo se hizo en R (tidyverse y caret), con nivel de significancia de 0.05.

4. Resultados

4.1 Panorama general

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.")
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

4.2 ¿El local gana más de lo esperado?

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.

4.3 ¿Es por jugar en casa o por ser mejores?

# 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.")
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

4.4 Tarjetas amarillas

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.

4.5 Tiros a puerta y probabilidad de ganar

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)."))
Tabla 3. Regresión logística (912 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

5. Discusión

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.")
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.

6. Conclusiones