Excel en R
Curso para principiantes

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.

~5 horas
12 módulos
R (con notas en Python)
Nivel básico

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.

Descargar pacientes_ejemplo.xlsx →
⚙️

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 →
01

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 H42 puede depender de otra en B17 que 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 .xlsx desde 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 con DBI + dplyr y 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

  1. Conocer la planilla con ojos humanos: ¿cuántas hojas tiene?, ¿dónde están los encabezados?, ¿hay celdas combinadas?, ¿hay totales mezclados?
  2. Importar el archivo a R con readxl.
  3. Inspeccionar y diagnosticar qué tipo tiene cada columna, qué valores faltan, qué cosas se ven sospechosas.
  4. Limpiar: nombres de columnas, tipos, fechas, NAs.
  5. Manipular: filtrar, agrupar, resumir, pivotar.
  6. Exportar el resultado a Excel con openxlsx para 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 .xlsx con 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 45292 a fechas reales (2024-01-01).
  • Reemplazar "S/I" por NA real.
  • 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

BloqueMódulosQué aprendes
Empezar1 – 3Mentalidad, instalar herramientas, diagnosticar tu planilla.
Importar4 – 5Llevar el Excel a R, controlando hojas, rangos y tipos.
Inspeccionar y limpiar6 – 9Detectar problemas, arreglar nombres, tipos, fechas y NAs.
Manipular y exportar10 – 12Procesar 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:

  1. 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.
  2. pak::pak("paquete") para instalar paquetes, en lugar de install.packages("paquete"). pak es 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.
  3. readxl para leer y openxlsx para escribir. Hay otros paquetes (xlsx, writexl, openpyxl para 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:

  1. Abre un Excel real que hayas recibido por trabajo (o uno que te imagines: un REM, una planilla de inscritos, etc.).
  2. 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 NA disfrazados 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):

  1. 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”).
  2. 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.
  3. Las tareas con 2 o más cruces son las primeras candidatas a moverse a R.
Ver ejemplo de respuestas

Ejemplo:

TareaRepetitiva>5 pasosErrores difícilesEntregable
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:

  1. Conocer — ¿qué ya sabes del archivo?
  2. Importar — ¿qué hoja necesitas?
  3. Inspeccionar — ¿qué columna o tipo te preocupa más?
  4. Limpiar — ¿qué transformación específica imaginas (ej: “convertir fecha”, “quitar tildes”)?
  5. Manipular — ¿qué pregunta concreta le quieres hacer a los datos? (ej: “promedio por sexo”)
  6. 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:

  1. Conocer: archivo rem_enero_2026.xlsx, hoja Sección A, encabezados en fila 4, 12 columnas (los meses).
  2. Importar: read_excel("rem_enero_2026.xlsx", sheet = "Sección A", skip = 3).
  3. Inspeccionar: la columna establecimiento tiene tildes inconsistentes; la columna indicador tiene guiones que probablemente son NAs.
  4. Limpiar: clean_names(), na_if(indicador, "-"), normalizar nombres de establecimientos.
  5. Manipular: total de controles HTA por mes y por establecimiento.
  6. Exportar: un .xlsx con 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.

02

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:

  1. Crea en tu computador una carpeta llamada curso-excel (donde tú quieras).
  2. Adentro, crea una subcarpeta datos/ y coloca ahí el archivo pacientes_ejemplo.xlsx del curso.
  3. 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:

PasoComando¿Cuántas veces?
Instalarpak::pak("readxl")Una sola vez por computador.
Cargarlibrary(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-dateutil

pak 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, ggplot2
library(readxl)
library(openxlsx)
library(janitor)
library(here)
import pandas as pd
💡

Nota

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 1
2 EPOC 1

Si 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 instalar pak (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

  1. En Positron: File → New File y elige R File.

  2. Pega este código:

    library(dplyr)
    mi_tabla <- tibble(
    paciente = c("A", "B", "C"),
    edad = c(67, 45, 72)
    )
    mi_tabla |>
    filter(edad >= 60)
  3. Guárdalo como prueba.R en la carpeta de tu proyecto.

  4. Posiciona el cursor en cualquier línea y dale Ctrl + Enter (Windows/Linux) o Cmd + Enter (Mac) para ejecutar línea por línea.

  5. 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 67
2 C 72

Si 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:

  1. Listar los instalados y filtrar los del curso:
    instalados <- rownames(installed.packages())
    paquetes_curso <- c("tidyverse", "readxl", "openxlsx", "janitor", "here")
    paquetes_curso %in% instalados
  2. 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 TRUE

Un 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:

  1. Crea en tu computador una carpeta nueva llamada prueba_curso/ y dentro una subcarpeta datos/.
  2. En Positron, File → Open Folder y abre prueba_curso/.
  3. En la consola de R ejecuta:
    getwd()
    list.files()
    list.files("datos")
  4. Anota: ¿la ruta que devuelve getwd() termina en prueba_curso? ¿list.files() muestra la carpeta datos?
  5. Bonus: ahora cierra Positron, vuelve a abrirlo sin elegir carpeta (File → New Window directo), y repite getwd(). ¿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") muestra character(0) porque la subcarpeta está vacía.

  • Sin abrir carpeta: getwd() te devuelve la carpeta personal del usuario (~ o C:/Users/usuario). Ahí está el problema: si escribes read_excel("datos/archivo.xlsx") sin haber abierto la carpeta correcta, R va a buscar ~/datos/archivo.xlsx que 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.

03

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

  1. ¿Cuántas hojas tiene? Mira las pestañas abajo. ¿Hay 1, 5, 20?
  2. ¿Dónde está la fila de encabezados? ¿Fila 1, o hay título / fecha / fuente arriba?
  3. ¿Hay celdas combinadas? Selecciona la fila de encabezados y mira si dos columnas comparten una celda.
  4. ¿Hay totales mezclados con datos? ¿Hay una fila al final con "TOTAL" o un subtotal por grupo en medio?
  5. ¿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ñaQué contieneTrampa
pacientes20 pacientes con id, nombre, edad, sexo, diagnóstico, fecha y presiónNombres con espacios sucios, fecha como número raro (45292), presión con "S/I" y celdas vacías
laboratorioExámenes por pacienteFechas como texto "15/01/2024" (no como fecha de Excel)
resumen_REMReporte mensual de indicadoresDos filas de metadata arriba (Reporte estadístico..., Fuente:...) — los encabezados reales están en la fila 3
ocultaHoja oculta para demostraciónNo 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 Completo tiene espacios “sucios”: al menos un valor ("Juan Soto") tiene doble espacio interno.
  • La columna fecha_ingreso muestra 45292 (un número), no 2024-01-01. Es una fecha serializada de Excel.
  • La columna presion_sistolica tiene 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_REM no 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_names

Salida 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 hoja
read_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

  1. Descarga pacientes_ejemplo.xlsx desde la página de inicio del curso y guárdalo en una carpeta datos/ dentro de tu proyecto.
  2. Á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?
  3. Confirma con código:
    library(readxl)
    excel_sheets("datos/pacientes_ejemplo.xlsx")
    read_excel("datos/pacientes_ejemplo.xlsx", sheet = "pacientes", n_max = 3)
  4. 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 pero excel_sheets() la lista.
  • Encabezados: pacientes y laboratorio los tienen en fila 1. resumen_REM los 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_sistolica mezcla números, "S/I" y vacíos. fecha_ingreso es un número (serial). fecha en laboratorio es texto con formato dd/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:

  1. Abre pacientes_ejemplo.xlsx en Excel/LibreOffice (no en R todavía).

  2. Para cada una de las 7 columnas de la hoja pacientes, anota en una tabla qué tipo esperas que asigne R:

    ColumnaTipo que esperas (dbl, chr, date, lgl)Por qué
    id?
    Nombre Completo?
    edad?
    Sexo?
    diagnostico?
    fecha_ingreso?
    presion_sistolica?
  3. Ahora confirma con código:

    library(readxl)
    library(dplyr)
    glimpse(read_excel("datos/pacientes_ejemplo.xlsx", sheet = "pacientes"))
  4. ¿En cuántas columnas acertaste? ¿Qué te sorprendió?

Ver tipos esperados y reales
ColumnaEsperadoReal¿Calza?
iddbl (números 1-20)dbl
Nombre Completochr (texto)chr
edaddbl (números)dbl
Sexochr (“F”/“M”)chr
diagnosticochrchr
fecha_ingresodate (¡es una fecha!)dblsorpresa
presion_sistolicadbl (presión sistólica)chrsorpresa

Las dos sorpresas son exactamente lo que vamos a arreglar en los módulos 7-9.

  • fecha_ingreso salió <dbl> porque Excel la guardó como número serial sin formato de fecha aplicado. El 45292 es el día 2024-01-01 en el calendario interno de Excel.
  • presion_sistolica salió <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:

  1. Abre el archivo en Excel/LibreOffice. Anota cuántas pestañas ves abajo.
  2. Desde R, ejecuta:
    library(readxl)
    excel_sheets("datos/pacientes_ejemplo.xlsx")
    ¿Cuántas hojas lista? ¿Calza con lo que viste?
  3. Lee la hoja oculta directamente:
    read_excel("datos/pacientes_ejemplo.xlsx", sheet = "oculta")
  4. ¿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 × 1
    nota
    <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á.

04

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")
pacientes
import pandas as pd
pacientes = pd.read_excel("datos/pacientes_ejemplo.xlsx")
pacientes

Línea por línea:

  1. library(readxl) — carga el paquete (acuérdate del módulo 2: instalar es una vez, cargar es cada sesión).
  2. pacientes <- read_excel("datos/pacientes_ejemplo.xlsx") — lee el archivo y guarda el resultado en una variable llamada pacientes. La flechita <- significa “asigna a la izquierda lo de la derecha”.
  3. 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 0

Tip

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: 20
Columns: 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:

  1. El archivo no está en la carpeta datos/ (está en otro lado).
  2. No abriste la carpeta correcta en Positron, así que el directorio de trabajo es otro. Ejecuta getwd() para verificarlo y usa File → Open Folder para abrir la raíz del curso.
  3. Escribiste mal el nombre del archivo (mayúsculas, espacios, extensión .xls vs .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

  1. Asegúrate de tener pacientes_ejemplo.xlsx en datos/ dentro de tu proyecto.
  2. Carga readxl y dplyr.
  3. Lee la hoja "laboratorio" en una variable llamada lab.
  4. Aplícale glimpse().
  5. 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: 20
Columns: 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").

  1. Importa la hoja 2 del archivo en una variable por_numero:
    por_numero <- read_excel("datos/pacientes_ejemplo.xlsx", sheet = 2)
  2. Importa la hoja "laboratorio" por nombre en por_nombre.
  3. Confirma con identical(por_numero, por_nombre) que ambas son idénticas ahora.
  4. Pregúntate: ¿qué pasaría si mañana alguien agrega una nueva hoja instructivo como primera pestaña del archivo? Anota qué leería sheet = 2 en 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] TRUE

Ambas 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:

  1. instructivo
  2. pacientes ← antes era posición 2
  3. laboratorio
  4. resumen_REM
  5. oculta

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:

  1. Ruta mala:
    read_excel("datos/no_existe.xlsx")
  2. Nombre de hoja mal escrito (mayúscula donde no va):
    read_excel("datos/pacientes_ejemplo.xlsx", sheet = "Pacientes")
  3. 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 found

R 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.

05

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)
rem
rem = pd.read_excel("datos/pacientes_ejemplo.xlsx",
sheet_name="resumen_REM",
skiprows=2)
rem

Ahora 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 505
2 Controles DM 318 322 340 355 360 372
3 Controles EPOC 95 102 110 108 115 120
4 Vacunaciones 610 580 555 590 620 640

Tip

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:

  1. Ver una “foto” de un archivo grande sin cargarlo entero.
  2. Detenerte antes de una fila de totales o de basura al final.
# Solo las primeras 5 filas
read_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 + 1
💡

Nota

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 defecto
pd.read_excel("datos/pacientes_ejemplo.xlsx",
sheet_name="pacientes")
# header=0 por defecto

Modo 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 original
pd.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 datos

Si 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 encabezado
crudo <- 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 110

Las 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_sistolica
pacientes = 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 lista
todas <- lapply(hojas, function(h) read_excel(ruta, sheet = h))
# 3. Asignar nombres a la lista
names(todas) <- hojas
# Acceder a una:
todas$pacientes
todas$laboratorio
import 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 vino
consolidado <- 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.xlsx

Cada 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 carpeta
archivos <- 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 trazabilidad
consolidado <- lapply(archivos, function(f) {
read_excel(f) |>
mutate(archivo = basename(f))
}) |>
bind_rows()
consolidado
import pandas as pd
from 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:

  1. list.files("datos/", pattern = "\\.xlsx$", full.names = TRUE)pattern filtra solo archivos que terminen en .xlsx (el \\. escapa el punto y $ ancla al final). full.names = TRUE te devuelve la ruta completa ("datos/enero_2024.xlsx"), no solo el nombre.
  2. lapply(archivos, function(f) ...) — aplica la función a cada archivo de la lista. Por cada uno: lee con read_excel(f) y agrega mutate(archivo = basename(f)).
  3. 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.
  4. bind_rows() — apila todas las tablas verticalmente. Si una fila vino de enero_2024.xlsx, su columna archivo lo 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 archivos
archivos <- list.files("datos/",
pattern = "\\.xlsx$",
full.names = TRUE)
# 2. Función auxiliar: leer todas las hojas de un archivo
leer_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 todo
consolidado <- lapply(archivos, leer_todas_las_hojas) |>
bind_rows()
consolidado
import pandas as pd
from 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 urgencias
2 1 DM 110 2024-01-15 2024.xlsx urgencias
3 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 hoja
consolidado |>
count(archivo, hoja)
# Filtrar solo lo que vino de 2026
consolidado |>
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, o pattern = "\\.[xX][lL][sS][xX]$".
  • Estás en datos/2024/ pero los archivos están en datos/. Usa recursive = TRUE para 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

  1. Lee la hoja resumen_REM del archivo de ejemplo saltándote las dos filas de metadata y guárdalo en una variable rem.
  2. Aplica glimpse(rem) y confirma que el primer indicador es "Controles HTA".
  3. Bonus: lee solo las primeras 3 filas con n_max = 3 y 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
#> ...
# Bonus
read_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.

  1. Lee pacientes_ejemplo.xlsx hoja por hoja: pacientes y laboratorio.
  2. Por cada una, agrega una columna origen con el nombre de la hoja.
  3. Únelas con bind_rows() en una sola tabla consolidado.
  4. 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 20

Como 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:

  1. Lee solo las primeras 5 filas y las 3 primeras columnas (id, Nombre Completo, edad) de la hoja pacientes. Pista: el rango en notación Excel es "A1:C6" (incluye fila 1 de encabezado + 5 filas de datos).
  2. Confirma que el tibble resultante tiene exactamente 5 filas y 3 columnas.
  3. Ahora lee solo las filas 10 a 15 (sin encabezado) con range = "A11:G16" y col_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 columnas
parte_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 encabezado
parte_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.

  1. Lee la hoja pacientes forzando los tipos correctos desde el inicio:
    • id"numeric"
    • Nombre Completo"text"
    • edad"numeric"
    • Sexo"text"
    • diagnostico"text"
    • fecha_ingreso"date"
    • presion_sistolica"numeric"
  2. Vas a ver muchos warnings tipo Expecting numeric in G3 / R3C7: got 'S/I' — ¿qué te están diciendo?
  3. Aplica glimpse() y confirma que fecha_ingreso ahora es <dttm> (o <date>) y presion_sistolica es <dbl> con NA donde 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_ingreso como <dttm> directamente (no número serial).
  • presion_sistolica como <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.

06

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ónPara 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: 20
Columns: 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:

  1. 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.
  2. Tipos de cada columna. <dbl>, <int>, <chr>, <date>, <lgl>. ¿Algún tipo te sorprende? (En este caso: presion_sistolica <chr> y fecha_ingreso <dbl> son sorpresas — las arreglamos en módulos 7 y 8.)
  3. 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: 3 al 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 5
tail(pacientes, n = 5) # últimos 5
pacientes.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 1

Esto 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 casos
1 CESFAM Norte Enero 45
2 CESFAM Norte Febrero 38
3 CESFAM Norte Marzo 52
NA Total CESFAM Norte NA 135 ← fila de subtotal
4 CESFAM Sur Enero 30
5 CESFAM Sur Febrero 35
NA Total CESFAM Sur NA 65 ← otra
NA TOTAL GENERAL NA 200 ← y otra al final

Si 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 final
tail(rem, 10)
# 2. Filas donde el id está vacío
rem |>
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 seguridad
rem_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

  1. glimpse(datos) → ¿filas, columnas y tipos esperados?
  2. summary(datos) → ¿valores numéricos dentro de rango razonable? ¿cuántos NA?
  3. tail(datos) → ¿basura al final?
  4. count(datos, columna_categórica, sort = TRUE) para cada categórica → ¿valores únicos esperados?
  5. ¿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_ingreso salió <dbl> (debería ser fecha).
  • presion_sistolica salió <chr> (debería ser número).
  • Nombre Completo tiene espacios sucios (no se ve aquí, pero los vamos a encontrar en el módulo 7).
  • ✅ No hay basura al final (tail muestra el paciente 20 normal).
  • ✅ Las categorías de diagnostico y Sexo se 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 azar
pacientes |> head(500) |> View() # primeras 500
⚠️

Pitfall 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

  1. Lee las hojas pacientes, laboratorio y resumen_REM (recuerda skip = 2 para REM) en tres variables.
  2. Aplica los 5 pasos del checklist a cada una.
  3. 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)
# pacientes
glimpse(pacientes)
summary(pacientes)
count(pacientes, diagnostico, sort = TRUE)
count(pacientes, Sexo)
# laboratorio
glimpse(lab)
count(lab, examen)
# rem
glimpse(rem)
summary(rem)

Hallazgos esperados:

  • En pacientes: presion_sistolica es <chr> (tiene "S/I" y vacíos); fecha_ingreso es <dbl> (es serial); el Nombre Completo tiene espacios sucios.
  • En lab: fecha es <chr> con formato "15/01/2024" — no es fecha real, es texto.
  • En rem: ya quedó bien tipada después del skip = 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:

  1. Lee la hoja pacientes (sin forzar tipos ni NAs).
  2. Filtra las filas en las que presion_sistolica no 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)
  3. Anota: ¿qué id y qué valor literal tienen las filas problemáticas?
  4. Bonus: filtra las filas con edad fuera de un rango razonable (digamos < 0 o > 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 × 7

No 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:

  1. Cantidad de filas y columnas.
  2. Cantidad de NAs por columna.
  3. 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.

07

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 (¿es pacientes$Nombre Completo o pacientes$"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óstico vs Diagnostico).

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 pd
import 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:

  1. Recorta espacios al inicio y al final (como trimws()).
  2. 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.0
import pandas as pd
import 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_numeric
s = 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.5

Receta 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éricas
pacientes <- 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 esperableas.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ón
⚠️

Error 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`

  1. Lee la hoja laboratorio.
  2. Aplica clean_names().
  3. Limpia espacios en la columna examen con str_squish().
  4. Confirma con glimpse() que los nombres son id_paciente, examen, valor, fecha (todos snake_case y minúsculas).
  5. Bonus: ¿la columna valor tiene 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:

  1. 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")
  2. Aplica parse_number(sucios) y observa qué devuelve para cada elemento.
  3. Anota: ¿qué hizo con "90/60"? ¿y con "12,5"? ¿qué problemas le ves a este enfoque en datos reales?
  4. Bonus: usa parse_number() con locale(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:

EntradaSalidaComentario
"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.2trampa: 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.5

Ahora "$ 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:

  1. Lea pacientes_ejemplo.xlsx hoja pacientes indicando na = c("", "S/I") al leer.
  2. Aplique clean_names().
  3. Use str_squish() para limpiar espacios sucios en nombre_completo.
  4. Renombre presion_sistolica a pas y diagnostico a dx.
  5. Convierta pas a numérico.
  6. 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érico

Cosas para comprobar:

  • "Juan Soto" ahora es "Juan Soto" (un solo espacio interno). El str_squish() funcionó.
  • pas es <dbl> con NA en las posiciones donde había "S/I". La combinación na = c("", "S/I") + as.numeric(pas) lo logró en dos pasos.
  • fecha_ingreso sigue 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.

08

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 ExcelCó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-30
pacientes["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-01
2 2 2024-01-10
3 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-Day
  • dmy("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-15
2 1 2024-01-15
3 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 convertir

Caso 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) o floor_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_horas
as.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:00
fh.hour

Operar 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_ingreso da 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 real
pacientes <- 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 real
lab <- 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

  1. Lee laboratorio y convierte la columna fecha a <date> con dmy().
  2. Para el paciente id_paciente == 1, calcula los días que pasaron entre su primer y su segundo examen.
  3. 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 1
lab |>
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 mes
lab |>
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:

  1. 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
    )
  2. Intenta parsear con dmy(). ¿Cuántas convierte? ¿Cuántas dejan NA?
  3. 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")
  4. ¿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:

Entradadmy()parse_date_time()Comentario
"15/01/2024"✅ 2024-01-15dmy es suficiente
"2024-01-15"❌ NA✅ 2024-01-15dmy() no acepta ISO; necesitas ymd también
"2024/01/15"❌ NA✅ 2024-01-15el separador / con ymd igual sirve
"31/02/2024"❌ NA❌ NAfecha imposible: no existe el 31 de febrero
""❌ NA❌ NAvacío
"no es fecha"❌ NA❌ NAbasura → NA
"01-feb-2024"❌ NA✅ 2024-02-01requiere locale español + formato d-b-Y

Conclusiones prácticas:

  • parse_date_time() con varios orders es más robusto cuando no sabes el formato exacto.
  • lubridate rechaza fechas imposibles (31/02). Eso es bueno: prefieres NA que 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:

  1. Construye un mini-tibble con 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")
    )
  2. Calcula la edad en años usando interval() y years(1):
    muestra |>
    mutate(
    fecha_nacimiento = ymd(fecha_nacimiento),
    edad = as.integer(interval(fecha_nacimiento, today()) / years(1))
    )
  3. Compara con la versión “ingenua” (today() - fecha_nacimiento) / 365. ¿Da lo mismo? ¿Por qué la versión con interval() es más correcta?
  4. 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 mayores
muestra |> 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?

  • / 365 asume 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).

09

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 solo
c(1, NA, 3) # vector con un NA en medio

Algo 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 # → NA
mean(c(1, NA, 3)) # → NA

Para hacer cuentas ignorando los NAs, usas na.rm = TRUE:

mean(c(1, NA, 3), na.rm = TRUE) # → 2

El problema: NAs disfrazados

En el mundo real, los NAs casi nunca vienen como celdas limpiamente vacías. Vienen disfrazados de:

DisfrazPor qué pasa
"NA" literalAlguien 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, 9999Có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 columna
pacientes[pacientes["presion_sistolica"].isna()] # filas
⚠️

Advertencia

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:

ValorSignificado
999No respondió
-99No aplica
9999Faltante

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 pd
import 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 columna
pacientes |> drop_na()
# Eliminar filas con NA solo en presion_sistolica
pacientes |> drop_na(presion_sistolica)
# Eliminar filas con NA en presion o edad
pacientes |> drop_na(presion_sistolica, edad)
pacientes.dropna() # cualquier columna
pacientes.dropna(subset=["presion_sistolica"]) # solo esa
💡

Nota

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 serie
pacientes |>
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 funciona
filter(is.na(x)) # ✅ correcto
⚠️

Error 2: mean() sin na.rm devuelve NA

mean(c(1, 2, NA)) # → NA
mean(c(1, 2, NA), na.rm = TRUE) # → 1.5

Si 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 desaparece
c(1, NA, 2) # → c(1, NA, 2): el NA queda

Receta 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 NAs
pacientes |>
summarise(across(everything(), ~ sum(is.na(.))))
# Cuántos pacientes con presión válida tenemos
pacientes |>
filter(!is.na(presion_sistolica)) |>
nrow()

Ejercicio

🧪

Ejercicio 9 — Contar y reportar NAs

  1. Lee pacientes con na = c("", "S/I") y aplica la limpieza completa (clean_names, fecha, numérico).
  2. Cuenta cuántos NAs hay por columna.
  3. Imprime las filas donde presion_sistolica es NA.
  4. Calcula la presión sistólica promedio ignorando los NAs.
  5. 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 columna
pacientes |>
summarise(across(everything(), ~ sum(is.na(.))))
# 3. Filas con presión NA
pacientes |>
filter(is.na(presion_sistolica))
# 4. Promedio ignorando NAs
mean(pacientes$presion_sistolica, na.rm = TRUE)
# 5. Bonus: pacientes >= 65 con presión válida
pacientes |>
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.

  1. 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)
    )
  2. Calcula el promedio “ingenuo” de cada columna sin convertir los códigos:
    summary(encuesta)
    ¿Qué pasa con edad_mean y glicemia_mean?
  3. Reemplaza 999 y -99 por NA en las tres columnas usando mutate(across(...)).
  4. 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 limpiar
summary(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.00

Catástrofe: la edad media sale 183 años y la glicemia 256 por culpa de los códigos.

# Con limpieza
encuesta_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 :1

Ahora los promedios son realistas y summary() te dice cuántos NA tienes por columna.

Lección clave:

  1. Antes de calcular cualquier promedio, revisa si la fuente codifica el faltante con números (preguntando o leyendo el diccionario de variables).
  2. mutate(across()) es la forma idiomática de aplicar el mismo reemplazo a varias columnas en una sola línea.
  3. 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() o range() 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:

  1. 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))
  2. Estrategia A — Eliminar: crea pac_sin_na con drop_na(presion_sistolica). ¿Cuántos pacientes quedan?
  3. Estrategia B — Imputar: crea pac_imputado reemplazando los NA por la mediana de presión:
    pac_imputado <- pacientes |>
    mutate(presion_sistolica = replace_na(
    presion_sistolica,
    median(presion_sistolica, na.rm = TRUE)
    ))
  4. Calcula el mean(presion_sistolica) en las dos tablas y compáralo con el promedio de la tabla original (con na.rm = TRUE). ¿Cuál es más conservador?
  5. Pregunta de discusión: si tu jefe te pide “el promedio de presión sistólica de los pacientes”, ¿qué reportas? ¿drop_na, replace_na o el original con na.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: eliminar
pac_sin_na <- pacientes |> drop_na(presion_sistolica)
nrow(pac_sin_na)
#> [1] 17 ← perdimos 3 pacientes
# B: imputar con mediana
pac_imputado <- pacientes |>
mutate(presion_sistolica = replace_na(
presion_sistolica,
median(presion_sistolica, na.rm = TRUE)
))
# Comparar promedios
mean(pacientes$presion_sistolica, na.rm = TRUE)
#> [1] 142.4
mean(pac_sin_na$presion_sistolica)
#> [1] 142.4 ← idéntico al original con na.rm
mean(pac_imputado$presion_sistolica)
#> [1] 142.1 ← se acerca a la mediana, levemente menor

Análisis:

  • drop_na() y mean(..., na.rm = TRUE) dan el mismo número matemáticamente. La diferencia es que drop_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/I en 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:

  1. Para reportes: usa na.rm = TRUE y reporta explícitamente cuántos faltantes hay.
  2. Para modelos predictivos: la imputación es una decisión metodológica que se documenta y se valida.
  3. Nunca mezcles datos imputados con originales sin marcarlos con una columna (fue_imputado = TRUE).
10

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 ruidoso
paso1 <- 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))
paso4

Con 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:

VerboQué 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óstico
pacientes |>
select(id, edad, diagnostico)
# Todas excepto nombre_completo
pacientes |>
select(-nombre_completo)
# Rango: desde edad hasta presion_sistolica
pacientes |>
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 65
pacientes |> 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 valores
pacientes |> filter(diagnostico %in% c("Hipertensión", "EPOC", "Asma"))
# Excluir NAs
pacientes |> 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 mayor
pacientes |> arrange(desc(edad)) # mayor a menor
pacientes |> arrange(diagnostico, desc(edad)) # primero por dx, luego edad desc
pacientes.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 151
2 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 combinaciones
pacientes |> 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 limpiar
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. Resumen por diagnóstico y sexo
resumen <- 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)
resumen

Errores 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) # ❌ error
filter(edad == 65) # ✅ correcto
⚠️

Error 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 |> arriba

Asegú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

  1. Lee y limpia pacientes (igual que la receta de arriba).
  2. Filtra solo pacientes >= 50 años con presión válida (!is.na).
  3. Agrupa por diagnostico y calcula n, edad_media y pas_media.
  4. Ordena por pas_media descendente.
  5. Bonus: une con laboratorio y 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-4
pacientes |>
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 laboratorio
lab <- 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:

  1. Lee resumen_REM con skip = 2.
  2. Aplica pivot_longer() para llevarla a formato largo (una fila por indicador y mes).
  3. Calcula el total anual por indicador.
  4. Calcula el mes con más atenciones para cada indicador (pista: group_by(indicador) |> slice_max(atenciones, n = 1)).
  5. 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 → largo
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
#> ...
# 3. Total anual por indicador
rem_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 indicador
rem_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 → ancho
rem_largo |>
pivot_wider(names_from = mes, values_from = atenciones)
# debería verse igual que rem original

Lecció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.

  1. Carga y limpia pacientes y laboratorio.
  2. Encuentra los pacientes que están en la tabla pacientes pero no tienen ningún examen en laboratorio:
    pacientes |>
    anti_join(lab, by = c("id" = "id_paciente"))
  3. Inversamente, encuentra los registros de laboratorio cuyo id_paciente no existe en pacientes (pacientes “fantasma”). Pista: invierte las tablas en el anti_join.
  4. Reporta cuántos pacientes hay en cada categoría.
  5. Bonus: usa count() con left_join + summarise para 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ámenes
sin_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ía
nrow(sin_examenes)
#> [1] 2
# 5. Bonus: cantidad de exámenes por paciente
pacientes |>
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 0

Por qué anti_join es indispensable en datos de salud:

  • Detectar pacientes mal vinculados: un id_paciente que 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_join te 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.

11

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 libro
wb <- createWorkbook()
# 2. Agregar hojas
addWorksheet(wb, "pacientes")
addWorksheet(wb, "laboratorio")
# 3. Escribir datos en cada hoja
writeData(wb, sheet = "pacientes", x = pacientes_resumen)
writeData(wb, sheet = "laboratorio", x = laboratorio_resumen)
# 4. Guardar
saveWorkbook(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 encabezado
estilo_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 contenido

O 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 ejemplo
pacientes <- 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) + 1
col_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 blanco
estilo_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 suave
estilo_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 aplicados

stack = 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 limpiar
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. Tres resúmenes
por_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 libro
wb <- 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. Guardar
saveWorkbook(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

  1. Importa y limpia pacientes_ejemplo.xlsx (hoja pacientes).
  2. Construye dos resúmenes:
    • Conteo por sexo (group_by(sexo) |> summarise(n = n())).
    • Edad media por diagnóstico.
  3. Exporta ambos a un archivo mi_informe.xlsx con dos hojas: por_sexo y por_diagnostico.
  4. 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_sexo
wb <- 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:

  1. Lee y limpia pacientes (incluyendo conversión de fecha_ingreso a <date>).
  2. Selecciona id, nombre_completo, fecha_ingreso y diagnostico.
  3. Exporta a pacientes_con_fecha.xlsx con un estilo de fecha aplicado a la columna fecha_ingreso:
    library(openxlsx)
    wb <- createWorkbook()
    addWorksheet(wb, "pacientes")
    writeData(wb, "pacientes", datos_para_exportar)
    # Estilo de fecha dd/mm/yyyy
    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)
    saveWorkbook(wb, "pacientes_con_fecha.xlsx", overwrite = TRUE)
  4. Abre el archivo en Excel. ¿Las fechas se ven como 01/01/2024? Si las ves como 45292, 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 seleccionar
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"))
datos_para_exportar <- pacientes |>
select(id, nombre_completo, fecha_ingreso, diagnostico)
# 3. Exportar con formato
wb <- 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:

idnombre_completofecha_ingresodiagnostico
1María Pérez01/01/2024Hipertensión
2Juan Soto10/01/2024Diabetes 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 pones rows = 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 que cols = 3 que 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:

  1. Lee y limpia pacientes.
  2. Divide la tabla por diagnostico con group_split(). group_split() de dplyr parte 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
  3. Crea una carpeta salida/ si no existe (dir.create("salida", showWarnings = FALSE)).
  4. Itera con for y escribe un .xlsx por diagnóstico, con un nombre sano (sin tildes ni espacios), por ejemplo:
    • salida/pacientes_hipertension.xlsx
    • salida/pacientes_diabetes_tipo_2.xlsx
    • etc.
  5. 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.xlsx
Guardado: salida/pacientes_diabetes_tipo_2.xlsx
Guardado: salida/pacientes_epoc.xlsx
Guardado: salida/pacientes_hipertension.xlsx

make_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_splitforwrite.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.

12

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 completas
compare(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 (pacienteslaboratorio), 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 pacientes
lab |>
anti_join(pacientes, by = c("id_paciente" = "id"))
# Pacientes sin examen: id que está en pacientes pero no en lab
pacientes |>
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:

TipoQué hace¿read_excel() funciona?
1. Contraseña al abrirEl archivo está cifrado. Sin la clave no se abre, ni en Excel ni en R.❌ No
2. Contraseña para modificarSe abre solo lectura. Los datos están visibles.✅ Sí
3. Hoja protegidaNo puedes editar celdas en Excel, pero los datos se leen.✅ Sí
4. Archivo solo lectura del sistemaEl 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:

  1. Pide el archivo sin contraseña a la fuente. Es lo más limpio.

  2. 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.

  3. Descifra desde R con la utilidad de línea de comandos msoffice-crypt.py (del paquete Python msoffcrypto-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")
  4. En Windows con Excel instalado, el paquete excel.link puede 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") # funciona
write.xlsx(datos_limpios, "archivo_resultado.xlsx") # ✅ a otro archivo

Tipo 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 editables
protectWorksheet(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óstico
file.access("ruta/archivo.xlsx", mode = 2) # 0 = puedes escribir; -1 = no
file.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:

  1. Pide CSV en vez de XLSX. readr::read_csv() o data.table::fread() leen un CSV 10–100× más rápido que el .xlsx equivalente. Si la fuente es un sistema (SIDRA, FONASA, una BD), pedir CSV es lo correcto.
  2. Lee solo el rango/columnas que necesitas:
    read_excel("grande.xlsx", range = "A1:G50000")
  3. 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 base
read.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:

  1. ¿Tiene la información correcta? Abrir el archivo recién guardado y mirar (¡no confiar a ciegas en el script!).
  2. ¿Los encabezados son legibles? Sin snake_case si va a humanos no técnicos.
  3. ¿Las fechas son fechas, no números? Revisa una columna de fecha visualmente.
  4. ¿Los NAs se ven bien? Decide: ¿celda vacía, "S/I", 0? Depende del contexto.
  5. ¿La primera fila está congelada y los anchos son razonables?
  6. ¿No tiene tu nombre de usuario / ruta absoluta en alguna celda? A veces los exports llevan metadata embarazosa.
  7. ¿El nombre del archivo dice claramente qué contiene y de cuándo es? informe_pacientes_2026-05-18.xlsx es mejor que final_v3_FINAL.xlsx.

Próximos pasos en tu camino

Felicitaciones, terminaste el curso de Manejo de Excel en R. Ahora puedes:

  • Importar cualquier .xlsx razonable con confianza.
  • Diagnosticar problemas en 2 minutos.
  • Limpiar nombres, tipos, fechas y NAs.
  • Manipular con dplyr usando 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:

  1. Lee las 3 hojas relevantes de pacientes_ejemplo.xlsx.
  2. Limpia nombres, fechas y NAs.
  3. Calcula:
    • Total de pacientes por diagnóstico.
    • Promedio de presión sistólica por sexo (ignorando NAs).
    • Total de exámenes por mes.
  4. Exporta un archivo informe_mensual.xlsx con esas 3 tablas como hojas separadas, con encabezado verde Excel y anchos automáticos.
  5. Borra el .xlsx resultante y vuelve a correr el script. ¿Funciona idéntico? ¿Te tomó un solo Ctrl + Shift + Enter? Felicitaciones: tienes un pipeline reproducible.
Ver solución
informe_mensual.R
library(readxl)
library(openxlsx)
library(dplyr)
library(janitor)
library(lubridate)
ruta_entrada <- "datos/pacientes_ejemplo.xlsx"
ruta_salida <- "informe_mensual.xlsx"
# 1. Importar y limpiar
pacientes <- 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úmenes
por_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 guardar
wb <- 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:

  1. Carga y limpia ambas tablas.
  2. Responde, con código, las siguientes preguntas:
    • ¿Cuántos id distintos hay en pacientes?
    • ¿Cuántos id_paciente distintos hay en laboratorio?
    • ¿Cuántos pacientes están en pacientes pero no tienen ningún examen en laboratorio? (usa anti_join)
    • ¿Cuántos id_paciente aparecen en laboratorio pero no en pacientes? (orden inverso)
    • ¿Cuántos exámenes promedio tiene cada paciente con al menos un examen?
  3. 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ía
n_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 : 20
IDs distintos en laboratorio : 18
Pacientes sin examen : 2
IDs fantasma en laboratorio : 0
Promedio exámenes por paciente con datos: 1.1

Interpretació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:

  1. 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"
  2. 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()
  3. Verifica con consolidado |> count(año, mes) que tienes los 3 meses esperados.
  4. 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. Generar
dir.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. Procesar
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()
# 3. Verificar
consolidado |> 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 eso componentes[, 2] es el año y componentes[, 3] es el mes.
  • \\d{4}: cuatro dígitos. \\d{2}: dos dígitos. Las dobles barras son porque en R los strings interpretan \d como secuencia de escape; el regex real es \d{4}.
  • %02d en sprintf(): 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é.

Siguiente 1. Por qué Excel importa