Clase 2 · Análisis exploratorio y procesamiento de datos

IRdisplay::display_html('
<style>
@import url("https://fonts.googleapis.com/css2?family=Work+Sans:wght@400;600&family=Amiri:wght@400;700&display=swap");
.rendered_html, .markdown, .cell .text_cell_render { font-family:"Work Sans",system-ui,sans-serif; color:#122535; }
.rendered_html h1,.rendered_html h2,.rendered_html h3 { font-family:"Amiri",Georgia,serif; color:#00529B; }
.rendered_html h2 { border-bottom:2px solid #00529B; padding-bottom:.2em; }
.rendered_html a { color:#00529B; }
.rendered_html table th { background:#00529B; color:#fff; }
.rendered_html h1,.rendered_html h2,.rendered_html h3 { scroll-margin-top:16px; }
</style>
')

Analítica de Datos · Maestría en Ciencias del Comportamiento · Universidad de San Andrés

2do Cuatrimestre 2026 · 15/08/2026

Lenguaje: R. Esta notebook hace exactamente lo mismo que la de Python: mismos datos, mismos pasos, mismos números. Lo que cambia son las herramientas, porque cada lenguaje tiene su forma natural de decir las cosas.


Hoy tomamos los datos de Nimbus, la empresa de la clase pasada, y los dejamos en una sola tabla lista para analizar. El recorrido es el mismo de las slides, en cinco pasos:

# Paso Qué hacemos
1 Cargar traer los archivos a R
2 Explorar qué hay adentro y cómo se distribuye
3 Estructurar pegar las tres tablas en una
4 Limpiar duplicados, valores imposibles, faltantes
5 Enriquecer construir variables nuevas
Ejercicio de cierre el mismo pipeline sobre otro dataset

Cada paso termina con una consigna corta. Están pensadas para resolverse en cinco o diez minutos durante la clase, no para llevarse a casa.

Cómo se usa esta notebook. Las celdas de código se corren con Shift+Enter, de arriba hacia abajo. Si te salteás una, las de abajo pueden fallar porque dependen de objetos definidos antes.

0. Preparación

Los datos: no hay que bajar nada

Esta notebook no trae los datos adentro y tampoco los busca en tu Drive: los lee por URL, directo desde el sitio de la materia. No tenés que preparar nada, solo estar conectada o conectado a internet.

Esto es una diferencia con la notebook de Python, y tiene una razón: desde R, en Colab, el Drive no se puede montar por código. En Python alcanza con una línea; en R no hay forma. Leer por URL esquiva el problema entero.

Lo bueno es que a read_csv() le da lo mismo: una URL va en el mismo lugar donde iría la ruta a un archivo de tu computadora.

Las dos primeras tablas de Nimbus salen del sitio de la materia:

https://analiticadedatos-udesa.com/data/toy-nimbus/nimbus_empleados.csv
https://analiticadedatos-udesa.com/data/toy-nimbus/nimbus_salario.csv

Las otras dos están alojadas en otro lado: el panel de bienestar y el dataset del ejercicio de cierre. Cada una trae su link en la celda donde se carga.

La celda de abajo carga el tidyverse: el conjunto de paquetes estándar para trabajar con tablas en R. En Colab ya viene instalado.

# Sin esto, las tablas del tidyverse salen con codigos de color ANSI que Colab
# imprime como basura (ESC[90m...) en vez de colorear.
options(crayon.enabled = FALSE)

library(readr)     # leer archivos
library(dplyr)     # manipular tablas
library(tidyr)     # reacomodar tablas
library(stringr)   # trabajar con texto

# Los datos se leen por URL desde el sitio de la materia, NO desde el Drive:
# en Colab, R no puede montar el Drive por código. A read_csv() le da lo mismo,
# una URL va donde iría la ruta a un archivo.
# Fuera de Colab (cuando la cátedra prepara el material) se usa la copia local.
IN_COLAB <- dir.exists("/content")

if (IN_COLAB) {
  BASE <- "https://analiticadedatos-udesa.com/data/toy-nimbus/"
} else {
  BASE <- "../../../data/toy-nimbus/"
}

cat("Listo. Los archivos se leen desde:", BASE, "\n")

Attaching package: 'dplyr'


The following objects are masked from 'package:stats':

    filter, lag


The following objects are masked from 'package:base':

    intersect, setdiff, setequal, union

Listo. Los archivos se leen desde: ../../../data/toy-nimbus/ 

1. Cargar

Cargar un CSV es una línea. read_csv() lee el archivo y devuelve un data frame (más precisamente un tibble): una tabla con filas y columnas con nombre.

Fijate cómo se compone la línea, porque la vas a escribir mil veces:

empleados  <-  read_csv(       RUTA        )
   ↑       ↑        ↑             ↑
 el nombre  la      la función   el archivo
 que le    flecha   que lee un   que queremos
 damos     asigna   CSV          leer

Antes: qué es esa RUTA

read_csv() no adivina dónde está el archivo. Hay que darle la ruta: la parte fija más el nombre del archivo, todo junto en un solo texto. Y da igual que esa parte fija sea una carpeta de tu computadora o una dirección de internet.

La parte fija ya la tenemos guardada en BASE, de la celda de arriba, y es la misma para los tres archivos de Nimbus. Lo único que cambia de uno a otro es el nombre. Entonces armamos la ruta pegando las dos partes.

En R los textos no se pegan con +: el + es solo para números, y si lo intentás vas a recibir un error (non-numeric argument to binary operator). Para pegar textos se usa paste0(). paste0("casa", "miento") da "casamiento". Por eso, en la celda de arriba, BASE termina en /, para que al pegar quede una ruta bien formada y no ...toy-nimbusnimbus_empleados.csv.

Mirá cómo queda:

cat("la base         :", BASE, "\n")
cat("el nombre       :", "nimbus_empleados.csv", "\n")
cat("la ruta, pegada :", paste0(BASE, "nimbus_empleados.csv"), "\n")
la base         : ../../../data/toy-nimbus/ 
el nombre       : nimbus_empleados.csv 
la ruta, pegada : ../../../data/toy-nimbus/nimbus_empleados.csv 

Eso último es lo que recibe read_csv(). Se puede escribir la ruta entera a mano cada vez, pero armarla así tiene una ventaja: si mañana los archivos cambian de lugar, cambiás BASE en un solo lugar y toda la notebook sigue andando.

empleados <- read_csv(paste0(BASE, "nimbus_empleados.csv"), show_col_types = FALSE)
dim(empleados)
  1. 600
  2. 7

Los argumentos: decirle cómo leer

Una función puede recibir varios datos, que se llaman argumentos. Algunos son obligatorios: sin ellos no puede hacer su trabajo. Otros son opcionales, porque tienen un valor por defecto, y solo los escribís si querés cambiarlo. Cuántos hay de cada tipo depende de la función: no hay una regla general.

En read_csv() es fácil de recordar: uno solo es obligatorio (qué archivo leer, y va primero) y todo el resto es opcional y se pasa con nombre (fijate col_types abajo, que se pone detrás de =). Esos opcionales no cambian qué se lee, sino cómo.

De hecho ya usamos uno: show_col_types = FALSE, que apaga el resumen de tipos que read_csv() imprime por defecto.

Los argumentos para esta función son muchísimos. No hay que saberlos: hay que saber que existen y dónde buscarlos. En R la ayuda está adentro del propio lenguaje: ?read_csv abre la documentación de la función. Estos tres resuelven el 90 % de los archivos que vienen raros.

# delim: especifica cuál es el separador (read_csv() asume coma; para otro, read_delim())
#   read_delim(RUTA, delim = ";")

# na: qué valores tratar como faltante, además de la celda vacía
#   read_csv(RUTA, na = c("", "sin dato", "N/A", "-999"))

# col_types: forzar el tipo de los valores de una columna antes de que R adivine
#   read_csv(RUTA, col_types = cols(empleado_id = col_character()))

# Un paso más: leamos la misma tabla forzando empleado_id a texto y comparemos.
prueba <- read_csv(paste0(BASE, "nimbus_empleados.csv"),
                   col_types = cols(empleado_id = col_character()))
cat("sin col_types:", class(empleados$empleado_id), "\n")
cat("con col_types:", class(prueba$empleado_id), "\n")
sin col_types: numeric 
con col_types: character 

✏️ Consigna 1

Cargá la tabla de salarios completando el nombre del archivo que falta. La parte fija ya está en BASE, así que vos solo ponés el nombre y paste0() arma la ruta.

Las tres tablas de Nimbus son nimbus_empleados.csv, nimbus_salario.csv y nimbus_bienestar_diario.csv.

# TODO: completá el nombre del archivo de salarios
ARCHIVO <- "___"

salario <- read_csv(paste0(BASE, ARCHIVO), show_col_types = FALSE)
print(dim(salario))
head(salario, 3)
# Y la tercera tabla, la del piloto de la fruta, que en la Clase 1 no habíamos abierto.
# Como el archivo está alojado en otro link, cambiamos la base.
BASE <- "https://raw.githubusercontent.com/tomdamelio/analitica_de_datos_alumnos/refs/heads/main/data/toy-nimbus/"

bienestar <- read_csv(paste0(BASE, "nimbus_bienestar_diario.csv"), show_col_types = FALSE)
head(bienestar, 3)
A tibble: 3 × 5
empleado_id fecha fase grupo_fruta bienestar
<dbl> <date> <chr> <chr> <dbl>
1 2025-03-03 baseline Tratamiento 4
1 2025-03-04 baseline Tratamiento 5
1 2025-03-05 baseline Tratamiento 3

2. Explorar

Ya tenemos las tablas. Antes de tocar nada, hay que mirarlas: qué son, qué tamaño tienen, de qué tipo es cada columna.

class(empleados)
  1. 'spec_tbl_df'
  2. 'tbl_df'
  3. 'tbl'
  4. 'data.frame'
head(empleados)
A tibble: 6 × 7
empleado_id sede area genero educacion_nivel antiguedad_anios grupo_fruta
<dbl> <chr> <chr> <chr> <chr> <dbl> <chr>
1 Buenos Aires Ventas Masculino Universitario 8 Tratamiento
2 Mar del Plata Ingenieria Femenino Posgrado 8 Control
3 Mendoza Producto Femenino Universitario 4 Tratamiento
4 Rosario Producto Masculino Universitario 1 Tratamiento
5 Rosario Operaciones Masculino Posgrado 3 Tratamiento
6 Bahia Blanca Producto Masculino Universitario 9 Control
glimpse(empleados)
Rows: 600
Columns: 7
$ empleado_id      <dbl> 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16…
$ sede             <chr> "Buenos Aires", "Mar del Plata", "Mendoza", "Rosario"…
$ area             <chr> "Ventas", "Ingenieria", "Producto", "Producto", "Oper…
$ genero           <chr> "Masculino", "Femenino", "Femenino", "Masculino", "Ma…
$ educacion_nivel  <chr> "Universitario", "Posgrado", "Universitario", "Univer…
$ antiguedad_anios <dbl> 8, 8, 4, 1, 3, 9, 10, 11, 2, 7, 1, 8, 4, 8, 8, 11, 5,…
$ grupo_fruta      <chr> "Tratamiento", "Control", "Tratamiento", "Tratamiento…

Todo en R es una función.

Si venís de Python, esto es una diferencia de fondo. Allá la tabla hace cosas (empleados.head()) y tiene cosas (empleados.dtypes). En R no: la tabla no hace nada, son las funciones las que le hacen cosas a la tabla, y siempre la reciben como primer argumento. Por eso escribís head(empleados) y no empleados.head().

Eso es justamente lo que hace posible el pipe (|>), que encadena funciones pasando el resultado de una como primer argumento de la siguiente. Lo vamos a usar en un rato.

glimpse() es la forma corta de ver todo de un saque: dimensiones, nombre de cada columna, tipo, y las primeras observaciones.

dim(empleados)
  1. 600
  2. 7

dim() devuelve (filas, columnas), siempre en ese orden. Es el chequeo más fácil de todos, porque si esperabas 600 empleados y te da otro número, sabés que algo raro pasó. También existen nrow() y ncol() si querés solo uno de los dos.

Elegir columnas

Hay dos formas, y conviene conocer las dos porque las vas a ver en cualquier código ajeno.

empleados$sede |> head()          # el signo peso: UNA columna, como vector
  1. 'Buenos Aires'
  2. 'Mar del Plata'
  3. 'Mendoza'
  4. 'Rosario'
  5. 'Rosario'
  6. 'Bahia Blanca'
select(empleados, sede, area)     # select: esas columnas, como TABLA
A tibble: 600 × 2
sede area
<chr> <chr>
Buenos Aires Ventas
Mar del Plata Ingenieria
Mendoza Producto
Rosario Producto
Rosario Operaciones
Bahia Blanca Producto
Buenos Aires Ingenieria
Mar del Plata Ventas
Cordoba Ingenieria
Buenos Aires Ingenieria
Mendoza Producto
Bahia Blanca Ingenieria
Mar del Plata Ingenieria
Mar del Plata Ingenieria
Mar del Plata Ingenieria
Mar del Plata Ingenieria
Mendoza Ingenieria
Buenos Aires Ventas
Bahia Blanca Ingenieria
Rosario RRHH
Mendoza Operaciones
Rosario Producto
Cordoba Ingenieria
Bahia Blanca Producto
Mar del Plata Operaciones
Mendoza Ventas
Rosario Operaciones
Mar del Plata Producto
Mendoza Ventas
Rosario Ingenieria
Bahia Blanca Ventas
Rosario Ingenieria
Cordoba RRHH
Buenos Aires Ingenieria
Buenos Aires Ventas
Rosario Operaciones
Bahia Blanca Ingenieria
Mendoza Ingenieria
Bahia Blanca Ventas
Buenos Aires Producto
Buenos Aires Ingenieria
Bahia Blanca Ventas
Buenos Aires RRHH
Buenos Aires Producto
Rosario Ingenieria
Bahia Blanca Ingenieria
Mar del Plata Ingenieria
Buenos Aires Ingenieria
Bahia Blanca Ingenieria
Bahia Blanca Ventas
Mar del Plata Ingenieria
Bahia Blanca RRHH
Cordoba Ingenieria
Bahia Blanca Ingenieria
Cordoba Ventas
Mar del Plata Operaciones
Mendoza Ingenieria
Mar del Plata Ingenieria
Mendoza Ingenieria
Mendoza Producto

Filtrar filas

Para quedarse con algunas filas se usa filter(), y adentro va una condición: una comparación que R evalúa fila por fila.

Ojo: un <- asigna un valor, un = se usa para nombrar argumentos, y dos == comparan. Es el error de tipeo más común de las primeras semanas.

# Paso 1: preguntar. Devuelve una respuesta por CADA fila, no los empleados de Mendoza.
mascara <- empleados$sede == "Mendoza" # Compara la columna sede con el valor "Mendoza"
head(mascara)
  1. FALSE
  2. FALSE
  3. TRUE
  4. FALSE
  5. FALSE
  6. FALSE
cat("largo de la pregunta:", length(mascara), "\n")

# Paso 2: filter() se queda con las filas donde la condición dio TRUE
empleados_mendoza <- filter(empleados, sede == "Mendoza")
cat("largo de la respuesta:", nrow(empleados_mendoza), "\n")
head(empleados_mendoza, 3)
largo de la pregunta: 600 
largo de la respuesta: 93 
A tibble: 3 × 7
empleado_id sede area genero educacion_nivel antiguedad_anios grupo_fruta
<dbl> <chr> <chr> <chr> <chr> <dbl> <chr>
3 Mendoza Producto Femenino Universitario 4 Tratamiento
11 Mendoza Producto Masculino Terciario 1 Tratamiento
17 Mendoza Ingenieria Femenino Terciario 5 Tratamiento

Fijate en el empleado_id del resultado: 3, 11, 17… Las filas que quedaron se llevaron su identificador, pero el número de fila de la tabla arrancó de nuevo en 1.

Esa es la diferencia entre dos formas de ubicar un dato:

  • filter() elige por condición (qué dice la fila)
  • slice() elige por posición (en qué lugar quedó la fila)

Y la posición cambia cada vez que filtrás. Es exactamente la trampa que en Python aparece como .loc contra .iloc, solo que R la resuelve al revés: acá lo que se pierde es la numeración original.

m <- select(empleados_mendoza, sede, area, antiguedad_anios)

# por posición: la SEGUNDA fila de la tabla de Mendoza (el 2 es el lugar, no el empleado_id)
print(slice(m, 2))

# por condición: el empleado 11 (el 11 es el empleado_id, esté en la posición que esté)
print(filter(empleados_mendoza, empleado_id == 11) |> select(sede, area, antiguedad_anios))
# A tibble: 1 × 3
  sede    area     antiguedad_anios
  <chr>   <chr>               <dbl>
1 Mendoza Producto                1
# A tibble: 1 × 3
  sede    area     antiguedad_anios
  <chr>   <chr>               <dbl>
1 Mendoza Producto                1
# La trampa: `slice(m, 1)` NO es el primer empleado de la empresa.
# Es el primer empleado DE MENDOZA, que en la tabla original era el número 3.
cat("primera fila de la tabla filtrada -> empleado_id:", empleados_mendoza$empleado_id[1], "\n")
cat("primera fila de la tabla original -> empleado_id:", empleados$empleado_id[1], "\n")
cat("\nSi filtrás y después usás posiciones, estás mirando otras filas.\n")
primera fila de la tabla filtrada -> empleado_id: 3 
primera fila de la tabla original -> empleado_id: 1 

Si filtrás y después usás posiciones, estás mirando otras filas.

Describir

summary() da los números de un saque. Se leen siempre en el mismo orden: dónde está el centro, cuánto se dispersan, y los extremos.

Mirá si aparece un renglón de NA's: si lo hay, hay faltantes.

summary(bienestar$bienestar)
   Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
  1.000   4.000   5.000   4.696   5.000   7.000 
# Sobre una columna de texto, summary() no dice gran cosa: para categóricas se cuenta.
count(empleados, sede)
A spec_tbl_df: 6 × 2
sede n
<chr> <int>
Bahia Blanca 95
Buenos Aires 109
Cordoba 98
Mar del Plata 108
Mendoza 93
Rosario 97
# Un paso más: ordenado de mayor a menor
count(empleados, sede, sort = TRUE)
A spec_tbl_df: 6 × 2
sede n
<chr> <int>
Buenos Aires 109
Mar del Plata 108
Cordoba 98
Rosario 97
Bahia Blanca 95
Mendoza 93

✏️ Consigna 2

Dos preguntas sobre el piloto de la fruta.

a) ¿Cuántas respuestas de bienestar máximo hubo en cada grupo? Completá la condición del filtro. (La escala de bienestar va de 1 a 7: mirá el summary() de arriba si no te acordás.)

b) Ese número de arriba viene en bruto. ¿Un grupo llegó al máximo más veces porque le fue mejor, o simplemente porque tiene más mediciones? Contá cuántas mediciones tiene cada grupo y después pasá los máximos a proporción para poder compararlos.

# a) respuestas de bienestar máximo, por grupo
# TODO: completá el valor máximo de la escala
maximos <- filter(bienestar, bienestar == ___)
cat("Maximos por grupo:\n")
print(count(maximos, grupo_fruta))
cat("\n")

# b) ¿es porque a un grupo le fue mejor, o porque tiene más mediciones?
por_grupo <- count(bienestar, grupo_fruta)
cat("Mediciones por grupo:\n")
print(por_grupo)
cat("\n")

maximos_por_grupo <- count(maximos, grupo_fruta)
# TODO: dividí los máximos de cada grupo por el total de mediciones por grupo
print(tibble(grupo_fruta = por_grupo$grupo_fruta,
             prop = round(maximos_por_grupo$n / ___, 3)))

3. Estructurar

Ninguna tabla sola contesta una pregunta interesante. Si querés saber si el piloto le hizo algo distinto a la gente con más antigüedad, necesitás la antigüedad (que está en empleados) y el bienestar (que está en bienestar).

left_join() las pega usando una columna en común como clave.

cat("empleados:", dim(empleados), "\n")
cat("bienestar:", dim(bienestar), "\n")
empleados: 600 7 
bienestar: 24000 5 

✏️ Consigna 3 (antes de correr la celda de abajo)

empleados tiene 7 columnas y bienestar tiene 5.

¿Cuántas columnas va a tener la tabla unida? Anotá tu número acá abajo y corré solo esta celda. La respuesta está en la celda siguiente, así que no bajes todavía.

# TODO: ¿cuántas columnas creés que va a tener la tabla unida?
# Anotá tu número ANTES de mirar la celda de abajo.
mi_respuesta <- ___

cat("anotaste:", mi_respuesta, "columnas. Ahora sí, seguí con la celda de abajo.\n")

Ahora sí: unimos y comparamos.

datos <- left_join(empleados, bienestar, by = "empleado_id")

cat("tu respuesta:", mi_respuesta, "\n")
cat("la realidad :", ncol(datos), "columnas\n\n")
cat("7 + 5 = 12, menos 1: empleado_id es la clave y queda UNA sola vez.\n")
tu respuesta: 12 
la realidad : 11 columnas

7 + 5 = 12, menos 1: empleado_id es la clave y queda UNA sola vez.
names(datos)
  1. 'empleado_id'
  2. 'sede'
  3. 'area'
  4. 'genero'
  5. 'educacion_nivel'
  6. 'antiguedad_anios'
  7. 'grupo_fruta.x'
  8. 'fecha'
  9. 'fase'
  10. 'grupo_fruta.y'
  11. 'bienestar'

Fijate que hay dos columnas grupo_fruta, una con .x y otra con .y. La columna grupo_fruta también estaba en las dos tablas, pero como no era la clave, R se trajo ambas y les puso un sufijo para distinguirlas.

El join que corre limpio y está mal

Si no le decís por qué columna unir, left_join() usa todas las que se repiten. R al menos te avisa con un mensaje (Joining with by = ...), cosa que pandas no hace. Pero es un mensaje, no un error: si no lo leés, pasa igual.

auto <- left_join(empleados, bienestar)          # sin by=, que indica por qué columna unir
cat("\nsin by= :", dim(auto), "\n")
cat("con by= :", dim(datos), "\n\n")
cat("Mismo número de filas. Sin error. Y sin embargo no es lo mismo:\n")
cat("el de arriba unió por empleado_id Y por grupo_fruta a la vez.\n")
Joining with `by = join_by(empleado_id, grupo_fruta)`

sin by= : 24000 10 
con by= : 24000 11 

Mismo número de filas. Sin error. Y sin embargo no es lo mismo:
el de arriba unió por empleado_id Y por grupo_fruta a la vez.
# Un paso más: qué pasa el día que las dos tablas no coinciden.
# Simulamos que a UN empleado lo reasignaron de grupo y solo se actualizó una tabla.
bienestar_desfasado <- bienestar
bienestar_desfasado$grupo_fruta[bienestar_desfasado$empleado_id == 7] <- "Control"

roto <- inner_join(empleados, bienestar_desfasado,
                   by = c("empleado_id", "grupo_fruta"))   # la clave "de más"
bien <- left_join(empleados, bienestar_desfasado, by = "empleado_id")

cat("uniendo por las dos columnas:", nrow(roto),
    " <- se perdieron", nrow(bien) - nrow(roto), "filas, en silencio\n")
cat("uniendo solo por la clave   :", nrow(bien), "\n")
uniendo por las dos columnas: 23960  <- se perdieron 40 filas, en silencio
uniendo solo por la clave   : 24000 

4. Limpiar

Hasta acá los datos venían prolijos porque nosotros los preparamos así. Los datos reales no vienen así.

La celda de abajo reconstruye el panel de bienestar tal como salió del sistema de encuestas, antes de que nadie lo revisara. No tiene nada de azar: elige siempre las mismas filas, así que te va a dar exactamente los mismos números que en las slides (y que en la versión de esta notebook en Python).

crudo <- bienestar

# el formulario guarda -999 cuando alguien lo abre y lo cierra sin contestar
crudo$bienestar[seq(1, nrow(crudo), by = 96)] <- -999

# un día el sistema reenvió parte de las respuestas de esa fecha
del_dia <- which(crudo$fecha == "2025-03-12")
crudo <- bind_rows(crudo, crudo[del_dia[1:300], ])

cat("archivo crudo:", dim(crudo), "\n")
archivo crudo: 24300 5 
summary(crudo$bienestar)
    Min.  1st Qu.   Median     Mean  3rd Qu.     Max. 
-999.000    4.000    5.000   -5.631    5.000    7.000 

Dos cosas no cierran: sobran 300 filas (esperábamos 24.000) y la media es negativa en una escala de 1 a 7.

Duplicados: primero averiguar de dónde vienen

cat("filas de más    :", nrow(crudo) - nrow(bienestar), "\n")
cat("duplicated()    :", sum(duplicated(crudo)), "\n")
filas de más    : 300 
duplicated()    : 300 
# Los dos números coinciden: todo lo que sobra son repeticiones. ¿Pero de dónde salieron?
unique(crudo$fecha[duplicated(crudo)])

Todas del mismo día. No es gente distraída mandando el formulario dos veces al azar: ese día el sistema reenvió parte de las respuestas.

Eso cambia la decisión. Si estuvieran repartidas por todo el panel habría que sospechar de algo sistemático; concentradas en un día, es un incidente puntual y borrarlas es seguro.

Antes de limpiar algo, averiguá de dónde vino. Casi siempre los datos sucios tienen un patrón que se puede encontrar.

crudo <- distinct(crudo)
cat("tras distinct():", dim(crudo), "\n")
tras distinct(): 24000 5 

Códigos de no respuesta

El -999 no es un bienestar: es la marca que usa el sistema para decir “acá no hubo respuesta”. Si no lo convertís a faltante, entra en el promedio como si fuera un puntaje.

Los vas a encontrar en casi cualquier base de encuestas, y casi siempre son números imposibles a propósito: -999, -99, 9999. En la documentación aparecen con varios nombres (valores centinela, códigos de faltante, missing values definidos por el usuario), pero son todos lo mismo: un faltante disfrazado de número.

En R el faltante se llama NA, y na_if() es la función que convierte un valor en NA.

cat("media con -999 adentro:", round(mean(crudo$bienestar), 2), "\n")

crudo$bienestar <- na_if(crudo$bienestar, -999)

cat("media después         :", round(mean(crudo$bienestar, na.rm = TRUE), 2), "\n")
media con -999 adentro: -5.76 
media después         : 4.7 

Prestá atención a ese na.rm = TRUE de la segunda línea, porque es una diferencia importante con Python: si hay un solo NA, mean() devuelve NA. R no promedia a menos que le digas explícitamente que descarte los faltantes.

Es más molesto de escribir, y es más seguro: en Python la media se calcula igual, sin avisarte de que descartó filas. Acá el NA te obliga a enterarte.

Y acá tuvimos suerte: una media negativa en una escala positiva grita que algo anda mal. Con un código menos claro (un 0, por ejemplo) la media daría 4,2 y nadie gritaría nada. Por eso el summary(), con su mínimo y su máximo, se corre siempre.

✏️ Consigna 4: texto que parece igual y no lo es

Cuando los datos se cargan a mano, la misma sede aparece escrita de varias maneras. Para vos es Mendoza; para R son categorías distintas.

Los dos métodos que arreglan esto son str_trim(), que saca los espacios de los bordes, y str_to_lower(), que pasa todo a minúscula. Se aplican uno sobre el resultado del otro.

Armá la cadena en la celda de abajo y fijate en cuántas categorías queda.

sedes_sucias <- c("Mendoza", "mendoza ", "MENDOZA", " Mendoza")
cat("categorías antes:", n_distinct(sedes_sucias), "\n")

# TODO: aplicá str_trim() y str_to_lower() sobre sedes_sucias
sedes_limpias <- ___(___(sedes_sucias))
cat("categorías después:", n_distinct(sedes_limpias), "\n\n")
print(sedes_limpias)

Ojo con esto, que es de los errores más frustrantes de los primeros días: las funciones de texto devuelven un vector nuevo, no modifican el original. Si querés que la corrección quede, tenés que guardarla de vuelta en la columna: df$sede <- str_to_lower(str_trim(df$sede)). Sin esa asignación, corrés todo, ves el resultado bien en pantalla, y la tabla sigue igual de sucia. Y no da ningún error.

Datos faltantes

cat("media    :", round(mean(crudo$bienestar, na.rm = TRUE), 2), "\n")
cat("faltantes:", sum(is.na(crudo$bienestar)), "\n")
media    : 4.7 
faltantes: 250 

Para calcularla, las 250 respuestas de bienestar faltantes fueron descartadas por el na.rm = TRUE, o sea por vos, no por la librería. A veces es lo que corresponde; a veces sesga todo el análisis. La diferencia está en por qué falta lo que falta:

Tipo de dato faltante Cuándo pasa Qué hacer
MCAR se cayó el formulario un martes: la falta no depende de nada borrar no sesga
MAR una sede tardó en adoptar la encuesta: depende de algo observado borrar sesga; condicionar o imputar por sede, no
MNAR quien está mal no contesta: depende del valor no observado ninguna imputación lo arregla: reconocerlo y acotar la conclusión

El tercero es el que muerde en ciencias del comportamiento. Veamos cuánto.

# Un paso más: simulamos MNAR. La mitad de los días con bienestar bajo no se contestan.
# Sin azar: de los días malos, uno de cada dos.
mnar <- bienestar
malos <- which(mnar$bienestar <= 4)
ocultar <- malos[seq(1, length(malos), by = 2)]
mnar$bienestar[ocultar] <- NA

cat("faltantes  :", sum(is.na(mnar$bienestar)),
    sprintf("(%.0f %%)", 100 * length(ocultar) / nrow(mnar)), "\n")
cat("media real :", round(mean(bienestar$bienestar), 2), "\n")
cat("media MNAR :", round(mean(mnar$bienestar, na.rm = TRUE), 2),
    " <- el na.rm lo tapó\n")
faltantes  : 4986 (21 %) 
media real : 4.7 
media MNAR : 4.95  <- el na.rm lo tapó

El sesgo es de +0,25. El efecto real del piloto de la fruta, el que vieron en la Clase 1, era de +0,34. O sea: un manejo descuidado de los faltantes puede inventar un efecto casi tan grande como el que estás buscando, o tapar uno que sí existe.

5. Enriquecer

Hasta acá sacamos cosas o las reacomodamos. Ahora vamos a agregar.

Primero, los valores extremos

sal25 <- filter(salario, anio == 2025)

q <- quantile(sal25$salario_mensual, c(0.25, 0.75)) # Cuartiles 1 y 3
ric <- q[2] - q[1] # Rango intercuartil
bajo <- q[1] - 1.5 * ric
alto <- q[2] + 1.5 * ric

extremos <- filter(sal25, salario_mensual < bajo | salario_mensual > alto)
cat(sprintf("umbrales (1,5 · RIC): %.0f mil  y  %.0f mil\n", bajo / 1000, alto / 1000))
cat("salarios fuera de esos límites:", nrow(extremos), "de", nrow(sal25), "\n")
umbrales (1,5 · RIC): 992 mil  y  1521 mil
salarios fuera de esos límites: 2 de 600 

Dos, sobre seiscientos. ¿Son errores? No: son probablemente los sueldos más altos de la empresa. La regla estadística los señala; qué hacer con ellos es una decisión sustantiva, y depende de si tu pregunta es sobre el empleado típico o sobre toda la empresa.

Sacarlos por defecto es la versión educada de inventar datos.

La variable que no existía

La pregunta del piloto es cuánto le cambió el bienestar a cada persona. Ninguna columna dice eso: hay que construirla.

Acá aparece el pipe (|>), que encadena pasos: agrupamos, promediamos, damos vuelta la tabla para tener una columna por fase, y restamos.

resumen <- bienestar |>
  group_by(empleado_id, fase) |>
  summarise(bienestar = mean(bienestar), .groups = "drop") |>
  pivot_wider(names_from = fase, values_from = bienestar) |>
  mutate(diferencia = intervencion - baseline) |>
  left_join(select(empleados, empleado_id, grupo_fruta), by = "empleado_id")

head(resumen, 3)
A tibble: 3 × 5
empleado_id baseline intervencion diferencia grupo_fruta
<dbl> <dbl> <dbl> <dbl> <chr>
1 4.55 5.15 0.60 Tratamiento
2 4.55 4.50 -0.05 Control
3 4.60 5.00 0.40 Tratamiento

pivot_wider() permite reordenar tus datos: los valores de una variable pasan a ser columnas (names_from = fase), y adentro de cada una va lo que querés ver (values_from = bienestar), con una fila por lo que no se mueve (los valores de empleado_id).

group_by() + summarise() permite agrupar tus datos por los valores de una variable (grupo_fruta), y mostrar algún atributo (en este caso la media) de otra variable (diferencia). Es el mismo par que ya usaste en la celda de arriba, ahí para promediar por empleado y fase.

resumen |>
  group_by(grupo_fruta) |>
  summarise(diferencia = round(mean(diferencia), 3))
A tibble: 2 × 2
grupo_fruta diferencia
<chr> <dbl>
Control -0.008
Tratamiento 0.354

El grupo que recibió fruta subió un tercio de punto; el otro no se movió. Y ese +0,35 es el mismo efecto que viste en la Clase 1 como un gráfico de barras ya cocinado. La diferencia es que ahora lo calculaste vos, desde el archivo, y sabés qué decisiones hay atrás.

✏️ Consigna 5

El +0,35 es un promedio. ¿A cuántos empleados tratados les fue peor que en su línea de base?

Completá la comparación. La comparación devuelve un vector de TRUE/FALSE, uno por empleado tratado, y sum() cuenta los TRUE: cada uno vale 1. Es el mismo truco que usamos recién con sum(duplicated(...)) y con sum(is.na(...)).

tratados <- filter(resumen, grupo_fruta == "Tratamiento")

# TODO: ¿qué comparación deja afuera a los que mejoraron y a los que quedaron igual?
empeoraron <- sum(tratados$diferencia ___ ___)

cat(empeoraron, "de", nrow(tratados), "empleados tratados empeoraron\n")

Un promedio positivo no significa que le haya servido a todos. Eso solo se ve construyendo la variable a nivel persona: con el promedio grupal, esta heterogeneidad es invisible.

Ejercicio de cierre

El resto de la clase vamos a utilizar HR Employee Attrition, de IBM. 1.470 empleados, 35 variables, y una columna que nos importa: Attrition, si la persona dejó la empresa o no.

Vamos a hacer el mismo recorrido de hoy, con los mismos comandos, sobre datos que no conocemos. Son tres consignas cortas, una por paso.

# Este no sale del sitio de la materia: se lee del repositorio oficial de IBM.
# Es exactamente el mismo archivo, y así la cátedra no lo redistribuye (IBM
# nunca declaró de forma explícita la licencia del dataset).
RUTA_HR <- if (IN_COLAB) {
  "https://raw.githubusercontent.com/IBM/employee-attrition-aif360/master/data/emp_attrition.csv"
} else {
  "../../../data/hr_attrition.csv"
}

hr <- read_csv(RUTA_HR, show_col_types = FALSE)
dim(hr)
  1. 1470
  2. 35
head(hr, 3)
A tibble: 3 × 35
Age Attrition BusinessTravel DailyRate Department DistanceFromHome Education EducationField EmployeeCount EmployeeNumber RelationshipSatisfaction StandardHours StockOptionLevel TotalWorkingYears TrainingTimesLastYear WorkLifeBalance YearsAtCompany YearsInCurrentRole YearsSinceLastPromotion YearsWithCurrManager
<dbl> <chr> <chr> <dbl> <chr> <dbl> <dbl> <chr> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
41 Yes Travel_Rarely 1102 Sales 1 2 Life Sciences 1 1 1 80 0 8 0 1 6 4 0 5
49 No Travel_Frequently 279 Research & Development 8 1 Life Sciences 1 2 4 80 1 10 3 3 10 7 1 7
37 Yes Travel_Rarely 1373 Research & Development 2 2 Other 1 4 2 80 0 7 3 3 0 0 0 0

✏️ Consigna 6 (explorar)

count() nos dice cuánta gente se fue. Pero 237 sobre 1.470 no es lo mismo que 237 sobre 300: el número en bruto no se puede interpretar solo.

Pasalo a proporción, igual que hicimos con los grupos de Nimbus.

salidas <- count(hr, Attrition)
print(salidas)

# TODO: dividí por el total de filas de hr para pasarlo a proporción
print(tibble(Attrition = salidas$Attrition,
             prop = round(salidas$n / ___, 3)))

Se fue el 16 %. Ese es el número con el que hay que comparar todo lo que venga después.

✏️ Consigna 7 (limpiar)

Dos chequeos de rutina antes de analizar nada, con las dos funciones que ya usaste hoy:

  • columnas constantes: si una columna tiene un solo valor distinto en las 1.470 filas, no distingue a nadie y no sirve para nada. La función que cuenta valores distintos es la misma que usaste para ver que las cuatro “Mendoza” eran una sola.
  • faltantes: la función que los marca es la misma que usamos con bienestar.
# valores distintos por columna, de menor a mayor
# TODO: completá la función que cuenta cuántos valores DISTINTOS tiene cada columna
distintos <- sapply(hr, ___)
print(head(sort(distintos), 3))

# TODO: completá la función que marca los faltantes
cat("\nfaltantes en total:", sum(___(hr)), "\n")

Tres columnas con un solo valor (EmployeeCount, Over18, StandardHours) y cero faltantes. Es un dataset armado para enseñar; una base real casi nunca viene así.

✏️ Consigna 8 (enriquecer)

Igual que con Nimbus, la pregunta interesante necesita una variable que no está en la tabla. OverTime y Attrition vienen como texto ("Yes"/"No"), y con texto no se puede promediar.

Las dos líneas de abajo los convierten en TRUE/FALSE. Después, acordate de la Consigna 5: el promedio de un vector de TRUE/FALSE es la proporción de TRUE.

Falta decidir qué va en cada lugar: cuál de las dos columnas define los grupos que querés comparar, y cuál es la que medís en cada grupo.

hr <- mutate(hr,
             hace_extra = OverTime == "Yes",
             se_fue = Attrition == "Yes")

# TODO: una de las dos columnas arma los grupos y la otra es la que se mide.
#       ¿Cuál va en cada lugar?
hr |>
  group_by(___) |>
  summarise(prop = round(mean(___), 3))

De los que no hacen horas extra se fue el 10 %; de los que hacen, el 30 %. Casi tres veces más, contra el 16 % general que calculaste en la Consigna 6.

Ahora bien: esto es una asociación, no una causa. Puede que las horas extra desgasten y la gente se vaya; puede que los puestos más exigentes tengan las dos cosas a la vez; puede que quien ya decidió irse deje de anotar horas extra. Los datos no alcanzan para decidir entre esas tres historias, y elegir entre ellas es exactamente el conocimiento de dominio del que hablábamos.

Resumen de hoy

Paso Qué usamos En una línea
Cargar read_csv(ruta) carga una tabla desde un archivo y devuelve un data frame
paste0(BASE, "archivo.csv") la ruta es la parte fija más el nombre, sea una carpeta o una URL; en R los textos se pegan con paste0(), no con +
delim, na, col_types argumentos: afinan cómo se lee, sin cambiar qué se lee
Explorar dim(), head(), glimpse() las tres preguntas de siempre apenas cargás algo
todo es función la tabla no hace nada: las funciones la reciben como primer argumento, y por eso encaja el pipe
df$col vs. select(df, a, b) una columna como vector vs. una tabla más chica
filter(df, condicion) filtra filas: adentro va una comparación con ==
filter() vs. slice() condición vs. posición; y la posición cambia cada vez que filtrás
summary(), count() los números de un saque y la tabla de frecuencias
Estructurar left_join(a, b, by = "clave") une dos tablas. Siempre especificar by
Limpiar duplicated(), distinct() encontrar y sacar filas repetidas, después de ver de dónde salieron
na_if(x, -999) convertir códigos de no respuesta en NA de verdad
str_trim(), str_to_lower(), n_distinct() emparejar texto y contar categorías. Hay que reasignar el resultado
sum(is.na(x)), na.rm = TRUE cuántos faltantes hay, y el pedido explícito de ignorarlos
MCAR / MAR / MNAR por qué falta lo que falta, y qué se puede hacer con cada uno
Enriquecer quantile() y el RIC señala los valores extremos. Señalarlos no es sacarlos
group_by(), summarise(), pivot_wider() resumir por grupo y reacomodar la tabla
construir una variable nueva lo que los datos no traen y la pregunta necesita

Lo que nos llevamos hoy: procesar datos conlleva muchas decisiones que requieren conocimiento de dominio. Y esas decisiones no se delegan.