Manejo de Excel en R para Datos de Salud
Aprende a importar, limpiar, manipular y exportar archivos Excel desde R, paso a paso y con explicaciones claras. Pensado para personas que recién parten en programación.
En cada módulo encontrarás ejemplos con datos reales tipo salud (REM, fichas, encuestas) y los errores comunes que se ven en planillas del mundo real.
Este curso es un complemento de Hazla con Datos. Puedes tomarlo antes, durante o después del curso principal.
Descarga el archivo de ejemplo
Durante todo el curso usaremos un mismo archivo Excel ficticio (pacientes_ejemplo.xlsx) con varias hojas y los típicos "problemas reales" que vas a aprender a resolver: nombres sucios, fechas como número, NAs disfrazados de texto y una hoja en formato REM.
Prerequisito: Configurar tu entorno
Para seguir los ejemplos necesitas tener R y Positron instalados. Si recién partes, sigue primero la guía Configuración del Entorno: te lleva paso a paso de cero (terminal, R con Rig, Pak, Positron y más).
Ir a Configuración del Entorno →Parte de Hazla con Datos
Este curso es un recurso complementario de Hazla con Datos, una comunidad enfocada en programación y ciencia de datos en salud. Encuentra más cursos, recursos y herramientas para tu camino profesional.
Visitar hazlacondatos.com →Por qué Excel sigue importando en salud
Bienvenida
Antes de tocar una sola línea de código, conversemos. Si trabajas con datos de salud (en un hospital, un servicio de salud, un centro de investigación, una ONG, un seguro privado, lo que sea), una verdad incómoda es esta:
La gran mayoría de los datos que vas a recibir van a venir en Excel.
REM, fichas clínicas exportadas, encuestas, planillas de inscripción, listados de stock de farmacia, reportes mensuales, bases de datos “exportadas” desde un sistema que en realidad solo te entrega un .xlsx… Excel es el formato universal de intercambio de información en salud, te guste o no.
Este curso es sobre cómo dejar de pelearte con esas planillas y empezar a sacarles información útil desde R, usando código que puedas leer mañana, repetir el próximo mes y compartir con un colega.
Por qué no quedarse solo en Excel
Excel es excelente para mirar datos rápidamente, ordenar una columna, sumar un total, hacer un gráfico exploratorio o entregar un informe final a alguien no técnico. Para eso es muy bueno y nadie te lo va a quitar.
Pero usar Excel para todo lo demás es un riesgo — y en salud, donde los datos derivan en decisiones sobre pacientes, indicadores que llegan a la autoridad sanitaria o presupuestos asignados, los riesgos se vuelven inaceptables. Cuando tu trabajo crece, Excel empieza a fallar de formas concretas:
- No es reproducible. Si haces “ordenar por edad”, “filtrar mayores de 65”, “calcular promedio” a mano, el próximo mes tienes que volver a hacer todos los clics. Si te equivocas en un paso, no hay forma de rastrear qué pasó.
- Esconde lo que hiciste. Una fórmula en la celda
H42puede depender de otra enB17que tú escribiste hace 3 meses. Nadie (ni tú) recuerda por qué. - Se rompe con escala. Más de 100.000 filas y empieza a ir lento. Más de 1 millón y simplemente no puedes abrirlo.
- No se conecta a bases de datos. En cuanto los datos serios viven en una base de datos institucional (SQL Server, PostgreSQL, el sistema clínico del hospital, el data warehouse de la red), Excel queda fuera del juego. Lo máximo que se hace es exportar a
.xlsxdesde la BD — y ahí pierdes trazabilidad, te quedas con una foto vieja, y duplicas información que ya estaba viva en otra parte. Con R te conectas directo conDBI+dplyry consultas la fuente fresca cada vez. - No deja huella. No puedes versionar “qué cambió entre la planilla de enero y la de febrero” sin abrir las dos a mano.
- No se puede automatizar ni gobernar. No puedes programar Excel para que el día 1 de cada mes lea los archivos nuevos, valide reglas de negocio, marque registros sospechosos y entregue un informe. Y tampoco puedes establecer estándares de calidad y gobierno de datos (chequeos automáticos, trazabilidad de cambios, control de versiones, permisos por rol) sobre una planilla que cualquiera abre y edita a mano. En una organización seria, esto deja de ser opcional.
R resuelve los seis problemas: tu análisis queda escrito en un script, se puede volver a correr idéntico, queda documentado, soporta cualquier tamaño razonable de datos, se conecta directo a bases de datos institucionales (DBI + dplyr) y se integra con sistemas de calidad, validación y automatización (cron jobs, GitHub Actions, pipelines de datos, paquetes como pointblank o validate).
R no es la única herramienta — pero Excel sí deja de ser la principal
En ambientes profesionales de datos casi nunca se usa una sola tecnología. Lo habitual es combinar varias según el rol que cumplen: SQL para consultar bases de datos, Python para ciencia de datos y machine learning, R para análisis estadístico, reportes reproducibles y trabajo con datos de salud (donde tiene tradición fuerte), Git para versionar, dbt o Airflow para pipelines, Power BI o Tableau para visualización ejecutiva.
Lo que tienen en común todas estas herramientas es que son programables, auditables y reproducibles. Excel no entra en esa lista como motor del análisis — entra solo en los extremos (input rápido, output a usuarios no técnicos).
Este curso elige R porque es excelente para datos de salud y porque ya lo usamos en otros cursos de Hazla con Datos. Pero el principio es general: aprender un lenguaje serio de datos — el que sea — es lo que te saca de la trampa de “todo a mano en Excel”.
La regla práctica que vamos a seguir
Excel → para mirar datos rápido y para entregar el resultado final a usuarios menos técnicos (jefaturas, equipos clínicos, autoridad sanitaria) que esperan recibir un .xlsx.
R (o cualquier lenguaje) → para todo lo que está en medio: importar, validar, limpiar, calcular, auditar, versionar, automatizar.
Hacer el análisis “en medio” con Excel a mano, en datos de salud, agrega puertas de falla que ningún sistema serio debería tener.
La meta de este curso es justamente esa: que aprendas a abrir el Excel desde R, hacer todo lo que necesites (con código auditable y reproducible), y guardar el resultado de vuelta en Excel — porque el resto de tu equipo te lo va a seguir pidiendo en ese formato.
El ciclo de trabajo con Excel desde R
Vamos a aprender un patrón que se repite en absolutamente todos los proyectos de análisis de datos cuando la fuente es una planilla:
El ciclo
- Conocer la planilla con ojos humanos: ¿cuántas hojas tiene?, ¿dónde están los encabezados?, ¿hay celdas combinadas?, ¿hay totales mezclados?
- Importar el archivo a R con
readxl. - Inspeccionar y diagnosticar qué tipo tiene cada columna, qué valores faltan, qué cosas se ven sospechosas.
- Limpiar: nombres de columnas, tipos, fechas, NAs.
- Manipular: filtrar, agrupar, resumir, pivotar.
- Exportar el resultado a Excel con
openxlsxpara entregárselo a alguien más.
Cada uno de los módulos de este curso cubre uno de esos pasos. No te saltes ninguno — son el mismo flujo que vas a hacer una y otra vez, en un solo proyecto y en cientos de proyectos.
Qué vas a saber al terminar
Al final del curso vas a poder, sin pánico:
- Recibir un archivo
.xlsxcon 3 hojas y dos filas de basura arriba. - Abrirlo en R, leer la hoja que te interesa, saltarte la basura.
- Diagnosticar que la columna “edad” tiene un
"99 años"que rompió el tipo de toda la columna. - Convertir fechas que vinieron como número
45292a fechas reales (2024-01-01). - Reemplazar
"S/I"porNAreal. - Filtrar los pacientes >65 con hipertensión, calcular el promedio de presión por sexo, y exportar el resultado en un Excel nuevo con dos hojas y los encabezados en negrita.
Todo eso lo vamos a hacer paso a paso, explicando cada línea de código.
Detente y piensa
Antes de avanzar, anota mentalmente: ¿cuál es el último archivo Excel que recibiste y te dio problemas? Ese va a ser tu “caso real” mientras avances en el curso. En cada módulo, cuando aprendas una técnica nueva, pregúntate “¿me sirve para mi planilla?”.
Cómo está organizado el curso
| Bloque | Módulos | Qué aprendes |
|---|---|---|
| Empezar | 1 – 3 | Mentalidad, instalar herramientas, diagnosticar tu planilla. |
| Importar | 4 – 5 | Llevar el Excel a R, controlando hojas, rangos y tipos. |
| Inspeccionar y limpiar | 6 – 9 | Detectar problemas, arreglar nombres, tipos, fechas y NAs. |
| Manipular y exportar | 10 – 12 | Procesar con dplyr, exportar con openxlsx, recetario de errores. |
Cada módulo tiene explicación narrativa, código paso a paso, errores comunes y un ejercicio con solución colapsable. Lee el módulo, prueba el código en tu propio R, falla un par de veces, lee la sección de errores comunes y vuelve al ejercicio. Así es como se aprende.
Convenciones de código de este curso
Para que el código sea coherente entre módulos, vamos a seguir tres convenciones desde el principio:
- El pipe nativo
|>, no%>%. Es parte de R desde la versión 4.1 (año 2021) y no necesita ningún paquete extra. Si has visto código viejo con%>%, no te asustes: hacen casi lo mismo, pero|>es el estándar moderno. pak::pak("paquete")para instalar paquetes, en lugar deinstall.packages("paquete").pakes más rápido, te explica mejor los errores y maneja las dependencias del sistema operativo (Rtools, librerías del sistema) sin que tengas que pelearte con ellas.readxlpara leer yopenxlsxpara escribir. Hay otros paquetes (xlsx,writexl,openpyxlpara Python), pero estos dos cubren el 95% de los casos reales en salud y no requieren Java, que es la fuente número uno de dolor de cabeza con Excel desde R.
Tip
Si vienes de otro tutorial donde usaban %>% o install.packages(), no estás haciendo nada mal: simplemente este curso usa la versión moderna. Cuando termines, vas a poder leer ambas y elegir.
¿Y Python?
En cada bloque de código vas a ver dos pestañas: R (la principal, donde se enseña) y Python (como referencia, usando pandas). No es necesario que sepas Python para tomar el curso — es solo por si alguna vez te toca trabajar en un equipo donde Python es el lenguaje de la casa. El núcleo del curso es R.
Listo para empezar
En el próximo módulo vamos a dejar el entorno listo: R, Positron (el IDE que vamos a usar) y los paquetes del curso instalados con pak. Si ya tienes todo configurado puedes saltarte la parte de instalación, pero te recomiendo igual leer el módulo 2 porque ahí explico la diferencia entre “instalar” y “cargar” un paquete, que es una confusión clásica al partir.
Ejercicio 1 — Define tu caso real
Antes de avanzar:
- Abre un Excel real que hayas recibido por trabajo (o uno que te imagines: un REM, una planilla de inscritos, etc.).
- Anota en una hoja en blanco las respuestas a estas tres preguntas:
- ¿Cuántas hojas tiene?
- ¿Los encabezados están en la fila 1, o hay metadata arriba?
- ¿Qué columna te parece que va a darte problemas y por qué?
No tienes que escribir código todavía. Solo tener tu “caso real” en mente para cuando llegue cada técnica.
Ver ejemplo de respuestas
Ejemplo con un REM ficticio:
- Hojas: 4 (resumen, sección A, sección B, instructivo).
- Encabezados: fila 4. Las filas 1–3 son título, mes, fuente.
- Columna problema: “indicador” — viene con guiones, asteriscos y abreviaciones distintas en cada fila. Probablemente tenga muchos
NAdisfrazados de"-"o"S/I".
Una vez que tienes claras estas tres cosas, el resto del curso se vuelve concreto: cada módulo te va a dar la herramienta para resolver exactamente un problema de tu planilla real.
Ejercicio 1b — Excel vs R: dónde te conviene saltar
Sigue pensando en tu trabajo real (sin escribir código todavía):
- Anota tres tareas que hagas habitualmente en Excel (por ejemplo: “filtrar pacientes >65”, “calcular promedio mensual de un indicador”, “consolidar 12 planillas mensuales en una sola”).
- Para cada una, marca con una cruz si cumple alguno de estos criterios:
- ☐ La repites cada mes / cada semana.
- ☐ Tiene más de 5 pasos manuales (clics, fórmulas, copiar-pegar).
- ☐ Si te equivocas en un paso, es difícil saber dónde fue.
- ☐ Tienes que entregar el resultado a otra persona.
- Las tareas con 2 o más cruces son las primeras candidatas a moverse a R.
Ver ejemplo de respuestas
Ejemplo:
| Tarea | Repetitiva | >5 pasos | Errores difíciles | Entregable |
|---|---|---|---|---|
| Filtrar pacientes >65 con HTA | ☐ | ☐ | ☐ | ☐ |
| Calcular indicadores REM mensuales | ✅ | ✅ | ✅ | ✅ |
| Consolidar 12 planillas en una | ✅ | ✅ | ✅ | ☐ |
Las dos últimas son las que más sentido tienen escribir como script. La primera es perfecta para Excel: un filtro y listo.
Lección: no todo se mueve a R. La pregunta no es “¿podría hacerlo en R?” — casi siempre la respuesta es sí. La pregunta es “¿me ahorra tiempo neto haciéndolo en R?”. Cuando es repetitivo, complejo o entregable, R gana siempre.
Ejercicio 1c — Identifica el ciclo en tu propio caso
Toma la planilla del Ejercicio 1 y, sin escribir código todavía, anota qué harías concretamente en cada paso del ciclo de 6 etapas:
- Conocer — ¿qué ya sabes del archivo?
- Importar — ¿qué hoja necesitas?
- Inspeccionar — ¿qué columna o tipo te preocupa más?
- Limpiar — ¿qué transformación específica imaginas (ej: “convertir fecha”, “quitar tildes”)?
- Manipular — ¿qué pregunta concreta le quieres hacer a los datos? (ej: “promedio por sexo”)
- Exportar — ¿a quién se lo vas a entregar y en qué formato (Excel, PDF, gráfico)?
No hay respuesta única. La meta es que tengas un boceto mental del proyecto antes de tocar código.
Ver ejemplo
Ejemplo con un REM ficticio:
- Conocer: archivo
rem_enero_2026.xlsx, hojaSección A, encabezados en fila 4, 12 columnas (los meses). - Importar:
read_excel("rem_enero_2026.xlsx", sheet = "Sección A", skip = 3). - Inspeccionar: la columna
establecimientotiene tildes inconsistentes; la columnaindicadortiene guiones que probablemente sonNAs. - Limpiar:
clean_names(),na_if(indicador, "-"), normalizar nombres de establecimientos. - Manipular: total de controles HTA por mes y por establecimiento.
- Exportar: un
.xlsxcon una hoja resumen y un gráfico para la reunión mensual de gestión.
Tener este boceto antes de escribir código es lo que diferencia “pelearse con la planilla” de “ejecutar un plan”. Cada módulo del curso te dará la herramienta para uno de estos seis pasos.
Preparar el entorno: R, Positron y pak
Antes de empezar: configura tu entorno
Para seguir el curso necesitas tener tres cosas funcionando: R, Positron (el IDE) y pak (el instalador de paquetes). No vamos a cubrir la instalación paso a paso aquí — eso lo hace en detalle la guía de Configuración del Entorno de Hazla con Datos.
Si todavía no tienes el entorno listo
👉 Haz primero la guía Configuración del Entorno.
Cubre paso a paso: terminal, WSL2 para Windows (lo recomendado), R moderno con Rig, pak, Positron y más. Es la base sobre la que se monta este curso y los demás.
Cuando termines la guía, tendrás: R instalado, Positron abierto y conociendo tu R, y pak listo para instalar paquetes. Con eso, el resto de este módulo te lleva 5 minutos.
Abrir tu carpeta de trabajo en Positron
Positron trabaja con carpetas: la carpeta que abras se vuelve la raíz del proyecto y R toma esa carpeta como su directorio de trabajo automáticamente. Así, una ruta relativa como "datos/pacientes_ejemplo.xlsx" se resuelve sola.
Paso a paso:
- Crea en tu computador una carpeta llamada
curso-excel(donde tú quieras). - Adentro, crea una subcarpeta
datos/y coloca ahí el archivopacientes_ejemplo.xlsxdel curso. - En Positron: File → Open Folder y elige
curso-excel.
Para confirmar que estás bien parado, en la consola de R ejecuta:
getwd()Debería devolverte la ruta de la carpeta que abriste.
Tip
Regla mental: “la carpeta que tengo abierta en Positron es la raíz del proyecto”. Si abres otra carpeta, cambias de proyecto. Sin .Rproj, sin setwd(), sin complicaciones.
Más adelante (módulo 4) vas a ver que para leer archivos siempre usamos here::here("datos", "archivo.xlsx") en vez de rutas tipo "datos/archivo.xlsx". here() se ancla en esa misma raíz que tú abriste con File → Open Folder — es la combinación que hace que el código funcione igual en cualquier computador.
¿Qué es un paquete? La analogía clave
R viene con muchas funciones de fábrica (mean(), sum(), read.csv()). Pero todo lo bueno para análisis moderno vive en paquetes: colecciones de funciones extra que la comunidad publica gratis.
Para los paquetes hay dos pasos:
| Paso | Comando | ¿Cuántas veces? |
|---|---|---|
| Instalar | pak::pak("readxl") | Una sola vez por computador. |
| Cargar | library(readxl) | Cada vez que abras R y quieras usarlo. |
La analogía que funciona: instalar un paquete es como bajar una app del App Store (lo haces una vez); cargar un paquete es como abrir la app cuando la quieres usar (cada sesión).
Confusión típica
Si te aparece Error: could not find function "read_excel" es porque instalaste readxl pero no lo cargaste con library(readxl). Casi todos pasamos por esto el primer mes.
Instalar los paquetes del curso con pak
Si seguiste la guía de configuración, ya tienes pak instalado. En la consola de R de Positron ejecuta:
pak::pak(c( "tidyverse", # dplyr, tidyr, lubridate, stringr, readr, purrr, ggplot2 "readxl", # leer Excel "openxlsx", # escribir Excel "janitor", # limpiar nombres de columnas "here" # rutas portátiles))# Equivalentes en Python (instala una sola vez):# uv pip install pandas openpyxl python-dateutilpak te muestra una tabla con cada paquete y al final pregunta si quieres continuar. Escribe Y y enter. Toma un par de minutos la primera vez.
¿Por qué pak y no install.packages()?
pak es más rápido (trabaja en paralelo), explica los errores mejor y maneja las dependencias del sistema operativo solo. Es la forma recomendada en 2026.
¿Por qué tidyverse en vez de instalar dplyr, tidyr, lubridate uno por uno?
tidyverse es un meta-paquete: instalar tidyverse instala de un saque dplyr, tidyr, lubridate, stringr, readr, purrr, tibble, forcats y ggplot2. Son los paquetes estándar del ecosistema R moderno y los vas a usar todos a lo largo del curso (algunos seguido, otros menos).
Pesa un poco más en disco (~80 MB) pero te ahorra estar instalando paquetes a destiempo cuando un script de un módulo más avanzado los pida.
Cargar los paquetes con library()
Una vez instalados, cada vez que abras R y quieras usarlos, los cargas. Esto va al inicio de cada script:
library(tidyverse) # carga dplyr, tidyr, lubridate, stringr, readr, purrr, ggplot2library(readxl)library(openxlsx)library(janitor)library(here)import pandas as pdNota
Al cargar tidyverse vas a ver un bloque tipo:
── Attaching core tidyverse packages ──✔ dplyr 1.1.4 ✔ readr 2.1.5✔ forcats 1.0.0 ✔ stringr 1.5.1...── Conflicts ──✖ dplyr::filter() masks stats::filter()✖ dplyr::lag() masks stats::lag()No son errores. Solo te avisan que dplyr tiene una función filter() que tapa a una vieja de R base — eso es lo que queremos.
¿Y los `library()` específicos que aparecen en otros módulos?
A lo largo del curso vas a ver scripts que abren con library(dplyr) o library(stringr) directo, en vez de library(tidyverse). Eso es para que veas explícitamente qué paquete aporta cada función (dplyr da filter, stringr da str_squish, etc.) — es bueno saberlo cuando aprendes.
Y hay una razón práctica también: eficiencia de memoria (RAM). Cargar tidyverse levanta de un golpe ~9 paquetes y consume bastante más RAM que cargar solo los 2 o 3 que tu script realmente usa. En un computador con 8 GB esto pasa desapercibido, pero importa cuando:
- Procesas archivos grandes (>500 MB) y cada MB cuenta.
- Ejecutas el script en un servidor compartido o un contenedor con límite de memoria.
- Corres muchos scripts en paralelo (pipelines automáticos, jobs programados).
Regla práctica:
- Exploración interactiva en Positron:
library(tidyverse)y listo, no te compliques. - Scripts de producción (
informe_mensual.R, jobs automatizados, funciones reutilizables): carga solo los paquetes que el script realmente usa. Es más explícito, más rápido al iniciar y deja una huella de RAM más pequeña.
Probar que todo funciona
Pega esto en tu consola de R. Si no da error, está todo listo:
library(dplyr)
prueba <- tibble( id = 1:3, diagnostico = c("HTA", "DM", "EPOC"))
prueba |> filter(id > 1) |> count(diagnostico)Salida esperada:
# A tibble: 2 × 2 diagnostico n <chr> <int>1 DM 12 EPOC 1Si lo ves, estás listo para todo el curso.
Si algo falla
La mayoría de los problemas de instalación se resuelven en la guía de Configuración del Entorno. Errores típicos que cubre esa guía:
could not find function pak→ te falta instalarpak(paso de la guía).- Positron no detecta R → falta apuntar el intérprete (paso de la guía).
- Permisos al instalar paquetes → resolver con la librería personal (paso de la guía).
- Proxy o firewall → configurar acceso a CRAN (paso de la guía).
Ejercicio
Ejercicio 2 — Tu primer script
-
En Positron: File → New File y elige
R File. -
Pega este código:
library(dplyr)mi_tabla <- tibble(paciente = c("A", "B", "C"),edad = c(67, 45, 72))mi_tabla |>filter(edad >= 60) -
Guárdalo como
prueba.Ren la carpeta de tu proyecto. -
Posiciona el cursor en cualquier línea y dale Ctrl + Enter (Windows/Linux) o Cmd + Enter (Mac) para ejecutar línea por línea.
-
Confirma que en la consola te aparecen los pacientes A y C (los de edad ≥ 60).
Ver qué debería pasar
La consola debería mostrar:
# A tibble: 2 × 2 paciente edad <chr> <dbl>1 A 672 C 72Si te dio could not find function "filter", te falta cargar dplyr. Si te dio could not find function "tibble", te falta cargar dplyr o tibble. Los library() siempre van arriba del script.
Si te falló por algo distinto (R no responde, no se encuentra pak, etc.), vuelve a la guía de Configuración del Entorno — ahí está todo el detalle.
Ejercicio 2b — Verifica que los paquetes están instalados
Antes de avanzar al módulo 3, asegúrate de que los 5 paquetes del curso quedaron bien instalados. Hay dos formas de comprobarlo:
- Listar los instalados y filtrar los del curso:
instalados <- rownames(installed.packages())paquetes_curso <- c("tidyverse", "readxl", "openxlsx", "janitor", "here")paquetes_curso %in% instalados
- Cargarlos con
library()y verificar que ninguno emita error:library(tidyverse)library(readxl); library(openxlsx); library(janitor); library(here)
Si alguno falta o falla, reinstálalo individualmente con pak::pak("nombre_del_paquete").
Ver salida esperada
La primera forma te debería devolver:
[1] TRUE TRUE TRUE TRUE TRUEUn TRUE por cada paquete. Si alguno aparece como FALSE, ese paquete no se instaló bien. Re-ejecuta pak::pak("nombre") solo para ese.
La segunda forma carga cada paquete. Si uno tira Error: there is no package called 'janitor', ya sabes cuál falta. Al cargar tidyverse verás un bloque grande con todos los sub-paquetes (dplyr, readr, stringr, etc.) y un par de avisos de “masking” — no son errores, solo avisos.
Advertencia
Si todo te dice FALSE o ningún library() funciona, lo más probable es que pak::pak(...) haya fallado silenciosamente (firewall, librería personal con permisos raros). Vuelve a la guía de Configuración del Entorno, sección “Permisos al instalar paquetes”.
Ejercicio 2c — Confirma que tu carpeta de trabajo es la correcta
La mayoría de los errores “no encuentro el archivo” del módulo 4 vienen de abrir la carpeta equivocada en Positron. Practiquemos:
- Crea en tu computador una carpeta nueva llamada
prueba_curso/y dentro una subcarpetadatos/. - En Positron, File → Open Folder y abre
prueba_curso/. - En la consola de R ejecuta:
getwd()list.files()list.files("datos")
- Anota: ¿la ruta que devuelve
getwd()termina enprueba_curso? ¿list.files()muestra la carpetadatos? - Bonus: ahora cierra Positron, vuelve a abrirlo sin elegir carpeta (
File → New Windowdirecto), y repitegetwd(). ¿Cambió?
Ver qué debería pasar
-
Después de abrir
prueba_curso/:getwd()te devuelve algo como"/home/usuario/prueba_curso"(Linux/Mac) o"C:/Users/usuario/prueba_curso"(Windows).list.files()muestra"datos".list.files("datos")muestracharacter(0)porque la subcarpeta está vacía. -
Sin abrir carpeta:
getwd()te devuelve la carpeta personal del usuario (~oC:/Users/usuario). Ahí está el problema: si escribesread_excel("datos/archivo.xlsx")sin haber abierto la carpeta correcta, R va a buscar~/datos/archivo.xlsxque no existe.
Lección clave: “abrir la carpeta del proyecto en Positron” no es decoración. Es lo que define dónde busca R los archivos. Si después en el módulo 4 te aparece path does not exist, lo primero que vuelves a revisar es getwd().
Tip
En equipos chicos a veces conviene tener un archivo LEEME.md o README.md en la raíz del proyecto que diga “abrir esta carpeta en Positron antes de correr cualquier script”. Suena tonto, pero ahorra horas de soporte.
Conocer tu Excel antes de programar
La regla más importante del curso
Antes de leer tu Excel con R, léelo con tus propios ojos.
Suena obvio, pero el 80% de los errores que vas a ver más adelante (fecha rara, columna toda NA, nombres duplicados, números que aparecen como texto) tienen su origen en algo que se podía ver simplemente abriendo el archivo en Excel antes de tocarlo con código.
Este módulo entero está dedicado a esa inspección visual previa, y luego a confirmar con código lo que viste. Sin escribir nada todavía.
Las 5 preguntas que tienes que hacerle a tu Excel
Cuando recibas un nuevo .xlsx, ábrelo en Excel (o Google Sheets, o LibreOffice) y respóndete esto:
Checklist visual
- ¿Cuántas hojas tiene? Mira las pestañas abajo. ¿Hay 1, 5, 20?
- ¿Dónde está la fila de encabezados? ¿Fila 1, o hay título / fecha / fuente arriba?
- ¿Hay celdas combinadas? Selecciona la fila de encabezados y mira si dos columnas comparten una celda.
- ¿Hay totales mezclados con datos? ¿Hay una fila al final con
"TOTAL"o un subtotal por grupo en medio? - ¿Las columnas tienen un solo tipo? Mira columna por columna: ¿hay celdas con texto en una columna que debería ser número (típico:
"S/I","-")?
Tomar nota mental de esto te ahorra horas de debugging después.
Caso real: nuestro pacientes_ejemplo.xlsx
Para el resto del curso vamos a usar el archivo pacientes_ejemplo.xlsx (descargable desde la página de inicio). Vamos a inspeccionarlo paso a paso aplicando el checklist.
Paso A — Abre el archivo en Excel (o en LibreOffice si estás en Linux). Vas a ver lo siguiente:
| Pestaña | Qué contiene | Trampa |
|---|---|---|
pacientes | 20 pacientes con id, nombre, edad, sexo, diagnóstico, fecha y presión | Nombres con espacios sucios, fecha como número raro (45292), presión con "S/I" y celdas vacías |
laboratorio | Exámenes por paciente | Fechas como texto "15/01/2024" (no como fecha de Excel) |
resumen_REM | Reporte mensual de indicadores | Dos filas de metadata arriba (Reporte estadístico..., Fuente:...) — los encabezados reales están en la fila 3 |
oculta | Hoja oculta para demostración | No se ve a simple vista, pero excel_sheets() la lista igual |
Paso B — Anota qué te llamó la atención. En este archivo, las cosas que vamos a tener que resolver son:
- La columna
Nombre Completotiene espacios “sucios”: al menos un valor ("Juan Soto") tiene doble espacio interno. - La columna
fecha_ingresomuestra45292(un número), no2024-01-01. Es una fecha serializada de Excel. - La columna
presion_sistolicatiene celdas con"S/I"y celdas vacías mezcladas con números. Eso significa que cuando R la lea, la va a marcar como texto, no como número. - La hoja
resumen_REMno tiene los encabezados en la fila 1.
Nada de esto rompe el archivo, pero cada uno requiere una técnica distinta para limpiarlo desde R. Las vamos a aprender en los módulos 6 al 9.
Confirmar con código: excel_sheets()
Aunque la inspección visual es lo primero, también puedes preguntarle al archivo cuántas hojas tiene desde R, sin abrirlo:
library(readxl)
excel_sheets("datos/pacientes_ejemplo.xlsx")import pandas as pd
archivo = pd.ExcelFile("datos/pacientes_ejemplo.xlsx")archivo.sheet_namesSalida esperada:
[1] "pacientes" "laboratorio" "resumen_REM" "oculta"Sí, excel_sheets() también te muestra las hojas ocultas. Eso es útil: a veces alguien te dice “el archivo solo tiene 3 hojas” pero en realidad hay una cuarta escondida con datos viejos.
Tip
excel_sheets() es lo primero que ejecuto siempre al recibir un nuevo Excel. Antes de leer cualquier hoja, quiero saber qué hojas existen y si alguna me sorprende.
Confirmar con código: ver la primera “foto” sin importar
A veces quieres mirar un pedacito de la planilla sin cargarla entera, especialmente si es grande. Para eso read_excel() tiene el argumento n_max (lo veremos a fondo en el módulo 5):
library(readxl)
# Solo las primeras 5 filas de cada hojaread_excel("datos/pacientes_ejemplo.xlsx", sheet = "pacientes", n_max = 5)import pandas as pd
pd.read_excel("datos/pacientes_ejemplo.xlsx", sheet_name="pacientes", nrows=5)Vas a ver una tabla con 5 filas. Mira los tipos debajo de cada columna (<chr>, <dbl>, etc.):
<chr>= texto (character).<dbl>= número con decimales (double).<int>= número entero.<lgl>=TRUE/FALSE.<date>= fecha.
En nuestra hoja pacientes, presion_sistolica aparece como <chr>, no <dbl>, porque tiene los "S/I" mezclados. Es la confirmación con código de lo que viste con los ojos.
El principio
Detente y piensa
Si una columna que tú sabes que debería ser numérica aparece como <chr>, hay 99% de probabilidad de que tenga al menos una celda con texto disfrazado. Tu trabajo va a ser encontrarla y limpiarla.
Cuándo conviene quedarse en Excel
Una nota honesta: no todo lo que recibes en Excel tiene que pasar por R. Si te llega una planilla con 10 filas, 3 columnas y tu pega es solo sumar, hazlo en Excel y sigue tu vida. R brilla cuando:
- El mismo proceso se repite mensualmente.
- Los datos son grandes (más de unas miles de filas).
- Necesitas combinar varias planillas.
- Necesitas dejar trazabilidad (“este número salió de esta fórmula”).
- Vas a generar un reporte que se distribuye a otros.
Si no es ninguno de esos casos, Excel está bien. Pero el momento exacto en que sientes que estás “perdiendo el hilo” entre celdas, fórmulas y pestañas, es cuando R te empieza a ahorrar tiempo de verdad.
Errores comunes en la inspección
Trampa 1: encabezados sucios
Si la fila de encabezados tiene celdas combinadas (por ejemplo, “Datos del paciente” arriba de “id” y “nombre”), la primera fila puede no ser el encabezado real, sino una etiqueta de agrupación. Vas a tener que usar skip = N al importar para saltártela.
Trampa 2: 'NA' como texto vs NA real
Una celda vacía en Excel se lee como NA en R. Pero una celda con la palabra literal "NA" o "S/I" se lee como texto, no como NA. Estos “NAs disfrazados” son la causa #1 de columnas mal tipadas. Lo resolveremos en el módulo 9.
Trampa 3: filas de totales al final
Es habitual ver una fila final con "TOTAL" en la primera columna y la suma en las siguientes. Si la importas sin querer, el promedio de tu columna edad va a salir mal porque incluiste la fila de totales. Tienes dos opciones: importar con n_max = N para detenerte antes, o filtrar después con filter(!is.na(id)).
Trampa 4: hojas con el mismo encabezado pero distinto contenido
Si tu archivo tiene enero, febrero, marzo… cada una con la misma estructura pero datos distintos, no las importes una por una. Más adelante (módulo 5) vamos a ver cómo importar todas con un lapply() o purrr::map() en una línea.
Trampa 5: el archivo está abierto en Excel
Si tienes el archivo abierto en Excel al mismo tiempo que intentas leerlo desde R, en Windows vas a recibir un error de permisos (Permission denied). En Mac y Linux usualmente sí se puede, pero igual es buena práctica cerrarlo antes. Esta es la causa #1 de “no entiendo por qué no funciona” en clases.
Ejercicio
Ejercicio 3 — Inspecciona el archivo del curso
- Descarga
pacientes_ejemplo.xlsxdesde la página de inicio del curso y guárdalo en una carpetadatos/dentro de tu proyecto. - Ábrelo en Excel o LibreOffice y responde el checklist visual de las 5 preguntas:
- ¿Cuántas hojas tiene?
- ¿Dónde están los encabezados en cada una?
- ¿Hay celdas combinadas?
- ¿Hay filas de totales?
- ¿Qué columnas tienen tipos mezclados?
- Confirma con código:
library(readxl)excel_sheets("datos/pacientes_ejemplo.xlsx")read_excel("datos/pacientes_ejemplo.xlsx", sheet = "pacientes", n_max = 3)
- Anota en una hoja en blanco qué columna sospechosa identificaste y por qué.
Ver respuestas
- Hojas: 4 (
pacientes,laboratorio,resumen_REM,oculta). La última está oculta peroexcel_sheets()la lista. - Encabezados:
pacientesylaboratoriolos tienen en fila 1.resumen_REMlos tiene en fila 3 (las filas 1 y 2 son título y fuente). - Celdas combinadas: no hay en este archivo, pero en planillas reales del Ministerio de Salud son frecuentes.
- Totales: no hay filas de totales en este archivo. En REM reales sí suele haber.
- Tipos mezclados:
presion_sistolicamezcla números,"S/I"y vacíos.fecha_ingresoes un número (serial).fechaenlaboratorioes texto con formatodd/mm/yyyy.
Si identificaste al menos presion_sistolica y fecha_ingreso como sospechosas, estás listo. En los módulos 7, 8 y 9 vamos a resolverlas una por una.
Ejercicio 3b — Predice los tipos antes de leer
La gracia del diagnóstico visual es que puedes adivinar lo que va a hacer R antes de tocar el código. Practiquemos:
-
Abre
pacientes_ejemplo.xlsxen Excel/LibreOffice (no en R todavía). -
Para cada una de las 7 columnas de la hoja
pacientes, anota en una tabla qué tipo esperas que asigne R:Columna Tipo que esperas ( dbl,chr,date,lgl)Por qué id ? Nombre Completo ? edad ? Sexo ? diagnostico ? fecha_ingreso ? presion_sistolica ? -
Ahora confirma con código:
library(readxl)library(dplyr)glimpse(read_excel("datos/pacientes_ejemplo.xlsx", sheet = "pacientes")) -
¿En cuántas columnas acertaste? ¿Qué te sorprendió?
Ver tipos esperados y reales
| Columna | Esperado | Real | ¿Calza? |
|---|---|---|---|
| id | dbl (números 1-20) | dbl | ✅ |
| Nombre Completo | chr (texto) | chr | ✅ |
| edad | dbl (números) | dbl | ✅ |
| Sexo | chr (“F”/“M”) | chr | ✅ |
| diagnostico | chr | chr | ✅ |
| fecha_ingreso | date (¡es una fecha!) | dbl ❌ | sorpresa |
| presion_sistolica | dbl (presión sistólica) | chr ❌ | sorpresa |
Las dos sorpresas son exactamente lo que vamos a arreglar en los módulos 7-9.
fecha_ingresosalió<dbl>porque Excel la guardó como número serial sin formato de fecha aplicado. El45292es el día2024-01-01en el calendario interno de Excel.presion_sistolicasalió<chr>porque tiene"S/I"mezclado con números. R lee toda la columna como texto en el momento que detecta un solo valor no numérico.
Esta es la regla operativa más importante del diagnóstico: si una columna que debería ser numérica sale como <chr>, hay basura adentro. Si una fecha sale como <dbl>, está serializada.
Ejercicio 3c — Descubre la hoja oculta y mira qué dice
La hoja oculta de pacientes_ejemplo.xlsx no aparece cuando abres el archivo en Excel (no ves la pestaña). Pero R la encuentra. Verifícalo:
- Abre el archivo en Excel/LibreOffice. Anota cuántas pestañas ves abajo.
- Desde R, ejecuta:
¿Cuántas hojas lista? ¿Calza con lo que viste?library(readxl)excel_sheets("datos/pacientes_ejemplo.xlsx")
- Lee la hoja oculta directamente:
read_excel("datos/pacientes_ejemplo.xlsx", sheet = "oculta")
- ¿Qué contenido tiene? ¿Te sorprendería encontrarte algo así en un archivo del mundo real?
Ver respuesta
- En Excel ves 3 pestañas:
pacientes,laboratorio,resumen_REM. excel_sheets()lista 4: las tres anteriores más"oculta".- Al leerla, contiene:
# A tibble: 1 × 1nota<chr>1 Esta hoja está oculta. excel_sheets() la lista igual.
¿Por qué importa? En planillas reales, las hojas ocultas suelen ser:
- Datos viejos que alguien “no quiso borrar por si acaso”.
- Tablas auxiliares con listas de validación.
- Información sensible (sueldos, RUNs, contraseñas) que el autor pensó que estaba “escondida” pero R la encuentra al primer
excel_sheets().
Tip
Por eso excel_sheets() es lo primero que corro al recibir un Excel. Saber qué hay en el archivo te protege tanto de sorpresas técnicas como éticas. Si vas a compartir un .xlsx, borra las hojas ocultas antes — no las dejes “ocultas” pensando que nadie las verá.
Importar con readxl: lo esencial
El objetivo del módulo
En este módulo vamos a llevar un archivo Excel desde tu disco hasta una variable en R que puedas inspeccionar y manipular. No vamos a limpiar nada todavía — solo “abrir” el archivo. La función estrella es read_excel() del paquete readxl.
El esqueleto mínimo
Toda lectura de Excel desde R se reduce a tres líneas. Las vamos a desarmar una por una.
library(readxl)
pacientes <- read_excel("datos/pacientes_ejemplo.xlsx")
pacientesimport pandas as pd
pacientes = pd.read_excel("datos/pacientes_ejemplo.xlsx")
pacientesLínea por línea:
library(readxl)— carga el paquete (acuérdate del módulo 2: instalar es una vez, cargar es cada sesión).pacientes <- read_excel("datos/pacientes_ejemplo.xlsx")— lee el archivo y guarda el resultado en una variable llamadapacientes. La flechita<-significa “asigna a la izquierda lo de la derecha”.pacientes— imprime la variable. R te muestra las primeras filas y los tipos de columna.
Si lo corres con el archivo del curso en datos/pacientes_ejemplo.xlsx, R te imprime las primeras 20 filas porque la hoja pacientes tiene 20 pacientes.
¿Qué hoja leyó si no le dije ninguna?
Cuando no pones sheet =, read_excel() lee la primera hoja. En nuestro caso es pacientes. Si te interesa otra hoja, tienes que decírselo (lo vemos en seguida).
Elegir una hoja con sheet =
Si tu archivo tiene varias hojas, le dices a read_excel() cuál quieres. Hay dos formas:
Por nombre (recomendado, porque no se rompe si alguien reordena las hojas):
lab <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "laboratorio")lab = pd.read_excel("datos/pacientes_ejemplo.xlsx", sheet_name="laboratorio")Por número (más corto pero frágil):
lab <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = 2)lab = pd.read_excel("datos/pacientes_ejemplo.xlsx", sheet_name=1) # ojo: en Python parte de 0Tip
Casi siempre conviene usar el nombre, no el número. Si el equipo de TI le agrega una hoja nueva al principio del archivo el próximo mes, tu código por número se rompe sin avisar. Por nombre sigue funcionando.
Rutas: el origen del 70% de los errores
Cuando escribes "datos/pacientes_ejemplo.xlsx", R va a buscar ese archivo relativo a tu directorio de trabajo. Si el directorio de trabajo no es lo que crees, R no lo encuentra y te dice path does not exist.
Hay tres formas de manejar rutas. De peor a mejor:
Forma 1 — Ruta absoluta (no recomendado)
# Funciona pero es frágil: si compartes el script o cambias de# computador, esta ruta deja de existir.pacientes <- read_excel("C:/Users/Paulo/Documents/curso/datos/pacientes_ejemplo.xlsx")pacientes = pd.read_excel("C:/Users/Paulo/Documents/curso/datos/pacientes_ejemplo.xlsx")Funciona pero se rompe en cuanto cambies de computador. Y si subes el script a GitHub, otra persona no va a tener C:/Users/Paulo/....
Forma 2 — Ruta relativa simple (funciona, pero frágil)
Si abriste la carpeta del proyecto en Positron (módulo 2 — File → Open Folder), el directorio de trabajo ya es la raíz del proyecto. Entonces:
pacientes <- read_excel("datos/pacientes_ejemplo.xlsx")pacientes = pd.read_excel("datos/pacientes_ejemplo.xlsx")Funciona, pero se rompe el día que organizas el proyecto en subcarpetas (scripts/, informes/). Si guardas un script dentro de scripts/ y lo ejecutas desde ahí, R va a buscar scripts/datos/... y no lo encuentra. También se rompe si alguien usa setwd() por error.
Forma 3 — Con here() (✅ la que vamos a usar en todo el curso)
library(here)
pacientes <- read_excel(here("datos", "pacientes_ejemplo.xlsx"))from pathlib import Path
pacientes = pd.read_excel(Path("datos") / "pacientes_ejemplo.xlsx")here() siempre construye la ruta desde la raíz del proyecto, sin importar desde qué subcarpeta llames al script. Da igual si lo ejecutas desde scripts/limpieza.R, desde un notebook en notebooks/, o desde la consola: here("datos", "pacientes_ejemplo.xlsx") siempre apunta al mismo archivo.
Adicionalmente, here() separa los pedazos de la ruta con comas en vez de /, lo que hace que el mismo código funcione idéntico en Windows, Mac y Linux (Windows usa \, los demás / — here() se encarga).
La regla de oro del curso
Siempre here::here() para abrir o guardar archivos. Las dos primeras semanas se siente más largo que escribir "datos/archivo.xlsx", pero te ahorra horas de “a mí me funcionaba” cuando compartes el código.
En los próximos módulos verás muchas veces ejemplos cortos del tipo read_excel("datos/x.xlsx") para no recargar visualmente cada bloque. En tus scripts reales, envuélvelos siempre con here(): read_excel(here("datos", "x.xlsx")).
Revisar el resultado: glimpse()
Una vez importado, lo primero es siempre mirar qué leyó R. La función glimpse() (de dplyr) es ideal: te muestra columnas, tipos y los primeros valores en una sola pantalla.
library(readxl)library(dplyr)
pacientes <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "pacientes")
glimpse(pacientes)import pandas as pd
pacientes = pd.read_excel("datos/pacientes_ejemplo.xlsx", sheet_name="pacientes")
pacientes.info()Salida (resumida):
Rows: 20Columns: 7$ id <dbl> 1, 2, 3, 4, 5, ...$ `Nombre Completo` <chr> "María Pérez", "Juan Soto", "Ana Díaz", ...$ edad <dbl> 67, 45, 72, 58, 39, ...$ Sexo <chr> "F", "M", "F", "M", "F", ...$ diagnostico <chr> "Hipertensión", "Diabetes tipo 2", ...$ fecha_ingreso <dbl> 45292, 45301, 45315, ...$ presion_sistolica <chr> "145", "S/I", "160", "138", "120", ...Mira los tipos. fecha_ingreso salió como <dbl> (número) y presion_sistolica como <chr> (texto). Eso es exactamente lo que esperábamos del módulo 3: la fecha está como número serial y la presión está contaminada por "S/I". Ahora lo confirmaste con código.
Y mira el segundo nombre: "Juan Soto" — con dos espacios entre nombre y apellido. Lo limpiaremos en el módulo 7.
Detente y piensa
Pregúntate siempre: ¿es esperable este tipo? Si una columna que debería ser numérica salió como texto, hay un valor “sucio” adentro. Si una fecha salió como número, está serializada.
Lectura básica de varias hojas
Si quieres tener los datos de las tres hojas principales del archivo, simplemente llamas read_excel() tres veces:
pacientes <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "pacientes")lab <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "laboratorio")rem <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "resumen_REM")pacientes = pd.read_excel("datos/pacientes_ejemplo.xlsx", sheet_name="pacientes")lab = pd.read_excel("datos/pacientes_ejemplo.xlsx", sheet_name="laboratorio")rem = pd.read_excel("datos/pacientes_ejemplo.xlsx", sheet_name="resumen_REM")Si tienes 12 hojas (enero, febrero…) escribir esto 12 veces es tedioso. Hay una forma más corta con lapply() o purrr::map(), pero la dejamos para el módulo 5 — donde también veremos cómo controlar range, skip y los tipos.
Errores comunes al importar
Error 1: `Error: `path` does not exist: 'datos/pacientes_ejemplo.xlsx'`
Causa: R no encuentra el archivo. Casi siempre es uno de tres motivos:
- El archivo no está en la carpeta
datos/(está en otro lado). - No abriste la carpeta correcta en Positron, así que el directorio de trabajo es otro. Ejecuta
getwd()para verificarlo y usaFile → Open Folderpara abrir la raíz del curso. - Escribiste mal el nombre del archivo (mayúsculas, espacios, extensión
.xlsvs.xlsx).
Solución: ejecuta getwd() para ver dónde está R parado. Confirma con file.exists("datos/pacientes_ejemplo.xlsx") — si te devuelve FALSE, el archivo no está donde le dices.
Error 2: `Error: Sheet 'pacientes' not found`
Causa: la hoja se llama distinto a como pensabas (mayúsculas, espacios, tildes).
Solución: ejecuta excel_sheets("datos/pacientes_ejemplo.xlsx") y copia/pega el nombre exacto. R distingue entre mayúsculas y minúsculas: "Pacientes" no es igual a "pacientes".
Error 3: `Error: Evaluation error: zip file ... cannot be opened`
Causa: estás intentando leer un archivo .xls antiguo con read_excel() mientras tienes una versión muy vieja de readxl, o el archivo está corrupto.
Solución: readxl moderno soporta tanto .xls como .xlsx. Si el archivo es realmente un .xls viejo y falla, intenta abrirlo en Excel y guardarlo como .xlsx. Si te dicen que es .xlsx pero falla, probablemente está corrupto: pide la copia original.
Error 4: `Error: Permission denied`
Causa: el archivo está abierto en Excel mientras intentas leerlo desde R.
Solución: cierra el archivo en Excel y vuelve a correr el script. Esto pasa muchísimo en Windows.
Error 5: tildes y caracteres especiales en la ruta
Si tu carpeta se llama C:/Documentos/Análisis/... con tildes y estás en Windows, a veces R falla con un error críptico de codificación. Renombra la carpeta sin tildes (Analisis) o usa here() que normaliza la codificación.
Ejercicio
Ejercicio 4 — Importa las 3 hojas y revisa una columna
- Asegúrate de tener
pacientes_ejemplo.xlsxendatos/dentro de tu proyecto. - Carga
readxlydplyr. - Lee la hoja
"laboratorio"en una variable llamadalab. - Aplícale
glimpse(). - Anota: ¿qué tipo tiene la columna
fecha? ¿Es lo que esperabas?
Ver solución
library(readxl)library(dplyr)
lab <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "laboratorio")glimpse(lab)Salida esperada (resumida):
Rows: 20Columns: 4$ id_paciente <dbl> 1, 1, 2, 3, 3, 4, ...$ examen <chr> "Glicemia", "Colesterol", ...$ valor <dbl> 110, 220, 145, 13.5, ...$ fecha <chr> "15/01/2024", "15/01/2024", ...Sorpresa: la columna fecha salió como <chr> (texto), no como <date>. Esto pasa porque el archivo guardó las fechas con formato "15/01/2024" como texto, no como fechas reales de Excel. En el módulo 8 vamos a aprender a convertir ese texto a fechas reales con lubridate::dmy().
Si te salió <date> en cambio, probablemente tu sistema operativo o tu Excel reformatearon las fechas al abrir/guardar. Eso también pasa en la vida real, y por eso siempre hay que confirmar con glimpse() antes de asumir cualquier cosa.
Ejercicio 4b — Importa por número de hoja y compara
En este ejercicio vas a comprobar por qué no conviene importar por número (sheet = 2) y prefieres siempre el nombre (sheet = "laboratorio").
- Importa la hoja
2del archivo en una variablepor_numero:por_numero <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = 2) - Importa la hoja
"laboratorio"por nombre enpor_nombre. - Confirma con
identical(por_numero, por_nombre)que ambas son idénticas ahora. - Pregúntate: ¿qué pasaría si mañana alguien agrega una nueva hoja
instructivocomo primera pestaña del archivo? Anota qué leeríasheet = 2en ese caso, sin correr nada.
Ver respuesta
library(readxl)
por_numero <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = 2)por_nombre <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "laboratorio")
identical(por_numero, por_nombre)#> [1] TRUEAmbas son idénticas hoy, porque la hoja 2 efectivamente es laboratorio.
Pero si alguien agrega una hoja instructivo arriba, el orden pasaría a ser:
instructivopacientes← antes era posición 2laboratorioresumen_REMoculta
Tu código sheet = 2 ahora leería pacientes sin avisar. Tu análisis correría sin error pero con los datos equivocados.
Esta es la falla silenciosa más peligrosa: el script no falla, solo entrega resultados malos. Por eso casi siempre conviene sheet = "nombre_exacto": si la hoja se renombra o se borra, readxl falla rápido con Sheet 'laboratorio' not found y tú te enteras de inmediato.
Ejercicio 4c — Provoca y diagnostica errores comunes
Aprender a leer errores es tan importante como evitarlos. Provoca a propósito los siguientes 3 errores y anota qué mensaje exacto te da R en cada caso:
- Ruta mala:
read_excel("datos/no_existe.xlsx")
- Nombre de hoja mal escrito (mayúscula donde no va):
read_excel("datos/pacientes_ejemplo.xlsx", sheet = "Pacientes")
- Olvido de
library(readxl): reinicia R (Positron: Session → Restart R) y, sin cargar readxl, ejecuta:read_excel("datos/pacientes_ejemplo.xlsx")
Después de cada error, anota: ¿qué dice exactamente el mensaje? ¿Es claro lo que tienes que arreglar?
Ver mensajes esperados y diagnóstico
Error 1 — Ruta mala:
Error: `path` does not exist: 'datos/no_existe.xlsx'Mensaje súper claro: R no encuentra el archivo. Reviso list.files("datos/") para ver qué hay realmente.
Error 2 — Nombre de hoja mal escrito:
Error: Sheet 'Pacientes' not foundR distingue mayúsculas y minúsculas. La hoja se llama "pacientes" (minúscula). Reviso con excel_sheets("datos/pacientes_ejemplo.xlsx").
Error 3 — Falta cargar readxl:
Error in read_excel("datos/pacientes_ejemplo.xlsx") : could not find function "read_excel"La función no existe porque su paquete no está cargado. Agrego library(readxl) arriba del script.
Lección: los tres errores son distintos y los mensajes te dicen exactamente qué arreglar. Lee siempre el mensaje completo antes de buscar en Google — el 70% de las veces ya tienes la respuesta ahí mismo.
Tip
Una buena práctica: cuando un script tuyo funciona, reinicia R (Session → Restart R) y vuélvelo a correr de principio a fin. Si te aparecen could not find function, te faltaba un library(). Mejor descubrirlo ahora que cuando lo compartas con un colega.
Importar con control fino: rangos, hojas y tipos
Cuando read_excel() no alcanza
En el módulo anterior aprendimos lo básico: read_excel("ruta", sheet = "hoja"). Eso funciona cuando la planilla está limpia: encabezados en la fila 1, una sola hoja de interés, sin metadata arriba.
Pero en planillas reales (REM, registros mensuales, exports de sistemas viejos) casi siempre hay alguna complicación:
- Dos filas de metadata arriba de los encabezados.
- Una fila de subtotales al final.
- Encabezados que tú quieres reemplazar.
- Columnas que R lee con el tipo equivocado.
- 12 hojas que quieres unir en una sola tabla.
read_excel() tiene argumentos para todo eso. Vamos a verlos uno por uno.
skip — saltarse filas de arriba
Recuerda la hoja resumen_REM de nuestro archivo de ejemplo: las primeras dos filas son metadata ("REPORTE ESTADÍSTICO MENSUAL — Año 2024" y "Fuente: ..."), y los encabezados reales (indicador, enero, febrero, …) están en la fila 3.
Si la leemos sin parámetros, sale un desastre:
library(readxl)
read_excel("datos/pacientes_ejemplo.xlsx", sheet = "resumen_REM")import pandas as pd
pd.read_excel("datos/pacientes_ejemplo.xlsx", sheet_name="resumen_REM")R interpreta "REPORTE ESTADÍSTICO..." como el nombre de la primera columna y todo queda corrido. La solución es skip = 2:
rem <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "resumen_REM", skip = 2)
remrem = pd.read_excel("datos/pacientes_ejemplo.xlsx", sheet_name="resumen_REM", skiprows=2)
remAhora la fila 3 (indicador, enero, febrero…) se interpreta como encabezado. Salida esperada:
# A tibble: 4 × 7 indicador enero febrero marzo abril mayo junio <chr> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>1 Controles HTA 420 445 460 478 490 5052 Controles DM 318 322 340 355 360 3723 Controles EPOC 95 102 110 108 115 1204 Vacunaciones 610 580 555 590 620 640Tip
Regla: skip = N se salta las primeras N filas y trata la fila N+1 como encabezado. Si tu encabezado está en la fila 5, usa skip = 4.
n_max — limitar cuántas filas leer
Útil para dos cosas:
- Ver una “foto” de un archivo grande sin cargarlo entero.
- Detenerte antes de una fila de totales o de basura al final.
# Solo las primeras 5 filasread_excel("datos/pacientes_ejemplo.xlsx", sheet = "pacientes", n_max = 5)pd.read_excel("datos/pacientes_ejemplo.xlsx", sheet_name="pacientes", nrows=5)range — leer un bloque exacto de celdas
A veces la planilla tiene una tabla “metida” en medio de la hoja (por ejemplo, celdas B5:H50), rodeada de gráficos, notas o textos. Usas range:
# Lee solo el bloque B5:H50 de la hoja "pacientes"read_excel("datos/pacientes_ejemplo.xlsx", sheet = "pacientes", range = "B5:H50")
# También puedes nombrar el rango con la hoja incluida:read_excel("datos/pacientes_ejemplo.xlsx", range = "pacientes!B5:H50")# Pandas no tiene un parámetro 'range' tan directo,# se hace combinando skiprows + usecols + nrows:pd.read_excel("datos/pacientes_ejemplo.xlsx", sheet_name="pacientes", skiprows=4, # salta filas 1-4 usecols="B:H", # columnas B a H nrows=46) # 50 - 5 + 1Nota
range es una pieza increíblemente útil cuando recibes planillas tipo dashboard con datos en cualquier lado. Aprende a identificar el rango exacto en Excel (clic en la primera celda → Ctrl+Shift+End) y pásalo a R.
col_names — controlar los nombres de columnas
Tres modos:
Modo 1 — Usar los del archivo (por defecto):
read_excel("datos/pacientes_ejemplo.xlsx", sheet = "pacientes")# col_names = TRUE por defectopd.read_excel("datos/pacientes_ejemplo.xlsx", sheet_name="pacientes")# header=0 por defectoModo 2 — No usar encabezado, R inventa ...1, ...2, …:
read_excel("datos/pacientes_ejemplo.xlsx", sheet = "pacientes", col_names = FALSE)pd.read_excel("datos/pacientes_ejemplo.xlsx", sheet_name="pacientes", header=None)Modo 3 — Pasar los nombres que tú quieres:
read_excel("datos/pacientes_ejemplo.xlsx", sheet = "pacientes", col_names = c("id", "nombre", "edad", "sexo", "dx", "fecha", "pas"), skip = 1) # saltar el encabezado originalpd.read_excel("datos/pacientes_ejemplo.xlsx", sheet_name="pacientes", names=["id", "nombre", "edad", "sexo", "dx", "fecha", "pas"], skiprows=1)Advertencia
Si usas col_names = c(...) acuérdate de skip = 1, porque si no, la fila original de encabezados se va a leer como datos.
Encabezados con celdas combinadas (el caso REM clásico)
Muchas planillas oficiales (REM del MINSAL, encuestas, formularios) traen encabezados en dos filas con celdas combinadas, algo así:
A B C D E┌─────────────────────────┬──────────────────────┐│ Datos del paciente │ Indicadores │ ← fila 1 combinada A1:C1 y D1:E1├──────────┬──────┬───────┼─────────┬────────────┤│ id │ run │ edad │ presion │ glicemia │ ← fila 2 (los nombres reales)├──────────┼──────┼───────┼─────────┼────────────┤│ 1 │ ... │ 67 │ 145 │ 110 │ ← desde aquí los datosSi lees esto con read_excel() directamente, el resultado es un desastre: R toma la fila 1 como encabezado, ve "Datos del paciente" en una celda y NA en las celdas combinadas, y todo queda corrido.
La estrategia correcta es saltarse las filas combinadas y declarar tú los nombres. Hay dos caminos:
Camino 1 — skip + col_names manual (el más confiable):
library(readxl)
# Salta las 2 filas de encabezados y declara los nombres túpacientes <- read_excel("datos/rem_clasico.xlsx", sheet = "datos", skip = 2, col_names = c("id", "run", "edad", "presion", "glicemia"))pacientes = pd.read_excel("datos/rem_clasico.xlsx", sheet_name="datos", skiprows=2, names=["id", "run", "edad", "presion", "glicemia"])Camino 2 — range para apuntar exactamente al bloque de datos:
# Los datos viven en A3 hacia abajo. Apuntamos al bloque exacto:pacientes <- read_excel("datos/rem_clasico.xlsx", sheet = "datos", range = "A3:E1000", # ajusta el final al tamaño esperado col_names = c("id", "run", "edad", "presion", "glicemia"))`range` y `skip` no se combinan
Cuando especificas range, readxl ignora skip y n_max. Es uno u otro: o usas skip = 2 (y dejas que readxl infiera el final), o usas range = "A3:E1000" (y declaras tú las dos esquinas). Mezclarlos lleva a “ese skip no hizo nada” silencioso.
Detectar celdas combinadas antes de leer
¿Cómo sabes si una planilla tiene encabezados combinados sin abrirla en Excel? Lee solo las primeras filas como texto sin encabezado y mira el patrón de NAs:
library(readxl)
# Vista cruda: primeras 3 filas, sin encabezadocrudo <- read_excel("datos/rem_clasico.xlsx", sheet = "datos", n_max = 3, col_names = FALSE)crudo#> # A tibble: 3 × 5#> ...1 ...2 ...3 ...4 ...5#> <chr> <chr> <chr> <chr> <chr>#> 1 Datos del paciente NA NA Indicadores NA ← celdas combinadas!#> 2 id run edad presion glicemia#> 3 1 ... 67 145 110Las celdas combinadas se ven como una sola celda con texto seguida de varias NA consecutivas en la misma fila. Si ves ese patrón en la fila 1, casi seguro hay encabezados combinados y tu skip real es la fila donde aparecen los nombres reales.
Inspección visual sin abrir Excel
tidyxl::xlsx_cells("archivo.xlsx") te devuelve cada celda con su fila, columna, valor y formato como un data frame. Es la herramienta definitiva cuando una planilla está rara y quieres entender el layout exacto sin abrir Excel. Instálalo con pak::pak("tidyxl").
Antes de programar nada, pregunta
Si la planilla la genera un sistema (REM, FONASA), pregunta si hay una versión “plana” sin celdas combinadas — muchas veces existe pero nadie la usa. Programar el workaround una sola vez está bien; mantenerlo durante años porque “el archivo nuevo cambió” es una pesadilla evitable.
col_types — forzar el tipo de cada columna
Recuerda que presion_sistolica salió como texto por culpa de los "S/I". Si tú sabes que esa columna debería ser numérica y quieres forzarlo (los "S/I" se convertirán en NA):
pacientes <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "pacientes", col_types = c("numeric", # id "text", # Nombre Completo "numeric", # edad "text", # Sexo "text", # diagnostico "date", # fecha_ingreso "numeric")) # presion_sistolicapacientes = pd.read_excel("datos/pacientes_ejemplo.xlsx", sheet_name="pacientes", dtype={"id": "Int64", "edad": "Int64", "presion_sistolica": "Float64"})Los tipos válidos en col_types son: "skip" (no leer), "guess" (que R adivine), "logical", "numeric", "date", "text", "list".
Nota
Cuando fuerzas col_types, readxl te va a tirar muchos warnings (uno por cada celda que tiene que coercionar): Expecting numeric in ...: got 'S/I', Coercing numeric to date in ..., etc. No te asustes — son esperables. Cada warning solo te avisa “esta celda no calzaba con el tipo que me pediste, la convertí a NA o a fecha real”. Si quieres ocultarlos: envuelve la llamada en suppressWarnings().
na — qué texto considerar como NA al leer
Por defecto, read_excel() interpreta solo las celdas vacías como NA. Si la planilla usa "S/I", "-" o "NA" como marcadores, son texto y “contaminan” la columna. Puedes decirle a read_excel() que esos también son NA:
pacientes <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "pacientes", na = c("", "S/I", "-", "NA"))pacientes = pd.read_excel("datos/pacientes_ejemplo.xlsx", sheet_name="pacientes", na_values=["", "S/I", "-", "NA"])Ahora los "S/I" ya son NA. Ojo: la columna sigue siendo <chr> porque en Excel está almacenada como texto. Para que sea <dbl> tienes que agregar col_types = "numeric" para esa columna (como en la sección anterior) o un mutate(presion_sistolica = as.numeric(presion_sistolica)) después. El na = c(...) por sí solo no re-tipa la columna, solo convierte los textos indicados a NA.
trim_ws — limpiar espacios en los textos
Si las celdas tienen espacios al inicio o final (" María Pérez " vs "María Pérez"), trim_ws = TRUE los recorta automáticamente al leer:
pacientes <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "pacientes", trim_ws = TRUE) # TRUE es el default, pero conviene saberlo# Pandas no tiene trim_ws nativo en read_excel;# se hace después con .str.strip() por columna.Importar varias hojas a la vez
Cuando tienes 12 hojas (enero, febrero, …) con la misma estructura, no quieres copiar read_excel() 12 veces. La idea es: lista las hojas, aplica read_excel() a cada una, junta el resultado.
library(readxl)library(dplyr)
ruta <- "datos/pacientes_ejemplo.xlsx"
# 1. Listar todas las hojas (filtrando la oculta y la metadata)hojas <- excel_sheets(ruta)hojas <- hojas[hojas %in% c("pacientes", "laboratorio")]
# 2. Leer todas en una listatodas <- lapply(hojas, function(h) read_excel(ruta, sheet = h))
# 3. Asignar nombres a la listanames(todas) <- hojas
# Acceder a una:todas$pacientestodas$laboratorioimport pandas as pd
ruta = "datos/pacientes_ejemplo.xlsx"hojas = ["pacientes", "laboratorio"]
todas = {h: pd.read_excel(ruta, sheet_name=h) for h in hojas}
todas["pacientes"]todas["laboratorio"]Si todas las hojas tienen la misma estructura (mismas columnas) y quieres unirlas en una sola tabla, agregas un bind_rows() final:
library(readxl)library(dplyr)
ruta <- "datos/pacientes_ejemplo.xlsx"hojas_mensuales <- c("enero", "febrero", "marzo") # ejemplo ficticio
# Leer todas y agregar una columna 'mes' indicando de qué hoja vinoconsolidado <- lapply(hojas_mensuales, function(h) { read_excel(ruta, sheet = h) |> mutate(mes = h)}) |> bind_rows()import pandas as pd
ruta = "datos/pacientes_ejemplo.xlsx"hojas_mensuales = ["enero", "febrero", "marzo"]
consolidado = pd.concat([ pd.read_excel(ruta, sheet_name=h).assign(mes=h) for h in hojas_mensuales], ignore_index=True)Tip
Agregar una columna mes = h (o origen = h, según el caso) antes de unir es crucial para no perder la trazabilidad de “esta fila vino de qué hoja”. Es un patrón que vas a repetir muchísimo.
Importar varios archivos Excel en una sola tabla
Otro escenario muy frecuente: en vez de varias hojas en un archivo, tienes varios archivos en una carpeta. Por ejemplo, datos/ contiene:
datos/├── enero_2024.xlsx├── febrero_2024.xlsx├── marzo_2024.xlsx└── abril_2024.xlsxCada archivo tiene una sola hoja con la misma estructura (mismas columnas). El objetivo es leerlos todos y consolidarlos en una sola tabla.
El patrón es: listar archivos → leer cada uno → unir todo con bind_rows().
library(readxl)library(dplyr)
# 1. Listar todos los .xlsx de la carpetaarchivos <- list.files("datos/", pattern = "\\.xlsx$", full.names = TRUE)archivos#> [1] "datos/enero_2024.xlsx" "datos/febrero_2024.xlsx"#> [3] "datos/marzo_2024.xlsx" "datos/abril_2024.xlsx"
# 2. Leer cada archivo y agregar columna 'archivo' para trazabilidadconsolidado <- lapply(archivos, function(f) { read_excel(f) |> mutate(archivo = basename(f))}) |> bind_rows()
consolidadoimport pandas as pdfrom pathlib import Path
archivos = sorted(Path("datos").glob("*.xlsx"))
consolidado = pd.concat([ pd.read_excel(f).assign(archivo=f.name) for f in archivos], ignore_index=True)Línea por línea:
list.files("datos/", pattern = "\\.xlsx$", full.names = TRUE)—patternfiltra solo archivos que terminen en.xlsx(el\\.escapa el punto y$ancla al final).full.names = TRUEte devuelve la ruta completa ("datos/enero_2024.xlsx"), no solo el nombre.lapply(archivos, function(f) ...)— aplica la función a cada archivo de la lista. Por cada uno: lee conread_excel(f)y agregamutate(archivo = basename(f)).basename(f)— extrae solo el nombre del archivo de la ruta completa:"datos/enero_2024.xlsx"→"enero_2024.xlsx". Es lo que queremos guardar como trazabilidad.bind_rows()— apila todas las tablas verticalmente. Si una fila vino deenero_2024.xlsx, su columnaarchivolo dice.
Tip
Si necesitas extraer el mes desde un nombre como "enero_2024.xlsx", primero quita la extensión con tools::file_path_sans_ext("enero_2024.xlsx") → "enero_2024", y después separa con stringr::str_extract(., "^[a-z]+") → "enero". La trazabilidad fina conviene siempre preservarla.
Importar varios archivos con varias hojas cada uno
El caso más exigente: tienes varios archivos y cada uno tiene varias hojas. Por ejemplo:
datos/├── 2024.xlsx (hojas: urgencias, consultas, vacunas)├── 2025.xlsx (hojas: urgencias, consultas, vacunas)└── 2026.xlsx (hojas: urgencias, consultas, vacunas)Quieres consolidar todo en una sola tabla, conservando dos columnas de trazabilidad: archivo y hoja.
El patrón es un loop dentro de otro loop: por cada archivo, leer todas sus hojas, agregar las dos columnas, juntar todo.
library(readxl)library(dplyr)
# 1. Listar archivosarchivos <- list.files("datos/", pattern = "\\.xlsx$", full.names = TRUE)
# 2. Función auxiliar: leer todas las hojas de un archivoleer_todas_las_hojas <- function(f) { hojas <- excel_sheets(f) lapply(hojas, function(h) { read_excel(f, sheet = h) |> mutate(archivo = basename(f), hoja = h) }) |> bind_rows()}
# 3. Aplicar la función a cada archivo y unir todoconsolidado <- lapply(archivos, leer_todas_las_hojas) |> bind_rows()
consolidadoimport pandas as pdfrom pathlib import Path
def leer_todas_las_hojas(f): partes = [] for h in pd.ExcelFile(f).sheet_names: df = pd.read_excel(f, sheet_name=h) df["archivo"] = f.name df["hoja"] = h partes.append(df) return pd.concat(partes, ignore_index=True)
archivos = sorted(Path("datos").glob("*.xlsx"))
consolidado = pd.concat( [leer_todas_las_hojas(f) for f in archivos], ignore_index=True)Resultado típico:
# A tibble: 36 × 6 id_paciente diagnostico valor fecha archivo hoja <dbl> <chr> <dbl> <date> <chr> <chr>1 1 HTA 145 2024-01-15 2024.xlsx urgencias2 1 DM 110 2024-01-15 2024.xlsx urgencias3 2 HTA 160 2024-02-20 2024.xlsx urgencias...Con esas dos columnas (archivo, hoja) puedes filtrar, agrupar y verificar que todo lo que esperabas se leyó correctamente:
# Verificar conteo por archivo y hojaconsolidado |> count(archivo, hoja)
# Filtrar solo lo que vino de 2026consolidado |> filter(archivo == "2026.xlsx")¿Y si las hojas no tienen la misma estructura?
Si una hoja tiene columnas distintas (por ejemplo, urgencias tiene 6 columnas pero vacunas tiene 4), bind_rows() igual funciona: rellena con NA donde falten columnas. Eso puede ser lo que quieres (consolidado con muchas columnas) o un desastre silencioso. Confirma con glimpse() el resultado.
Si solo quieres consolidar una hoja específica de cada archivo (por ejemplo, solo urgencias), filtra dentro de leer_todas_las_hojas con if (h == "urgencias") ... o salta hojas con next.
Tip
Este patrón de “leer carpeta + consolidar” es probablemente el script que más veces vas a escribir en tu vida laboral de datos. Cada mes te llegan los REMs nuevos, los pones en datos/2024/, y este mismo código los consolida. Si todavía estás haciendo esto a mano en Excel, es la primera cosa que automatizar.
Errores comunes con control fino
Error 1: `skip` y `col_names` peleándose
Si pones skip = 3 y también col_names = c("a", "b", "c"), R interpreta que la fila 4 es el primer dato (no el encabezado). Si la fila 4 era el encabezado, tu primer registro de datos va a ser literalmente "a", "b", "c". Cuidado: col_names = c(...) no salta la fila de encabezado; tienes que sumar skip = encabezado_real.
Error 2: `col_types` con largo equivocado
Si tu archivo tiene 7 columnas y le pasas col_types = c("text", "numeric") (2 elementos), readxl falla con Sheet has 7 columns, but col_types has 2 values. Tienes que pasar uno por columna, o usar col_types = "guess" para las que no quieres forzar.
Error 3: `range` apuntando a una hoja que no existe
Si escribes range = "Pacientes!B5:H50" con P mayúscula pero la hoja se llama pacientes, readxl falla con Sheet 'Pacientes' not found. Confirma con excel_sheets() el nombre exacto.
Error 4: archivo cambió y tus skips ya no calzan
Si tu código asume skip = 2 y un día le agregan una fila más arriba al archivo, todo se desfasa silenciosamente. Lección: después de cada lectura grande, glimpse() el resultado y verifica que los encabezados sean los esperados. No confíes a ciegas.
Error 5: `list.files()` no encuentra nada
Si list.files("datos/", pattern = "\\.xlsx$") te devuelve un vector vacío (character(0)):
- La carpeta no existe (typeaste mal el nombre, o no estás en la carpeta correcta del proyecto).
- Los archivos terminan en
.XLSX(mayúsculas) y tu pattern espera minúsculas. Solución:pattern = "\\.xlsx$"+ignore.case = TRUE, opattern = "\\.[xX][lL][sS][xX]$". - Estás en
datos/2024/pero los archivos están endatos/. Usarecursive = TRUEpara bajar a subcarpetas.
Error 6: columnas no calzan al hacer bind_rows()
Si un archivo tiene id_paciente y otro tiene id_pcte (mismo concepto, distinto nombre), bind_rows() los trata como columnas distintas y vas a terminar con muchas columnas medio vacías. Solución: aplica janitor::clean_names() a cada tabla antes del bind, y/o renombra explícitamente con rename() antes de juntar.
Error 7: archivos con encabezados en filas distintas
Si enero.xlsx tiene metadata arriba pero febrero.xlsx no, el skip = 2 que sirve para uno rompe el otro. Solución: detecta por archivo (por ejemplo, en una función que reciba f y decida el skip según el nombre), o pide a quien arme los archivos que mantengan un formato consistente.
Ejercicio
Ejercicio 5 — Importar resumen_REM correctamente
- Lee la hoja
resumen_REMdel archivo de ejemplo saltándote las dos filas de metadata y guárdalo en una variablerem. - Aplica
glimpse(rem)y confirma que el primer indicador es"Controles HTA". - Bonus: lee solo las primeras 3 filas con
n_max = 3y verifica que el resultado coincide.
Ver solución
library(readxl)library(dplyr)
rem <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "resumen_REM", skip = 2)
glimpse(rem)#> Rows: 4#> Columns: 7#> $ indicador <chr> "Controles HTA", "Controles DM", "Controles EPOC", "Vacunaciones"#> $ enero <dbl> 420, 318, 95, 610#> ...
# Bonusread_excel("datos/pacientes_ejemplo.xlsx", sheet = "resumen_REM", skip = 2, n_max = 3)Si tu primer indicador no fue "Controles HTA" sino algo como "REPORTE..." o "Fuente: ...", el skip no fue el correcto. Recuerda: si los encabezados están en la fila 3, hay que saltarse las 2 anteriores. Si fuera fila 5, sería skip = 4.
Ejercicio 5b — Consolidar dos hojas del mismo archivo
Como aún no tienes varios archivos .xlsx para practicar el patrón “varios archivos”, vamos a simular el caso con las dos hojas principales de nuestro archivo de ejemplo, pacientes y laboratorio, tratándolas como si fueran “dos archivos” distintos.
- Lee
pacientes_ejemplo.xlsxhoja por hoja:pacientesylaboratorio. - Por cada una, agrega una columna
origencon el nombre de la hoja. - Únelas con
bind_rows()en una sola tablaconsolidado. - Verifica con
count(origen)que tienes las filas esperadas (20 de cada hoja).
Bonus real: si tienes en tu computador una carpeta con varios .xlsx reales (tus propios datos), aplica el patrón list.files() + lapply() + bind_rows() de la sección anterior.
Ver solución
library(readxl)library(dplyr)
ruta <- "datos/pacientes_ejemplo.xlsx"hojas <- c("pacientes", "laboratorio")
consolidado <- lapply(hojas, function(h) { read_excel(ruta, sheet = h) |> mutate(origen = h)}) |> bind_rows()
consolidado |> count(origen)#> # A tibble: 2 × 2#> origen n#> <chr> <int>#> 1 laboratorio 20#> 2 pacientes 20Como las dos hojas tienen columnas distintas, vas a ver que bind_rows() rellena con NA donde no calzan. Esto es normal cuando juntas tablas con esquemas distintos — y es exactamente cuando los glimpse() y count() post-bind se vuelven críticos para no dejar pasar errores en silencio.
Para el patrón con varios archivos reales:
archivos <- list.files("mis_datos/", pattern = "\\.xlsx$", full.names = TRUE)
consolidado <- lapply(archivos, function(f) { read_excel(f) |> mutate(archivo = basename(f))}) |> bind_rows()Ejercicio 5c — Lee solo un bloque con `range`
A veces solo necesitas una porción de la planilla. Practica range:
- Lee solo las primeras 5 filas y las 3 primeras columnas (
id,Nombre Completo,edad) de la hojapacientes. Pista: el rango en notación Excel es"A1:C6"(incluye fila 1 de encabezado + 5 filas de datos). - Confirma que el
tibbleresultante tiene exactamente 5 filas y 3 columnas. - Ahora lee solo las filas 10 a 15 (sin encabezado) con
range = "A11:G16"ycol_names = FALSE. ¿Qué tipo asignó R a cada columna? ¿Por qué crees que la columna de fecha sigue siendo<dbl>?
Ver solución
library(readxl)library(dplyr)
# 1. Primeras 5 filas, 3 columnasparte_1 <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "pacientes", range = "A1:C6")glimpse(parte_1)#> Rows: 5#> Columns: 3#> $ id <dbl> 1, 2, 3, 4, 5#> $ `Nombre Completo` <chr> "María Pérez", "Juan Soto", ...#> $ edad <dbl> 67, 45, 72, 58, 39
# 2. Filas 10 a 15 sin encabezadoparte_2 <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "pacientes", range = "A11:G16", col_names = FALSE)glimpse(parte_2)¿Por qué la fecha sigue siendo <dbl>? Porque en el archivo de ejemplo fecha_ingreso está guardada como número serial sin formato de fecha aplicado a la celda. range solo recorta un rectángulo: no cambia cómo R interpreta los tipos. Para forzar el tipo, usas col_types = (siguiente sección del módulo) o conviertes después con as.Date(x, origin = "1899-12-30") (módulo 8).
Advertencia
range cuenta filas incluyendo el encabezado. Si quieres 5 filas de datos a partir de la fila 1 de la hoja, el rango es "A1:C6" (1 fila de encabezado + 5 de datos = 6 filas en total). Confundir esto es la causa #1 de “me faltó/sobró una fila”.
Ejercicio 5d — Forzar tipos con `col_types`
Hasta ahora hemos dejado que read_excel() “adivine” los tipos. Vamos a forzarlos.
- Lee la hoja
pacientesforzando los tipos correctos desde el inicio:id→"numeric"Nombre Completo→"text"edad→"numeric"Sexo→"text"diagnostico→"text"fecha_ingreso→"date"presion_sistolica→"numeric"
- Vas a ver muchos warnings tipo
Expecting numeric in G3 / R3C7: got 'S/I'— ¿qué te están diciendo? - Aplica
glimpse()y confirma quefecha_ingresoahora es<dttm>(o<date>) ypresion_sistolicaes<dbl>conNAdonde antes había"S/I".
Ver solución
library(readxl)library(dplyr)
pacientes <- read_excel( "datos/pacientes_ejemplo.xlsx", sheet = "pacientes", col_types = c("numeric", "text", "numeric", "text", "text", "date", "numeric"))#> Warning: Expecting numeric in G3 / R3C7: got 'S/I'#> Warning: Expecting numeric in G10 / R10C7: got 'S/I'#> ...
glimpse(pacientes)#> Rows: 20#> Columns: 7#> $ id <dbl> 1, 2, 3, ...#> $ `Nombre Completo` <chr> "María Pérez", "Juan Soto", ...#> $ edad <dbl> 67, 45, 72, ...#> $ Sexo <chr> "F", "M", "F", ...#> $ diagnostico <chr> "Hipertensión", ...#> $ fecha_ingreso <dttm> 2024-01-01, 2024-01-10, ...#> $ presion_sistolica <dbl> 145, NA, 160, ...¿Qué te dicen los warnings? Cada warning identifica una celda específica que no pudo coercionar al tipo pedido. Por ejemplo: Expecting numeric in G3 / R3C7: got 'S/I' significa “celda G3 (fila 3, columna 7) tenía 'S/I', que no es número — lo convertí a NA”. No son errores, son notas para que sepas cuántas y cuáles celdas se perdieron al forzar el tipo.
Comparado con leer sin col_types, ahora tienes:
fecha_ingresocomo<dttm>directamente (no número serial).presion_sistolicacomo<dbl>con NAs ya convertidos.
Es un “atajo” de los módulos 7-9 que verás más adelante: con col_types + na = c("","S/I") puedes resolver muchas limpiezas en la línea de read_excel() misma.
Tip
Si te molestan los warnings, suppressWarnings(read_excel(...)) los oculta. Yo prefiero verlos al menos la primera vez para confirmar cuántas celdas se convirtieron en NA. Si esperaba 1 y veo 30, hay algo más sucio de lo que pensaba.
Inspeccionar y diagnosticar los datos
Por qué inspeccionar
Cuando importas un archivo Excel, R te muestra una tabla bonita y es tentador correr directo a summarise() o a un gráfico. Resiste. El paso intermedio entre “leí el archivo” y “lo analizo” es diagnosticar: ¿qué tipo tiene cada columna?, ¿cuántos NAs hay?, ¿hay valores imposibles? Saltarse este paso significa que vas a descubrir el problema dentro de tu análisis final, lo que es 10× más caro de arreglar.
Este módulo te da una rutina de inspección que toma 2 minutos y previene horas de debugging después.
Las 4 herramientas básicas
Voy a presentarte cuatro funciones que casi siempre uso, en este orden:
| Función | Para qué |
|---|---|
glimpse() | Ver columnas, tipos y los primeros valores en formato compacto. |
summary() | Estadísticas resumen por columna numérica + cuentas de NA. |
head() / tail() | Ver los primeros / últimos registros completos. |
View() | Abrir la tabla en el visor de datos de Positron para hacer scroll. |
1. glimpse() — la vista de pájaro
library(readxl)library(dplyr)
pacientes <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "pacientes")
glimpse(pacientes)import pandas as pd
pacientes = pd.read_excel("datos/pacientes_ejemplo.xlsx", sheet_name="pacientes")
pacientes.info()pacientes.head()Output (resumido):
Rows: 20Columns: 7$ id <dbl> 1, 2, 3, 4, 5, ...$ `Nombre Completo` <chr> "María Pérez", "Juan Soto", ...$ edad <dbl> 67, 45, 72, 58, 39, ...$ Sexo <chr> "F", "M", "F", "M", "F", ...$ diagnostico <chr> "Hipertensión", "Diabetes tipo 2", ...$ fecha_ingreso <dbl> 45292, 45301, 45315, ...$ presion_sistolica <chr> "145", "S/I", "160", ...Qué leer aquí, en orden:
- Número de filas y columnas. ¿Es consistente con lo que esperabas? Si esperabas 20 pacientes y dice 18, alguien borró 2 filas o se filtraron.
- Tipos de cada columna.
<dbl>,<int>,<chr>,<date>,<lgl>. ¿Algún tipo te sorprende? (En este caso:presion_sistolica <chr>yfecha_ingreso <dbl>son sorpresas — las arreglamos en módulos 7 y 8.) - Los primeros valores. ¿Hay nombres con espacios raros (
"Juan Soto"con doble espacio)? ¿Hay valores fuera de rango (edad 250)?
2. summary() — estadísticas mínimas
summary(pacientes)pacientes.describe(include="all")Para columnas numéricas te muestra mín, 1er cuartil, mediana, media, 3er cuartil, máx y NA's. Para columnas de texto solo te dice Length y Class. Es útil para:
- Detectar outliers o valores imposibles. Si la edad va de 0 a 250, hay un dato malo.
- Ver cuántos NAs hay por columna. Aparece como
NA's: 3al final de cada columna numérica.
Tip
summary() es la primera función que corro después de cargar un archivo. Si una columna numérica tiene un Max. raro, ahí está el problema.
3. head() y tail() — los extremos
head(pacientes, n = 5) # primeros 5tail(pacientes, n = 5) # últimos 5pacientes.head(5)pacientes.tail(5)tail() es crucial para detectar filas de totales o basura al final. Si la última fila dice id = NA y Nombre Completo = "TOTAL", ahí está tu fila de subtotal.
4. View() — la tabla completa
View(pacientes)# En Jupyter:pacientes# En VSCode/PyCharm: usa el visor de variables del IDE.View() (con V mayúscula) abre el visor de datos de Positron en una pestaña nueva, donde puedes hacer scroll, ordenar y filtrar la tabla con clics. No reemplaza al código de análisis, pero es excelente para una mirada exploratoria a una tabla pequeña-mediana (menos de 50.000 filas).
Diagnóstico avanzado: contar NAs por columna
Una pregunta súper frecuente: “¿cuántos valores faltantes tengo en cada columna?”. summary() te lo dice para numéricas, pero no para texto. Una línea más completa:
library(dplyr)
pacientes |> summarise(across(everything(), ~ sum(is.na(.))))pacientes.isna().sum()Salida esperada (resumida):
# A tibble: 1 × 7 id `Nombre Completo` edad Sexo diagnostico fecha_ingreso presion_sistolica <int> <int> <int> <int> <int> <int> <int>1 0 0 0 0 0 0 1Esto te dice: solo presion_sistolica tiene 1 NA. Pero ojo: los "S/I" no son NAs todavía, son texto. Por eso is.na() cuenta solo el vacío real (1 valor). Si hubieras leído con na = c("", "S/I"), contaría 3.
Advertencia
Lección importante: is.na() solo detecta NA real. Los "S/I", "-", "NA" literales no son NA mientras no se los digas a read_excel() o no los conviertas tú con na_if(). Los confundidos NAs son tema del módulo 9.
Diagnóstico avanzado: valores únicos
Para columnas categóricas (sexo, diagnóstico), saber qué valores únicos hay es vital. Si esperabas "F"/"M" pero hay "f", "Mas." o "Masculino", tienes que homogeneizar.
library(dplyr)
pacientes |> distinct(Sexo)
pacientes |> count(diagnostico, sort = TRUE)pacientes["Sexo"].unique()pacientes["diagnostico"].value_counts()count(diagnostico, sort = TRUE) te ordena por frecuencia. Si ves una categoría con frecuencia 1 ("Hipertension" sin tilde) cuando todas las demás dicen "Hipertensión", sabes que hay una entrada inconsistente que homogeneizar.
Filas de totales y subtotales: el bug clásico del REM
Casi todos los REM, planillas de seguimiento y dashboards exportados desde un sistema vienen con filas de subtotal o total intercaladas o pegadas al final. Algo así:
id establecimiento mes casos1 CESFAM Norte Enero 452 CESFAM Norte Febrero 383 CESFAM Norte Marzo 52NA Total CESFAM Norte NA 135 ← fila de subtotal4 CESFAM Sur Enero 305 CESFAM Sur Febrero 35NA Total CESFAM Sur NA 65 ← otraNA TOTAL GENERAL NA 200 ← y otra al finalSi calculas sum(casos), te da 400, no 200. Cada caso quedó contado dos o tres veces. Este es un error masivo en informes y casi nadie lo detecta porque “el código corrió sin errores”.
Cómo detectarlos: mira tail() (totales suelen ir al final) y busca filas donde el id (o cualquier identificador obligatorio) es NA:
library(dplyr)
# 1. Mira el finaltail(rem, 10)
# 2. Filas donde el id está vacíorem |> filter(is.na(id))
# 3. Filas cuyo establecimiento contiene "total" (ignora mayúsculas)rem |> filter(grepl("total|subtotal", establecimiento, ignore.case = TRUE))rem.tail(10)rem[rem["id"].isna()]rem[rem["establecimiento"].str.contains("total|subtotal", case=False, na=False)]Cómo eliminarlos: filtra las filas que sí tienen el identificador, o que no matchean el patrón de total:
library(dplyr)
rem_limpio <- rem |> filter(!is.na(id)) |> # saca filas sin id filter(!grepl("total|subtotal", establecimiento, ignore.case = TRUE)) # red de seguridadrem_limpio = ( rem .dropna(subset=["id"]) .loc[~rem["establecimiento"].str.contains("total|subtotal", case=False, na=False)])Verificación obligatoria
Después de eliminar subtotales, suma una variable conocida y compara con la fila “TOTAL GENERAL” que acabas de eliminar:
sum(rem_limpio$casos, na.rm = TRUE) # ¿da 200, como decía la fila TOTAL?Si los números cuadran, perfecto. Si no, revisa: puede que falten subtotales que no atrapaste, o que la fila “TOTAL” del archivo ya estaba mal (sí pasa).
No confundas NAs legítimos con filas de subtotal
Si en tus datos reales el id puede venir vacío por una razón válida (paciente anónimo, por ejemplo), filter(!is.na(id)) te bota datos buenos junto con los subtotales. En ese caso, usa otra columna obligatoria (run, fecha_ingreso) o el patrón de texto en el nombre. Siempre verifica con count() cuántas filas estás eliminando antes de seguir.
Checklist de inspección (regla de los 2 minutos)
Mi rutina personal al cargar cualquier Excel nuevo
glimpse(datos)→ ¿filas, columnas y tipos esperados?summary(datos)→ ¿valores numéricos dentro de rango razonable? ¿cuántos NA?tail(datos)→ ¿basura al final?count(datos, columna_categórica, sort = TRUE)para cada categórica → ¿valores únicos esperados?- ¿Hay alguna columna
<chr>que tú jurarías que debería ser<dbl>o<date>?
Si las 5 respuestas son OK, puedes pasar a manipular. Si alguna está mal, arréglala antes de seguir.
Caso real: aplicar el checklist a pacientes
Apliquemos los 5 pasos a nuestro archivo de ejemplo:
library(readxl)library(dplyr)
pacientes <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "pacientes")
glimpse(pacientes)summary(pacientes)tail(pacientes)count(pacientes, diagnostico, sort = TRUE)count(pacientes, Sexo)Lo que vamos a encontrar:
- ✅ Filas (20) y columnas (7) son las esperadas.
- ❌
fecha_ingresosalió<dbl>(debería ser fecha). - ❌
presion_sistolicasalió<chr>(debería ser número). - ❌
Nombre Completotiene espacios sucios (no se ve aquí, pero los vamos a encontrar en el módulo 7). - ✅ No hay basura al final (
tailmuestra el paciente 20 normal). - ✅ Las categorías de
diagnosticoySexose ven consistentes.
Conclusión: la tabla está usable, pero hay 3 cosas que arreglar antes de analizar. Los módulos 7, 8 y 9 las resuelven.
Errores comunes en la inspección
Pitfall 1: confiar solo en `head()`
head() muestra las primeras 6 filas. Si tus datos sucios están en la fila 1500, head() no los detecta. Por eso summary() (que mira la columna entera) es complementario.
Pitfall 2: 'sí pero la fila 47 está mala'
Cuando detectes que algo está mal (por ejemplo, una columna numérica con un texto adentro), encuentra exactamente dónde está:
pacientes |> filter(is.na(as.numeric(presion_sistolica)))Esto te muestra las filas donde presion_sistolica no se puede convertir a número. Te va a aparecer un warning NAs introduced by coercion — es esperable; estás convirtiendo justamente para detectar las celdas malas.
Pitfall 3: tablas con 50.000+ filas en `View()`
El visor de Positron se pone lento al usar View() con tablas grandes. Para esos casos:
pacientes |> slice_sample(n = 100) |> View() # 100 al azarpacientes |> head(500) |> View() # primeras 500Pitfall 4: 'el `summary()` dice 0 NA pero yo vi celdas vacías'
Si las celdas “vacías” tienen un espacio (" ") en vez de estar de verdad vacías, is.na() cuenta 0. Hay que limpiar con trim_ws = TRUE al leer (módulo 5) o con na_if(x, "") después.
Ejercicio
Ejercicio 6 — Diagnóstico de las 3 hojas
- Lee las hojas
pacientes,laboratorioyresumen_REM(recuerdaskip = 2para REM) en tres variables. - Aplica los 5 pasos del checklist a cada una.
- Anota: ¿qué columna te llama más la atención por su tipo? ¿En qué hoja hay potenciales NAs disfrazados?
Ver solución
library(readxl)library(dplyr)
pacientes <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "pacientes")lab <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "laboratorio")rem <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "resumen_REM", skip = 2)
# pacientesglimpse(pacientes)summary(pacientes)count(pacientes, diagnostico, sort = TRUE)count(pacientes, Sexo)
# laboratorioglimpse(lab)count(lab, examen)
# remglimpse(rem)summary(rem)Hallazgos esperados:
- En
pacientes:presion_sistolicaes<chr>(tiene"S/I"y vacíos);fecha_ingresoes<dbl>(es serial); elNombre Completotiene espacios sucios. - En
lab:fechaes<chr>con formato"15/01/2024"— no es fecha real, es texto. - En
rem: ya quedó bien tipada después delskip = 2.
Estos son los problemas que vamos a resolver en los siguientes 3 módulos.
Ejercicio 6b — Encuentra exactamente las filas problemáticas
Detectar que hay un problema es fácil. Encontrar dónde está es lo útil. Vamos a localizar las celdas conflictivas:
- Lee la hoja
pacientes(sin forzar tipos ni NAs). - Filtra las filas en las que
presion_sistolicano se puede convertir a número:library(readxl)library(dplyr)pacientes <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "pacientes")pacientes |>filter(is.na(as.numeric(presion_sistolica))) |>select(id, `Nombre Completo`, presion_sistolica) - Anota: ¿qué
idy qué valor literal tienen las filas problemáticas? - Bonus: filtra las filas con
edadfuera de un rango razonable (digamos< 0o> 120). ¿Hay alguna?
Ver respuesta
pacientes |> filter(is.na(as.numeric(presion_sistolica))) |> select(id, `Nombre Completo`, presion_sistolica)#> Warning: NAs introduced by coercion#> # A tibble: 3 × 3#> id `Nombre Completo` presion_sistolica#> <dbl> <chr> <chr>#> 1 2 Juan Soto S/I#> 2 9 Valentina Rojas S/I#> 3 16 Benjamín Vargas ""Los pacientes problemáticos son los id = 2, 9 (con "S/I" literal) y el id = 16 (con celda vacía, que read_excel() ya leyó como "" en una columna texto).
Bonus — edades raras:
pacientes |> filter(edad < 0 | edad > 120)#> # A tibble: 0 × 7No hay edades raras en este archivo. Pero en datos reales, este chequeo encuentra edad = 999, edad = 250 o edad = -5 (típicos errores de digitación o códigos de “no responde” mal interpretados).
Lección clave: el warning NAs introduced by coercion que viste no es un problema: es la consecuencia esperada de as.numeric() sobre texto. Lo usamos justamente como un detector de qué filas no podían convertirse a número.
Ejercicio 6c — Mini-reporte de calidad de datos
Junta todo lo del módulo en un solo “reporte de calidad” reutilizable. Crea una función que, recibida una tabla, te imprima:
- Cantidad de filas y columnas.
- Cantidad de NAs por columna.
- Valores únicos de cada columna de tipo
<chr>.
library(readxl)library(dplyr)
reporte_calidad <- function(datos) { cat("Filas:", nrow(datos), " | Columnas:", ncol(datos), "\n\n")
cat("NAs por columna:\n") nas <- datos |> summarise(across(everything(), ~ sum(is.na(.)))) print(nas)
cat("\nValores únicos en columnas <chr>:\n") datos |> select(where(is.character)) |> lapply(unique) |> print()}
pacientes <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "pacientes")reporte_calidad(pacientes)Corre la función sobre las tres hojas (pacientes, laboratorio, resumen_REM con skip = 2) y compara los reportes.
Ver salida típica y por qué sirve
La salida para pacientes se ve aproximadamente así:
Filas: 20 | Columnas: 7
NAs por columna:# A tibble: 1 × 7 id `Nombre Completo` edad Sexo diagnostico fecha_ingreso presion_sistolica <int> <int> <int> <int> <int> <int> <int>1 0 0 0 0 0 0 1
Valores únicos en columnas <chr>:$`Nombre Completo` [1] "María Pérez" "Juan Soto" ...
$Sexo[1] "F" "M"
$diagnostico[1] "Hipertensión" "Diabetes tipo 2" "EPOC" "Asma"
$presion_sistolica[1] "145" "S/I" "160" "138" ...Por qué sirve: en un solo paso ves tamaño, faltantes y categorías de la tabla. Las categorías te muestran inmediatamente las inconsistencias: si Sexo tuviera "f" y "F" mezclados, lo verías en unique().
Para resumen_REM con skip = 2, no deberías ver NAs ni categorías raras. Para laboratorio, la salida es similar pero más corta.
Próximo paso natural: convertir esta función en reporte_calidad.R y usarla al inicio de cada análisis. Es la práctica más subestimada: 3 segundos de inspección automatizada ahorran 30 minutos de debugging.
Tip
Existen paquetes como skimr (skimr::skim(pacientes)) que hacen un reporte mucho más completo en una sola línea. Vale la pena conocerlo cuando tu rutina personal se quede corta.
Limpiar nombres y convertir tipos
Por qué los nombres importan
Cuando recibes un Excel del mundo real, los encabezados son un campo minado:
"Nombre Completo"con un espacio (¿espacientes$Nombre Completoopacientes$"Nombre Completo"?)."Edad (años)"con paréntesis y caracteres especiales."% adherencia"con un símbolo de porcentaje."ID Paciente"aquí,"id_paciente"en otra hoja.- Tildes inconsistentes (
DiagnósticovsDiagnostico).
Si trabajas así, cada vez que quieras referenciar una columna tienes que envolverla en backticks (`Nombre Completo`) y vas a olvidarte la mitad de las veces. La solución es normalizar los nombres una vez, al principio.
Lo mismo aplica a los tipos: las columnas con "S/I", "99 años", "45,3" (con coma decimal) se leen como texto. Hay que convertirlas a número antes de analizar.
Limpiar nombres con janitor::clean_names()
janitor (que instalamos en el módulo 2) tiene la mejor función para esto. clean_names() convierte tus encabezados a un estándar snake_case sin tildes, sin espacios, sin paréntesis, todo minúscula:
library(readxl)library(dplyr)library(janitor)
pacientes <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "pacientes") |> clean_names()
names(pacientes)import pandas as pdimport re
pacientes = pd.read_excel("datos/pacientes_ejemplo.xlsx", sheet_name="pacientes")
def clean_name(s): s = s.strip().lower() s = re.sub(r"[áàä]", "a", s) s = re.sub(r"[éèë]", "e", s) s = re.sub(r"[íìï]", "i", s) s = re.sub(r"[óòö]", "o", s) s = re.sub(r"[úùü]", "u", s) s = re.sub(r"[^a-z0-9]+", "_", s) return s.strip("_")
pacientes.columns = [clean_name(c) for c in pacientes.columns]pacientes.columns.tolist()Antes:
[1] "id" "Nombre Completo" "edad"[4] "Sexo" "diagnostico" "fecha_ingreso"[7] "presion_sistolica"Después de clean_names():
[1] "id" "nombre_completo" "edad"[4] "sexo" "diagnostico" "fecha_ingreso"[7] "presion_sistolica"Ahora puedes escribir pacientes$nombre_completo sin backticks. Tu yo del futuro te lo agradece.
Tip
clean_names() es lo segundo que aplico a cualquier tabla que importo (después de glimpse()). Es una operación de un solo segundo que ahorra dolores de cabeza el resto del proyecto.
`clean_names()` vs `make_clean_names()`
janitor te da dos funciones que se parecen pero tienen usos distintos:
clean_names(df)— recibe un data frame y limpia sus nombres de columnas. Es lo que vimos arriba.make_clean_names(x)— recibe un vector de strings y devuelve los strings limpios. Útil cuando quieres limpiar nombres de archivo, claves para una lista, o cualquier texto que no esté dentro de un data frame.
janitor::make_clean_names(c("Hipertensión arterial", "Diabetes tipo 2"))#> [1] "hipertension_arterial" "diabetes_tipo_2"Lo vamos a usar más adelante (módulo 11) para construir nombres de archivo automáticamente desde valores de una columna.
Renombrar columnas específicas con rename()
Si solo quieres cambiar una o dos columnas (no todas), usa rename() de dplyr. La sintaxis es nuevo_nombre = nombre_viejo:
library(dplyr)
pacientes <- pacientes |> rename(pas = presion_sistolica, dx = diagnostico)pacientes = pacientes.rename(columns={ "presion_sistolica": "pas", "diagnostico": "dx",})Limpiar espacios sobrantes en valores de texto
clean_names() arregla los encabezados, pero no los valores dentro de las celdas. Si la columna nombre_completo tiene "Juan Soto" (con doble espacio interno) o " Ana Díaz " (con espacios envolventes), todavía está sucio. Para limpiarlo, usa mutate() + trimws() o stringr::str_squish():
library(dplyr)library(stringr)
pacientes <- pacientes |> mutate( nombre_completo = str_squish(nombre_completo) # recorta + colapsa espacios internos )pacientes["nombre_completo"] = pacientes["nombre_completo"].str.strip()# Para colapsar espacios internos múltiples:pacientes["nombre_completo"] = pacientes["nombre_completo"].str.replace(r"\s+", " ", regex=True)str_squish() hace dos cosas:
- Recorta espacios al inicio y al final (como
trimws()). - Colapsa múltiples espacios internos en uno solo (
"Juan Soto"→"Juan Soto").
Es la forma “limpia con todo” de un solo paso.
Nota
stringr viene incluido en tidyverse (que instalamos en el módulo 2), así que library(stringr) ya funciona. Si por alguna razón te dice there is no package called 'stringr': reinstala con pak::pak("tidyverse"). Alternativamente, base R tiene trimws() y gsub("\\s+", " ", x) que hacen lo mismo en dos pasos.
Convertir texto a número con as.numeric() y parse_number()
La columna presion_sistolica salió como <chr> porque tiene "S/I" adentro. Si la conviertes con as.numeric(), los valores no convertibles se vuelven NA automáticamente (con un warning):
library(dplyr)
pacientes <- pacientes |> mutate(presion_sistolica = as.numeric(presion_sistolica))# Warning: NAs introduced by coercion ← esperable
glimpse(pacientes)# $ presion_sistolica <dbl> 145, NA, 160, 138, ...pacientes["presion_sistolica"] = pd.to_numeric( pacientes["presion_sistolica"], errors="coerce")errors = "coerce" en Python equivale a as.numeric() en R: lo que no puede convertir, lo convierte a NA.
Cuándo usar readr::parse_number() en su lugar
Si la columna tiene caracteres mezclados (números pegados a símbolos), as.numeric() no los soporta. Por ejemplo, una columna presion con "145 mmHg" o "$ 1.200" o "99%":
library(readr)
valores_sucios <- c("145 mmHg", "S/I", "99%", "$ 1.200")
parse_number(valores_sucios)#> [1] 145.0 NA 99.0 1200.0import pandas as pdimport re
def parse_number(s): if not isinstance(s, str): return s s = re.sub(r"[^0-9.\-]", "", s) return float(s) if s else None
[parse_number(x) for x in ["145 mmHg", "S/I", "99%", "$ 1.200"]]# [145.0, None, 99.0, 1200.0]parse_number() extrae el número dentro del texto y descarta el resto. Es útil para sueldos, porcentajes, unidades pegadas, etc.
La trampa silenciosa de la coma decimal chilena
Este es probablemente el error más común en planillas de salud chilenas y casi nadie te lo va a advertir. En Chile (y en todo Latinoamérica) escribimos los números así:
"145,3"— la coma es decimal."1.200"— el punto es separador de miles."1.234,50"— combinación.
Pero R, por defecto, asume convención estadounidense (punto decimal, coma de miles). Resultado:
as.numeric("145,3")#> [1] NA#> Warning: NAs introduced by coercion
as.numeric("1.200")#> [1] 1.2 ← ¡R cree que es 1.2, no 1200!Esto falla en silencio: una columna presion_diastolica con valores como "85,5", "90,2" se convierte a NAs sin que tú lo notes hasta que el promedio te dé NA o salga ridículamente bajo. Es uno de los bugs más caros porque el script “funciona” — pero los números están mal.
La solución correcta: usar parse_number() con el locale chileno:
library(readr)
locale_cl <- locale(decimal_mark = ",", grouping_mark = ".")
parse_number(c("145,3", "1.200", "85,5"), locale = locale_cl)#> [1] 145.3 1200.0 85.5# pandas: replace separators before to_numerics = pd.Series(["145,3", "1.200", "85,5"])s.str.replace(".", "", regex=False).str.replace(",", ".", regex=False).astype(float)# 0 145.3# 1 1200.0# 2 85.5Receta defensiva
Cuando recibas una planilla nueva, antes de cualquier análisis, mira al menos 5 valores de cada columna numérica:
pacientes |> count(presion_sistolica) |> head(10)Si ves comas ("145,3") o puntos de miles ("1.200"), trabaja desde el primer momento con parse_number(..., locale = locale_cl). Asumir convención USA con datos chilenos es la fuente número uno de errores silenciosos.
Aplicarlo a una columna del data frame
library(dplyr)library(readr)
locale_cl <- locale(decimal_mark = ",", grouping_mark = ".")
pacientes <- pacientes |> mutate(presion_sistolica = parse_number(presion_sistolica, locale = locale_cl))pacientes["presion_sistolica"] = ( pacientes["presion_sistolica"] .str.replace(".", "", regex=False) .str.replace(",", ".", regex=False) .pipe(pd.to_numeric, errors="coerce"))Convertir texto a fecha (preview)
Las fechas son su propio módulo (8), pero el patrón básico es similar: para texto tipo "15/01/2024" usas lubridate::dmy():
library(lubridate)dmy("15/01/2024") # día/mes/año#> [1] "2024-01-15"Lo veremos a fondo en el siguiente módulo.
Convertir varias columnas a la vez con mutate(across())
Si tienes varias columnas de texto que en realidad deberían ser número (por ejemplo presion_sistolica y glicemia), no escribas as.numeric() una por una. Usa across() para aplicar la conversión a varias columnas en una sola línea:
library(dplyr)
# Ejemplo: convertir dos columnas tipo texto a numéricaspacientes <- pacientes |> mutate(across(c(presion_sistolica, glicemia), as.numeric))cols = ["presion_sistolica", "glicemia"]pacientes[cols] = pacientes[cols].apply(pd.to_numeric, errors="coerce")(El ejemplo asume que tu tabla tiene una columna glicemia. En nuestro archivo de ejemplo no existe; el ejemplo ilustra el patrón.)
across(c(col1, col2), funcion) aplica la función a varias columnas en una sola línea.
Nombres duplicados
Si el archivo tenía dos columnas con el mismo nombre (por ejemplo, dos columnas Fecha), read_excel() agrega un sufijo: Fecha, Fecha...1, etc. Después de clean_names() quedan como fecha, fecha_1. Renómbralas explícitamente para no confundirte:
pacientes |> rename(fecha_ingreso = fecha, fecha_alta = fecha_1)Advertencia
Si dos columnas con el mismo nombre tienen significados distintos, no las pegues: confirma con quien te pasó el archivo qué es cada una antes de renombrar.
Errores comunes al limpiar
Error 1: `as.numeric()` sobre una columna que sí era numérica
Si aplicas as.numeric() a una columna que ya era <dbl>, no pasa nada (queda igual). Pero si la columna era <int>, se convierte a <dbl>. Para la mayoría de los análisis da igual.
Error 2: `clean_names()` rompió los joins con otra tabla
Si dos tablas tienen una columna en común (id_paciente) pero solo limpiaste los nombres en una, los nombres ya no calzan al hacer el join. Solución: aplica clean_names() a todas tus tablas al inicio, no solo a algunas.
Error 3: `Warning: NAs introduced by coercion`
Este warning aparece cada vez que conviertes texto a número y hay algún valor no convertible. Es esperable — as.numeric() te está avisando que algunas celdas no eran números. Verifica cuáles eran:
pacientes |> filter(is.na(presion_sistolica)) # las que quedaron NA tras la coerciónError 4: `parse_number()` con coma decimal vs punto decimal
En Chile escribimos "1.234,50" (punto miles, coma decimal). En USA es "1,234.50". parse_number() por defecto usa convención USA. Para datos chilenos:
parse_number(x, locale = locale(decimal_mark = ",", grouping_mark = "."))Error 5: limpieza destructiva sin guardar el original
Si haces pacientes <- pacientes |> mutate(...) y te equivocas, perdiste el original. Buena práctica: guarda el resultado en una variable nueva mientras experimentas:
pacientes_limpios <- pacientes |> clean_names() |> mutate(presion_sistolica = as.numeric(presion_sistolica))Cuando estés seguro, sobreescribes.
Receta completa: el pipeline de limpieza para pacientes
Juntando todo, el pipeline completo de limpieza para nuestro archivo de ejemplo se ve así:
library(readxl)library(dplyr)library(janitor)library(stringr)
pacientes_limpios <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "pacientes", na = c("", "S/I")) |> # ← módulo 5 clean_names() |> # nombres a snake_case mutate( nombre_completo = str_squish(nombre_completo), # limpiar espacios presion_sistolica = as.numeric(presion_sistolica) # forzar numérico )
glimpse(pacientes_limpios)Después de esto, todas las columnas tienen el tipo correcto excepto fecha_ingreso, que sigue como serial numérico. Eso lo arreglamos en el módulo 8.
Ejercicio
Ejercicio 7 — Pipeline de limpieza de la hoja `laboratorio`
- Lee la hoja
laboratorio. - Aplica
clean_names(). - Limpia espacios en la columna
examenconstr_squish(). - Confirma con
glimpse()que los nombres sonid_paciente,examen,valor,fecha(todos snake_case y minúsculas). - Bonus: ¿la columna
valortiene el tipo correcto?
Ver solución
library(readxl)library(dplyr)library(janitor)library(stringr)
lab_limpio <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "laboratorio") |> clean_names() |> mutate(examen = str_squish(examen))
glimpse(lab_limpio)#> Rows: 20#> Columns: 4#> $ id_paciente <dbl> 1, 1, 2, 3, 3, ...#> $ examen <chr> "Glicemia", "Colesterol", ...#> $ valor <dbl> 110, 220, 145, 13.5, ...#> $ fecha <chr> "15/01/2024", ...Bonus: valor ya está como <dbl> (porque en el archivo de ejemplo no tiene valores sucios). Pero fecha sigue como <chr> — eso es lo que arreglaremos en el próximo módulo.
Si tu output muestra nombres todavía con espacios o mayúsculas, no aplicaste clean_names(). Si examen tiene espacios raros, te faltó el str_squish(). Si valor salió como <chr>, probablemente hay alguna celda con texto adentro — usa summary() y filter(is.na(as.numeric(valor))) para detectarla.
Ejercicio 7b — Practica `parse_number()` con texto sucio
parse_number() es ideal para columnas tipo “100 mmHg” o ”$ 1.200” donde el número viene pegado a un símbolo. Practica con un vector inventado:
- Crea un vector con valores realistas que podrías ver en una planilla de salud:
library(readr)sucios <- c("145 mmHg", "S/I", "99 %", "$ 1.200","90/60", " 120 ", "-", "12,5")
- Aplica
parse_number(sucios)y observa qué devuelve para cada elemento. - Anota: ¿qué hizo con
"90/60"? ¿y con"12,5"? ¿qué problemas le ves a este enfoque en datos reales? - Bonus: usa
parse_number()conlocale(decimal_mark = ",", grouping_mark = ".")para datos chilenos con coma decimal.
Ver resultado y aprendizajes
library(readr)
sucios <- c("145 mmHg", "S/I", "99 %", "$ 1.200", "90/60", " 120 ", "-", "12,5")
parse_number(sucios)#> [1] 145.0 NA 99.0 1.2 90.0 120.0 NA 125.0#> Warning: 1 parsing failure.Análisis fila por fila:
| Entrada | Salida | Comentario |
|---|---|---|
"145 mmHg" | 145 | ✅ extrae el número |
"S/I" | NA | ✅ no hay número |
"99 %" | 99 | ✅ pero perdiste el ”%” — ¿ese 99 era 0.99 o 99? |
"$ 1.200" | 1.2 | ❌ trampa: con locale USA, el punto es decimal |
"90/60" | 90 | ❌ ¡toma solo el primer número! Si era presión arterial, perdiste la diastólica |
" 120 " | 120 | ✅ ignora espacios |
"-" | NA | ✅ |
"12,5" | 125 | ❌ ignora la coma como separador de miles, perdiste el decimal |
Bonus con locale chileno:
parse_number(sucios, locale = locale(decimal_mark = ",", grouping_mark = "."))#> [1] 145.0 NA 99.0 1200.0 90.0 120.0 NA 12.5Ahora "$ 1.200" → 1200 y "12,5" → 12.5. Esto es lo que querías en Chile.
Lección crítica: parse_number() extrae el primer número y descarta el resto. Para columnas como "90/60" (presión sistólica/diastólica) no sirve: tienes que separar con tidyr::separate() antes. Y para datos con miles/decimales, el locale correcto es obligatorio, no opcional.
Ejercicio 7c — Pipeline completo de limpieza de pacientes
Es momento de juntar todo lo del módulo. Escribe un único pipeline que:
- Lea
pacientes_ejemplo.xlsxhojapacientesindicandona = c("", "S/I")al leer. - Aplique
clean_names(). - Use
str_squish()para limpiar espacios sucios ennombre_completo. - Renombre
presion_sistolicaapasydiagnosticoadx. - Convierta
pasa numérico. - Aplique
glimpse()al resultado.
El pipeline completo debe entrar en un solo flujo de |>. La meta es que veas que toda la limpieza son 6 verbos encadenados, no 6 scripts separados.
Ver solución
library(readxl)library(dplyr)library(janitor)library(stringr)
pacientes_limpios <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "pacientes", na = c("", "S/I")) |> clean_names() |> mutate(nombre_completo = str_squish(nombre_completo)) |> rename(pas = presion_sistolica, dx = diagnostico) |> mutate(pas = as.numeric(pas))
glimpse(pacientes_limpios)#> Rows: 20#> Columns: 7#> $ id <dbl> 1, 2, 3, 4, ...#> $ nombre_completo <chr> "María Pérez", "Juan Soto", ... # ¡un solo espacio!#> $ edad <dbl> 67, 45, 72, ...#> $ sexo <chr> "F", "M", "F", ...#> $ dx <chr> "Hipertensión", "Diabetes tipo 2", ...#> $ fecha_ingreso <dbl> 45292, 45301, ... # ← siguiente módulo#> $ pas <dbl> 145, NA, 160, ... # ✅ ya es numéricoCosas para comprobar:
"Juan Soto"ahora es"Juan Soto"(un solo espacio interno). Elstr_squish()funcionó.pases<dbl>conNAen las posiciones donde había"S/I". La combinaciónna = c("", "S/I")+as.numeric(pas)lo logró en dos pasos.fecha_ingresosigue como<dbl>con números seriales. Eso es lo que arreglaremos en el próximo módulo.
Reflexión: las 5 transformaciones que hicimos (leer con NAs, limpiar nombres de columnas, squish del nombre, renombrar, coercionar a numérico) toman literalmente 8 líneas de código y son completamente reproducibles. La próxima vez que recibas un archivo similar, pegas este pipeline, cambias la ruta y listo. Eso es ciencia de datos en serio.
Tip
Si tu pipeline empieza a tener más de 10 pasos, vale la pena guardarlo en una función (limpiar_pacientes <- function(ruta) { ... }). Así reutilizas el mismo flujo para enero, febrero, marzo… y mantienes un único lugar donde actualizar la lógica.
Fechas y horas: el problema clásico de Excel
Por qué las fechas merecen su propio módulo
Si hay un solo tipo de dato que ha hecho llorar a más analistas con Excel, son las fechas. Lo que se ve como 15/01/2024 en Excel puede ser, internamente:
- Una fecha real (Excel guarda 45307 y lo muestra como fecha).
- Un número decimal (45307.5 = fecha + hora).
- Texto puro (la cadena
"15/01/2024"sin que Excel “sepa” que es fecha). - Un número entero serializado (45307, sin formato de fecha aplicado).
Y cuando lo lee R, depende de lo que era internamente:
| En el archivo Excel | Cómo lo lee read_excel() |
|---|---|
| Fecha con formato de fecha | <date> o <dttm> ← bien |
| Número con formato general | <dbl> (numérico) ← serial |
Texto "15/01/2024" | <chr> ← string |
Este módulo te enseña a reconocer cada caso y arreglarlo. Es el módulo más práctico del curso porque casi cualquier archivo del mundo real tiene al menos un problema de fechas.
Caso 1: la fecha vino como número serial (<dbl>)
Recuerda nuestra columna fecha_ingreso en pacientes:
fecha_ingreso <dbl> 45292, 45301, 45315, ...45292 es el número de días desde el 30 de diciembre de 1899 (el “origen” de Excel en Windows). Para convertirlo a fecha real en R:
library(readxl)library(dplyr)library(janitor)
pacientes <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "pacientes") |> clean_names() |> mutate(fecha_ingreso = as.Date(fecha_ingreso, origin = "1899-12-30"))
pacientes |> select(id, fecha_ingreso)import pandas as pd
pacientes = pd.read_excel("datos/pacientes_ejemplo.xlsx", sheet_name="pacientes")
# pandas convierte serial → fecha con el origen 1899-12-30pacientes["fecha_ingreso"] = pd.to_datetime( pacientes["fecha_ingreso"], origin="1899-12-30", unit="D")Salida:
# A tibble: 20 × 2 id fecha_ingreso <dbl> <date>1 1 2024-01-012 2 2024-01-103 3 2024-01-24...¿Por qué 1899-12-30 y no 1900-01-01?
Es una historia divertida: Lotus 1-2-3 (el competidor histórico de Excel) tenía un bug de año bisiesto que consideraba 1900 como bisiesto cuando no lo era. Excel copió el bug para mantener compatibilidad. El resultado es que el origen efectivo en Windows es 1899-12-30 y no 1900-01-01. Sí, casi todo el mundo digital depende de un bug heredado de 1985.
Excel en Mac usaba 1904 como origen
Las versiones viejas de Excel para Mac usaban 1904 como origen. Si te llega un archivo creado en un Mac de hace mucho, las fechas pueden estar corridas por 4 años. Si ves todas tus fechas en 2028 cuando deberían ser 2024, prueba origin = "1904-01-01".
Caso 2: la fecha vino como texto (<chr>)
En nuestra hoja laboratorio, la columna fecha es:
fecha <chr> "15/01/2024", "15/01/2024", "24/01/2024", ...Esto es texto: R no sabe que es una fecha. Para convertirlo necesitamos lubridate, que tiene funciones nombradas según el orden de los componentes:
ymd("2024-01-15")— Year-Month-Daydmy("15/01/2024")— Day-Month-Year (estándar latino)mdy("01/15/2024")— Month-Day-Year (estándar USA)dmy_hms("15/01/2024 09:30:00")— con hora
library(readxl)library(dplyr)library(janitor)library(lubridate)
lab <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "laboratorio") |> clean_names() |> mutate(fecha = dmy(fecha))
lab |> select(id_paciente, fecha) |> head()import pandas as pd
lab = pd.read_excel("datos/pacientes_ejemplo.xlsx", sheet_name="laboratorio")
lab["fecha"] = pd.to_datetime(lab["fecha"], format="%d/%m/%Y")Salida:
# A tibble: 6 × 2 id_paciente fecha <dbl> <date>1 1 2024-01-152 1 2024-01-153 2 2024-01-24...Tip
Si no sabes qué formato tiene la fecha y mezcla varios, lubridate::parse_date_time() permite probar múltiples:
parse_date_time(c("15/01/2024", "2024-01-15", "Jan 15, 2024"), orders = c("dmy", "ymd", "mdy"))Caso 3: la columna mezcla fechas de varios formatos
A veces te llega:
"15/01/2024""2024-01-15""15-ene-2024"Es un horror, pero pasa. parse_date_time() puede ayudar, pero muchas veces la mejor solución es separar las que no se entienden y revisarlas a mano:
library(lubridate)library(dplyr)
mixto <- tibble(raw = c("15/01/2024", "2024-01-15", "15-ene-2024", "NA", ""))
mixto |> mutate(fecha_parseada = parse_date_time(raw, orders = c("dmy", "ymd"), locale = "es_ES.UTF-8")) |> filter(is.na(fecha_parseada)) # ← te muestra las que no se pudieron convertirCaso 4: la fecha es fecha+hora (datetime)
Si la columna se ve como 2024-01-15 09:30:00, es un <dttm> (date-time) en R. Puedes:
- Quedarte con solo la fecha:
as.Date(fecha)ofloor_date(fecha, "day"). - Extraer la hora:
hour(fecha)te devuelve un número entero (0-23).
library(lubridate)
timestamps <- c("2024-01-15 09:30:00", "2024-01-15 18:45:00")fechas_horas <- ymd_hms(timestamps)
fechas_horasas.Date(fechas_horas)hour(fechas_horas)import pandas as pd
fh = pd.to_datetime(["2024-01-15 09:30:00", "2024-01-15 18:45:00"])fh.normalize() # solo fecha, hora en 00:00:00fh.hourOperar con fechas
Una vez que tus fechas son <date> reales, puedes hacer matemática:
library(dplyr)library(lubridate)
pacientes |> mutate( dias_desde_ingreso = as.numeric(today() - fecha_ingreso), mes_ingreso = month(fecha_ingreso, label = TRUE), año_ingreso = year(fecha_ingreso) )today()te da la fecha actual.today() - fecha_ingresoda una diferencia (<difftime>);as.numeric()la pasa a número de días.month(x, label = TRUE)te da el nombre del mes (ene,feb…).year(x)el año.
Errores comunes con fechas
Error 1: `Warning: All formats failed to parse. No formats found.`
Causa: lubridate no reconoció ninguno de los formatos que le pasaste.
Diagnóstico: mira un valor concreto: ¿es "15/01/2024", "15-ene-24", "2024-1-15"? Cada uno necesita la función correcta (dmy, dmy con locale español, ymd).
Error 2: las fechas se ven bien pero el filtro no funciona
Si haces filter(fecha >= "2024-01-15") y no devuelve nada (cuando deberían haber filas), probablemente la columna sigue siendo <chr> (texto), no <date>. Confirma con class(pacientes$fecha).
Error 3: 'fecha_ingreso' se ve como 1970-01-01 después de convertir
Causa: olvidaste el origin = "1899-12-30" en as.Date(). Por defecto R usa origen 1970-01-01 (Unix), que da fechas absurdas para números seriales de Excel.
Solución correcta:
as.Date(45292, origin = "1899-12-30") # → 2024-01-01 ✅as.Date(45292) # → 2094-01-04 ❌ (origin 1970)Error 4: fechas como 31 de febrero
Algunos sistemas exportan fechas inválidas (31/02/2024). lubridate te dice Warning: 1 failed to parse y deja un NA en esa fila. Bien: tu siguiente paso es ir a buscar esa fila y corregirla o descartarla.
Error 5: 'la fecha cambió 1 día' por zona horaria
Si tus fechas son <dttm> (con hora) y haces operaciones, R aplica la zona horaria del sistema. Si vives en Chile y la fecha viene en UTC, 2024-01-15 02:00 UTC se ve como 2024-01-14 23:00 en hora chilena. Solución: trabaja con <date> puros si no te importa la hora:
mutate(fecha = as.Date(fecha))Receta completa: limpieza de fechas en pacientes_ejemplo.xlsx
library(readxl)library(dplyr)library(janitor)library(lubridate)
# Hoja pacientes: fecha serial → fecha realpacientes <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "pacientes") |> clean_names() |> mutate(fecha_ingreso = as.Date(fecha_ingreso, origin = "1899-12-30"))
# Hoja laboratorio: texto dd/mm/yyyy → fecha reallab <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "laboratorio") |> clean_names() |> mutate(fecha = dmy(fecha))
glimpse(pacientes)glimpse(lab)Después de esto, ambas columnas son <date> y puedes filtrarlas, restarlas o agregarlas con seguridad.
Ejercicio
Ejercicio 8 — Calcular días entre exámenes
- Lee
laboratorioy convierte la columnafechaa<date>condmy(). - Para el paciente
id_paciente == 1, calcula los días que pasaron entre su primer y su segundo examen. - Bonus: cuenta cuántos exámenes hay por mes (usa
month(fecha, label = TRUE)).
Ver solución
library(readxl)library(dplyr)library(janitor)library(lubridate)
lab <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "laboratorio") |> clean_names() |> mutate(fecha = dmy(fecha))
# Días entre primer y segundo examen del paciente 1lab |> filter(id_paciente == 1) |> arrange(fecha) |> mutate(dias_desde_anterior = as.numeric(fecha - lag(fecha)))#> # A tibble: 2 × 5#> id_paciente examen valor fecha dias_desde_anterior#> <dbl> <chr> <dbl> <date> <dbl>#> 1 1 Glicemia 110 2024-01-15 NA#> 2 1 Colesterol 220 2024-01-15 0
# Bonus: exámenes por meslab |> count(mes = month(fecha, label = TRUE))En el paciente 1 ambos exámenes son el mismo día (0 días entre ellos), por eso la segunda fila muestra 0. La primera fila siempre es NA porque no hay “examen anterior”.
Si te quedó <chr> en fecha después del mutate, es porque lubridate no parseó. Confirma el formato del texto original con lab$fecha[1].
Ejercicio 8b — Lidia con un vector mixto de formatos de fecha
En planillas reales, una misma columna puede mezclar varios formatos. Practica con un vector inventado que combina los formatos más comunes en Chile:
- Define el vector:
library(lubridate)fechas_raras <- c("15/01/2024", # latino día/mes/año"2024-01-15", # ISO año-mes-día"2024/01/15", # ISO con barra"31/02/2024", # ¡fecha imposible!"", # vacío"no es fecha", # basura"01-feb-2024" # texto con mes abreviado)
- Intenta parsear con
dmy(). ¿Cuántas convierte? ¿Cuántas dejanNA? - Intenta ahora con
parse_date_time()probando varios formatos:parse_date_time(fechas_raras,orders = c("dmy", "ymd", "d-b-Y"),locale = "es_ES.UTF-8") - ¿Qué pasa con
"31/02/2024"? ¿Y con"no es fecha"?
Ver resultado y análisis
library(lubridate)
fechas_raras <- c("15/01/2024", "2024-01-15", "2024/01/15", "31/02/2024", "", "no es fecha", "01-feb-2024")
dmy(fechas_raras)#> Warning: 5 failed to parse.#> [1] "2024-01-15" NA NA NA#> [5] NA NA NA
parse_date_time(fechas_raras, orders = c("dmy", "ymd", "d-b-Y"), locale = "es_ES.UTF-8")#> Warning: 3 failed to parse.#> [1] "2024-01-15 UTC" "2024-01-15 UTC" "2024-01-15 UTC"#> [4] NA NA NA#> [7] "2024-02-01 UTC"Análisis:
| Entrada | dmy() | parse_date_time() | Comentario |
|---|---|---|---|
"15/01/2024" | ✅ 2024-01-15 | ✅ | dmy es suficiente |
"2024-01-15" | ❌ NA | ✅ 2024-01-15 | dmy() no acepta ISO; necesitas ymd también |
"2024/01/15" | ❌ NA | ✅ 2024-01-15 | el separador / con ymd igual sirve |
"31/02/2024" | ❌ NA | ❌ NA | fecha imposible: no existe el 31 de febrero |
"" | ❌ NA | ❌ NA | vacío |
"no es fecha" | ❌ NA | ❌ NA | basura → NA |
"01-feb-2024" | ❌ NA | ✅ 2024-02-01 | requiere locale español + formato d-b-Y |
Conclusiones prácticas:
parse_date_time()con variosorderses más robusto cuando no sabes el formato exacto.lubridaterechaza fechas imposibles (31/02). Eso es bueno: prefieresNAque un dato inventado.- Para textos como
"01-feb-2024"necesitas configurar el locale del sistema en español. En Windows:locale = "Spanish_Chile.utf8". En Linux/Mac:"es_ES.UTF-8". Si tu sistema no lo soporta, el parseo falla.
Lección operativa: después de convertir fechas, filtra siempre filter(is.na(fecha)) para revisar qué se perdió. Si esperabas 0 NAs y ves 17, todavía tienes datos sucios.
Ejercicio 8c — Calcula edad clínica desde fecha de nacimiento
En epidemiología es muy común tener una columna de fecha de nacimiento y querer calcular la edad en años. Simulemos ese caso:
- Construye un mini-
tibblecon 3 pacientes y sus fechas de nacimiento:library(dplyr)library(lubridate)muestra <- tibble(id = 1:3,nombre = c("Ana", "Luis", "Marta"),fecha_nacimiento = c("1958-03-12", "1990-07-25", "2010-11-30")) - Calcula la edad en años usando
interval()yyears(1):muestra |>mutate(fecha_nacimiento = ymd(fecha_nacimiento),edad = as.integer(interval(fecha_nacimiento, today()) / years(1))) - Compara con la versión “ingenua”
(today() - fecha_nacimiento) / 365. ¿Da lo mismo? ¿Por qué la versión coninterval()es más correcta? - Bonus: filtra los pacientes que sean adultos mayores (>= 65 años) usando la nueva columna
edad.
Ver solución y por qué interval() es más exacto
library(dplyr)library(lubridate)
muestra <- tibble( id = 1:3, nombre = c("Ana", "Luis", "Marta"), fecha_nacimiento = c("1958-03-12", "1990-07-25", "2010-11-30"))
muestra <- muestra |> mutate( fecha_nacimiento = ymd(fecha_nacimiento), edad_exacta = as.integer(interval(fecha_nacimiento, today()) / years(1)), edad_ingenua = as.numeric((today() - fecha_nacimiento) / 365) )
muestra#> # A tibble: 3 × 5#> id nombre fecha_nacimiento edad_exacta edad_ingenua#> <int> <chr> <date> <int> <dbl>#> 1 1 Ana 1958-03-12 68 68.2#> 2 2 Luis 1990-07-25 35 35.8#> 3 3 Marta 2010-11-30 15 15.5
# Bonus: adultos mayoresmuestra |> filter(edad_exacta >= 65)#> # A tibble: 1 × 5#> id nombre fecha_nacimiento edad_exacta edad_ingenua#> 1 1 Ana 1958-03-12 68 68.2¿Por qué interval() es más correcto que / 365?
/ 365asume que un año tiene exactamente 365 días, ignorando los bisiestos. En 60 años acumulas ~15 días extra y la edad puede aparecer “+1 año antes” de tu cumpleaños.interval(nac, today()) / years(1)usa el calendario real: cuenta años completos exactos, igual que un médico cuando dice “tiene 68 años cumplidos”.
Para análisis clínicos y reportes oficiales, siempre usa interval() / years(1). La diferencia parece pequeña pero en estudios con miles de pacientes corre el riesgo de mover gente entre grupos etarios.
Tip
Hoy es 2026-05-18 (fecha de tu sistema en este ejemplo). Si corres este código mañana, las edades cambian solas — porque today() siempre se actualiza. Por eso para análisis reproducibles a veces conviene fijar una fecha de corte: fecha_corte <- ymd("2026-01-01"); interval(fecha_nacimiento, fecha_corte) / years(1).
Valores faltantes: NAs y celdas vacías
Qué es NA en R
NA (de “Not Available”) es la forma en que R representa un valor faltante. No es 0, no es vacío, no es texto. Es un valor especial que dice “aquí no hay dato”. Cuando una celda de Excel viene vacía, read_excel() la convierte en NA.
NA # un NA soloc(1, NA, 3) # vector con un NA en medioAlgo súper importante: cualquier operación matemática con NA da NA. Es R diciéndote “si no sé este número, no puedo saber el resultado”:
NA + 1 # → NAmean(c(1, NA, 3)) # → NAPara hacer cuentas ignorando los NAs, usas na.rm = TRUE:
mean(c(1, NA, 3), na.rm = TRUE) # → 2El problema: NAs disfrazados
En el mundo real, los NAs casi nunca vienen como celdas limpiamente vacías. Vienen disfrazados de:
| Disfraz | Por qué pasa |
|---|---|
"NA" literal | Alguien escribió “NA” como texto. |
"S/I" | Convención de salud: “Sin Información”. |
"-" | Convención de planillas viejas. |
"." | Convención de STATA. |
"NULL" | Convención de algunos exports de bases de datos. |
"N/D" | ”No Disponible”. |
999, -99, 9999 | Códigos numéricos para faltante. |
Espacios (" ") | Celda parece vacía pero tiene un espacio. |
Mientras esos disfraces sigan ahí, las columnas afectadas se leen como texto y todo análisis numérico falla. Este módulo te enseña a desenmascararlos.
Detectar dónde hay NAs
library(readxl)library(dplyr)library(janitor)
pacientes <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "pacientes") |> clean_names()
# ¿Hay algún NA en la tabla?anyNA(pacientes)
# ¿Cuántos NAs por columna?pacientes |> summarise(across(everything(), ~ sum(is.na(.))))
# ¿Qué filas tienen NA en presion_sistolica?pacientes |> filter(is.na(presion_sistolica))import pandas as pd
pacientes = pd.read_excel("datos/pacientes_ejemplo.xlsx", sheet_name="pacientes")
pacientes.isna().any().any() # ¿algún NA?pacientes.isna().sum() # NAs por columnapacientes[pacientes["presion_sistolica"].isna()] # filasAdvertencia
is.na() solo detecta el NA real. Los "S/I" literales no son NA hasta que tú los conviertas. Por eso pacientes |> summarise(across(everything(), ~ sum(is.na(.)))) te puede dar 0 cuando visualmente ves muchos "S/I" en la planilla — están ahí, pero como texto.
Estrategia 1: convertirlos al leer con na = c(...)
La forma más limpia es decirle a read_excel() qué textos considerar como NA:
pacientes <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "pacientes", na = c("", "S/I", "-", "NA", "N/D")) |> clean_names() |> # na= convierte a NA, pero la columna sigue <chr> porque Excel # la guardó como texto. Hay que pasarla a numérica: mutate(presion_sistolica = as.numeric(presion_sistolica))
glimpse(pacientes)import pandas as pd
pacientes = pd.read_excel("datos/pacientes_ejemplo.xlsx", sheet_name="pacientes", na_values=["", "S/I", "-", "NA", "N/D"])Es mi forma preferida porque deja todo limpio desde el inicio.
Estrategia 2: convertirlos después con na_if() o mutate()
Si ya leíste el archivo y no quieres re-leerlo, dplyr::na_if() reemplaza un valor específico por NA:
library(dplyr)
pacientes <- pacientes |> mutate(presion_sistolica = na_if(presion_sistolica, "S/I"), presion_sistolica = na_if(presion_sistolica, "-"))
# Para varios valores a la vez:pacientes |> mutate(presion_sistolica = if_else(presion_sistolica %in% c("S/I", "-", "N/D"), NA_character_, presion_sistolica))pacientes["presion_sistolica"] = pacientes["presion_sistolica"].replace( ["S/I", "-", "N/D"], None)Estrategia 3: códigos numéricos como 999 o -99
Algunos estudios (especialmente epidemiológicos) codifican el faltante con números:
| Valor | Significado |
|---|---|
999 | No respondió |
-99 | No aplica |
9999 | Faltante |
Si los lees como número, van a contar como datos reales en tus promedios. Conviene reemplazarlos:
library(dplyr)
encuesta <- encuesta |> mutate(across(c(presion, glicemia), ~ if_else(. %in% c(-99, 999, 9999), NA_real_, .)))import pandas as pdimport numpy as np
encuesta[["presion", "glicemia"]] = ( encuesta[["presion", "glicemia"]] .replace([-99, 999, 9999], np.nan))Advertencia
Antes de reemplazar 999 por NA, confirma con quien te pasó los datos que 999 es código de faltante y no un valor real. En algunos índices (puntajes), 999 puede ser un valor válido.
Estrategia 4: eliminar filas con drop_na()
Si decides que las filas con NAs en cierta columna no te sirven:
library(tidyr)
# Eliminar filas con NA en cualquier columnapacientes |> drop_na()
# Eliminar filas con NA solo en presion_sistolicapacientes |> drop_na(presion_sistolica)
# Eliminar filas con NA en presion o edadpacientes |> drop_na(presion_sistolica, edad)pacientes.dropna() # cualquier columnapacientes.dropna(subset=["presion_sistolica"]) # solo esaNota
drop_na() puede borrar muchísimas filas si las usas sin pensar. Si tu tabla tiene 100 columnas y aunque sea una está con NA, drop_na() te borra esa fila completa. Especifica qué columnas te importan.
Estrategia 5: rellenar NAs con replace_na() o coalesce()
A veces no quieres borrar la fila, sino imputar un valor por defecto:
library(dplyr)library(tidyr)
pacientes |> mutate(diagnostico = replace_na(diagnostico, "Sin diagnóstico"))
# coalesce: usa el primer valor no-NA de una seriepacientes |> mutate(presion_final = coalesce(presion_sistolica, mean(presion_sistolica, na.rm = TRUE)))pacientes["diagnostico"] = pacientes["diagnostico"].fillna("Sin diagnóstico")pacientes["presion_final"] = pacientes["presion_sistolica"].fillna( pacientes["presion_sistolica"].mean())Imputar NAs no es 'limpiar', es decidir
Rellenar un NA con el promedio, la mediana o un valor por defecto es una decisión analítica que cambia los resultados. Si estás haciendo análisis serio (clínico, epidemiológico), consulta con tu equipo antes de imputar. A veces es mejor reportar “20 pacientes con presión faltante” que esconderlos.
Operaciones que ignoran NAs
Casi todas las funciones de resumen aceptan na.rm = TRUE:
mean(pacientes$presion_sistolica, na.rm = TRUE)sum(pacientes$edad >= 65, na.rm = TRUE)median(pacientes$edad, na.rm = TRUE)En dplyr::summarise():
pacientes |> summarise(presion_media = mean(presion_sistolica, na.rm = TRUE), n_validos = sum(!is.na(presion_sistolica)))Errores comunes con NAs
Error 1: `NA == NA` es `NA`, no `TRUE`
Confundir esto rompe muchos filtros:
NA == NA # → NA (no TRUE)NA == "S/I" # → NA (no FALSE)Para preguntar “¿es NA?”, usa is.na(), nunca == NA:
filter(x == NA) # ❌ no funcionafilter(is.na(x)) # ✅ correctoError 2: mean() sin na.rm devuelve NA
mean(c(1, 2, NA)) # → NAmean(c(1, 2, NA), na.rm = TRUE) # → 1.5Si tu media salió NA, casi seguro te falta na.rm = TRUE.
Error 3: NA en filter() no devuelve la fila
filter(x > 5) deja afuera las filas donde x es NA (porque NA > 5 es NA, no TRUE). Si quieres incluirlas, súmalas explícitamente: filter(x > 5 | is.na(x)).
Error 4: 'el archivo decía vacío pero R ve un espacio'
A veces la celda visualmente está vacía pero tiene un " " (espacio). is.na() te devuelve FALSE. Convierte espacios a NA primero:
mutate(x = na_if(trimws(x), ""))Error 5: confundir NA con NULL
NULL es “no existe nada” (vector vacío), NA es “existe pero no se sabe el valor”. NULL no se puede tener “dentro” de una columna; solo NA.
c(1, NULL, 2) # → c(1, 2): el NULL desaparecec(1, NA, 2) # → c(1, NA, 2): el NA quedaReceta completa: limpiar NAs en pacientes
library(readxl)library(dplyr)library(janitor)library(lubridate)
pacientes <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "pacientes", na = c("", "S/I", "-", "NA")) |> # ← clave clean_names() |> mutate( fecha_ingreso = as.Date(fecha_ingreso, origin = "1899-12-30"), presion_sistolica = as.numeric(presion_sistolica) )
# Reporte de NAspacientes |> summarise(across(everything(), ~ sum(is.na(.))))
# Cuántos pacientes con presión válida tenemospacientes |> filter(!is.na(presion_sistolica)) |> nrow()Ejercicio
Ejercicio 9 — Contar y reportar NAs
- Lee
pacientesconna = c("", "S/I")y aplica la limpieza completa (clean_names, fecha, numérico). - Cuenta cuántos NAs hay por columna.
- Imprime las filas donde
presion_sistolicaes NA. - Calcula la presión sistólica promedio ignorando los NAs.
- Bonus: ¿cuántos pacientes >= 65 años tienen presión válida (no NA)?
Ver solución
library(readxl)library(dplyr)library(janitor)
pacientes <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "pacientes", na = c("", "S/I")) |> clean_names() |> mutate( fecha_ingreso = as.Date(fecha_ingreso, origin = "1899-12-30"), presion_sistolica = as.numeric(presion_sistolica) )
# 2. NAs por columnapacientes |> summarise(across(everything(), ~ sum(is.na(.))))
# 3. Filas con presión NApacientes |> filter(is.na(presion_sistolica))
# 4. Promedio ignorando NAsmean(pacientes$presion_sistolica, na.rm = TRUE)
# 5. Bonus: pacientes >= 65 con presión válidapacientes |> filter(edad >= 65, !is.na(presion_sistolica)) |> nrow()Si tu output muestra 0 NAs en presion_sistolica, te faltó el na = c("", "S/I") al leer. Si te dio NA al calcular el promedio, te faltó el na.rm = TRUE.
Ejercicio 9b — Detecta códigos numéricos de faltante (999, -99)
En estudios epidemiológicos chilenos es común codificar el faltante como 999 o -99 en columnas numéricas. Si no los conviertes a NA, van a sumar al promedio y contaminar tu análisis.
- Construye una tabla ficticia tipo encuesta:
library(dplyr)encuesta <- tibble(id = 1:6,edad = c(45, 32, 999, 58, 67, -99),glicemia = c(110, 145, 102, 999, 88, 95),adherencia_pct = c(80, 75, -99, 90, 85, 70))
- Calcula el promedio “ingenuo” de cada columna sin convertir los códigos:
¿Qué pasa consummary(encuesta)
edad_meanyglicemia_mean? - Reemplaza
999y-99porNAen las tres columnas usandomutate(across(...)). - Vuelve a calcular
summary(). Comenta el cambio.
Ver el desastre y la solución
library(dplyr)
encuesta <- tibble( id = 1:6, edad = c(45, 32, 999, 58, 67, -99), glicemia = c(110, 145, 102, 999, 88, 95), adherencia_pct = c(80, 75, -99, 90, 85, 70))
# Sin limpiarsummary(encuesta)#> edad glicemia adherencia_pct#> Min. :-99.0 Min. : 88.0 Min. :-99.00#> Mean :183.5 Mean :256.5 Mean : 50.17 ← TODOS están MAL#> Max. :999.0 Max. :999.0 Max. : 90.00Catástrofe: la edad media sale 183 años y la glicemia 256 por culpa de los códigos.
# Con limpiezaencuesta_limpia <- encuesta |> mutate(across(c(edad, glicemia, adherencia_pct), ~ if_else(. %in% c(-99, 999), NA_real_, .)))
summary(encuesta_limpia)#> edad glicemia adherencia_pct#> Min. :32.00 Min. : 88.0 Min. :70.00#> Mean :50.50 Mean :108.0 Mean :80.00 ← correcto#> Max. :67.00 Max. :145.0 Max. :90.00#> NA's :2 NA's :1 NA's :1Ahora los promedios son realistas y summary() te dice cuántos NA tienes por columna.
Lección clave:
- Antes de calcular cualquier promedio, revisa si la fuente codifica el faltante con números (preguntando o leyendo el diccionario de variables).
mutate(across())es la forma idiomática de aplicar el mismo reemplazo a varias columnas en una sola línea.- El warning de R nunca te avisa de este problema: el código corre limpio aunque los promedios estén locos. Tu única defensa es mirar los rangos con
summary()orange()antes de confiar en una media.
Advertencia
Antes de reemplazar 999 con NA confirma con el origen del dato que 999 realmente es código de faltante. En algunos puntajes psicométricos 999 puede ser un valor válido (improbable, pero posible). Pregunta antes de borrar.
Ejercicio 9c — `drop_na()` vs `replace_na()`: cuándo cada uno
Hay dos estrategias muy distintas frente a un NA: eliminar la fila o imputar un valor. Practiquemos las dos:
- Lee y limpia
pacientes:library(readxl)library(dplyr)library(janitor)library(tidyr)pacientes <- read_excel("datos/pacientes_ejemplo.xlsx",sheet = "pacientes",na = c("", "S/I")) |>clean_names() |>mutate(presion_sistolica = as.numeric(presion_sistolica)) - Estrategia A — Eliminar: crea
pac_sin_nacondrop_na(presion_sistolica). ¿Cuántos pacientes quedan? - Estrategia B — Imputar: crea
pac_imputadoreemplazando los NA por la mediana de presión:pac_imputado <- pacientes |>mutate(presion_sistolica = replace_na(presion_sistolica,median(presion_sistolica, na.rm = TRUE))) - Calcula el
mean(presion_sistolica)en las dos tablas y compáralo con el promedio de la tabla original (conna.rm = TRUE). ¿Cuál es más conservador? - Pregunta de discusión: si tu jefe te pide “el promedio de presión sistólica de los pacientes”, ¿qué reportas? ¿
drop_na,replace_nao el original conna.rm = TRUE?
Ver comparación y recomendación
library(readxl)library(dplyr)library(janitor)library(tidyr)
pacientes <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "pacientes", na = c("", "S/I")) |> clean_names() |> mutate(presion_sistolica = as.numeric(presion_sistolica))
# A: eliminarpac_sin_na <- pacientes |> drop_na(presion_sistolica)nrow(pac_sin_na)#> [1] 17 ← perdimos 3 pacientes
# B: imputar con medianapac_imputado <- pacientes |> mutate(presion_sistolica = replace_na( presion_sistolica, median(presion_sistolica, na.rm = TRUE) ))
# Comparar promediosmean(pacientes$presion_sistolica, na.rm = TRUE)#> [1] 142.4mean(pac_sin_na$presion_sistolica)#> [1] 142.4 ← idéntico al original con na.rmmean(pac_imputado$presion_sistolica)#> [1] 142.1 ← se acerca a la mediana, levemente menorAnálisis:
drop_na()ymean(..., na.rm = TRUE)dan el mismo número matemáticamente. La diferencia es quedrop_na()deja la tabla más chica de forma permanente.replace_na()con la mediana introduce 3 valores artificiales y baja levemente la media. Estás “inventando” datos.
Qué reportar al jefe — el principio honesto:
“El promedio de presión sistólica es 142.4 mmHg, calculado sobre 17 pacientes con presión registrada. 3 pacientes no tienen el dato disponible (
S/Ien la planilla original).”
Nunca reportas el imputado sin decirlo. Imputar puede tener sentido para modelos predictivos avanzados, pero para un reporte clínico/gerencial siempre prefieres ser transparente sobre los faltantes.
Advertencia
Reglas operativas:
- Para reportes: usa
na.rm = TRUEy reporta explícitamente cuántos faltantes hay. - Para modelos predictivos: la imputación es una decisión metodológica que se documenta y se valida.
- Nunca mezcles datos imputados con originales sin marcarlos con una columna (
fue_imputado = TRUE).
Manipular con el pipe nativo |>
Por qué el pipe nativo |> cambia todo
Hasta aquí hemos hecho transformaciones simples, paso a paso, asignando resultados intermedios. Pero el análisis real implica encadenar muchas operaciones: filtrar, agrupar, resumir, ordenar. Hacerlo con variables intermedias se vuelve un caos:
# Sin pipe — feo y ruidosopaso1 <- filter(pacientes, edad >= 65)paso2 <- group_by(paso1, diagnostico)paso3 <- summarise(paso2, n = n(), pas_media = mean(presion_sistolica, na.rm = TRUE))paso4 <- arrange(paso3, desc(n))paso4Con el pipe |> queda como una receta legible de arriba a abajo:
pacientes |> filter(edad >= 65) |> group_by(diagnostico) |> summarise(n = n(), pas_media = mean(presion_sistolica, na.rm = TRUE)) |> arrange(desc(n))Léelo así: “toma pacientes, luego filtra edad ≥ 65, luego agrupa por diagnóstico, luego resume…”. Cada |> significa “y después…”.
¿`|>` o `%>%`?
%>% es el pipe del paquete magrittr (originalmente de dplyr). Es válido y funcional, pero ahora R 4.1+ tiene su propio pipe nativo |> y es la opción moderna. Funcionan casi idénticamente; la diferencia para principiantes es nula. En este curso usamos solo |>.
Las 6 verbos esenciales de dplyr
dplyr (que también ya instalamos) tiene un set pequeño de funciones que vas a usar el 90% del tiempo:
| Verbo | Qué hace |
|---|---|
select() | Elegir columnas. |
filter() | Elegir filas según condición. |
mutate() | Crear o modificar columnas. |
arrange() | Ordenar filas. |
group_by() + summarise() | Agrupar y resumir. |
count() | Contar combinaciones (atajo de group_by() + summarise(n = n())). |
select() — elegir columnas
library(readxl)library(dplyr)library(janitor)library(lubridate)
pacientes <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "pacientes", na = c("", "S/I")) |> clean_names() |> mutate( fecha_ingreso = as.Date(fecha_ingreso, origin = "1899-12-30"), presion_sistolica = as.numeric(presion_sistolica) )
# Solo id, edad y diagnósticopacientes |> select(id, edad, diagnostico)
# Todas excepto nombre_completopacientes |> select(-nombre_completo)
# Rango: desde edad hasta presion_sistolicapacientes |> select(edad:presion_sistolica)import pandas as pd
pacientes = pd.read_excel("datos/pacientes_ejemplo.xlsx", sheet_name="pacientes", na_values=["", "S/I"])
pacientes[["id", "edad", "diagnostico"]]pacientes.drop(columns=["nombre_completo"])filter() — elegir filas
# Solo mayores de 65pacientes |> filter(edad > 65)
# Múltiples condiciones (AND con coma o &)pacientes |> filter(edad >= 65, diagnostico == "Hipertensión")
# OR con |pacientes |> filter(diagnostico == "Hipertensión" | diagnostico == "EPOC")
# %in% para muchos valorespacientes |> filter(diagnostico %in% c("Hipertensión", "EPOC", "Asma"))
# Excluir NAspacientes |> filter(!is.na(presion_sistolica))pacientes[pacientes["edad"] > 65]pacientes[(pacientes["edad"] >= 65) & (pacientes["diagnostico"] == "Hipertensión")]pacientes[pacientes["diagnostico"].isin(["Hipertensión", "EPOC", "Asma"])]pacientes[pacientes["presion_sistolica"].notna()]mutate() — crear o modificar columnas
pacientes |> mutate( edad_categoria = case_when( edad < 18 ~ "Menor", edad < 65 ~ "Adulto", TRUE ~ "Adulto mayor" ), hipertenso = presion_sistolica >= 140 )import numpy as np
pacientes = pacientes.assign( edad_categoria = np.select( [pacientes["edad"] < 18, pacientes["edad"] < 65], ["Menor", "Adulto"], default="Adulto mayor" ), hipertenso = pacientes["presion_sistolica"] >= 140)case_when() es como un “if-else encadenado”: evalúa de arriba abajo y devuelve el primer caso que coincida. El TRUE ~ ... final es el “caso por defecto”.
arrange() — ordenar
pacientes |> arrange(edad) # menor a mayorpacientes |> arrange(desc(edad)) # mayor a menorpacientes |> arrange(diagnostico, desc(edad)) # primero por dx, luego edad descpacientes.sort_values("edad")pacientes.sort_values("edad", ascending=False)pacientes.sort_values(["diagnostico", "edad"], ascending=[True, False])group_by() + summarise() — agrupar y resumir
Acá está la potencia de dplyr: resumir por grupo en una línea legible.
pacientes |> group_by(diagnostico) |> summarise( n = n(), edad_media = mean(edad, na.rm = TRUE), pas_media = mean(presion_sistolica, na.rm = TRUE) ) |> arrange(desc(n))(pacientes .groupby("diagnostico") .agg(n=("id", "size"), edad_media=("edad", "mean"), pas_media=("presion_sistolica", "mean")) .reset_index() .sort_values("n", ascending=False))Resultado (asumiendo que ya hiciste la limpieza de presion_sistolica que viste en el módulo 9 — na = c("","S/I") + as.numeric()):
# A tibble: 4 × 4 diagnostico n edad_media pas_media <chr> <int> <dbl> <dbl>1 Hipertensión 8 64.9 1512 Diabetes tipo 2 5 46.8 142.3 Asma 4 27.5 116.4 EPOC 3 68.3 154.Advertencia
Si te dio NA en pas_media, te falta la limpieza: la columna sigue siendo texto. Vuelve al módulo 9 y aplica na = c("","S/I") al leer y as.numeric(presion_sistolica) al limpiar.
Tip
n() dentro de summarise() da la cantidad de filas del grupo. Es el atajo más común.
count() — atajo para contar
count(col) es equivalente a group_by(col) |> summarise(n = n()):
pacientes |> count(diagnostico, sort = TRUE)
# Con dos columnas: cuenta combinacionespacientes |> count(diagnostico, sexo, sort = TRUE)pacientes["diagnostico"].value_counts()pacientes.groupby(["diagnostico", "sexo"]).size().reset_index(name="n")Unir tablas con *_join()
Si tienes pacientes y laboratorio y quieres unir cada examen con los datos del paciente:
library(dplyr)
lab <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "laboratorio") |> clean_names()
pacientes_con_lab <- pacientes |> left_join(lab, by = c("id" = "id_paciente"))
pacientes_con_lab |> select(id, nombre_completo, examen, valor, fecha)lab = pd.read_excel("datos/pacientes_ejemplo.xlsx", sheet_name="laboratorio")
pacientes_con_lab = pacientes.merge( lab, left_on="id", right_on="id_paciente", how="left")Tipos de join:
left_join← deja todas las filas de la izquierda; rellena con NA donde no hay match.inner_join← solo filas con match en ambos lados.full_join← todas las filas de ambos lados.anti_join← filas de la izquierda sin match en la derecha (útil para detectar inconsistencias).
Reorganizar formato: ancho ↔ largo con pivot_*
A veces el formato no es el ideal. Los datos REM, por ejemplo, vienen “anchos” (un mes por columna). Para graficar o analizar conviene alargarlos:
library(tidyr)
rem <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "resumen_REM", skip = 2)
rem_largo <- rem |> pivot_longer(cols = enero:junio, names_to = "mes", values_to = "atenciones")
rem_largo#> # A tibble: 24 × 3#> indicador mes atenciones#> <chr> <chr> <dbl>#> 1 Controles HTA enero 420#> 2 Controles HTA febrero 445#> ...import pandas as pd
rem = pd.read_excel("datos/pacientes_ejemplo.xlsx", sheet_name="resumen_REM", skiprows=2)
rem_largo = rem.melt(id_vars="indicador", value_vars=["enero","febrero","marzo","abril","mayo","junio"], var_name="mes", value_name="atenciones")Y la operación inversa, ancho desde largo, con pivot_wider():
rem_largo |> pivot_wider(names_from = mes, values_from = atenciones)Nota
Regla mental: los datos largos son los amigos de los gráficos y de dplyr. Los anchos son los amigos de Excel y de los reportes para humanos. Aprende a moverte entre ambos.
Receta completa: análisis end-to-end de pacientes
library(readxl)library(dplyr)library(janitor)library(lubridate)
# 1. Importar y limpiarpacientes <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "pacientes", na = c("", "S/I")) |> clean_names() |> mutate( fecha_ingreso = as.Date(fecha_ingreso, origin = "1899-12-30"), presion_sistolica = as.numeric(presion_sistolica) )
# 2. Resumen por diagnóstico y sexoresumen <- pacientes |> filter(!is.na(presion_sistolica)) |> group_by(diagnostico, sexo) |> summarise( n = n(), edad_media = mean(edad), pas_media = mean(presion_sistolica), .groups = "drop" # quita el agrupado al final ) |> arrange(diagnostico, sexo)
resumenErrores comunes con dplyr
Error 1: `Error: object 'edad' not found`
Causa: intentaste usar edad antes de cargar la tabla, o edad no existe (revisa los nombres exactos con names(pacientes)).
Error 2: `summarise()` regrouping output
Después de group_by(x) |> summarise(...), dplyr emite un mensaje informativo. No es un error. Te avisa que el grupo x quedó vigente (o no) tras el summarise. Para evitar el mensaje, agrega .groups = "drop":
pacientes |> group_by(diagnostico) |> summarise(n = n(), .groups = "drop")Error 3: `filter(edad = 65)`
Confundir = con ==. = es asignación, == es comparación.
filter(edad = 65) # ❌ errorfilter(edad == 65) # ✅ correctoError 4: pipe que rompe sin error claro
Si entre dos |> falta una función, R no se queja y devuelve cosas raras:
pacientes |> filter(edad > 65) select(id, edad) # ❌ falta |> arribaAsegúrate que cada línea termine en |> excepto la última.
Error 5: `select()` y `MASS::select()` peleándose
Si cargas el paquete MASS o algún otro que tiene select, puede tapar el de dplyr. Síntoma: Error in select(...): unused argument. Solución: llamar explícitamente dplyr::select(...).
Ejercicio
Ejercicio 10 — Análisis completo
- Lee y limpia
pacientes(igual que la receta de arriba). - Filtra solo pacientes >= 50 años con presión válida (
!is.na). - Agrupa por
diagnosticoy calculan,edad_mediaypas_media. - Ordena por
pas_mediadescendente. - Bonus: une con
laboratorioy muestra cuántos exámenes tiene cada paciente >= 50.
Ver solución
library(readxl)library(dplyr)library(janitor)
pacientes <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "pacientes", na = c("", "S/I")) |> clean_names() |> mutate( fecha_ingreso = as.Date(fecha_ingreso, origin = "1899-12-30"), presion_sistolica = as.numeric(presion_sistolica) )
# 2-4pacientes |> filter(edad >= 50, !is.na(presion_sistolica)) |> group_by(diagnostico) |> summarise( n = n(), edad_media = mean(edad), pas_media = mean(presion_sistolica), .groups = "drop" ) |> arrange(desc(pas_media))
# 5. Bonus: con laboratoriolab <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "laboratorio") |> clean_names()
pacientes |> filter(edad >= 50) |> left_join(lab, by = c("id" = "id_paciente")) |> group_by(id, nombre_completo) |> summarise(n_examenes = sum(!is.na(examen)), .groups = "drop") |> arrange(desc(n_examenes))Si te dio un error de “object not found”, revisa los nombres con names(pacientes). Si el join no encontró matches, asegúrate de que las columnas clave tengan el mismo tipo (is.numeric(pacientes$id) y is.numeric(lab$id_paciente)).
Ejercicio 10b — Convierte el REM ancho en largo y calcula totales
La hoja resumen_REM viene en formato ancho (un mes por columna). Es práctica para humanos pero terrible para dplyr. Vamos a alargarla y resumir:
- Lee
resumen_REMconskip = 2. - Aplica
pivot_longer()para llevarla a formato largo (una fila por indicador y mes). - Calcula el total anual por indicador.
- Calcula el mes con más atenciones para cada indicador (pista:
group_by(indicador) |> slice_max(atenciones, n = 1)). - Bonus: vuelve al formato ancho con
pivot_wider()y verifica que obtienes la tabla original.
Ver solución
library(readxl)library(dplyr)library(tidyr)
rem <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "resumen_REM", skip = 2)
# 2. Ancho → largorem_largo <- rem |> pivot_longer(cols = enero:junio, names_to = "mes", values_to = "atenciones")
rem_largo#> # A tibble: 24 × 3#> indicador mes atenciones#> <chr> <chr> <dbl>#> 1 Controles HTA enero 420#> 2 Controles HTA febrero 445#> ...
# 3. Total anual por indicadorrem_largo |> group_by(indicador) |> summarise(total_anual = sum(atenciones), .groups = "drop") |> arrange(desc(total_anual))#> # A tibble: 4 × 2#> indicador total_anual#> <chr> <dbl>#> 1 Vacunaciones 3595#> 2 Controles HTA 2798#> 3 Controles DM 2067#> 4 Controles EPOC 650
# 4. Mes peak de cada indicadorrem_largo |> group_by(indicador) |> slice_max(atenciones, n = 1) |> ungroup()#> # A tibble: 4 × 3#> indicador mes atenciones#> <chr> <chr> <dbl>#> 1 Controles DM junio 372#> 2 Controles EPOC junio 120#> 3 Controles HTA junio 505#> 4 Vacunaciones junio 640
# 5. Bonus: largo → anchorem_largo |> pivot_wider(names_from = mes, values_from = atenciones)# debería verse igual que rem originalLección clave: los datos largos son ideales para group_by(), summarise() y ggplot2 (próximos cursos). Los anchos son ideales para reportes Excel para humanos. Aprender a moverte entre ambos con pivot_longer() y pivot_wider() es uno de los superpoderes de tidyverse.
Tip
La regla “una observación por fila, una variable por columna” se llama tidy data. Cuando los datos están en formato tidy (largo en este caso), prácticamente cualquier operación de dplyr/ggplot fluye natural. Si te encuentras peleando con mutate() y summarise(), probablemente lo que necesitas no es más código, sino pivotar primero.
Ejercicio 10c — `anti_join` para detectar pacientes sin exámenes
Cuando tienes dos tablas relacionadas (pacientes y laboratorio), una pregunta súper frecuente es: ¿quién falta?. anti_join() te lo responde en una línea.
- Carga y limpia
pacientesylaboratorio. - Encuentra los pacientes que están en la tabla
pacientespero no tienen ningún examen enlaboratorio:pacientes |>anti_join(lab, by = c("id" = "id_paciente")) - Inversamente, encuentra los registros de
laboratoriocuyoid_pacienteno existe enpacientes(pacientes “fantasma”). Pista: invierte las tablas en elanti_join. - Reporta cuántos pacientes hay en cada categoría.
- Bonus: usa
count()conleft_join+summarisepara mostrar la cantidad de exámenes por paciente (incluyendo los que tienen 0).
Ver solución y por qué anti_join es oro
library(readxl)library(dplyr)library(janitor)library(lubridate)
pacientes <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "pacientes", na = c("", "S/I")) |> clean_names() |> mutate( fecha_ingreso = as.Date(fecha_ingreso, origin = "1899-12-30"), presion_sistolica = as.numeric(presion_sistolica) )
lab <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "laboratorio") |> clean_names() |> mutate(fecha = dmy(fecha))
# 2. Pacientes sin exámenessin_examenes <- pacientes |> anti_join(lab, by = c("id" = "id_paciente"))
sin_examenes |> select(id, nombre_completo, diagnostico)#> # A tibble: 2 × 3#> id nombre_completo diagnostico#> <dbl> <chr> <chr>#> 1 19 Emilia Lara Diabetes tipo 2#> 2 20 Agustín Núñez Hipertensión
# 3. Exámenes de pacientes "fantasma"lab |> anti_join(pacientes, by = c("id_paciente" = "id"))#> # A tibble: 0 × 4# En este archivo no hay fantasmas — está consistente.
# 4. Conteo por categoríanrow(sin_examenes)#> [1] 2
# 5. Bonus: cantidad de exámenes por pacientepacientes |> left_join(lab |> count(id_paciente, name = "n_examenes"), by = c("id" = "id_paciente")) |> mutate(n_examenes = coalesce(n_examenes, 0L)) |> select(id, nombre_completo, n_examenes) |> arrange(desc(n_examenes))#> # A tibble: 20 × 3#> id nombre_completo n_examenes#> <dbl> <chr> <int>#> 1 1 María Pérez 2#> 2 3 Ana Díaz 2#> 3 2 Juan Soto 1#> ...#> 19 19 Emilia Lara 0#> 20 20 Agustín Núñez 0Por qué anti_join es indispensable en datos de salud:
- Detectar pacientes mal vinculados: un
id_pacienteque aparece en lab pero no en pacientes indica un error de digitación o un paciente que se borró por error. - Auditar cobertura: si tu programa de control de hipertensión “atendió a 100 pacientes” pero el laboratorio solo tiene exámenes de 87, el
anti_jointe muestra exactamente quiénes no tienen examen. - Encontrar inconsistencias entre dos sistemas: REM vs SIDRA, registro local vs estadística nacional, etc.
Lección: anti_join(tabla_A, tabla_B) se lee como “filas de A que no tienen match en B”. Es una herramienta de auditoría que vas a usar muchísimo. Mantenla siempre cerca cuando trabajes con dos tablas relacionadas.
Exportar a Excel con openxlsx
El final del ciclo
Hasta ahora hemos importado, limpiado y manipulado. La última etapa es devolver el resultado — porque el equipo, la jefatura o tu colega quieren un .xlsx, no un objeto en memoria que solo tú ves.
Para escribir Excel desde R vamos a usar openxlsx. No requiere Java (a diferencia de xlsx), funciona en Windows, Mac y Linux, y permite formato (negrita, colores, bordes, anchos de columna). Es la elección ganadora en 2026.
Lo más simple: una tabla en una hoja
library(openxlsx)
resumen <- data.frame( diagnostico = c("Hipertensión", "Diabetes tipo 2", "EPOC", "Asma"), n = c(8, 5, 4, 3), pas_media = c(150.2, 143.0, 154.5, 115.0))
write.xlsx(resumen, "resumen.xlsx")import pandas as pd
resumen = pd.DataFrame({ "diagnostico": ["Hipertensión", "Diabetes tipo 2", "EPOC", "Asma"], "n": [8, 5, 4, 3], "pas_media": [150.2, 143.0, 154.5, 115.0],})
resumen.to_excel("resumen.xlsx", index=False)Eso es todo. write.xlsx() crea el archivo con la tabla en una hoja llamada "Sheet 1". Si te bastara, has terminado.
Múltiples hojas en un mismo archivo
Cuando write.xlsx() recibe una lista nombrada, cada elemento se convierte en una hoja con su nombre:
library(openxlsx)
# Tablas de ejemplo armadas a mano (valores ilustrativos,# no calculados del archivo del curso).pacientes_resumen <- data.frame( diagnostico = c("Hipertensión", "Diabetes tipo 2"), n = c(8, 5))
laboratorio_resumen <- data.frame( examen = c("Glicemia", "Colesterol", "Hemoglobina"), n = c(10, 5, 5))
write.xlsx( list( pacientes = pacientes_resumen, laboratorio = laboratorio_resumen ), "informe.xlsx")with pd.ExcelWriter("informe.xlsx") as writer: pacientes_resumen.to_excel(writer, sheet_name="pacientes", index=False) laboratorio_resumen.to_excel(writer, sheet_name="laboratorio", index=False)El archivo informe.xlsx queda con dos pestañas: pacientes y laboratorio.
Flujo completo: createWorkbook → addWorksheet → writeData → saveWorkbook
Para tener control fino (formato, estilos, anchos, congelar filas), usas el flujo más explícito de openxlsx:
library(openxlsx)
# 1. Crear el librowb <- createWorkbook()
# 2. Agregar hojasaddWorksheet(wb, "pacientes")addWorksheet(wb, "laboratorio")
# 3. Escribir datos en cada hojawriteData(wb, sheet = "pacientes", x = pacientes_resumen)writeData(wb, sheet = "laboratorio", x = laboratorio_resumen)
# 4. GuardarsaveWorkbook(wb, "informe.xlsx", overwrite = TRUE)with pd.ExcelWriter("informe.xlsx", engine="openpyxl") as writer: pacientes_resumen.to_excel(writer, sheet_name="pacientes", index=False) laboratorio_resumen.to_excel(writer, sheet_name="laboratorio", index=False)Este flujo es el que te permite todo lo que viene a continuación.
Estilos: negrita, colores, bordes
createStyle() define un estilo; addStyle() lo aplica a un rango.
library(openxlsx)
wb <- createWorkbook()addWorksheet(wb, "informe")writeData(wb, "informe", pacientes_resumen)
# Estilo para la fila de encabezadoestilo_header <- createStyle( textDecoration = "bold", fontColour = "white", fgFill = "#107C41", # verde Excel halign = "center", border = "Bottom", borderColour = "white")
addStyle(wb, sheet = "informe", style = estilo_header, rows = 1, cols = 1:ncol(pacientes_resumen), gridExpand = TRUE)
saveWorkbook(wb, "informe_estilado.xlsx", overwrite = TRUE)Nota
Para los rangos en addStyle():
rows = 1← la primera fila.cols = 1:3← las primeras 3 columnas.gridExpand = TRUE← aplica el estilo a todas las combinaciones (rejilla).
Anchos de columna ajustados al contenido
Por defecto, todas las columnas miden 8.43 (el ancho clásico de Excel) y los textos largos se ven cortados. Ajusta así:
setColWidths(wb, sheet = "informe", cols = 1:ncol(pacientes_resumen), widths = "auto") # se ajusta al contenidoO un ancho fijo:
setColWidths(wb, sheet = "informe", cols = 1, widths = 25)Congelar la primera fila
Para que la fila de encabezado quede fija mientras se hace scroll:
freezePane(wb, sheet = "informe", firstRow = TRUE)
# O congelar primera fila y primera columna:freezePane(wb, sheet = "informe", firstActiveRow = 2, firstActiveCol = 2)Destacar valores según condición (formato condicional)
Hasta aquí los estilos son fijos: el encabezado verde, las columnas anchas. Pero muchas veces lo que realmente ayuda a la jefatura es destacar visualmente los valores fuera de rango: presión alta en rojo, edad pediátrica en azul, fechas vencidas en amarillo. Hay dos enfoques para lograrlo.
Enfoque 1: addStyle() con índices calculados desde los datos
Calculas en R qué filas/celdas cumplen la condición y aplicas el estilo a esos índices exactos. Tiene la ventaja de que el archivo .xlsx queda con el formato “quemado” (no depende de Excel para evaluar la fórmula).
library(openxlsx)library(dplyr)
# Tabla de ejemplopacientes <- data.frame( id = 1:5, paciente = c("María Pérez", "Juan Soto", "Ana Díaz", "Luis Rojas", "Eva Lara"), edad = c(67, 45, 4, 72, 58), presion = c(145, 175, 95, 165, 138))
wb <- createWorkbook()addWorksheet(wb, "pacientes")writeData(wb, "pacientes", pacientes)
# Estilo para presión alta (rojo)estilo_alerta <- createStyle(fontColour = "white", fgFill = "#C00000", textDecoration = "bold")
# ¿Qué filas tienen presion > 160? (sumamos 1 porque la fila 1 es encabezado)filas_alerta <- which(pacientes$presion > 160) + 1col_presion <- which(names(pacientes) == "presion")
addStyle(wb, "pacientes", style = estilo_alerta, rows = filas_alerta, cols = col_presion, gridExpand = TRUE)
saveWorkbook(wb, "alerta_presion.xlsx", overwrite = TRUE)Al abrir el archivo, las celdas con presion = 175 y presion = 165 se ven en rojo con texto blanco. Lo que destacas, lo decides en R, no en Excel.
Enfoque 2: conditionalFormatting() — formato evaluado por Excel
openxlsx::conditionalFormatting() escribe en el archivo una regla que Excel evalúa al abrirlo. Es ideal cuando el destinatario va a editar la planilla (los valores actualizados se vuelven a colorear automáticamente):
library(openxlsx)
wb <- createWorkbook()addWorksheet(wb, "pacientes")writeData(wb, "pacientes", pacientes)
# Regla: si presion > 160, fondo rojo + texto blancoestilo_alto <- createStyle(fontColour = "white", fgFill = "#C00000")
conditionalFormatting(wb, "pacientes", cols = which(names(pacientes) == "presion"), rows = 2:(nrow(pacientes) + 1), rule = ">160", style = estilo_alto)
# Una segunda regla en la misma columna: 140-160 en amarillo (alerta intermedia)estilo_medio <- createStyle(fgFill = "#FFD966")
conditionalFormatting(wb, "pacientes", cols = which(names(pacientes) == "presion"), rows = 2:(nrow(pacientes) + 1), type = "between", rule = c(140, 160), # ← límites inferior y superior style = estilo_medio)
saveWorkbook(wb, "presion_con_reglas.xlsx", overwrite = TRUE)Alternar colores por fila (filas zebra)
Para tablas largas, alternar colores entre filas mejora la lectura:
estilo_par <- createStyle(fgFill = "#F2F2F2") # gris muy suaveestilo_impar <- createStyle(fgFill = "#FFFFFF")
filas_datos <- 2:(nrow(pacientes) + 1)
addStyle(wb, "pacientes", estilo_par, rows = filas_datos[filas_datos %% 2 == 0], cols = 1:ncol(pacientes), gridExpand = TRUE)Destacar una fila entera según una columna
Caso clásico: pintar toda la fila de un paciente cuyo edad < 18 en azul claro (pediátrico):
estilo_pediatrico <- createStyle(fgFill = "#DDEBF7")
filas_pediatricas <- which(pacientes$edad < 18) + 1
addStyle(wb, "pacientes", estilo_pediatrico, rows = filas_pediatricas, cols = 1:ncol(pacientes), gridExpand = TRUE)Cuándo usar cada enfoque
addStyle()con índices → cuando el archivo es un informe final y el destinatario no lo va a editar. El color queda “quemado” en las celdas exactas.conditionalFormatting()→ cuando el destinatario va a editar el archivo (cambia valores, agrega filas) y quieres que las reglas sigan funcionando. Las reglas viven en el archivo y Excel las re-evalúa.
Para reportes de salud que solo se entregan (no se editan), prefiero addStyle(): es más predecible y no depende de la versión de Excel del destinatario.
Bordes en toda la tabla
Por defecto la tabla exportada no tiene bordes. Para agregarles:
estilo_celda <- createStyle(border = "TopBottomLeftRight", borderColour = "#BFBFBF", borderStyle = "thin")
addStyle(wb, "pacientes", estilo_celda, rows = 1:(nrow(pacientes) + 1), cols = 1:ncol(pacientes), gridExpand = TRUE, stack = TRUE) # ← combina con estilos ya aplicadosstack = TRUE es importante: sin él, el estilo nuevo reemplaza los anteriores (perderías el header verde, los colores condicionales, etc.). Con stack = TRUE se combina con lo que ya hay.
Receta completa: informe profesional desde cero
Pongamos todo junto en un caso real: tomamos pacientes_ejemplo.xlsx, lo procesamos, y exportamos un informe ejecutivo con tres hojas y formato.
library(readxl)library(openxlsx)library(dplyr)library(janitor)library(lubridate)
# 1. Importar y limpiarpacientes <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "pacientes", na = c("", "S/I")) |> clean_names() |> mutate( fecha_ingreso = as.Date(fecha_ingreso, origin = "1899-12-30"), presion_sistolica = as.numeric(presion_sistolica) )
# 2. Tres resúmenespor_diagnostico <- pacientes |> group_by(diagnostico) |> summarise(n = n(), edad_media = round(mean(edad), 1), pas_media = round(mean(presion_sistolica, na.rm = TRUE), 1), .groups = "drop") |> arrange(desc(n))
por_sexo <- pacientes |> group_by(sexo) |> summarise(n = n(), edad_media = round(mean(edad), 1), .groups = "drop")
por_mes <- pacientes |> mutate(mes = month(fecha_ingreso, label = TRUE, abbr = FALSE)) |> count(mes, name = "ingresos")
# 3. Construir el librowb <- createWorkbook()hojas <- c("por_diagnostico", "por_sexo", "por_mes")datos <- list(por_diagnostico, por_sexo, por_mes)
estilo_header <- createStyle(textDecoration = "bold", fontColour = "white", fgFill = "#107C41", halign = "center")
for (i in seq_along(hojas)) { addWorksheet(wb, hojas[i]) writeData(wb, hojas[i], datos[[i]]) addStyle(wb, hojas[i], estilo_header, rows = 1, cols = 1:ncol(datos[[i]]), gridExpand = TRUE) setColWidths(wb, hojas[i], cols = 1:ncol(datos[[i]]), widths = "auto") freezePane(wb, hojas[i], firstRow = TRUE)}
# 4. GuardarsaveWorkbook(wb, "informe_pacientes.xlsx", overwrite = TRUE)Cuando lo abras en Excel, vas a ver tres pestañas, cada una con encabezado verde en negrita, columnas anchas y la primera fila congelada. Eso es entregable.
Errores comunes al exportar
Error 1: `Error: zip file ... cannot be opened`
Causa: el archivo de destino está abierto en Excel mientras tú intentas sobreescribirlo.
Solución: ciérralo en Excel. Es la causa #1 de errores al exportar.
Error 2: `Error: File already exists`
Causa: olvidaste overwrite = TRUE. Por defecto openxlsx no sobreescribe sin que se lo digas, para evitar accidentes.
Solución:
saveWorkbook(wb, "informe.xlsx", overwrite = TRUE)Error 3: 'la fecha aparece como 45292 en Excel'
Causa: escribiste una columna numérica con valores de fecha pero openxlsx no sabe que son fechas.
Solución: asegúrate de que la columna sea <date> antes de escribir. Si quieres aplicar formato explícito:
addStyle(wb, "informe", style = createStyle(numFmt = "dd/mm/yyyy"), rows = 2:(nrow(datos)+1), cols = columna_fecha, gridExpand = TRUE)Error 4: 'se ven los acentos raros (ñ, ó)'
Causa: problema de codificación al abrir el archivo con un Excel viejo en Windows. openxlsx siempre escribe en UTF-8 (lo correcto).
Solución: abre el archivo con Excel moderno (2013+) o con LibreOffice. Si tu cliente tiene Excel 2003, no hay solución limpia; considera exportar a .xls con otro paquete.
Error 5: archivo .xls (viejo) en lugar de .xlsx
openxlsx solo escribe .xlsx. Si te piden el formato viejo .xls, necesitas el paquete xlsx (que sí requiere Java) o escribir .xlsx y pedirle a la otra persona que lo abra normalmente. Excel desde 2007 abre ambos sin problema.
Error 6: ruta no existe (`Error: path does not exist`)
Causa: estás intentando guardar en informes/2026/mayo/archivo.xlsx pero la carpeta informes/2026/mayo/ no existe.
Solución: crea la carpeta antes:
dir.create("informes/2026/mayo", recursive = TRUE, showWarnings = FALSE)saveWorkbook(wb, "informes/2026/mayo/archivo.xlsx", overwrite = TRUE)Error 7: archivo enorme tarda mucho en abrirse
Si exportas 500.000 filas con estilos, el .xlsx resultante puede pesar 30 MB y ser lento de abrir. Considera:
- Exportar sin estilos (más liviano).
- Exportar a CSV si el destinatario lo acepta.
- Filtrar antes de exportar (¿de verdad necesitan ver las 500k filas?).
Ejercicio
Ejercicio 11 — Tu primer informe de Excel
- Importa y limpia
pacientes_ejemplo.xlsx(hojapacientes). - Construye dos resúmenes:
- Conteo por sexo (
group_by(sexo) |> summarise(n = n())). - Edad media por diagnóstico.
- Conteo por sexo (
- Exporta ambos a un archivo
mi_informe.xlsxcon dos hojas:por_sexoypor_diagnostico. - Bonus: aplica el estilo header verde y ancho automático a la primera hoja.
Ver solución
library(readxl)library(openxlsx)library(dplyr)library(janitor)
pacientes <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "pacientes", na = c("", "S/I")) |> clean_names() |> mutate( fecha_ingreso = as.Date(fecha_ingreso, origin = "1899-12-30"), presion_sistolica = as.numeric(presion_sistolica) )
por_sexo <- pacientes |> group_by(sexo) |> summarise(n = n(), .groups = "drop")
por_diagnostico <- pacientes |> group_by(diagnostico) |> summarise(edad_media = round(mean(edad), 1), .groups = "drop")
# Forma simple (sin estilos)write.xlsx( list(por_sexo = por_sexo, por_diagnostico = por_diagnostico), "mi_informe.xlsx")
# Bonus: con estilo en por_sexowb <- createWorkbook()addWorksheet(wb, "por_sexo")addWorksheet(wb, "por_diagnostico")writeData(wb, "por_sexo", por_sexo)writeData(wb, "por_diagnostico", por_diagnostico)
estilo_header <- createStyle(textDecoration = "bold", fontColour = "white", fgFill = "#107C41", halign = "center")
addStyle(wb, "por_sexo", estilo_header, rows = 1, cols = 1:ncol(por_sexo), gridExpand = TRUE)setColWidths(wb, "por_sexo", cols = 1:ncol(por_sexo), widths = "auto")
saveWorkbook(wb, "mi_informe_estilado.xlsx", overwrite = TRUE)Abre mi_informe_estilado.xlsx en Excel y confirma que la fila 1 de por_sexo tiene fondo verde y texto blanco en negrita.
Si te apareció Error: File already exists, agregar overwrite = TRUE. Si Excel te dice que el archivo está corrupto, lo más probable es que lo tenías abierto al sobreescribir.
Ejercicio 11b — Exportar con fecha real y formato dd/mm/yyyy
Cuando exportas una columna de fechas, openxlsx la escribe como número serial si no le aplicas formato. Vamos a hacerlo bien:
- Lee y limpia
pacientes(incluyendo conversión defecha_ingresoa<date>). - Selecciona
id,nombre_completo,fecha_ingresoydiagnostico. - Exporta a
pacientes_con_fecha.xlsxcon un estilo de fecha aplicado a la columnafecha_ingreso:library(openxlsx)wb <- createWorkbook()addWorksheet(wb, "pacientes")writeData(wb, "pacientes", datos_para_exportar)# Estilo de fecha dd/mm/yyyyaddStyle(wb, "pacientes",style = createStyle(numFmt = "dd/mm/yyyy"),rows = 2:(nrow(datos_para_exportar) + 1),cols = which(names(datos_para_exportar) == "fecha_ingreso"),gridExpand = TRUE)saveWorkbook(wb, "pacientes_con_fecha.xlsx", overwrite = TRUE) - Abre el archivo en Excel. ¿Las fechas se ven como
01/01/2024? Si las ves como45292, algo falló — revisa que la columna sea<date>antes de escribir.
Ver solución completa
library(readxl)library(dplyr)library(janitor)library(openxlsx)
# 1-2. Limpiar y seleccionarpacientes <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "pacientes", na = c("", "S/I")) |> clean_names() |> mutate(fecha_ingreso = as.Date(fecha_ingreso, origin = "1899-12-30"))
datos_para_exportar <- pacientes |> select(id, nombre_completo, fecha_ingreso, diagnostico)
# 3. Exportar con formatowb <- createWorkbook()addWorksheet(wb, "pacientes")writeData(wb, "pacientes", datos_para_exportar)
addStyle(wb, "pacientes", style = createStyle(numFmt = "dd/mm/yyyy"), rows = 2:(nrow(datos_para_exportar) + 1), cols = which(names(datos_para_exportar) == "fecha_ingreso"), gridExpand = TRUE)
setColWidths(wb, "pacientes", cols = 1:ncol(datos_para_exportar), widths = "auto")
saveWorkbook(wb, "pacientes_con_fecha.xlsx", overwrite = TRUE)Verificación al abrir en Excel:
| id | nombre_completo | fecha_ingreso | diagnostico |
|---|---|---|---|
| 1 | María Pérez | 01/01/2024 | Hipertensión |
| 2 | Juan Soto | 10/01/2024 | Diabetes tipo 2 |
Las fechas se ven correctamente formateadas. Si las ves como 45292 o 01-01-2024 00:00:00, falta el estilo de formato. Si las ves como texto "2024-01-01", la columna no era <date> al escribir.
Algunos números de fila importantes en addStyle:
rows = 2:(nrow + 1)porque la fila 1 es el encabezado. Si ponesrows = 1:nrow(...)el formato de fecha se aplica al encabezado (que es texto) — no rompe, pero es feo.cols = which(names(...) == "fecha_ingreso")detecta dinámicamente el índice de la columna. Más robusto quecols = 3que se rompe si reordenas columnas.
Tip
Otros numFmt útiles: "#,##0" (entero con miles), "#,##0.00" (2 decimales), "0.00%" (porcentaje), "yyyy-mm-dd" (ISO). El catálogo completo es el de Excel mismo — cualquier formato que puedas escribir en Format Cells → Custom lo puedes pasar a createStyle(numFmt = ...).
Ejercicio 11c — Genera un Excel por cada diagnóstico (iteración)
Un caso súper común: necesitas enviar un archivo distinto a cada referente clínico, dividido por diagnóstico. Vamos a automatizarlo:
- Lee y limpia
pacientes. - Divide la tabla por
diagnosticocongroup_split().group_split()dedplyrparte el data frame en una lista: un elemento por cada valor único de la columna que le pases. Si hay 4 diagnósticos distintos, devuelve una lista de 4 tibbles (uno por diagnóstico):library(dplyr)por_dx <- pacientes |> group_split(diagnostico)length(por_dx) # cantidad de diagnósticos distintos - Crea una carpeta
salida/si no existe (dir.create("salida", showWarnings = FALSE)). - Itera con
fory escribe un.xlsxpor diagnóstico, con un nombre sano (sin tildes ni espacios), por ejemplo:salida/pacientes_hipertension.xlsxsalida/pacientes_diabetes_tipo_2.xlsx- etc.
- Bonus: usa
janitor::make_clean_names()para construir el nombre del archivo automáticamente.
Ver solución
library(readxl)library(dplyr)library(janitor)library(openxlsx)
pacientes <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "pacientes", na = c("", "S/I")) |> clean_names() |> mutate( fecha_ingreso = as.Date(fecha_ingreso, origin = "1899-12-30"), presion_sistolica = as.numeric(presion_sistolica) )
dir.create("salida", showWarnings = FALSE)
por_dx <- pacientes |> group_split(diagnostico)
for (tabla in por_dx) { dx <- unique(tabla$diagnostico) nombre_archivo <- paste0( "salida/pacientes_", make_clean_names(dx), ".xlsx" ) write.xlsx(tabla, nombre_archivo) message("Guardado: ", nombre_archivo)}Salida típica:
Guardado: salida/pacientes_asma.xlsxGuardado: salida/pacientes_diabetes_tipo_2.xlsxGuardado: salida/pacientes_epoc.xlsxGuardado: salida/pacientes_hipertension.xlsxmake_clean_names() de janitor se encarga de:
- Quitar tildes (
"Hipertensión"→"hipertension"). - Reemplazar espacios por
_("Diabetes tipo 2"→"diabetes_tipo_2"). - Pasar todo a minúsculas.
Patrón mental clave: este flujo (group_split → for → write.xlsx) es uno de los patrones más reutilizables de tu vida laboral. Cada vez que te pidan “mándame un Excel por cada [hospital/comuna/región/unidad]”, este es tu esqueleto. Reemplaza diagnostico por la columna que te toque y listo.
Tip
Si te piden enviar los archivos por email, considera complementar con el paquete RDCOMClient (Windows + Outlook) o gmailr para automatizar el envío también. Pero siempre revisa visualmente al menos 1 de los archivos generados antes de enviar 30 emails con un bug silencioso.
Caja de herramientas y errores comunes
El módulo recetario
Llegaste al final. Este módulo no introduce conceptos nuevos: es un catálogo de recetas avanzadas para los casos límite que no calzaron en los módulos anteriores. Las primeras recetas son patrones de auditoría y validación — el tipo de cosa que separa un script que “corre sin errores” de uno que entrega datos correctos. Las últimas son trucos puntuales para situaciones raras pero reales.
Úsalo como referencia: salta directo a la receta que necesitas.
Receta 1: comparar dos versiones de la misma planilla
Escenario clásico: te entregan pacientes_enero.xlsx y pacientes_febrero.xlsx. ¿Qué cambió entre los dos? En Excel esto se hace a mano comparando celda por celda. En R toma 5 líneas.
Versión simple — sets de IDs:
library(readxl)library(dplyr)
enero <- read_excel("datos/pacientes_enero.xlsx") |> select(id)febrero <- read_excel("datos/pacientes_febrero.xlsx") |> select(id)
# Nuevos en febrero (no estaban en enero)nuevos <- febrero |> anti_join(enero, by = "id")
# Eliminados (estaban en enero, ya no están)eliminados <- enero |> anti_join(febrero, by = "id")Versión profunda — qué cambió a nivel de fila:
waldo::compare() te muestra todas las diferencias entre dos objetos en un formato visual:
# pak::pak("waldo")library(waldo)
enero <- read_excel("datos/pacientes_enero.xlsx")febrero <- read_excel("datos/pacientes_febrero.xlsx")
# Compara las tablas completascompare(enero, febrero)#> `old$presion_sistolica[5]`: 145#> `new$presion_sistolica[5]`: 150#>#> `old$diagnostico[7]`: "HTA"#> `new$diagnostico[7]`: "Hipertensión"Por qué importa en salud
Auditar cambios mes a mes es lo que separa “los datos cambiaron” de “alguien corrigió/borró/duplicó algo y nadie lo documentó”. Hazlo el primer día de cada mes, antes de calcular indicadores. Si los cambios son sospechosos, pregunta antes de avanzar.
Receta 2: validar reglas de negocio antes de analizar
Tus columnas pueden tener el tipo correcto (números en columnas numéricas, fechas en columnas de fecha) y aun así tener valores imposibles según la realidad clínica:
- Edad = 250
- Presión sistólica = 5 (debería ser 50–250)
- Fecha de ingreso posterior a fecha de alta
- RUN con dígito verificador incorrecto
El patrón básico: defines las reglas y filtras las violaciones. Si hay alguna, detén el pipeline antes de calcular un indicador con datos malos.
library(dplyr)
violaciones <- pacientes |> mutate( edad_imposible = edad < 0 | edad > 120, pas_imposible = !is.na(presion_sistolica) & (presion_sistolica < 40 | presion_sistolica > 280), fechas_invertidas = !is.na(fecha_alta) & fecha_alta < fecha_ingreso ) |> filter(edad_imposible | pas_imposible | fechas_invertidas)
if (nrow(violaciones) > 0) { print(violaciones) stop("Hay ", nrow(violaciones), " filas con reglas violadas. Revisa antes de continuar.")}Para validaciones más elaboradas existe el paquete pointblank, que permite declarar reglas (col_vals_between, rows_distinct, etc.) y genera reportes de calidad reutilizables. Vale la pena conocerlo si haces el mismo análisis con planillas distintas todos los meses.
Validación silenciosa = bug futuro
Si el script “corre limpio” y entrega un promedio de presión de 850, alguien va a publicar ese número. Un stop() molesto hoy te ahorra un correo de “¿cómo entregaste esto?” mañana.
Receta 3: detectar inconsistencias entre tablas
Cuando tienes dos tablas relacionadas (pacientes ↔ laboratorio), las dos preguntas claves son:
- ¿Hay exámenes de pacientes que no existen en la tabla principal? (datos fantasma)
- ¿Hay pacientes que nunca aparecen en laboratorio? (cobertura)
anti_join() responde ambas con la misma función, cambiando el orden:
library(dplyr)
# Exámenes "fantasma": id_paciente que está en lab pero no en pacienteslab |> anti_join(pacientes, by = c("id_paciente" = "id"))
# Pacientes sin examen: id que está en pacientes pero no en labpacientes |> anti_join(lab, by = c("id" = "id_paciente"))Es el primer chequeo que hago siempre que recibo dos planillas que “deberían” relacionarse.
Receta 4: exportar varias planillas, una por grupo
Útil cuando tienes que mandar un Excel por establecimiento, región o servicio:
library(dplyr)library(openxlsx)library(here)
# Asegúrate que la columna por la que vas a dividir existe (aquí: diagnostico)por_grupo <- pacientes |> group_split(diagnostico)
for (i in seq_along(por_grupo)) { grupo <- por_grupo[[i]] nombre_grupo <- unique(grupo$diagnostico) archivo <- here("informes", paste0("informe_", nombre_grupo, ".xlsx")) write.xlsx(grupo, archivo)}Un script en vez de hacerlo a mano. Si el próximo mes hay 4 diagnósticos en vez de 3, el código se adapta solo.
Tip
Si los nombres de grupo tienen espacios o tildes ("Hipertensión arterial"), pásalos por janitor::make_clean_names() antes de usarlos en el nombre de archivo para evitar problemas en Windows.
Receta 5: archivos con contraseña, hojas protegidas y celdas bloqueadas
Cuando alguien dice “el Excel está bloqueado” puede referirse a cuatro cosas distintas, y cada una se maneja diferente. Aprende a distinguirlas:
| Tipo | Qué hace | ¿read_excel() funciona? |
|---|---|---|
| 1. Contraseña al abrir | El archivo está cifrado. Sin la clave no se abre, ni en Excel ni en R. | ❌ No |
| 2. Contraseña para modificar | Se abre solo lectura. Los datos están visibles. | ✅ Sí |
| 3. Hoja protegida | No puedes editar celdas en Excel, pero los datos se leen. | ✅ Sí |
| 4. Archivo solo lectura del sistema | El permiso del archivo en disco prohíbe escritura. | ✅ Sí para leer, ❌ para sobreescribir |
Tipo 1: archivo cifrado con contraseña al abrir
readxl y openxlsx no soportan archivos cifrados. Síntoma típico: read_excel() falla con error -103 o un mensaje de “zip file cannot be opened” sin sentido.
Opciones, en orden de preferencia:
-
Pide el archivo sin contraseña a la fuente. Es lo más limpio.
-
Pide la contraseña y guarda una copia desprotegida desde Excel (
Archivo → Información → Proteger libro → Cifrar con contraseña → borrar la clave → Guardar). Después léelo desde R normalmente. -
Descifra desde R con la utilidad de línea de comandos
msoffice-crypt.py(del paquete Pythonmsoffcrypto-tool). Solo si vas a procesar muchos archivos cifrados con la misma clave:# Requiere instalar Python + msoffcrypto-tool una sola vez:# uv pip install msoffcrypto-tool## Después desde R, `system2()` ejecuta un comando externo del sistema# (no es código R, es R invocando la herramienta Python desde la terminal):system2("msoffice-crypt.py",args = c("-d", "-p", "tu_clave","entrada_cifrada.xlsx", "salida_descifrada.xlsx"))datos <- read_excel("salida_descifrada.xlsx") -
En Windows con Excel instalado, el paquete
excel.linkpuede abrir archivos cifrados vía COM. Es frágil (depende de la instalación de Excel) y no portable, pero funciona en último recurso.
Antes de descifrar: autorización
La contraseña casi siempre existe por una razón (datos clínicos sensibles, RUNs, sueldos). Antes de “destrabar” un archivo, confirma que tienes autorización formal para acceder a esos datos y que tu manejo posterior cumple las políticas de privacidad de la organización. Esto no es opcional en salud.
Tipo 2: contraseña para modificar (read-only password)
El archivo se abre, pero Excel pide clave para editar. Desde R no hay problema: read_excel() lee los datos sin pedir nada. Lo que no puedes hacer es sobrescribir el archivo original (te conviene guardar el resultado con otro nombre).
datos <- read_excel("archivo_con_modify_password.xlsx") # funcionawrite.xlsx(datos_limpios, "archivo_resultado.xlsx") # ✅ a otro archivoTipo 3: hojas o celdas protegidas (sheet/cell protection)
Esto es protección dentro del archivo: en Excel verás un candado y no puedes editar ciertas celdas. Para leer desde R no es problema — los valores están ahí, read_excel() los devuelve igual.
Sí se nota cuando escribes un Excel desde R y quieres replicar esa protección. openxlsx permite proteger una hoja y desbloquear celdas específicas:
library(openxlsx)
wb <- createWorkbook()addWorksheet(wb, "datos")writeData(wb, "datos", pacientes)
# Proteger toda la hoja con clave; opcionalmente desbloquear celdas editablesprotectWorksheet(wb, "datos", password = "clave_robusta", lockSelectingLockedCells = TRUE, lockFormattingCells = TRUE)
saveWorkbook(wb, "informe_protegido.xlsx", overwrite = TRUE)Útil cuando entregas un informe a jefatura y quieres que vea los números pero no los edite por accidente.
Detectar protección sin abrir Excel
Si quieres saber programáticamente si una hoja viene protegida, tidyxl::xlsx_cells() y tidyxl::xlsx_formats() te entregan los atributos de formato y protección de cada celda. Útil para auditar planillas que llegan en lote.
Tipo 4: archivo “solo lectura” en el sistema operativo
A veces el archivo en sí no tiene contraseña, pero el sistema de archivos lo marcó como read-only (típico en carpetas compartidas con permisos restringidos). R puede leerlo, pero write.xlsx() sobre la misma ruta falla con Error: cannot open file ... Permission denied.
# Diagnósticofile.access("ruta/archivo.xlsx", mode = 2) # 0 = puedes escribir; -1 = nofile.info("ruta/archivo.xlsx")$mode # permisos en octal (Unix)Solución: escribe el resultado a una ruta donde sí tengas permisos (here("salidas", "informe.xlsx")), no encima del original.
Receta 6: planillas demasiado grandes — cuándo es momento de cambiar de herramienta
readxl aguanta razonablemente hasta ~200-300 mil filas. Si tu archivo es más grande, hay tres caminos en orden de preferencia:
- Pide CSV en vez de XLSX.
readr::read_csv()odata.table::fread()leen un CSV 10–100× más rápido que el.xlsxequivalente. Si la fuente es un sistema (SIDRA, FONASA, una BD), pedir CSV es lo correcto. - Lee solo el rango/columnas que necesitas:
read_excel("grande.xlsx", range = "A1:G50000")
- Migra a una base de datos. Si la planilla pesa >100 MB o cuesta abrirla en Excel, es señal de que los datos ya no caben en Excel. Es momento de pedir acceso a la base de datos fuente o cargar el archivo en SQLite/DuckDB local. Sigue con el curso de SQL.
Receta 7: encoding raro (ó en vez de ó)
Síntoma: ves caracteres tipo Hipertensión en R. Causa: el archivo fue exportado en Latin1 / ISO-8859-1 desde un sistema viejo.
readxl siempre lee .xlsx en UTF-8, así que el problema casi nunca está ahí: típicamente lo encuentras en archivos CSV exportados desde un sistema legado.
# Forma readr (recomendada)readr::read_csv("archivo.csv", locale = readr::locale(encoding = "latin1"))
# Forma R baseread.csv("archivo.csv", fileEncoding = "latin1")Si te llega un .xlsx con encoding malo, el archivo está dañado: pide la fuente original.
Receta 8: fórmulas vs valores
read_excel() te entrega el valor calculado, no la fórmula. Si una celda tenía =SUMA(A1:A10) y mostraba 100, R recibe 100 — y eso es lo que quieres el 99% del tiempo.
Si realmente necesitas la fórmula (auditar de dónde sale un número, por ejemplo), tidyxl::xlsx_cells("archivo.xlsx") te devuelve cada celda con su valor, fórmula y formato como data frame.
Cuándo dejar de usar Excel
Excel es excelente para muchas cosas. Pero hay señales claras de que tu flujo ya no debería pasar por Excel:
- La planilla pesa más de 50 MB.
- Tienes que mantener más de una versión del mismo archivo abierta para “no perder los cambios” de otros.
- Recibes el mismo archivo del mismo sistema todos los meses, con la misma estructura.
- Necesitas que varias personas escriban al mismo tiempo.
- Las fórmulas se rompen cuando alguien filtra/ordena.
Cuando dos o tres de estos te aplican, es momento de pedir a TI acceso a la base de datos fuente y aprender SQL básico. Excel sigue siendo útil para reportes finales y exploración rápida, pero deja de ser el sistema de almacenamiento.
Tu trabajo no es seguir parchando flujos en Excel para siempre. Es subir la palanca de la organización un escalón a la vez. Aprender R y luego SQL es ese escalón.
Checklist final: “antes de entregar un Excel”
Checklist de entrega
Antes de mandar un .xlsx a alguien, revisa:
- ¿Tiene la información correcta? Abrir el archivo recién guardado y mirar (¡no confiar a ciegas en el script!).
- ¿Los encabezados son legibles? Sin snake_case si va a humanos no técnicos.
- ¿Las fechas son fechas, no números? Revisa una columna de fecha visualmente.
- ¿Los NAs se ven bien? Decide: ¿celda vacía,
"S/I",0? Depende del contexto. - ¿La primera fila está congelada y los anchos son razonables?
- ¿No tiene tu nombre de usuario / ruta absoluta en alguna celda? A veces los exports llevan metadata embarazosa.
- ¿El nombre del archivo dice claramente qué contiene y de cuándo es?
informe_pacientes_2026-05-18.xlsxes mejor quefinal_v3_FINAL.xlsx.
Próximos pasos en tu camino
Felicitaciones, terminaste el curso de Manejo de Excel en R. Ahora puedes:
- Importar cualquier
.xlsxrazonable con confianza. - Diagnosticar problemas en 2 minutos.
- Limpiar nombres, tipos, fechas y NAs.
- Manipular con
dplyrusando el pipe nativo|>. - Exportar informes profesionales con
openxlsx.
Para seguir creciendo en Hazla con Datos:
- 🗄️ Curso de SQL — porque cuando los datos crecen, dejan Excel y se van a una base de datos.
- 🔧 Curso de Git y GitHub — para versionar tus scripts y trabajar en equipo.
- 📊 El programa principal de Hazla con Datos — análisis estadístico y visualización aplicada a salud.
Y un consejo final: automatiza lo que repites tres veces. Cuando notes que estás haciendo manualmente el mismo flujo de “importar planilla, calcular indicadores, exportar informe” cada mes, conviértelo en un script reproducible. Eso es ciencia de datos en serio.
Ejercicio final
Ejercicio 12 — Tu primer pipeline reproducible
Crea un archivo informe_mensual.R que haga todo el proceso de una vez:
- Lee las 3 hojas relevantes de
pacientes_ejemplo.xlsx. - Limpia nombres, fechas y NAs.
- Calcula:
- Total de pacientes por diagnóstico.
- Promedio de presión sistólica por sexo (ignorando NAs).
- Total de exámenes por mes.
- Exporta un archivo
informe_mensual.xlsxcon esas 3 tablas como hojas separadas, con encabezado verde Excel y anchos automáticos. - Borra el
.xlsxresultante y vuelve a correr el script. ¿Funciona idéntico? ¿Te tomó un soloCtrl + Shift + Enter? Felicitaciones: tienes un pipeline reproducible.
Ver solución
library(readxl)library(openxlsx)library(dplyr)library(janitor)library(lubridate)
ruta_entrada <- "datos/pacientes_ejemplo.xlsx"ruta_salida <- "informe_mensual.xlsx"
# 1. Importar y limpiarpacientes <- read_excel(ruta_entrada, sheet = "pacientes", na = c("", "S/I")) |> clean_names() |> mutate( fecha_ingreso = as.Date(fecha_ingreso, origin = "1899-12-30"), presion_sistolica = as.numeric(presion_sistolica) )
lab <- read_excel(ruta_entrada, sheet = "laboratorio") |> clean_names() |> mutate(fecha = dmy(fecha))
# 2. Resúmenespor_dx <- pacientes |> count(diagnostico, sort = TRUE, name = "pacientes")
pas_por_sexo <- pacientes |> group_by(sexo) |> summarise(pas_media = round(mean(presion_sistolica, na.rm = TRUE), 1), n_validos = sum(!is.na(presion_sistolica)), .groups = "drop")
examenes_por_mes <- lab |> mutate(mes = month(fecha, label = TRUE, abbr = FALSE)) |> count(mes, name = "examenes")
# 3. Construir y guardarwb <- createWorkbook()hojas <- c("por_diagnostico", "pas_por_sexo", "examenes_por_mes")tablas <- list(por_dx, pas_por_sexo, examenes_por_mes)
estilo_header <- createStyle(textDecoration = "bold", fontColour = "white", fgFill = "#107C41", halign = "center")
for (i in seq_along(hojas)) { addWorksheet(wb, hojas[i]) writeData(wb, hojas[i], tablas[[i]]) addStyle(wb, hojas[i], estilo_header, rows = 1, cols = 1:ncol(tablas[[i]]), gridExpand = TRUE) setColWidths(wb, hojas[i], cols = 1:ncol(tablas[[i]]), widths = "auto") freezePane(wb, hojas[i], firstRow = TRUE)}
saveWorkbook(wb, ruta_salida, overwrite = TRUE)message("Listo: ", ruta_salida)Si te corre limpio y al borrar informe_mensual.xlsx y volver a correr el script obtienes el mismo archivo: has alcanzado el nivel “ciencia de datos básica” que era la meta de este curso. 🎉
A partir de aquí, todo lo demás es práctica con datos reales y aprender más patrones. Mucho éxito.
Ejercicio 12b — Auditoría de consistencia entre tablas
En cualquier proyecto con dos o más tablas relacionadas, el primer chequeo serio es verificar que las claves calzan. Hagamos una auditoría completa de pacientes vs laboratorio:
- Carga y limpia ambas tablas.
- Responde, con código, las siguientes preguntas:
- ¿Cuántos
iddistintos hay enpacientes? - ¿Cuántos
id_pacientedistintos hay enlaboratorio? - ¿Cuántos pacientes están en
pacientespero no tienen ningún examen enlaboratorio? (usaanti_join) - ¿Cuántos
id_pacienteaparecen enlaboratoriopero no enpacientes? (orden inverso) - ¿Cuántos exámenes promedio tiene cada paciente con al menos un examen?
- ¿Cuántos
- Imprime un mini-reporte con las 5 cifras anteriores.
Ver solución
library(readxl)library(dplyr)library(janitor)library(lubridate)
pacientes <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "pacientes", na = c("", "S/I")) |> clean_names() |> mutate( fecha_ingreso = as.Date(fecha_ingreso, origin = "1899-12-30"), presion_sistolica = as.numeric(presion_sistolica) )
lab <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = "laboratorio") |> clean_names() |> mutate(fecha = dmy(fecha))
# Auditorían_pacientes <- n_distinct(pacientes$id)n_lab_ids <- n_distinct(lab$id_paciente)sin_examenes <- pacientes |> anti_join(lab, by = c("id" = "id_paciente")) |> nrow()fantasmas <- lab |> anti_join(pacientes, by = c("id_paciente" = "id")) |> nrow()prom_examenes <- lab |> count(id_paciente) |> summarise(prom = round(mean(n), 1)) |> pull(prom)
cat("AUDITORÍA pacientes vs laboratorio\n")cat("===================================\n")cat("Pacientes en tabla pacientes : ", n_pacientes, "\n")cat("IDs distintos en laboratorio : ", n_lab_ids, "\n")cat("Pacientes sin examen : ", sin_examenes, "\n")cat("IDs fantasma en laboratorio : ", fantasmas, "\n")cat("Promedio exámenes por paciente con datos: ", prom_examenes, "\n")Salida esperada:
AUDITORÍA pacientes vs laboratorio===================================Pacientes en tabla pacientes : 20IDs distintos en laboratorio : 18Pacientes sin examen : 2IDs fantasma en laboratorio : 0Promedio exámenes por paciente con datos: 1.1Interpretación clínica:
- 2 pacientes (Emilia Lara y Agustín Núñez, ids 19 y 20) no tienen exámenes registrados — ¿son nuevos ingresos sin laboratorio aún, o son un olvido?
- 0 IDs fantasma: la integridad referencial está OK (todos los exámenes corresponden a pacientes conocidos).
- 1.1 exámenes en promedio: la cobertura es baja. Si la meta del programa era 2 exámenes por paciente, hay un gap.
Lección operativa: esta auditoría toma 30 líneas de código y se ejecuta en menos de 1 segundo. Es el primer chequeo que deberías hacer al recibir dos planillas relacionadas. Hacerla a mano en Excel es media tarde de trabajo, y muchas veces no se hace por flojera. Por eso aparecen bugs en los reportes.
Ejercicio 12c — Lee múltiples planillas con nombres tipo `data_YYYY_MM.xlsx`
Simulemos el caso clásico: cada mes te entregan un archivo con nombre data_2024_01.xlsx, data_2024_02.xlsx… y necesitas extraer el año y mes desde el nombre del archivo. Crea primero los archivos de prueba y luego procésalos:
- Generar archivos de prueba (simulación para no depender de archivos externos):
library(openxlsx)dir.create("data_mensual", showWarnings = FALSE)for (mes in 1:3) {df <- data.frame(id = 1:5,valor = sample(100:200, 5))nombre <- sprintf("data_mensual/data_2024_%02d.xlsx", mes)write.xlsx(df, nombre)}list.files("data_mensual")#> [1] "data_2024_01.xlsx" "data_2024_02.xlsx" "data_2024_03.xlsx"
- Procesarlos todos a la vez, extrayendo año y mes del nombre del archivo con
stringr::str_match():library(readxl)library(dplyr)library(stringr)archivos <- list.files("data_mensual", pattern = "\\.xlsx$", full.names = TRUE)consolidado <- lapply(archivos, function(f) {componentes <- str_match(basename(f),"data_(\\d{4})_(\\d{2})\\.xlsx")read_excel(f) |>mutate(año = as.integer(componentes[, 2]),mes = as.integer(componentes[, 3]))}) |> bind_rows() - Verifica con
consolidado |> count(año, mes)que tienes los 3 meses esperados. - Bonus: ¿qué pasaría si llega un archivo
data_2024_03_v2.xlsx(el típico “v2”, “final”, “definitivo”)? Adapta el regex para incluirlo o excluirlo según prefieras.
Ver solución completa
library(readxl)library(openxlsx)library(dplyr)library(stringr)
# 1. Generardir.create("data_mensual", showWarnings = FALSE)for (mes in 1:3) { df <- data.frame(id = 1:5, valor = sample(100:200, 5)) write.xlsx(df, sprintf("data_mensual/data_2024_%02d.xlsx", mes))}
# 2. Procesararchivos <- list.files("data_mensual", pattern = "\\.xlsx$", full.names = TRUE)
consolidado <- lapply(archivos, function(f) { componentes <- str_match(basename(f), "data_(\\d{4})_(\\d{2})\\.xlsx") read_excel(f) |> mutate( año = as.integer(componentes[, 2]), mes = as.integer(componentes[, 3]) )}) |> bind_rows()
# 3. Verificarconsolidado |> count(año, mes)#> # A tibble: 3 × 3#> año mes n#> <int> <int> <int>#> 1 2024 1 5#> 2 2024 2 5#> 3 2024 3 5
# 4. Bonus: incluir archivos con sufijo "_v2"# El regex actual requiere "_NN.xlsx" exacto, así que data_2024_03_v2.xlsx NO calza.# Si quieres incluirlo:str_match("data_2024_03_v2.xlsx", "data_(\\d{4})_(\\d{2})(?:_v\\d+)?\\.xlsx")#> [,1] [,2] [,3]#> [1,] "data_2024_03_v2.xlsx" "2024" "03"
# Si prefieres EXCLUIRLO (porque "_v2" es archivo accidental):archivos_limpios <- archivos[!str_detect(archivos, "_v\\d+\\.xlsx$")]Cosas para entender:
str_match()con grupos: cada par de paréntesis(...)en el regex captura un grupo.str_match()devuelve una matriz donde la columna 1 es la coincidencia completa y las siguientes son los grupos. Por esocomponentes[, 2]es el año ycomponentes[, 3]es el mes.\\d{4}: cuatro dígitos.\\d{2}: dos dígitos. Las dobles barras son porque en R los strings interpretan\dcomo secuencia de escape; el regex real es\d{4}.%02densprintf(): formatea un entero con dos dígitos y relleno de cero.sprintf("%02d", 3)→"03".(?:...)?: grupo no capturador opcional. Útil para “este pedazo puede o no estar”. Lo usamos para aceptar_v2,_v3, etc.
Lección final del curso: dominar regex básico (caracteres, repetición, grupos) te permite extraer información de nombres de archivo, columnas tipo "HTA-A23-2024", fechas mezcladas con texto y un sinfín de casos reales. Es la “última milla” entre tener archivos limpios y tener un pipeline robusto. Si quieres profundizar, busca el cheat sheet de stringr en posit.co.
Tip
La regex de este ejercicio (data_(\\d{4})_(\\d{2})\\.xlsx) es estricta: solo acepta data_AAAA_MM.xlsx. Si los archivos reales vienen con variaciones (Data_2024_01.xlsx, data-2024-01.xlsx, DATA_2024_Jan.xlsx), tu primera tarea cuando llegan es estandarizar el nombre o flexibilizar el regex. Documenta cuál de las dos opciones tomaste y por qué.