El EDA (Exploratory Data Analysis, o análisis exploratorio de datos) es la primera revisión de una base de datos: sirve para conocer qué variables tiene, cómo están organizadas, si hay datos faltantes o valores raros, y qué patrones se ven a simple vista en gráficos. No incluye pruebas estadísticas; esas se trabajarán en el Tema 2.
appointments.csv (una
fila = una cita médica).Para la gestión de recursos sanitarios, esta base sirve para mirar la demanda (cuántas citas se piden y cuándo), el aprovechamiento de la capacidad instalada (citas atendidas frente a citas perdidas) y la eficiencia operativa (tiempos de espera y de consulta).
Paquetes solicitados en la actividad, más plotly (para
que los gráficos sean interactivos) y knitr (para
tablas).
# Si es la primera vez, instalar (se ejecuta una sola vez en la consola):
# install.packages(c("tidyverse", "ggplot2", "dplyr", "skimr", "janitor", "plotly", "knitr"))
library(tidyverse) # conjunto de paquetes para manejar datos
library(ggplot2) # gráficos
library(dplyr) # transformar tablas (filtrar, agrupar, resumir)
library(skimr) # resumen rápido de la base
library(janitor) # limpieza de nombres y tablas de frecuencia
library(plotly) # gráficos interactivos
library(knitr) # tablas en el informe
El archivo CSV (valores separados por comas) debe estar en
la misma carpeta que este archivo .Rmd.
citas <- read_csv("appointments.csv") %>%
clean_names() # deja los nombres de columnas en minúscula y sin espacios
# Variables derivadas útiles para el análisis
citas <- citas %>%
mutate(
across(where(hms::is_hms), as.character), # horas como texto (evita errores en summary)
status = factor(status),
sex = factor(sex),
age_group = factor(age_group, levels = unique(age_group[order(age)])),
dia_semana = factor(weekdays(as.Date(appointment_date)),
levels = c("lunes", "martes", "miércoles", "jueves",
"viernes", "sábado", "domingo",
"Monday", "Tuesday", "Wednesday", "Thursday",
"Friday", "Saturday", "Sunday")) %>% droplevels(),
mes = format(as.Date(appointment_date), "%Y-%m")
)
dim(citas) # número de filas (citas) y columnas (variables)
## [1] 111488 18
La base tiene 111.488 citas y 18 columnas, con fechas de cita entre 2015-01-01 y 2024-12-24.
str() y glimpse()str() y glimpse() muestran el nombre de
cada variable, su tipo (número, texto, fecha) y los primeros
valores.
glimpse(citas)
## Rows: 111,488
## Columns: 18
## $ appointment_id <chr> "0000138", "0000146", "0000021", "0000233", "0000…
## $ slot_id <chr> "0000001", "0000023", "0000024", "0000025", "0000…
## $ scheduling_date <date> 2014-12-28, 2014-12-29, 2014-12-17, 2014-12-31, …
## $ appointment_date <date> 2015-01-01, 2015-01-01, 2015-01-01, 2015-01-01, …
## $ appointment_time <chr> "08:00:00", "13:30:00", "13:45:00", "14:00:00", "…
## $ scheduling_interval <dbl> 4, 3, 15, 1, 6, 2, 2, 2, 4, 4, 4, 1, 1, 7, 3, 2, …
## $ status <fct> did not attend, did not attend, attended, attende…
## $ check_in_time <chr> NA, NA, "13:36:45", "13:59:32", NA, "14:08:53", "…
## $ appointment_duration <dbl> NA, NA, 5.2, 28.9, NA, 7.7, 4.2, 27.1, NA, 1.2, 7…
## $ start_time <chr> NA, NA, "13:37:57", "14:00:40", NA, "14:30:38", "…
## $ end_time <chr> NA, NA, "13:43:09", "14:29:34", NA, "14:38:20", "…
## $ waiting_time <dbl> NA, NA, 1.2, 1.1, NA, 21.7, 16.2, 1.0, NA, 8.5, 2…
## $ patient_id <chr> "08285", "05972", "06472", "05376", "08028", "043…
## $ sex <fct> Male, Male, Male, Female, Male, Female, Male, Mal…
## $ age <dbl> 37, 84, 77, 37, 72, 51, 28, 33, 29, 90, 66, 64, 3…
## $ age_group <fct> 35-39, 80-84, 75-79, 35-39, 70-74, 50-54, 25-29, …
## $ dia_semana <fct> jueves, jueves, jueves, jueves, jueves, jueves, j…
## $ mes <chr> "2015-01", "2015-01", "2015-01", "2015-01", "2015…
summary()summary() entrega, para cada variable numérica, el
mínimo, el máximo y los valores centrales; para las de texto, cuántos
registros tiene.
summary(citas)
## appointment_id slot_id scheduling_date
## Length :111488 Length :111488 Min. :2014-12-09
## N.unique :111488 N.unique :103646 1st Qu.:2017-06-18
## N.blank : 0 N.blank : 0 Median :2019-12-14
## Min.nchar: 7 Min.nchar: 7 Mean :2019-12-13
## Max.nchar: 7 Max.nchar: 7 3rd Qu.:2022-06-08
## Max. :2024-12-01
##
## appointment_date appointment_time scheduling_interval
## Min. :2015-01-01 Length :111488 Min. : 1.00
## 1st Qu.:2017-06-26 N.unique : 40 1st Qu.: 2.00
## Median :2019-12-20 N.blank : 0 Median : 5.00
## Mean :2019-12-20 Min.nchar: 8 Mean : 7.19
## 3rd Qu.:2022-06-15 Max.nchar: 8 3rd Qu.:10.00
## Max. :2024-12-24 Max. :30.00
##
## status check_in_time appointment_duration
## attended :86032 Length :111488 Min. : 0.00
## cancelled :18254 N.unique : 33251 1st Qu.: 8.60
## did not attend: 6615 N.blank : 0 Median :15.80
## scheduled : 141 Min.nchar: 8 Mean :17.48
## unknown : 446 Max.nchar: 8 3rd Qu.:24.70
## NAs : 25456 Max. :58.70
## NAs :25456
## start_time end_time waiting_time patient_id
## Length :111488 Length :111488 Min. : 0.60 Length :111488
## N.unique : 35984 N.unique : 36852 1st Qu.: 12.60 N.unique : 36697
## N.blank : 0 N.blank : 0 Median : 33.50 N.blank : 0
## Min.nchar: 8 Min.nchar: 8 Mean : 44.09 Min.nchar: 5
## Max.nchar: 8 Max.nchar: 8 3rd Qu.: 64.60 Max.nchar: 5
## NAs : 25456 NAs : 25456 Max. :297.30
## NAs :25456
## sex age age_group dia_semana
## Female:66086 Min. : 15.00 75-79 : 9844 lunes :22293
## Male :45402 1st Qu.: 40.00 60-64 : 9649 martes :22342
## Median : 59.00 70-74 : 9485 miércoles:22190
## Mean : 57.21 65-69 : 9133 jueves :22313
## 3rd Qu.: 74.00 55-59 : 8955 viernes :22350
## Max. :100.00 80-84 : 8140
## (Other):56282
## mes
## Length :111488
## N.unique : 120
## N.blank : 0
## Min.nchar: 7
## Max.nchar: 7
##
##
skimr::skim()skim() agrega el número de datos faltantes (NA,
celdas vacías) de cada variable y un mini histograma.
skim(citas)
| Name | citas |
| Number of rows | 111488 |
| Number of columns | 18 |
| _______________________ | |
| Column type frequency: | |
| character | 8 |
| Date | 2 |
| factor | 4 |
| numeric | 4 |
| ________________________ | |
| Group variables | None |
Variable type: character
| skim_variable | n_missing | complete_rate | min | max | empty | n_unique | whitespace |
|---|---|---|---|---|---|---|---|
| appointment_id | 0 | 1.00 | 7 | 7 | 0 | 111488 | 0 |
| slot_id | 0 | 1.00 | 7 | 7 | 0 | 103646 | 0 |
| appointment_time | 0 | 1.00 | 8 | 8 | 0 | 40 | 0 |
| check_in_time | 25456 | 0.77 | 8 | 8 | 0 | 33251 | 0 |
| start_time | 25456 | 0.77 | 8 | 8 | 0 | 35984 | 0 |
| end_time | 25456 | 0.77 | 8 | 8 | 0 | 36852 | 0 |
| patient_id | 0 | 1.00 | 5 | 5 | 0 | 36697 | 0 |
| mes | 0 | 1.00 | 7 | 7 | 0 | 120 | 0 |
Variable type: Date
| skim_variable | n_missing | complete_rate | min | max | median | n_unique |
|---|---|---|---|---|---|---|
| scheduling_date | 0 | 1 | 2014-12-09 | 2024-12-01 | 2019-12-14 | 3642 |
| appointment_date | 0 | 1 | 2015-01-01 | 2024-12-24 | 2019-12-20 | 2604 |
Variable type: factor
| skim_variable | n_missing | complete_rate | ordered | n_unique | top_counts |
|---|---|---|---|---|---|
| status | 0 | 1 | FALSE | 5 | att: 86032, can: 18254, did: 6615, unk: 446 |
| sex | 0 | 1 | FALSE | 2 | Fem: 66086, Mal: 45402 |
| age_group | 0 | 1 | FALSE | 16 | 75-: 9844, 60-: 9649, 70-: 9485, 65-: 9133 |
| dia_semana | 0 | 1 | FALSE | 5 | vie: 22350, mar: 22342, jue: 22313, lun: 22293 |
Variable type: numeric
| skim_variable | n_missing | complete_rate | mean | sd | p0 | p25 | p50 | p75 | p100 | hist |
|---|---|---|---|---|---|---|---|---|---|---|
| scheduling_interval | 0 | 1.00 | 7.19 | 6.15 | 1.0 | 2.0 | 5.0 | 10.0 | 30.0 | ▇▃▂▁▁ |
| appointment_duration | 25456 | 0.77 | 17.48 | 11.06 | 0.0 | 8.6 | 15.8 | 24.7 | 58.7 | ▇▇▅▂▁ |
| waiting_time | 25456 | 0.77 | 44.09 | 40.79 | 0.6 | 12.6 | 33.5 | 64.6 | 297.3 | ▇▂▁▁▁ |
| age | 0 | 1.00 | 57.21 | 20.16 | 15.0 | 40.0 | 59.0 | 74.0 | 100.0 | ▅▆▇▇▂ |
| Variable | Tipo | Descripción |
|---|---|---|
| appointment_id | Identificador | Código único de la cita |
| slot_id | Identificador | Código del espacio (franja horaria) de agenda |
| scheduling_date | Fecha | Día en que el paciente pidió la cita |
| appointment_date | Fecha | Día programado de la cita |
| appointment_time | Hora | Hora programada de la cita |
| scheduling_interval | Numérica | Días entre la solicitud y la cita (anticipación) |
| status | Categórica | Estado de la cita: atendida, cancelada, no asistió, etc. |
| check_in_time | Hora | Hora de llegada del paciente |
| appointment_duration | Numérica | Duración de la consulta (minutos) |
| start_time / end_time | Hora | Hora real de inicio y fin de la consulta |
| waiting_time | Numérica | Minutos de espera del paciente antes de ser atendido |
| patient_id | Identificador | Código del paciente |
| sex | Categórica | Sexo del paciente |
| age | Numérica | Edad del paciente (años) |
| age_group | Categórica | Grupo de edad |
| dia_semana / mes | Derivada | Variables creadas en este informe a partir de la fecha de la cita |
faltantes <- citas %>%
summarise(across(everything(), ~ sum(is.na(.)))) %>%
pivot_longer(everything(), names_to = "variable", values_to = "n_faltantes") %>%
mutate(porcentaje = round(100 * n_faltantes / nrow(citas), 1)) %>%
arrange(desc(n_faltantes))
kable(faltantes)
| variable | n_faltantes | porcentaje |
|---|---|---|
| check_in_time | 25456 | 22.8 |
| appointment_duration | 25456 | 22.8 |
| start_time | 25456 | 22.8 |
| end_time | 25456 | 22.8 |
| waiting_time | 25456 | 22.8 |
| appointment_id | 0 | 0.0 |
| slot_id | 0 | 0.0 |
| scheduling_date | 0 | 0.0 |
| appointment_date | 0 | 0.0 |
| appointment_time | 0 | 0.0 |
| scheduling_interval | 0 | 0.0 |
| status | 0 | 0.0 |
| patient_id | 0 | 0.0 |
| sex | 0 | 0.0 |
| age | 0 | 0.0 |
| age_group | 0 | 0.0 |
| dia_semana | 0 | 0.0 |
| mes | 0 | 0.0 |
Comentario: los faltantes se concentran en las
variables de la atención (check_in_time,
start_time, end_time,
waiting_time, appointment_duration). Esto es
lógico: si la cita fue cancelada o el paciente no asistió, no hay hora
de llegada ni duración. No son errores de digitación,
así que no se deben rellenar; los análisis de tiempos se hacen solo con
las citas atendidas.
Todos los gráficos son interactivos: al pasar el mouse se ven los valores, se puede hacer zoom arrastrando, y doble clic para volver a la vista original.
estado <- citas %>%
tabyl(status) %>% # tabla de frecuencias con janitor
arrange(desc(n)) %>%
mutate(porcentaje = round(100 * percent, 1))
kable(estado %>% select(status, n, porcentaje))
| status | n | porcentaje |
|---|---|---|
| attended | 86032 | 77.2 |
| cancelled | 18254 | 16.4 |
| did not attend | 6615 | 5.9 |
| unknown | 446 | 0.4 |
| scheduled | 141 | 0.1 |
g1 <- ggplot(estado, aes(x = reorder(status, n), y = n,
text = paste0("Estado: ", status,
"<br>Citas: ", n,
"<br>%: ", porcentaje))) +
geom_col(fill = "#2C7FB8") +
coord_flip() +
labs(title = "Número de citas por estado", x = "Estado de la cita", y = "Número de citas") +
theme_minimal()
ggplotly(g1, tooltip = "text")
Comentario: el 77.2% de las citas fueron atendidas; el 16.4% se cancelaron y el 5.9% corresponde a pacientes que no asistieron (inasistencia). Cada cita cancelada o perdida es una franja de agenda (tiempo de médico y consultorio) que no produce atención. Para la gestión, estos porcentajes muestran cuánta capacidad instalada se pierde y justifican acciones como recordatorios por mensaje de texto, listas de espera para reasignar cupos o sobreagendamiento controlado.
# Se excluye el último mes porque está incompleto (registros solo de los primeros días)
ultimo_mes <- max(citas$mes)
demanda_mes <- citas %>%
filter(mes != ultimo_mes) %>%
count(mes, status) %>%
mutate(fecha = as.Date(paste0(mes, "-01")))
g2 <- ggplot(demanda_mes, aes(x = fecha, y = n, color = status, group = status,
text = paste0("Mes: ", mes, "<br>Estado: ", status,
"<br>Citas: ", n))) +
geom_line() +
labs(title = "Citas por mes según su estado",
x = "Mes", y = "Número de citas", color = "Estado") +
scale_color_brewer(palette = "Set2") +
theme_minimal()
ggplotly(g2, tooltip = "text")
# Complemento: total de citas por día de la semana
citas %>% count(dia_semana) %>% kable(col.names = c("Día", "Citas"))
| Día | Citas |
|---|---|
| lunes | 22293 |
| martes | 22342 |
| miércoles | 22190 |
| jueves | 22313 |
| viernes | 22350 |
Comentario: la base cubre aproximadamente diez años de actividad. La demanda mensual se mantiene estable, entre unas 850 y 1.000 citas al mes, sin crecimientos ni caídas fuertes. Las citas atendidas son siempre la línea más alta y las canceladas y no asistidas mantienen niveles parecidos cada mes. Las citas se reparten casi por igual de lunes a viernes, y no hay atención los fines de semana. Para la gestión, una demanda estable y predecible permite planear con anticipación la capacidad instalada (consultorios y horarios) y los turnos del talento humano con una plantilla fija. El problema no es la falta de demanda, sino lo que se pierde por cancelaciones e inasistencias (Gráfico 1).
atendidas <- citas %>% filter(status == "attended", !is.na(waiting_time))
g3 <- ggplot(atendidas, aes(x = waiting_time)) +
geom_histogram(binwidth = 5, fill = "#41AB5D", color = "white") +
labs(title = "Distribución del tiempo de espera (citas atendidas)",
x = "Tiempo de espera (minutos)", y = "Número de citas") +
theme_minimal()
ggplotly(g3)
Comentario: el histograma (gráfico de barras que agrupa los valores en intervalos) muestra cuántos pacientes esperaron cada rango de minutos. Los tiempos van de 0.6 a 297.3 minutos. Observar dónde se acumulan las barras indica la espera “habitual”; las barras aisladas a la derecha son pacientes con esperas muy largas. El gráfico tiene una “cola” larga hacia la derecha: la mayoría espera menos de una hora, pero hay pacientes que esperan varias horas. Además, el 53.8% de los pacientes atendidos esperó más de 30 minutos. El tiempo de espera es un indicador de calidad percibida por el usuario, así que este es un hallazgo crítico: más de la mitad de los pacientes supera una meta razonable de 30 minutos. Para la gestión, esto indica revisar la puntualidad de la agenda, el flujo de recepción y si las franjas asignadas son suficientes.
g4 <- ggplot(atendidas %>% filter(!is.na(appointment_duration)),
aes(x = age_group, y = appointment_duration, fill = age_group)) +
geom_boxplot(outlier.colour = "red", show.legend = FALSE) +
labs(title = "Duración de la consulta según grupo de edad",
x = "Grupo de edad", y = "Duración (minutos)") +
theme_minimal() +
theme(axis.text.x = element_text(angle = 45, hjust = 1))
ggplotly(g4)
Comentario: el boxplot (diagrama de caja) resume cada grupo: la caja contiene la mitad central de las consultas, la línea interna es el valor del medio y los puntos rojos son valores atípicos (consultas mucho más largas o cortas que lo usual). Las cajas de todos los grupos de edad son prácticamente iguales: la consulta típica dura unos 15.8 minutos en todas las edades. Es decir, en esta base la edad no cambia el consumo de tiempo médico, y una misma duración de franja para todos los grupos es razonable. Los valores atípicos (consultas de hasta 58.7 minutos) aparecen en todos los grupos. Se deben revisar porque cada consulta muy larga retrasa las siguientes, y eso ayuda a explicar los tiempos de espera altos del Gráfico 3.
g5 <- ggplot(citas, aes(x = status, y = scheduling_interval, fill = status)) +
geom_boxplot(show.legend = FALSE) +
labs(title = "Días de anticipación con que se pidió la cita, según su estado",
x = "Estado de la cita", y = "Días entre solicitud y cita") +
scale_fill_brewer(palette = "Set2") +
theme_minimal()
ggplotly(g5)
Comentario: este gráfico explora, de forma visual y preliminar, si las citas pedidas con mucha anticipación tienden a cancelarse o perderse más que las pedidas para pocos días después. Las cajas de “attended”, “cancelled” y “did not attend” son casi idénticas: la mayoría de las citas se pide con 2 a 10 días de anticipación, sin importar si después se cumplen o no. Visualmente, la anticipación no parece explicar las cancelaciones ni las inasistencias. Para la gestión, esto sugiere que limitar el horizonte de agendamiento no reduciría las pérdidas, y que se deben buscar otras causas (recordatorios, barreras de acceso, horario de la cita). La comprobación formal de esta relación corresponde al Tema 2.
Decisiones de gestión sugeridas a partir del EDA: recordatorios y confirmación previa de citas, reasignación rápida de cupos liberados (lista de espera), revisión de la puntualidad y del flujo de recepción para bajar la espera, control de las consultas que se extienden y seguimiento mensual de estos indicadores (porcentaje de citas perdidas, espera promedio, porcentaje con espera mayor a 30 minutos) en un tablero.
Tema pendiente: se desarrollará en la siguiente actividad.