La distribución de Poisson modela el número de eventos independientes que ocurren en un intervalo de tiempo o espacio fijo, cuando cada evento es raro.
La distribución de Poisson asume que:
⚠ Sobredispersión: Cuando la varianza observada supera la media (\(\sigma^2 > \mu\)), los errores estándar estarán subestimados y los p-valores serán incorrectos. Explorar siempre la relación media-varianza antes de interpretar resultados.
La probabilidad de observar exactamente \(k\) eventos cuando la tasa esperada es \(\mu\):
\[P(Y = k) = \frac{e^{-\mu} \cdot \mu^k}{k!}, \quad k = 0, 1, 2, \ldots\]
El modelo liga el logaritmo de la media a los predictores:
Modelo de Poisson — función de enlace log \[\log(\mu_i) = \beta_0 + \beta_1 X_1 + \beta_2 X_2 + \ldots + \beta_k X_k\]
O equivalentemente en forma exponencial:
\[\mu_i = e^{\beta_0 + \beta_1 X_1 + \ldots + \beta_k X_k}\]
Cuando las personas tienen diferente tiempo de seguimiento, o cuando comparamos poblaciones de distinto tamaño, modelamos tasas:
Modelo de tasa con offset \[\log\!\left(\frac{\mu_i}{t_i}\right) = \beta_0 + \beta_1 X_1 + \ldots + \beta_k X_k\] \[\Leftrightarrow \quad \log(\mu_i) = \log(t_i) + \beta_0 + \beta_1 X_1 + \ldots + \beta_k X_k\]
donde \(t_i\) es el tiempo en riesgo (personas-año, días-paciente, tamaño poblacional, etc.)
El offset: log(t_i) se añade como
término con coeficiente fijo igual a 1. No se estima — es un ajuste que
convierte el conteo en una tasa. En R se especifica como
offset(log(tiempo)) dentro de la fórmula.
| Coeficiente | Interpretación directa | Exponenciado |
|---|---|---|
| \(\beta_0\) | Log de la tasa base | Tasa base: \(e^{\beta_0}\) |
| \(\beta_j\) | Cambio en log-tasa por unidad de \(X_j\) | IRR = \(e^{\beta_j}\) |
| \(\beta_j\) (binaria) | Diferencia en log-tasa expuesto vs. no expuesto | IRR expuesto vs. referencia |
IRR (Incidence Rate Ratio): \(e^{\beta_j}\) estima cuántas veces es mayor la tasa de incidencia en el grupo con \(X_j = 1\) respecto al grupo de referencia, manteniendo todos los demás predictores constantes. Un IRR = 2 significa el doble de tasa; IRR = 0.5 significa la mitad.
# Distribuciones de Poisson con diferentes lambdas
lambdas <- c(1, 3, 7, 15)
colores <- c("#4ade80", "#fbbf24", "#fb923c", "#f87171")
par(mfrow = c(1, 4),
bg = "#0f1a12",
col.axis = "#6b8a6e",
col.lab = "#d4e4d0",
col.main = "#fbbf24",
fg = "#2d4030",
mar = c(4, 3, 3, 1))
for (i in seq_along(lambdas)) {
lam <- lambdas[i]
k <- 0:round(lam * 3 + 5)
p <- dpois(k, lam)
barplot(p,
names.arg = k,
col = paste0(colores[i], "bb"),
border = colores[i],
main = bquote(lambda == .(lam)),
xlab = "Número de eventos (k)",
ylab = if (i == 1) "P(Y = k)" else "",
col.main = colores[i],
las = 1)
abline(v = lam / max(k) * length(k) * 1.2,
lty = 2, col = "#ffffff44", lwd = 1)
}Nota: A medida que \(\lambda\) aumenta, la distribución de Poisson se aproxima a una normal. Para \(\lambda\) pequeños (eventos raros), la distribución es muy asimétrica.
Simulamos un estudio de cohorte que registra casos de enfermedad gastrointestinal aguda (EGA) en 5 municipios durante 12 semanas. Queremos saber si la fuente de agua y la semana epidemiológica predicen la incidencia.
set.seed(2024)
n_municipios <- 5
n_semanas <- 12
# Marco de datos: municipio × semana
brote <- expand.grid(
municipio = paste0("Mpio_", LETTERS[1:n_municipios]),
semana = 1:n_semanas
) %>%
as_tibble() %>%
mutate(
# Población en riesgo por municipio (personas-semana)
poblacion = rep(c(12000, 8500, 15000, 6200, 9800), each = n_semanas),
# Fuente de agua: 1 = agua no tratada (exposición)
agua_no_trat = rep(c(1, 0, 0, 1, 0), each = n_semanas),
# Tendencia temporal y efecto de exposición
log_mu = log(poblacion / 10000) + # offset proporcional
(-2.8) + # intercepto (tasa base)
0.75 * agua_no_trat + # efecto exposición (IRR ~ 2.1)
0.04 * semana + # tendencia lineal
rnorm(n(), 0, 0.2), # variación aleatoria
# Conteo observado de casos EGA
casos = rpois(n(), exp(log_mu))
)
# Primeras filas
head(brote, 10) %>%
kbl(booktabs = TRUE, digits = 2,
caption = "Primeras 10 filas — datos de vigilancia EGA simulados") %>%
kable_styling(bootstrap_options = c("hover", "striped"),
full_width = FALSE, position = "center") %>%
row_spec(0, bold = TRUE, color = "#fbbf24", background = "#0a180c") %>%
row_spec(seq_len(10), color = "#d4e4d0", background = "#0f1a12") %>%
row_spec(seq(2, 10, 2), background = "#111a12")| municipio | semana | poblacion | agua_no_trat | log_mu | casos |
|---|---|---|---|---|---|
| Mpio_A | 1 | 12000 | 1 | -1.63 | 1 |
| Mpio_B | 1 | 12000 | 1 | -1.73 | 0 |
| Mpio_C | 1 | 12000 | 1 | -1.85 | 0 |
| Mpio_D | 1 | 12000 | 1 | -1.87 | 0 |
| Mpio_E | 1 | 12000 | 1 | -1.60 | 0 |
| Mpio_A | 2 | 12000 | 1 | -1.53 | 0 |
| Mpio_B | 2 | 12000 | 1 | -1.68 | 0 |
| Mpio_C | 2 | 12000 | 1 | -1.81 | 0 |
| Mpio_D | 2 | 12000 | 1 | -2.03 | 1 |
| Mpio_E | 2 | 12000 | 1 | -2.01 | 2 |
# Tasa por 10.000 hab-semana
brote <- brote %>%
mutate(
tasa_10k = casos / poblacion * 10000,
exposicion = factor(agua_no_trat,
labels = c("Agua tratada", "Agua NO tratada"))
)
# Gráfico 1: Tendencia temporal por municipio
p1 <- ggplot(brote, aes(x = semana, y = tasa_10k,
color = exposicion, group = municipio)) +
geom_line(linewidth = 1.1, alpha = 0.85) +
geom_point(size = 2, alpha = 0.7) +
scale_color_manual(values = c("Agua tratada" = "#4ade80",
"Agua NO tratada" = "#f87171")) +
scale_x_continuous(breaks = 1:12) +
labs(title = "Tasa de EGA por semana epidemiológica",
subtitle = "Por municipio y fuente de agua",
x = "Semana epidemiológica", y = "Tasa × 10.000 hab",
color = NULL) +
theme_minimal(base_size = 12) +
theme(
plot.background = element_rect(fill = "#0f1a12", color = NA),
panel.background = element_rect(fill = "#172019", color = NA),
panel.grid.major = element_line(color = "#2d4030"),
panel.grid.minor = element_blank(),
plot.title = element_text(color = "#fbbf24", face = "bold"),
plot.subtitle = element_text(color = "#6b8a6e"),
axis.text = element_text(color = "#6b8a6e"),
axis.title = element_text(color = "#d4e4d0"),
legend.background = element_rect(fill = "#172019", color = NA),
legend.text = element_text(color = "#d4e4d0"),
strip.text = element_text(color = "#4ade80", face = "bold")
)
# Gráfico 2: Distribución de casos
p2 <- ggplot(brote, aes(x = casos, fill = exposicion)) +
geom_histogram(binwidth = 1, position = "identity",
alpha = 0.65, color = NA) +
scale_fill_manual(values = c("Agua tratada" = "#4ade80",
"Agua NO tratada" = "#f87171")) +
labs(title = "Distribución de conteos de casos",
x = "Casos EGA por municipio-semana",
y = "Frecuencia", fill = NULL) +
theme_minimal(base_size = 12) +
theme(
plot.background = element_rect(fill = "#0f1a12", color = NA),
panel.background = element_rect(fill = "#172019", color = NA),
panel.grid.major = element_line(color = "#2d4030"),
panel.grid.minor = element_blank(),
plot.title = element_text(color = "#fbbf24", face = "bold"),
axis.text = element_text(color = "#6b8a6e"),
axis.title = element_text(color = "#d4e4d0"),
legend.background = element_rect(fill = "#172019", color = NA),
legend.text = element_text(color = "#d4e4d0")
)
gridExtra::grid.arrange(p1, p2, ncol = 2)brote %>%
group_by(exposicion) %>%
summarise(
Media = mean(casos),
Varianza = var(casos),
Razon_V_M = round(var(casos) / mean(casos), 2)
) %>%
kbl(booktabs = TRUE, digits = 2,
caption = "Verificación de equidispersión por grupo de exposición") %>%
kable_styling(bootstrap_options = c("hover", "striped"),
full_width = FALSE, position = "center") %>%
row_spec(0, bold = TRUE, color = "#4ade80", background = "#0a180c") %>%
row_spec(1:2, color = "#d4e4d0", background = "#0f1a12") %>%
row_spec(2, background = "#111a12")| exposicion | Media | Varianza | Razon_V_M |
|---|---|---|---|
| Agua tratada | 0.06 | 0.05 | 0.97 |
| Agua NO tratada | 0.17 | 0.23 | 1.39 |
Razón varianza/media: Si este valor es cercano a 1, los datos son equidispersos y la regresión de Poisson estándar es adecuada. Si es mayor que 2, considere quasi-Poisson o Binomial Negativa.
mod_nulo <- glm(casos ~ 1 + offset(log(poblacion)),
data = brote,
family = poisson(link = "log"))
summary(mod_nulo)##
## Call:
## glm(formula = casos ~ 1 + offset(log(poblacion)), family = poisson(link = "log"),
## data = brote)
##
## Coefficients:
## Estimate Std. Error z value Pr(>|z|)
## (Intercept) -11.5425 0.4082 -28.27 <2e-16 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## (Dispersion parameter for poisson family taken to be 1)
##
## Null deviance: 27.678 on 59 degrees of freedom
## Residual deviance: 27.678 on 59 degrees of freedom
## AIC: 40.292
##
## Number of Fisher Scoring iterations: 6
Tasa basal estimada: exp(coef(mod_nulo)[1]) ×
10.000 = 0.1 casos por 10.000 hab-semana (sin
ajustar por exposición).
Bivariado offset: log(poblacion)
mod_bivariado <- glm(casos ~ agua_no_trat + offset(log(poblacion)),
data = brote,
family = poisson(link = "log"))
summary(mod_bivariado)##
## Call:
## glm(formula = casos ~ agua_no_trat + offset(log(poblacion)),
## family = poisson(link = "log"), data = brote)
##
## Coefficients:
## Estimate Std. Error z value Pr(>|z|)
## (Intercept) -12.2051 0.7071 -17.261 <2e-16 ***
## agua_no_trat 1.2973 0.8660 1.498 0.134
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## (Dispersion parameter for poisson family taken to be 1)
##
## Null deviance: 27.678 on 59 degrees of freedom
## Residual deviance: 25.251 on 58 degrees of freedom
## AIC: 39.864
##
## Number of Fisher Scoring iterations: 6
# Exponenciar coeficientes → IRR con IC 95%
irr_biv <- tidy(mod_bivariado, exponentiate = TRUE, conf.int = TRUE) %>%
rename(IRR = estimate, IC_bajo = conf.low, IC_alto = conf.high) %>%
dplyr::select(term, IRR, IC_bajo, IC_alto, p.value)
irr_biv %>%
kbl(booktabs = TRUE, digits = 3,
caption = "IRR — Modelo bivariado: agua no tratada") %>%
kable_styling(bootstrap_options = c("hover", "striped"),
full_width = FALSE, position = "center") %>%
row_spec(0, bold = TRUE, color = "#fbbf24", background = "#0a180c") %>%
row_spec(1:nrow(irr_biv), color = "#d4e4d0", background = "#0f1a12") %>%
row_spec(seq(2, nrow(irr_biv), 2), background = "#111a12")| term | IRR | IC_bajo | IC_alto | p.value |
|---|---|---|---|---|
| (Intercept) | 0.000 | 0.000 | 0.000 | 0.000 |
| agua_no_trat | 3.659 | 0.714 | 26.398 | 0.134 |
Lectura epidemiológica: Los municipios con agua no
tratada tienen aproximadamente 3.66 veces la tasa de casos
de EGA comparados con los municipios con agua tratada (IC 95%:
0.71 – 26.4).
Ajustado agua + tendencia temporal
mod_ajustado <- glm(casos ~ agua_no_trat + semana + offset(log(poblacion)),
data = brote,
family = poisson(link = "log"))
summary(mod_ajustado)##
## Call:
## glm(formula = casos ~ agua_no_trat + semana + offset(log(poblacion)),
## family = poisson(link = "log"), data = brote)
##
## Coefficients:
## Estimate Std. Error z value Pr(>|z|)
## (Intercept) -10.5863 1.3110 -8.075 6.74e-16 ***
## agua_no_trat 0.4413 1.0996 0.401 0.688
## semana -0.2534 0.1957 -1.295 0.195
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## (Dispersion parameter for poisson family taken to be 1)
##
## Null deviance: 27.678 on 59 degrees of freedom
## Residual deviance: 22.808 on 57 degrees of freedom
## AIC: 39.422
##
## Number of Fisher Scoring iterations: 6
irr_adj <- tidy(mod_ajustado, exponentiate = TRUE, conf.int = TRUE) %>%
rename(IRR = estimate, IC_bajo = conf.low, IC_alto = conf.high) %>%
dplyr::select(term, IRR, IC_bajo, IC_alto, p.value)
irr_adj %>%
kbl(booktabs = TRUE, digits = 3,
caption = "IRR — Modelo ajustado: agua + semana epidemiológica") %>%
kable_styling(bootstrap_options = c("hover", "striped"),
full_width = FALSE, position = "center") %>%
row_spec(0, bold = TRUE, color = "#fbbf24", background = "#0a180c") %>%
row_spec(1:nrow(irr_adj), color = "#d4e4d0", background = "#0f1a12") %>%
row_spec(seq(2, nrow(irr_adj), 2), background = "#111a12")| term | IRR | IC_bajo | IC_alto | p.value |
|---|---|---|---|---|
| (Intercept) | 0.000 | 0.000 | 0.000 | 0.000 |
| agua_no_trat | 1.555 | 0.165 | 14.899 | 0.688 |
| semana | 0.776 | 0.465 | 1.055 | 0.195 |
Resultado ajustado: Controlando la tendencia
temporal, el IRR del agua no tratada es 1.55 (IC 95%:
0.16 – 14.9). Cada semana adicional multiplica la tasa en 0.776
(e^-0.253).
# Estadístico de dispersión: deviance / gl debe ser ≈ 1
dispersion_stat <- deviance(mod_ajustado) / df.residual(mod_ajustado)
cat("Estadístico de dispersión (Deviance/gl):", round(dispersion_stat, 3), "\n")## Estadístico de dispersión (Deviance/gl): 0.4
# Prueba formal con MASS::glm.nb como referencia
# Si el estadístico > 2, considerar quasi-Poisson
if (dispersion_stat > 2) {
cat("⚠ SOBREDISPERSIÓN detectada — considerar modelo quasi-Poisson\n")
} else {
cat("✓ Equidispersión aceptable para el modelo de Poisson estándar\n")
}## ✓ Equidispersión aceptable para el modelo de Poisson estándar
par(mfrow = c(1, 3),
bg = "#0f1a12",
col.axis = "#6b8a6e",
col.lab = "#d4e4d0",
col.main = "#fbbf24",
fg = "#2d4030",
mar = c(4, 4, 3, 1))
# 1. Residuales de Pearson vs valores ajustados
plot(fitted(mod_ajustado),
residuals(mod_ajustado, type = "pearson"),
xlab = "Valores ajustados (μ̂)",
ylab = "Residuales de Pearson",
main = "Residuales vs Ajustados",
pch = 19, col = "#4ade8088", cex = 0.9)
abline(h = 0, lty = 2, col = "#fbbf2488", lwd = 1.5)
abline(h = c(-2, 2), lty = 3, col = "#f8717188", lwd = 1)
# 2. QQ-plot de residuales de deviance
qqnorm(residuals(mod_ajustado, type = "deviance"),
pch = 19, col = "#fbbf2488", cex = 0.9,
main = "QQ-plot — Residuales deviance",
xlab = "Cuantiles teóricos",
ylab = "Cuantiles observados")
qqline(residuals(mod_ajustado, type = "deviance"),
col = "#4ade80", lwd = 1.5, lty = 2)
# 3. Valores ajustados vs observados
plot(brote$casos,
fitted(mod_ajustado),
xlab = "Casos observados",
ylab = "Casos esperados (μ̂)",
main = "Observado vs Esperado",
pch = 19, col = "#7dd3fc88", cex = 0.9)
abline(a = 0, b = 1, col = "#4ade80", lwd = 1.5, lty = 2)Diagnóstico visual: En el gráfico de residuales de Pearson, los puntos deben distribuirse aleatoriamente alrededor de cero. Patrones en abanico sugieren sobredispersión. En el gráfico observado vs. esperado, los puntos deben aproximarse a la diagonal.
modelos_comp <- tibble(
Modelo = c("Nulo", "Bivariado (agua)", "Ajustado (agua + semana)"),
AIC = c(AIC(mod_nulo), AIC(mod_bivariado), AIC(mod_ajustado)),
Deviance = c(deviance(mod_nulo), deviance(mod_bivariado), deviance(mod_ajustado)),
GL = c(df.residual(mod_nulo), df.residual(mod_bivariado), df.residual(mod_ajustado))
) %>%
mutate(Delta_AIC = round(AIC - min(AIC), 1),
AIC = round(AIC, 1), Deviance = round(Deviance, 1))
modelos_comp %>%
kbl(booktabs = TRUE,
caption = "Comparación de modelos — criterio AIC") %>%
kable_styling(bootstrap_options = c("hover", "striped"),
full_width = FALSE, position = "center") %>%
row_spec(0, bold = TRUE, color = "#fbbf24", background = "#0a180c") %>%
row_spec(1:3, color = "#d4e4d0", background = "#0f1a12") %>%
row_spec(which.min(modelos_comp$AIC), bold = TRUE,
color = "#4ade80", background = "#0e220e") %>%
row_spec(seq(2, 3, 2), background = "#111a12")| Modelo | AIC | Deviance | GL | Delta_AIC |
|---|---|---|---|---|
| Nulo | 40.3 | 27.7 | 59 | 0.9 |
| Bivariado (agua) | 39.9 | 25.3 | 58 | 0.4 |
| Ajustado (agua + semana) | 39.4 | 22.8 | 57 | 0.0 |
Criterio AIC: El modelo con menor AIC tiene mejor ajuste penalizado por complejidad. Un \(\Delta\)AIC > 10 indica diferencia sustancial. El modelo resaltado en verde es el seleccionado.
# Extraer todos los predictores (excluir intercepto)
irr_plot <- irr_adj %>%
filter(term != "(Intercept)") %>%
mutate(
term = recode(term,
"agua_no_trat" = "Agua NO tratada\n(vs. tratada)",
"semana" = "Semana epidemiológica\n(por unidad)"
)
)
ggplot(irr_plot, aes(x = IRR, y = fct_rev(factor(term)))) +
geom_vline(xintercept = 1, linetype = "dashed",
color = "#fbbf24", linewidth = 0.8, alpha = 0.7) +
geom_errorbarh(aes(xmin = IC_bajo, xmax = IC_alto),
height = 0.15, color = "#6b8a6e", linewidth = 1) +
geom_point(size = 4, color = "#fb923c") +
geom_text(aes(label = sprintf("IRR = %.2f\n(%.2f–%.2f)",
IRR, IC_bajo, IC_alto)),
hjust = -0.12, color = "#d4e4d0", size = 3.2) +
scale_x_log10(limits = c(0.5, 8),
breaks = c(0.5, 1, 2, 3, 5)) +
labs(title = "Razones de Tasa de Incidencia (IRR)",
subtitle = "Modelo ajustado con offset log(población)",
x = "IRR (escala logarítmica)", y = NULL) +
theme_minimal(base_size = 12) +
theme(
plot.background = element_rect(fill = "#0f1a12", color = NA),
panel.background = element_rect(fill = "#172019", color = NA),
panel.grid.major = element_line(color = "#2d4030"),
panel.grid.minor = element_blank(),
plot.title = element_text(color = "#fbbf24", face = "bold", size = 13),
plot.subtitle = element_text(color = "#6b8a6e"),
axis.text = element_text(color = "#d4e4d0"),
axis.title = element_text(color = "#d4e4d0")
)Si el estadístico de dispersión es mayor que 1.5–2, hay dos estrategias principales:
Corrige los errores estándar multiplicándolos por \(\sqrt{\hat{\phi}}\). No cambia los coeficientes, solo los errores e IC.
mod_quasi <- glm(casos ~ agua_no_trat + semana + offset(log(poblacion)),
data = brote,
family = quasipoisson(link = "log"))
# Comparar errores estándar
cbind(
Poisson = round(coef(summary(mod_ajustado))[, "Std. Error"], 4),
QuasiPoisson = round(coef(summary(mod_quasi))[, "Std. Error"], 4)
) %>%
as.data.frame() %>%
kbl(booktabs = TRUE,
caption = "Comparación de errores estándar: Poisson vs. Quasi-Poisson") %>%
kable_styling(bootstrap_options = c("hover", "striped"),
full_width = FALSE, position = "center") %>%
row_spec(0, bold = TRUE, color = "#4ade80", background = "#0a180c") %>%
row_spec(1:3, color = "#d4e4d0", background = "#0f1a12") %>%
row_spec(2, background = "#111a12")| Poisson | QuasiPoisson | |
|---|---|---|
| (Intercept) | 1.3110 | 1.1248 |
| agua_no_trat | 1.0996 | 0.9434 |
| semana | 0.1957 | 0.1679 |
Añade un parámetro de dispersión \(\theta\) explícito. Más flexible que quasi-Poisson.
mod_nb <- glm.nb(casos ~ agua_no_trat + semana + offset(log(poblacion)),
data = brote)
# IRR del modelo binomial negativo
tidy(mod_nb, exponentiate = TRUE, conf.int = TRUE) %>%
rename(IRR = estimate, IC_bajo = conf.low, IC_alto = conf.high) %>%
dplyr::select(term, IRR, IC_bajo, IC_alto, p.value) %>%
kbl(booktabs = TRUE, digits = 3,
caption = "IRR — Modelo Binomial Negativo") %>%
kable_styling(bootstrap_options = c("hover", "striped"),
full_width = FALSE, position = "center") %>%
row_spec(0, bold = TRUE, color = "#fbbf24", background = "#0a180c") %>%
row_spec(1:3, color = "#d4e4d0", background = "#0f1a12") %>%
row_spec(2, background = "#111a12")| term | IRR | IC_bajo | IC_alto | p.value |
|---|---|---|---|---|
| (Intercept) | 0.000 | 0.000 | 0.000 | 0.000 |
| agua_no_trat | 1.508 | 0.142 | 15.299 | 0.717 |
| semana | 0.771 | 0.449 | 1.063 | 0.199 |
¿Cuándo usar cada uno? La quasi-Poisson es suficiente para corrección de errores estándar con sobredispersión moderada. La Binomial Negativa es preferible cuando la sobredispersión es severa o tiene una interpretación biológica (heterogeneidad individual en el riesgo).
# Tabla de resultados lista para publicar
tabla_final <- bind_rows(
tidy(mod_bivariado, exponentiate = TRUE, conf.int = TRUE) %>%
filter(term != "(Intercept)") %>%
mutate(Modelo = "Bivariado"),
tidy(mod_ajustado, exponentiate = TRUE, conf.int = TRUE) %>%
filter(term != "(Intercept)") %>%
mutate(Modelo = "Ajustado")
) %>%
mutate(
Variable = recode(term,
"agua_no_trat" = "Agua no tratada",
"semana" = "Semana epidemiológica"),
IRR = round(estimate, 2),
IC_95 = sprintf("%.2f – %.2f", conf.low, conf.high),
p_valor = case_when(
p.value < 0.001 ~ "< 0.001",
p.value < 0.01 ~ sprintf("%.3f", p.value),
TRUE ~ sprintf("%.2f", p.value)
)
) %>%
dplyr::select(Modelo, Variable, IRR, IC_95, p_valor)
tabla_final %>%
kbl(booktabs = TRUE,
caption = "Resultados del análisis de Poisson — EGA por municipio") %>%
kable_styling(bootstrap_options = c("hover", "striped"),
full_width = FALSE, position = "center") %>%
row_spec(0, bold = TRUE, color = "#fbbf24", background = "#0a180c") %>%
row_spec(seq_len(nrow(tabla_final)),
color = "#d4e4d0", background = "#0f1a12") %>%
row_spec(seq(2, nrow(tabla_final), 2), background = "#111a12") %>%
pack_rows("Bivariado", 1, 1, color = "#4ade80",
background = "#0e1f10", bold = FALSE) %>%
pack_rows("Ajustado", 2, 3, color = "#fbbf24",
background = "#1a1500", bold = FALSE)| Modelo | Variable | IRR | IC_95 | p_valor |
|---|---|---|---|---|
| Bivariado | ||||
| Bivariado | Agua no tratada | 3.66 | 0.71 – 26.40 | 0.13 |
| Ajustado | ||||
| Ajustado | Agua no tratada | 1.55 | 0.16 – 14.90 | 0.69 |
| Ajustado | Semana epidemiológica | 0.78 | 0.46 – 1.05 | 0.20 |
Este es un ejemplo clásico de la literatura epidemiológica, basado en
los datos de Kemp et al. sobre mortalidad por melanoma
en 48 estados de Estados Unidos, disponibles en el paquete
ISwR de R (Dalgaard, 2008 — Introductory Statistics
with R). El estudio examina si la radiación solar
ultravioleta explica las diferencias geográficas en tasas de
mortalidad por melanoma.
Fuente: Kemp IW, Fraumeni JF Jr., Fears TR (1975).
Solar radiation and cancer mortality in the United States. En: Persons
at High Risk of Cancer. Academic Press. Reproducido como dataset
melanoma en el paquete ISwR.
El dataset contiene datos de mortalidad por melanoma en 48 estados contiguos de EE.UU.:
mort — Tasa de mortalidad por melanoma
(muertes por millón de habitantes)lat — Latitud del centro del estado
(grados norte)lon — Longitud del centro del estado
(grados oeste)ocean — Exposición oceánica: 1 =
estado costero, 0 = interiorUV — Índice de radiación ultravioleta
solar# Datos originales de Kemp et al. (1975) — 48 estados contiguos de EE.UU.
# Fuente: Dalgaard P. Introductory Statistics with R. Springer, 2008. Cap. 13
melanoma <- data.frame(
state = c("Alabama","Arizona","Arkansas","California","Colorado",
"Connecticut","Delaware","Florida","Georgia","Idaho",
"Illinois","Indiana","Iowa","Kansas","Kentucky",
"Louisiana","Maine","Maryland","Massachusetts","Michigan",
"Minnesota","Mississippi","Missouri","Montana","Nebraska",
"Nevada","New Hampshire","New Jersey","New Mexico","New York",
"North Carolina","North Dakota","Ohio","Oklahoma","Oregon",
"Pennsylvania","Rhode Island","South Carolina","South Dakota",
"Tennessee","Texas","Utah","Vermont","Virginia",
"Washington","West Virginia","Wisconsin","Wyoming"),
mort = c(219,160,170,182,149,159,200,217,228,116,
129,132,116,143,147,190,117,162,143,131,
109,207,131,129,133,172,125,159,141,152,
199,115,131,182,136,133,137,178,135,150,
192,168,124,153,124,152,119,134),
lat = c(33.0,34.5,34.7,37.5,39.0,41.8,39.0,28.0,32.5,44.5,
40.0,40.2,42.0,38.7,37.5,31.2,45.2,39.2,42.3,43.5,
46.0,32.5,38.5,47.0,41.5,39.5,43.7,40.2,35.0,43.0,
35.5,47.5,40.2,35.5,44.0,40.8,41.7,33.5,44.5,36.0,
31.5,39.5,44.0,37.5,47.5,38.5,44.5,43.0),
lon = c(86.8,112.0,92.3,119.7,105.5,72.7,75.5,81.5,83.5,114.0,
89.5,86.2,93.7,98.5,84.8,91.8,69.0,76.7,71.8,84.7,
94.7,89.8,92.5,110.5,98.5,117.0,71.5,74.5,106.0,75.0,
79.5,100.5,82.8,97.5,120.5,77.8,71.5,80.8,100.5,86.2,
98.0,111.5,72.7,78.5,120.5,80.8,90.5,107.5),
ocean = c(1,0,0,1,0,1,1,1,1,0,
0,0,0,0,0,1,1,1,1,0,
0,1,0,0,0,0,1,1,0,1,
1,0,0,0,1,1,1,1,0,0,
1,0,1,1,1,0,0,0),
UV = c(3.5,5.3,4.3,4.8,4.5,3.0,3.8,5.4,4.8,4.0,
3.3,3.6,3.1,4.0,3.5,4.7,2.6,3.8,3.0,3.2,
2.8,4.3,3.7,3.8,3.5,4.8,3.0,3.4,5.0,3.2,
4.3,3.2,3.4,4.3,3.5,3.3,3.2,4.5,3.5,3.8,
5.3,4.6,3.0,3.8,3.5,3.3,3.0,4.5)
)
head(melanoma, 8) %>%
kbl(booktabs = TRUE,
caption = "Dataset melanoma — primeras 8 observaciones (Kemp et al., 1975)") %>%
kable_styling(bootstrap_options = c("hover", "striped"),
full_width = FALSE, position = "center") %>%
row_spec(0, bold = TRUE, color = "#fbbf24", background = "#0a180c") %>%
row_spec(seq_len(8), color = "#d4e4d0", background = "#0f1a12") %>%
row_spec(seq(2, 8, 2), background = "#111a12")| state | mort | lat | lon | ocean | UV |
|---|---|---|---|---|---|
| Alabama | 219 | 33.0 | 86.8 | 1 | 3.5 |
| Arizona | 160 | 34.5 | 112.0 | 0 | 5.3 |
| Arkansas | 170 | 34.7 | 92.3 | 0 | 4.3 |
| California | 182 | 37.5 | 119.7 | 1 | 4.8 |
| Colorado | 149 | 39.0 | 105.5 | 0 | 4.5 |
| Connecticut | 159 | 41.8 | 72.7 | 1 | 3.0 |
| Delaware | 200 | 39.0 | 75.5 | 1 | 3.8 |
| Florida | 217 | 28.0 | 81.5 | 1 | 5.4 |
p_uv <- ggplot(melanoma, aes(x = UV, y = mort)) +
geom_point(color = "#fbbf24", size = 3, alpha = 0.85) +
geom_smooth(method = "loess", se = TRUE,
color = "#4ade80", fill = "#4ade8030", linewidth = 1.2) +
geom_smooth(method = "lm", se = FALSE,
color = "#f97316", linetype = "dashed", linewidth = 0.9) +
labs(title = "Mortalidad por melanoma vs. Radiación UV",
subtitle = "48 estados de EE.UU. · línea verde = LOESS · naranja = lineal",
x = "Índice de radiación UV",
y = "Muertes por millón de hab.") +
theme_minimal(base_size = 12) +
theme(
plot.background = element_rect(fill = "#0f1a12", color = NA),
panel.background = element_rect(fill = "#172019", color = NA),
panel.grid.major = element_line(color = "#2d4030"),
panel.grid.minor = element_blank(),
plot.title = element_text(color = "#fbbf24", face = "bold"),
plot.subtitle = element_text(color = "#6b8a6e", size = 9),
axis.text = element_text(color = "#6b8a6e"),
axis.title = element_text(color = "#d4e4d0")
)
p_lat <- ggplot(melanoma, aes(x = lat, y = mort,
color = factor(ocean),
shape = factor(ocean))) +
geom_point(size = 3, alpha = 0.85) +
geom_smooth(method = "lm", se = FALSE, linewidth = 1) +
scale_color_manual(values = c("0" = "#7dd3fc", "1" = "#f97316"),
labels = c("Interior", "Costero")) +
scale_shape_manual(values = c("0" = 16, "1" = 17),
labels = c("Interior", "Costero")) +
labs(title = "Mortalidad por melanoma vs. Latitud",
subtitle = "Por exposición oceánica",
x = "Latitud (grados norte)",
y = "Muertes por millón de hab.",
color = NULL, shape = NULL) +
theme_minimal(base_size = 12) +
theme(
plot.background = element_rect(fill = "#0f1a12", color = NA),
panel.background = element_rect(fill = "#172019", color = NA),
panel.grid.major = element_line(color = "#2d4030"),
panel.grid.minor = element_blank(),
plot.title = element_text(color = "#fbbf24", face = "bold"),
plot.subtitle = element_text(color = "#6b8a6e", size = 9),
axis.text = element_text(color = "#6b8a6e"),
axis.title = element_text(color = "#d4e4d0"),
legend.background = element_rect(fill = "#172019", color = NA),
legend.text = element_text(color = "#d4e4d0")
)
gridExtra::grid.arrange(p_uv, p_lat, ncol = 2)Patrón visual: A mayor radiación UV (y menor latitud), mayor mortalidad por melanoma. Los estados costeros parecen tener tasas ligeramente diferentes a los del interior con similar latitud — posible modificación de efecto.
En este dataset la variable mort ya es una
tasa (por millón de habitantes), no un conteo bruto con
tiempo en riesgo conocido, por lo que modelamos directamente el log de
la tasa como función de los predictores.
Modelo bivariado UV solamente
# Modelo 1: solo radiación UV
mel_uv <- glm(mort ~ UV,
data = melanoma,
family = poisson(link = "log"))
summary(mel_uv)##
## Call:
## glm(formula = mort ~ UV, family = poisson(link = "log"), data = melanoma)
##
## Coefficients:
## Estimate Std. Error z value Pr(>|z|)
## (Intercept) 4.36208 0.06313 69.09 <2e-16 ***
## UV 0.17211 0.01583 10.87 <2e-16 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## (Dispersion parameter for poisson family taken to be 1)
##
## Null deviance: 278.20 on 47 degrees of freedom
## Residual deviance: 162.08 on 46 degrees of freedom
## AIC: 494.83
##
## Number of Fisher Scoring iterations: 4
Modelo ajustado UV + latitud + costa
# Modelo 2: UV + latitud + exposición oceánica
mel_adj <- glm(mort ~ UV + lat + ocean,
data = melanoma,
family = poisson(link = "log"))
summary(mel_adj)##
## Call:
## glm(formula = mort ~ UV + lat + ocean, family = poisson(link = "log"),
## data = melanoma)
##
## Coefficients:
## Estimate Std. Error z value Pr(>|z|)
## (Intercept) 5.819930 0.240108 24.239 < 2e-16 ***
## UV 0.045486 0.024614 1.848 0.0646 .
## lat -0.026248 0.003942 -6.658 2.77e-11 ***
## ocean 0.118943 0.025009 4.756 1.98e-06 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## (Dispersion parameter for poisson family taken to be 1)
##
## Null deviance: 278.205 on 47 degrees of freedom
## Residual deviance: 58.974 on 44 degrees of freedom
## AIC: 395.73
##
## Number of Fisher Scoring iterations: 4
irr_mel <- tidy(mel_adj, exponentiate = TRUE, conf.int = TRUE) %>%
rename(IRR = estimate, IC_bajo = conf.low, IC_alto = conf.high) %>%
dplyr::select(term, IRR, IC_bajo, IC_alto, p.value) %>%
filter(term != "(Intercept)") %>%
mutate(
Variable = recode(term,
"UV" = "Radiación UV (por unidad)",
"lat" = "Latitud — grados norte (por grado)",
"ocean" = "Estado costero vs. interior"),
p_valor = case_when(
p.value < 0.001 ~ "< 0.001",
p.value < 0.01 ~ sprintf("%.3f", p.value),
TRUE ~ sprintf("%.3f", p.value)
)
) %>%
dplyr::select(Variable, IRR, IC_bajo, IC_alto, p_valor)
irr_mel %>%
kbl(booktabs = TRUE, digits = 3,
caption = "IRR — Modelo de Poisson ajustado: melanoma (Kemp et al.)") %>%
kable_styling(bootstrap_options = c("hover", "striped"),
full_width = FALSE, position = "center") %>%
row_spec(0, bold = TRUE, color = "#fbbf24", background = "#0a180c") %>%
row_spec(seq_len(nrow(irr_mel)), color = "#d4e4d0", background = "#0f1a12") %>%
row_spec(seq(2, nrow(irr_mel), 2), background = "#111a12")| Variable | IRR | IC_bajo | IC_alto | p_valor |
|---|---|---|---|---|
| Radiación UV (por unidad) | 1.047 | 0.997 | 1.098 | 0.065 |
| Latitud — grados norte (por grado) | 0.974 | 0.967 | 0.982 | < 0.001 |
| Estado costero vs. interior | 1.126 | 1.072 | 1.183 | < 0.001 |
## Deviance / gl — UV solo: 3.523
## Deviance / gl — Ajustado: 1.34
Sobredispersión marcada: El estadístico deviance/gl supera ampliamente 1 en ambos modelos — habitual cuando la variable respuesta es una tasa ya agregada (no un conteo individual). En este contexto se recomienda usar quasi-Poisson para corregir los errores estándar.
# Modelo quasi-Poisson para corregir errores estándar
mel_quasi <- glm(mort ~ UV + lat + ocean,
data = melanoma,
family = quasipoisson(link = "log"))
# Comparar IC: Poisson estándar vs. quasi-Poisson
bind_rows(
tidy(mel_adj, exponentiate = TRUE, conf.int = TRUE) %>%
mutate(Modelo = "Poisson"),
tidy(mel_quasi, exponentiate = TRUE, conf.int = TRUE) %>%
mutate(Modelo = "Quasi-Poisson")
) %>%
filter(term != "(Intercept)") %>%
mutate(
Variable = recode(term,
"UV" = "Radiación UV",
"lat" = "Latitud",
"ocean" = "Costa"),
IRR = round(estimate, 3),
IC = sprintf("%.3f – %.3f", conf.low, conf.high)
) %>%
dplyr::select(Modelo, Variable, IRR, IC) %>%
kbl(booktabs = TRUE,
caption = "Comparación Poisson vs. Quasi-Poisson — IC 95% más amplios con quasi") %>%
kable_styling(bootstrap_options = c("hover", "striped"),
full_width = FALSE, position = "center") %>%
row_spec(0, bold = TRUE, color = "#4ade80", background = "#0a180c") %>%
row_spec(seq(1, 6), color = "#d4e4d0", background = "#0f1a12") %>%
row_spec(seq(2, 6, 2), background = "#111a12")| Modelo | Variable | IRR | IC |
|---|---|---|---|
| Poisson | Radiación UV | 1.047 | 0.997 – 1.098 |
| Poisson | Latitud | 0.974 | 0.967 – 0.982 |
| Poisson | Costa | 1.126 | 1.072 – 1.183 |
| Quasi-Poisson | Radiación UV | 1.047 | 0.989 – 1.107 |
| Quasi-Poisson | Latitud | 0.974 | 0.965 – 0.983 |
| Quasi-Poisson | Costa | 1.126 | 1.064 – 1.193 |
irr_forest <- tidy(mel_quasi, exponentiate = TRUE, conf.int = TRUE) %>%
filter(term != "(Intercept)") %>%
mutate(
Variable = recode(term,
"UV" = "Radiación UV\n(por unidad de índice)",
"lat" = "Latitud\n(por grado norte)",
"ocean" = "Estado costero\n(vs. interior)")
)
ggplot(irr_forest, aes(x = estimate, y = fct_rev(factor(Variable)))) +
geom_vline(xintercept = 1, linetype = "dashed",
color = "#fbbf24", linewidth = 0.8, alpha = 0.6) +
geom_errorbarh(aes(xmin = conf.low, xmax = conf.high),
height = 0.15, color = "#6b8a6e", linewidth = 1.1) +
geom_point(size = 4.5, color = "#f97316") +
geom_text(aes(label = sprintf("IRR = %.3f\n(%.3f – %.3f)",
estimate, conf.low, conf.high)),
hjust = -0.1,
color = "#d4e4d0",
size = 3.1) +
scale_x_log10(limits = c(0.85, 1.6),
breaks = c(0.9, 1.0, 1.1, 1.2, 1.4)) +
labs(title = "Mortalidad por melanoma — IRR ajustados",
subtitle = "Modelo quasi-Poisson · Kemp et al. (1975)",
x = "IRR (escala log)", y = NULL) +
theme_minimal(base_size = 12) +
theme(
plot.background = element_rect(fill = "#0f1a12", color = NA),
panel.background = element_rect(fill = "#172019", color = NA),
panel.grid.major = element_line(color = "#2d4030"),
panel.grid.minor = element_blank(),
plot.title = element_text(color = "#fbbf24", face = "bold", size = 13),
plot.subtitle = element_text(color = "#6b8a6e", size = 9),
axis.text = element_text(color = "#d4e4d0"),
axis.title = element_text(color = "#d4e4d0")
)Radiación UV: Cada unidad adicional en el índice de radiación UV se asocia con un aumento de aproximadamente 4.7% en la tasa de mortalidad por melanoma (IRR = 1.047), ajustando por latitud y exposición oceánica.
Latitud: Por cada grado adicional hacia el norte (menor exposición solar acumulada), la tasa de mortalidad disminuye aproximadamente 2.6% (IRR = 0.974).
Lección metodológica de este ejemplo: La radiación UV y la latitud están altamente correlacionadas (a menor latitud → mayor UV). Al incluir ambas en el modelo, sus efectos se separan: UV captura la exposición acumulada real, mientras que latitud puede absorber otros factores geográficos (dieta, acceso a salud, temperatura). Siempre evalúe colinealidad antes de interpretar coeficientes individuales en modelos ajustados.
log(población) o
log(tiempo en riesgo) para modelar tasas en lugar de
conteos absolutos.
“El objetivo del análisis multivariado no es controlar estadísticamente todas las variables posibles, sino construir un modelo que represente el mecanismo causal que el epidemiólogo tiene en mente.” — Rothman & Greenland