Este informe continúa el análisis exploratorio (EDA) de la semana 1
(ver
informe), con la misma base Medical Appointment Scheduling
System de Kaggle (appointments.csv, una fila = una
cita). Ahora se pasa de describir a medir la
incertidumbre: se simulan escenarios con distribuciones de
probabilidad y se aplican pruebas estadísticas para saber si las
diferencias observadas entre grupos son reales o pueden deberse al azar.
Se tomó como referencia el análisis de Explorando medicaldata en
R (RPubs).
El código de cada paso está oculto para facilitar la lectura; se despliega con el botón Code a la derecha de cada bloque. Los gráficos de simulación son interactivos (pasar el mouse, zoom, doble clic para restablecer).
# Instala ggpubr solo si no está instalado
if (!requireNamespace("ggpubr", quietly = TRUE)) install.packages("ggpubr", repos = "https://cloud.r-project.org")
library(tidyverse) # manejo de datos y ggplot2
library(janitor) # limpieza de nombres
library(plotly) # gráficos interactivos
library(knitr) # tablas
library(ggpubr) # gráficos comparativos con pruebas estadísticas
set.seed(2026) # semilla: hace que las simulaciones den siempre el mismo resultado
citas <- read_csv("appointments.csv") %>%
clean_names() %>%
mutate(
across(where(hms::is_hms), as.character),
status = factor(status),
sex = factor(sex, labels = c("Mujer", "Hombre")),
hora = as.integer(substr(appointment_time, 1, 2)), # hora programada
jornada = factor(ifelse(hora < 12, "Mañana", "Tarde")),
anio = as.integer(substr(as.character(appointment_date), 1, 4)),
periodo = factor(ifelse(anio <= 2019, "2015-2019", "2020-2024")),
banda_edad = cut(age, breaks = c(0, 29, 49, 69, Inf),
labels = c("15-29", "30-49", "50-69", "70+"))
)
atendidas <- citas %>% filter(status == "attended") # citas con tiempos registrados
n_total <- nrow(citas)
Se trabaja con 111.488 citas. Se crearon variables nuevas para comparar grupos: jornada (mañana/tarde), periodo (2015-2019 / 2020-2024) y banda de edad (15-29, 30-49, 50-69, 70+).
sample() y table()El espacio muestral es el conjunto de resultados posibles de una cita: attended, cancelled, did not attend, scheduled, unknown. Un evento es un subconjunto de esos resultados; para la gestión interesa el evento A = “cita perdida” (cancelada o no asistida), porque es capacidad instalada que no produce atención.
# Probabilidades reales (frecuencia relativa en toda la base)
p_real <- prop.table(table(citas$status))
# 1) sample(): se eligen 500 citas al azar de la base real (muestreo aleatorio)
muestra <- citas[sample(1:n_total, size = 500), ]
p_muestra <- prop.table(table(muestra$status))
# 2) sample(): se simula una agenda de 500 citas usando las probabilidades reales
agenda_sim <- sample(names(p_real), size = 500, replace = TRUE, prob = p_real)
p_sim <- prop.table(table(factor(agenda_sim, levels = names(p_real))))
tabla_prob <- tibble(Estado = names(p_real),
`P. real (base completa)` = round(as.numeric(p_real), 3),
`P. empírica (muestra de 500)` = round(as.numeric(p_muestra[names(p_real)]), 3),
`P. empírica (agenda simulada)` = round(as.numeric(p_sim), 3))
kable(tabla_prob)
| Estado | P. real (base completa) | P. empírica (muestra de 500) | P. empírica (agenda simulada) |
|---|---|---|---|
| attended | 0.772 | 0.772 | 0.786 |
| cancelled | 0.164 | 0.162 | 0.142 |
| did not attend | 0.059 | 0.062 | 0.070 |
| scheduled | 0.001 | 0.002 | 0.002 |
| unknown | 0.004 | 0.002 | 0.000 |
p_perdida <- sum(p_real[c("cancelled", "did not attend")])
La probabilidad real del evento A es P(A) = 0.223: aproximadamente 22 de cada 100 citas se pierden. Las dos muestras de 500 dan probabilidades empíricas cercanas a las reales, pero no idénticas: esa diferencia es la variabilidad muestral.
# ¿Cuántas citas hacen falta para que la probabilidad empírica se estabilice?
sim_larga <- sample(c("Perdida", "Cumplida"), size = 5000, replace = TRUE,
prob = c(p_perdida, 1 - p_perdida))
lgn <- tibble(n = 1:5000, prop = cumsum(sim_larga == "Perdida") / (1:5000))
g_lgn <- ggplot(lgn, aes(n, prop)) +
geom_line(color = "#2C7FB8") +
geom_hline(yintercept = p_perdida, linetype = "dashed", color = "red") +
labs(title = "Ley de los grandes números: proporción acumulada de citas perdidas",
x = "Número de citas simuladas", y = "Proporción de citas perdidas") +
theme_minimal()
ggplotly(g_lgn)
Interpretación: con pocas citas la proporción salta mucho; a partir de unas mil se estabiliza alrededor de la línea roja (valor real). Para la gestión, un indicador calculado sobre pocos días o pocos pacientes (por ejemplo, el % de inasistencia de una semana) puede ser engañoso; conviene medirlo con periodos suficientemente largos.
runif(), rbinom() y
dpois()# Citas por día y número de inasistencias por día (datos reales)
por_dia <- citas %>%
group_by(appointment_date) %>%
summarise(citas = n(), no_asiste = sum(status == "did not attend"))
n_dia <- round(mean(por_dia$citas)) # citas promedio por día
p_dna <- mean(citas$status == "did not attend") # probabilidad de no asistir
lambda <- mean(por_dia$no_asiste) # inasistencias promedio por día
Cada día se programan en promedio 43 citas, la probabilidad de que un paciente no asista es p = 0.059 y en promedio hay λ = 2.54 inasistencias por día.
runif()
y rbinom(): inasistencias diarias en tres escenariosSe simulan 1.000 días de agenda. Con runif() cada
paciente recibe un número aleatorio entre 0 y 1; si es menor que
p, se cuenta como inasistencia (ensayo de Bernoulli). Con
rbinom() se obtiene directamente el número de inasistencias
de un día de 43 citas. Se comparan tres escenarios de gestión.
escenarios <- c("Con recordatorios (p = 3%)" = 0.03,
"Situación actual" = p_dna,
"Sin control (p = 12%)" = 0.12)
# runif(): un día simulado paciente por paciente en la situación actual
dia_runif <- runif(n_dia) < p_dna
inasist_runif <- sum(dia_runif)
# rbinom(): 1.000 días por escenario
sim_binom <- map_dfr(names(escenarios), function(e)
tibble(escenario = e, inasistencias = rbinom(1000, size = n_dia, prob = escenarios[e])))
sim_binom$escenario <- factor(sim_binom$escenario, levels = names(escenarios))
sim_binom %>% group_by(escenario) %>%
summarise(Promedio = round(mean(inasistencias), 2),
`P(5 o más por día)` = round(mean(inasistencias >= 5), 3),
Máximo = max(inasistencias)) %>% kable()
| escenario | Promedio | P(5 o más por día) | Máximo |
|---|---|---|---|
| Con recordatorios (p = 3%) | 1.37 | 0.011 | 6 |
| Situación actual | 2.63 | 0.119 | 8 |
| Sin control (p = 12%) | 5.15 | 0.590 | 13 |
g_bin <- ggplot(sim_binom, aes(inasistencias, fill = escenario)) +
geom_bar(position = "dodge") +
labs(title = "Inasistencias por día en 1.000 días simulados (rbinom)",
x = "Inasistencias en el día", y = "Número de días", fill = "Escenario") +
scale_fill_brewer(palette = "Set2") + theme_minimal()
ggplotly(g_bin)
Interpretación: el día simulado con
runif() tuvo 3 inasistencias. Con rbinom() se
ve todo el rango posible: en la situación actual lo usual son 2 a 4
inasistencias diarias, pero hay días con bastantes más. Bajar la
probabilidad al 3 % con recordatorios desplaza la distribución hacia
0-2, mientras que un escenario sin control (12 %) duplica las pérdidas.
La simulación permite estimar el beneficio de una intervención
antes de implementarla.
dpois():
probabilidad exacta del número de inasistenciask <- 0:10
pois <- tibble(k = k,
teorica = dpois(k, lambda), # modelo de Poisson
real = as.numeric(table(factor(pmin(por_dia$no_asiste, 10), levels = k))) /
nrow(por_dia)) # frecuencia real con table()
g_pois <- pois %>% pivot_longer(-k, names_to = "fuente", values_to = "prob") %>%
mutate(fuente = recode(fuente, teorica = "Poisson (dpois)", real = "Datos reales")) %>%
ggplot(aes(k, prob, fill = fuente)) +
geom_col(position = "dodge") +
labs(title = "Inasistencias por día: modelo de Poisson vs. datos reales",
x = "Inasistencias en el día", y = "Probabilidad", fill = "") +
scale_x_continuous(breaks = k) + theme_minimal()
ggplotly(g_pois)
p_cero <- dpois(0, lambda)
p_5omas <- 1 - sum(dpois(0:4, lambda))
Interpretación: el modelo de Poisson con λ = 2.54 se ajusta muy bien a los datos reales. Según el modelo, la probabilidad de un día sin ninguna inasistencia es apenas 7.9 %, y la de tener 5 o más es 11.4 %. Con esto se puede planear un sobreagendamiento controlado: agendar 2 cupos extra por día cubriría la pérdida típica sin generar congestión casi nunca.
rnorm(), rexp() y
runif()Se compara cada variable real con un modelo teórico y se usan las
funciones de densidad (d...) y de distribución acumulada
(p...) para calcular probabilidades útiles.
mu_dur <- mean(atendidas$appointment_duration); sd_dur <- sd(atendidas$appointment_duration)
mu_esp <- mean(atendidas$waiting_time)
n_sim <- 5000
sim_cont <- tibble(
`Duración - normal (rnorm)` = rnorm(n_sim, mean = mu_dur, sd = sd_dur),
`Espera - exponencial (rexp)` = rexp(n_sim, rate = 1 / mu_esp),
`Anticipación - uniforme (runif)` = runif(n_sim, min = 1, max = 30)
) %>% pivot_longer(everything(), names_to = "modelo", values_to = "valor")
g_cont <- ggplot(sim_cont, aes(valor)) +
geom_histogram(aes(y = after_stat(density)), bins = 40, fill = "#41AB5D", color = "white") +
facet_wrap(~ modelo, scales = "free") +
labs(title = "Datos simulados con distribución normal, exponencial y uniforme",
x = "Minutos / días", y = "Densidad") + theme_minimal()
ggplotly(g_cont)
# Datos reales vs. curva teórica (dnorm y dexp)
g_dnorm <- ggplot(atendidas, aes(appointment_duration)) +
geom_histogram(aes(y = after_stat(density)), binwidth = 2, fill = "grey80", color = "white") +
stat_function(fun = dnorm, args = list(mean = mu_dur, sd = sd_dur), color = "#2C7FB8", linewidth = 1) +
labs(title = "Duración real vs. curva normal (dnorm)", x = "Minutos", y = "Densidad") + theme_minimal()
g_dexp <- ggplot(atendidas, aes(waiting_time)) +
geom_histogram(aes(y = after_stat(density)), binwidth = 5, fill = "grey80", color = "white") +
stat_function(fun = dexp, args = list(rate = 1 / mu_esp), color = "red", linewidth = 1) +
labs(title = "Espera real vs. curva exponencial (dexp)", x = "Minutos", y = "Densidad") + theme_minimal()
subplot(ggplotly(g_dnorm), ggplotly(g_dexp), nrows = 1, titleX = TRUE, titleY = TRUE)
probs <- tibble(
Pregunta = c("P(consulta dure más de 30 min)", "P(espera más de 30 min)", "P(espera más de 60 min)"),
`Modelo teórico` = c(1 - pnorm(30, mu_dur, sd_dur), 1 - pexp(30, 1/mu_esp), 1 - pexp(60, 1/mu_esp)),
`Datos reales` = c(mean(atendidas$appointment_duration > 30),
mean(atendidas$waiting_time > 30), mean(atendidas$waiting_time > 60))
) %>% mutate(across(where(is.numeric), ~ round(.x, 3)))
kable(probs)
| Pregunta | Modelo teórico | Datos reales |
|---|---|---|
| P(consulta dure más de 30 min) | 0.129 | 0.148 |
| P(espera más de 30 min) | 0.506 | 0.538 |
| P(espera más de 60 min) | 0.256 | 0.278 |
Interpretación:
pnorm() se estima qué fracción de consultas
excede una franja de 30 minutos.pexp() el modelo estima que alrededor de la mitad
de los pacientes espera más de 30 minutos, igual que los datos reales:
la espera es el principal problema de calidad.runif()
simula un escenario donde la cita se pide con igual probabilidad entre 1
y 30 días. En la realidad la anticipación se concentra en pocos días
(mediana 5 días), así que la demanda no es uniforme: la
mayoría pide cita para la semana siguiente.resumen_ind <- function(x) c(Media = mean(x), Mediana = median(x), `Desv. estándar` = sd(x),
Varianza = var(x), Mínimo = range(x)[1], Máximo = range(x)[2])
desc <- rbind(`Tiempo de espera (min)` = resumen_ind(atendidas$waiting_time),
`Duración consulta (min)` = resumen_ind(atendidas$appointment_duration),
`Anticipación (días)` = resumen_ind(citas$scheduling_interval),
`Citas por día` = resumen_ind(por_dia$citas))
kable(round(desc, 2))
| Media | Mediana | Desv. estándar | Varianza | Mínimo | Máximo | |
|---|---|---|---|---|---|---|
| Tiempo de espera (min) | 44.09 | 33.5 | 40.79 | 1663.73 | 0.6 | 297.3 |
| Duración consulta (min) | 17.48 | 15.8 | 11.06 | 122.42 | 0.0 | 58.7 |
| Anticipación (días) | 7.19 | 5.0 | 6.15 | 37.78 | 1.0 | 30.0 |
| Citas por día | 42.81 | 43.0 | 3.28 | 10.78 | 1.0 | 52.0 |
summary(atendidas %>% select(waiting_time, appointment_duration, scheduling_interval, age))
## waiting_time appointment_duration scheduling_interval age
## Min. : 0.60 Min. : 0.00 Min. : 1.000 Min. : 15.00
## 1st Qu.: 12.60 1st Qu.: 8.60 1st Qu.: 2.000 1st Qu.: 40.00
## Median : 33.50 Median :15.80 Median : 5.000 Median : 59.00
## Mean : 44.09 Mean :17.48 Mean : 7.198 Mean : 57.23
## 3rd Qu.: 64.60 3rd Qu.:24.70 3rd Qu.:10.000 3rd Qu.: 74.00
## Max. :297.30 Max. :58.70 Max. :30.000 Max. :100.00
Interpretación: en el tiempo de espera la media (44.1 min) es mayor que la mediana (33.5 min) y la desviación estándar es casi igual a la media: la variable es muy asimétrica y dispersa (hay pacientes que esperan horas). La duración de la consulta es mucho más estable. Las citas por día tienen poca varianza, lo que confirma una demanda estable y predecible (hallazgo de la semana 1).
En todas las pruebas se usa un nivel de significancia de α = 0,05: si el valor p es menor que 0,05, la diferencia se considera estadísticamente significativa (es poco probable que se deba al azar).
t.test(): comparación de medias e intervalos de
confianza# a) Intervalo de confianza y comparación con una meta de 30 minutos
t_meta <- t.test(atendidas$waiting_time, mu = 30)
ic_esp <- t.test(atendidas$waiting_time)$conf.int
# b) Espera en la mañana vs. en la tarde
t_jornada <- t.test(waiting_time ~ jornada, data = atendidas)
# c) Espera por periodo: 2015-2019 vs. 2020-2024
t_periodo <- t.test(waiting_time ~ periodo, data = atendidas)
# d) Espera de mujeres vs. hombres
t_sexo <- t.test(waiting_time ~ sex, data = atendidas)
res_t <- function(t, nombre) tibble(Comparación = nombre,
`Media grupo 1` = as.character(round(t$estimate[1], 1)),
`Media grupo 2` = ifelse(length(t$estimate) > 1, as.character(round(t$estimate[2], 1)), "—"),
`IC 95% (dif. o media)` = paste0("[", round(t$conf.int[1], 1), " ; ", round(t$conf.int[2], 1), "]"),
`valor p` = fp(t$p.value))
bind_rows(res_t(t_meta, "Espera media vs. meta de 30 min"),
res_t(t_jornada, "Mañana vs. Tarde"),
res_t(t_periodo, "2015-2019 vs. 2020-2024"),
res_t(t_sexo, "Mujer vs. Hombre")) %>% kable()
| Comparación | Media grupo 1 | Media grupo 2 | IC 95% (dif. o media) | valor p |
|---|---|---|---|---|
| Espera media vs. meta de 30 min | 44.1 | — | [43.8 ; 44.4] | <0.001 |
| Mañana vs. Tarde | 27.2 | 55.3 | [-28.6 ; -27.7] | <0.001 |
| 2015-2019 vs. 2020-2024 | 43.2 | 45 | [-2.3 ; -1.2] | <0.001 |
| Mujer vs. Hombre | 44.1 | 44.1 | [-0.6 ; 0.6] | 0.992 |
Interpretación:
aov():
análisis de varianza (ANOVA) entre varios gruposanova_edad <- aov(waiting_time ~ banda_edad, data = atendidas)
anova_dur <- aov(appointment_duration ~ banda_edad, data = atendidas)
anova_hora <- aov(waiting_time ~ factor(hora), data = atendidas)
summary(anova_edad)
## Df Sum Sq Mean Sq F value Pr(>F)
## banda_edad 3 3124 1041 0.626 0.598
## Residuals 86028 143128841 1664
summary(anova_dur)
## Df Sum Sq Mean Sq F value Pr(>F)
## banda_edad 3 407 135.6 1.107 0.345
## Residuals 86028 10531256 122.4
summary(anova_hora)
## Df Sum Sq Mean Sq F value Pr(>F)
## factor(hora) 9 21722581 2413620 1710 <2e-16 ***
## Residuals 86022 121409384 1411
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
p_aov <- function(m) summary(m)[[1]][["Pr(>F)"]][1]
Interpretación:
chisq.test(): asociación entre variables categóricasperdidas <- citas %>% filter(status %in% c("attended", "cancelled", "did not attend")) %>%
mutate(status = droplevels(status))
tab_edad <- table(perdidas$banda_edad, perdidas$status)
tab_jornada <- table(perdidas$jornada, perdidas$status)
chi_edad <- chisq.test(tab_edad)
chi_jornada <- chisq.test(tab_jornada)
kable(round(prop.table(tab_edad, 1) * 100, 1), caption = "% de cada estado por banda de edad")
| attended | cancelled | did not attend | |
|---|---|---|---|
| 15-29 | 77.3 | 16.7 | 5.9 |
| 30-49 | 77.4 | 16.6 | 5.9 |
| 50-69 | 77.7 | 16.3 | 6.0 |
| 70+ | 77.7 | 16.4 | 5.9 |
kable(round(prop.table(tab_jornada, 1) * 100, 1), caption = "% de cada estado por jornada")
| attended | cancelled | did not attend | |
|---|---|---|---|
| Mañana | 77.4 | 16.6 | 6 |
| Tarde | 77.7 | 16.4 | 6 |
Interpretación: ni la banda de edad (χ² = 2.22, p = 0.898) ni la jornada (χ² = 1.23, p = 0.54) están asociadas con que la cita se cumpla, se cancele o se pierda: los porcentajes son casi idénticos en todas las filas. Para la gestión, las campañas de recordatorio deben dirigirse a todos los pacientes, no a un grupo de edad u horario específico.
prop.test(): comparación de proporciones# a) Proporción de inasistencia: mujeres vs. hombres
inas <- citas %>% filter(status %in% c("attended", "did not attend")) %>%
group_by(sex) %>% summarise(x = sum(status == "did not attend"), n = n())
pt_sexo <- prop.test(inas$x, inas$n)
# b) Proporción de cancelación: 2015-2019 vs. 2020-2024
canc <- citas %>% group_by(periodo) %>% summarise(x = sum(status == "cancelled"), n = n())
pt_periodo <- prop.test(canc$x, canc$n)
tibble(Comparación = c("Inasistencia: Mujer vs. Hombre", "Cancelación: 2015-2019 vs. 2020-2024"),
`Prop. grupo 1` = round(c(pt_sexo$estimate[1], pt_periodo$estimate[1]), 4),
`Prop. grupo 2` = round(c(pt_sexo$estimate[2], pt_periodo$estimate[2]), 4),
`valor p` = c(fp(pt_sexo$p.value), fp(pt_periodo$p.value))) %>% kable()
| Comparación | Prop. grupo 1 | Prop. grupo 2 | valor p |
|---|---|---|---|
| Inasistencia: Mujer vs. Hombre | 0.0709 | 0.0721 | 0.496 |
| Cancelación: 2015-2019 vs. 2020-2024 | 0.1649 | 0.1626 | 0.313 |
Interpretación: la inasistencia de mujeres y hombres (p = 0.496) y la tasa de cancelación entre los dos periodos (p = 0.313) no difieren significativamente. Las pérdidas de capacidad son un problema estructural y constante en el tiempo: no han mejorado solas en diez años, lo que justifica una intervención activa.
ggpubrg_a <- ggboxplot(atendidas, x = "jornada", y = "waiting_time", fill = "jornada",
palette = c("#66C2A5", "#FC8D62"), outlier.shape = NA,
xlab = "Jornada", ylab = "Espera (min)", title = "Espera: mañana vs. tarde") +
coord_cartesian(ylim = c(0, 200)) +
stat_compare_means(method = "t.test", label.y = 190) + theme(legend.position = "none")
g_b <- ggline(atendidas %>% mutate(hora = factor(hora)), x = "hora", y = "waiting_time", add = "mean_ci",
color = "#2C7FB8", xlab = "Hora programada", ylab = "Espera media (min, IC 95%)",
title = "La espera crece con cada hora del día")
ggarrange(g_a, g_b, ncol = 2, labels = c("A", "B"))
g_c <- ggviolin(atendidas, x = "banda_edad", y = "appointment_duration", fill = "banda_edad",
palette = "Set2", add = "boxplot", add.params = list(fill = "white"),
xlab = "Banda de edad", ylab = "Duración (min)", title = "Duración de consulta por edad") +
stat_compare_means(method = "anova", label.y = 65) + theme(legend.position = "none")
prop_banda <- perdidas %>% group_by(banda_edad) %>%
summarise(pct = round(100 * mean(status != "attended"), 1))
g_d <- ggbarplot(prop_banda, x = "banda_edad", y = "pct", fill = "#8DA0CB",
label = TRUE, xlab = "Banda de edad", ylab = "% de citas perdidas", title = "% de citas perdidas por edad") +
ylim(0, 30)
ggarrange(g_c, g_d, ncol = 2, labels = c("C", "D"))
Interpretación: el panel A muestra que toda la caja de la tarde está por encima de la de la mañana, y la prueba t lo confirma. El panel B es el hallazgo clave: la espera media sube de forma casi lineal desde la primera cita (8 a. m.) hasta la última (5 p. m.), es decir, cada retraso se arrastra a las citas siguientes (efecto “bola de nieve”). Los paneles C y D muestran lo contrario para la edad: las formas de los violines y las barras son prácticamente iguales, sin diferencias significativas.