Clase 2 · Análisis exploratorio y procesamiento de datos

#@title Estilo de la clase (ejecutar, no hace falta leer) {display-mode: "form"}
from IPython.display import HTML, display
display(HTML(r'''
<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


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 Python
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 variables definidas antes.

0. Preparación

Antes que nada: poné los archivos en tu Drive

Esta notebook no trae los datos adentro: los lee desde tu Google Drive. Si no hacés este paso, no va a arrancar.

  1. Bajá los cuatro archivos desde la página de la clase: nimbus_empleados.csv, nimbus_salario.csv, nimbus_bienestar_diario.csv y hr_attrition.csv.
  2. En tu Drive, dentro de Mi unidad, creá la carpeta analitica_de_datos y adentro otra llamada clase_02.
  3. Subí los cuatro archivos ahí.

Te tiene que quedar así:

Mi unidad/
└── analitica_de_datos/
    └── clase_02/
        ├── nimbus_empleados.csv
        ├── nimbus_salario.csv
        ├── nimbus_bienestar_diario.csv
        └── hr_attrition.csv

El nombre de la carpeta importa: si está escrito distinto, o los archivos quedaron en otro lado, la celda de abajo va a fallar. Es el error más común del día, y casi nunca es el código: es la ruta.

La primera celda hace dos cosas más: trae pandas y monta tu Drive. Colab te va a pedir permiso para acceder a tus archivos; hay que dárselo, y solo se hace una vez por sesión.

import pandas as pd
import numpy as np

# En Colab montamos tu Google Drive, igual que en la Clase 1.
# Fuera de Colab (cuando la cátedra prepara el material) se usa la copia local.
IN_COLAB = "google.colab" in str(get_ipython())

if IN_COLAB:
    from google.colab import drive
    drive.mount('/content/drive')
    BASE = '/content/drive/MyDrive/analitica_de_datos/clase_02/'
else:
    BASE = '../../../data/toy-nimbus/'

print("Listo. Los archivos se buscan en:", BASE)

# Chequeo rápido, para que el problema se vea acá y no tres celdas más abajo.
import os
if IN_COLAB and not os.path.isdir(BASE):
    print()
    print("OJO: no encuentro esa carpeta en tu Drive.")
    print("Revisa que se llame exactamente analitica_de_datos/clase_02")
Listo. Los archivos se buscan en: ../../../data/toy-nimbus/

1. Cargar

Cargar un CSV es una línea. pd.read_csv() lee el archivo y devuelve un DataFrame: una tabla con filas y columnas con nombre.

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

empleados  =  pd.        read_csv(       RUTA        )
   ↑          ↑              ↑             ↑
 el nombre   la          la función    el archivo
 que le      librería    que lee un    que queremos
 damos                   CSV           leer

Antes: qué es esa RUTA

read_csv no adivina dónde está el archivo. Hay que darle la ruta: la carpeta más el nombre del archivo, todo junto en un solo texto.

La carpeta 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 con un +.

Cuidado con ese +, que no es el de sumar: entre dos textos, + los pega uno detrás del otro. "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 ...clase_02nimbus_empleados.csv.

Mirá cómo queda:

print("la carpeta          :", BASE)
print("el nombre           :", "nimbus_empleados.csv")
print("la ruta, pegada     :", BASE + "nimbus_empleados.csv")
la carpeta          : ../../../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 movés los archivos de carpeta, cambiás BASE en un solo lugar y toda la notebook sigue andando.

empleados = pd.read_csv(BASE + "nimbus_empleados.csv")
empleados.shape
(600, 7)

Los parámetros: decirle cómo leer

Una función puede recibir varios datos, que se llaman parámetros. 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 dtype abajo, que se pone detrás de =). Esos opcionales no cambian qué se lee, sino cómo.

Los parámetros para esta función son más de 50. No hay que saberlos: hay que saber que existen y dónde buscarlos. Estos tres resuelven el 90 % de los archivos que vienen raros.

# sep: especifica cuál es el separador (por defecto es una coma, pero se puede cambiar)
#   pd.read_csv(RUTA, sep=";")

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

# dtype: forzar el tipo de los valores de una columna antes de que pandas adivine
#   pd.read_csv(RUTA, dtype={"empleado_id": str})

# Un paso más: leamos la misma tabla forzando empleado_id a texto y comparemos.
prueba = pd.read_csv(BASE + "nimbus_empleados.csv", dtype={"empleado_id": str})
print("sin dtype:", empleados["empleado_id"].dtype)
print("con dtype:", prueba["empleado_id"].dtype)
sin dtype: int64
con dtype: str

✏️ Consigna 1

Cargá la tabla de salarios completando el nombre del archivo que falta. La carpeta ya está en BASE, así que vos solo ponés el nombre y el + 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 = pd.read_csv(BASE + ARCHIVO)
print(salario.shape)
salario.head(3)
# Y la tercera tabla, la del piloto de la fruta, que en la Clase 1 no habíamos abierto.
bienestar = pd.read_csv(BASE + "nimbus_bienestar_diario.csv")
bienestar.head(3)
empleado_id fecha fase grupo_fruta bienestar
0 1 2025-03-03 baseline Tratamiento 4
1 1 2025-03-04 baseline Tratamiento 5
2 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.

type(empleados)
pandas.DataFrame
empleados.head()
empleado_id sede area genero educacion_nivel antiguedad_anios grupo_fruta
0 1 Buenos Aires Ventas Masculino Universitario 8 Tratamiento
1 2 Mar del Plata Ingenieria Femenino Posgrado 8 Control
2 3 Mendoza Producto Femenino Universitario 4 Tratamiento
3 4 Rosario Producto Masculino Universitario 1 Tratamiento
4 5 Rosario Operaciones Masculino Posgrado 3 Tratamiento
empleados.dtypes
empleado_id         int64
sede                  str
area                  str
genero                str
educacion_nivel       str
antiguedad_anios    int64
grupo_fruta           str
dtype: object

¿Por qué head() lleva paréntesis y dtypes no?

  • head() es un método: algo que el DataFrame hace. Los paréntesis son la orden de hacerlo, y adentro pueden ir parámetros (head(10) te da diez filas).
  • dtypes es un atributo: algo que el DataFrame tiene. Ya está ahí, no hay nada que ejecutar, y por eso no acepta parámetros.

Si te confundís, Python te avisa: dtypes() tira un error de que el objeto no es llamable, y head sin paréntesis te devuelve una descripción del método en vez de la tabla.

empleados.shape
(600, 7)

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

Elegir columnas

Los corchetes [ ] siempre piden una parte de la tabla. Lo que va adentro decide qué parte.

empleados["sede"]          # un nombre  -> esa columna
0       Buenos Aires
1      Mar del Plata
2            Mendoza
3            Rosario
4            Rosario
           ...      
595    Mar del Plata
596          Mendoza
597    Mar del Plata
598          Mendoza
599          Mendoza
Name: sede, Length: 600, dtype: str
empleados[["sede", "area"]]     # una lista -> esas columnas (ojo: corchetes dobles)
sede area
0 Buenos Aires Ventas
1 Mar del Plata Ingenieria
2 Mendoza Producto
3 Rosario Producto
4 Rosario Operaciones
... ... ...
595 Mar del Plata Operaciones
596 Mendoza Ingenieria
597 Mar del Plata Ingenieria
598 Mendoza Ingenieria
599 Mendoza Producto

600 rows × 2 columns

Filtrar filas: dos pasos

Y hay un tercer uso de los corchetes: si adentro ponés una columna de True/False, pandas te devuelve las filas donde dice True.

Esa columna la fabricás vos con una comparación. Ojo: un = asigna un valor, dos == comparan.

# Paso 1: preguntar. Devuelve una respuesta por CADA fila, no los empleados de Mendoza.
mascara = empleados["sede"] == "Mendoza" # Compara la columna sedes con el valor "Mendoza"
mascara.head()
0    False
1    False
2     True
3    False
4    False
Name: sede, dtype: bool
print("largo de la pregunta:", len(mascara))

# Paso 2: usar esa máscara para quedarse con las filas donde dio True
empleados_mendoza = empleados[mascara]
print("largo de la respuesta:", len(empleados_mendoza))
empleados_mendoza.head(3)
largo de la pregunta: 600
largo de la respuesta: 93
empleado_id sede area genero educacion_nivel antiguedad_anios grupo_fruta
2 3 Mendoza Producto Femenino Universitario 4 Tratamiento
10 11 Mendoza Producto Masculino Terciario 1 Tratamiento
16 17 Mendoza Ingenieria Femenino Terciario 5 Tratamiento

Fijate en el índice del resultado: 2, 10, 16… No arranca en cero y no va de a uno, porque al filtrar cada fila se llevó la etiqueta que tenía en la tabla original.

Por eso hay dos formas de ubicar un dato:

  • .loc[fila, columna] usa etiquetas (los nombres)
  • .iloc[fila, columna] usa posiciones (el orden, empezando en 0)

Las dos toman fila primero, columna después.

m = empleados_mendoza[["sede", "area", "antiguedad_anios"]]

print("por etiqueta :", m.loc[10, "area"]) # Fijate arriba. 10 es el índice original, "area" es la columna.
print("por posición :", m.iloc[1, 1]) # 1 es la posición de la segunda fila (empezando desde 0), 1 es la segunda columna
por etiqueta : Producto
por posición : Producto
# Mientras el índice sea 0,1,2 los dos dan lo mismo y nadie nota la diferencia.
# En cuanto filtrás, dejan de coincidir. Esto explota:
try:
    m.loc[0]
except KeyError as e:
    print("KeyError:", e)
    print("No hay ninguna fila etiquetada 0 en Mendoza: el empleado 0 era de Buenos Aires.")

# iloc[0], en cambio, funciona perfecto: es la PRIMERA fila de esta tabla.
m.iloc[0]
KeyError: 0
No hay ninguna fila etiquetada 0 en Mendoza: el empleado 0 era de Buenos Aires.
sede                 Mendoza
area                Producto
antiguedad_anios           4
Name: 2, dtype: object

Describir

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

Mirá count primero: si no da lo que esperabas, hay faltantes.

bienestar["bienestar"].describe()
count    24000.000000
mean         4.696333
std          0.959509
min          1.000000
25%          4.000000
50%          5.000000
75%          5.000000
max          7.000000
Name: bienestar, dtype: float64
# Sobre una columna de texto, describe() devuelve otra cosa
empleados["sede"].describe()
count              600
unique               6
top       Buenos Aires
freq               109
Name: sede, dtype: object
# Un paso más: la tabla de frecuencias completa
empleados["sede"].value_counts()
sede
Buenos Aires     109
Mar del Plata    108
Cordoba           98
Rosario           97
Bahia Blanca      95
Mendoza           93
Name: count, dtype: int64

✏️ 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 describe() 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 = bienestar[bienestar["bienestar"] == ___]
print("Maximos por grupo:")
print(maximos["grupo_fruta"].value_counts())
print()

# b) ¿es porque a un grupo le fue mejor, o porque tiene más mediciones?
por_grupo = bienestar["grupo_fruta"].value_counts()
print("Mediciones por grupo:")
print(por_grupo)
print()
# TODO: dividí los máximos de cada grupo por el total de mediciones por grupo
print((maximos["grupo_fruta"].value_counts() / ___).round(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).

merge las pega usando una columna en común como clave.

print("empleados:", empleados.shape)
print("bienestar:", bienestar.shape)
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 = ___

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

Ahora sí: unimos y comparamos.

datos = pd.merge(empleados, bienestar, on="empleado_id")

print("tu respuesta:", mi_respuesta)
print("la realidad :", datos.shape[1], "columnas")
print()
print("7 + 5 = 12, menos 1: empleado_id es la clave y queda UNA sola vez.")
tu respuesta: 12
la realidad : 11 columnas

7 + 5 = 12, menos 1: empleado_id es la clave y queda UNA sola vez.
list(datos.columns)
['empleado_id',
 'sede',
 'area',
 'genero',
 'educacion_nivel',
 'antiguedad_anios',
 'grupo_fruta_x',
 'fecha',
 'fase',
 'grupo_fruta_y',
 '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, pandas se trajo ambas y les puso un sufijo para distinguirlas.

El merge que corre limpio y está mal

Si no le decís por qué columna unir, pandas usa todas las que se repiten. Mirá:

auto = pd.merge(empleados, bienestar)          # sin on=, que indica por qué columna unir
print("sin on= :", auto.shape)
print("con on= :", datos.shape)
print()
print("Mismo número de filas. Sin error, sin warning. Y sin embargo no es lo mismo:")
print("el de arriba unió por empleado_id Y por grupo_fruta a la vez.")
sin on= : (24000, 10)
con on= : (24000, 11)

Mismo número de filas. Sin error, sin warning. 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.copy()
fila = bienestar_desfasado["empleado_id"] == 7
bienestar_desfasado.loc[fila, "grupo_fruta"] = "Control"

roto = pd.merge(empleados, bienestar_desfasado)              # sin on=
bien = pd.merge(empleados, bienestar_desfasado, on="empleado_id")

print("sin on= :", roto.shape, " <- se perdieron", len(bien) - len(roto), "filas, en silencio")
print("con on= :", bien.shape)
sin on= : (23960, 10)  <- se perdieron 40 filas, en silencio
con on= : (24000, 11)

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

#@title Así salió del sistema de encuestas (ejecutar, lo miramos en un minuto) {display-mode: "form"}
crudo = bienestar.copy()

# el formulario guarda -999 cuando alguien lo abre y lo cierra sin contestar
crudo.loc[::96, "bienestar"] = -999

# un día el sistema reenvió parte de las respuestas de esa fecha
del_dia = crudo.index[crudo["fecha"] == "2025-03-12"]
crudo = pd.concat([crudo, crudo.loc[del_dia[:300]]], ignore_index=True)

print("archivo crudo:", crudo.shape)
archivo crudo: (24300, 5)
crudo["bienestar"].describe()
count    24300.000000
mean        -5.630947
std        101.286445
min       -999.000000
25%          4.000000
50%          5.000000
75%          5.000000
max          7.000000
Name: bienestar, dtype: float64

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

print("filas de más   :", len(crudo) - len(bienestar))
print("duplicated()   :", crudo.duplicated().sum())
filas de más   : 300
duplicated()   : 300
# Los dos números coinciden: todo lo que sobra son repeticiones. ¿Pero de dónde salieron?
crudo[crudo.duplicated()]["fecha"].unique()
<ArrowStringArray>
['2025-03-12']
Length: 1, dtype: str

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 = crudo.drop_duplicates()
print("tras drop_duplicates():", crudo.shape)
tras drop_duplicates(): (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.

print("media con -999 adentro:", round(crudo["bienestar"].mean(), 2))

crudo = crudo.replace(-999, np.nan)

print("media después         :", round(crudo["bienestar"].mean(), 2))
media con -999 adentro: -5.76
media después         : 4.7

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 describe(), con su min y su max, 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 pandas son categorías distintas.

Los dos métodos que arreglan esto son str.strip(), que saca los espacios de los bordes, y str.lower(), que pasa todo a minúscula. Se encadenan uno detrás del otro.

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

sedes_sucias = pd.Series(["Mendoza", "mendoza ", "MENDOZA", " Mendoza"])
print("categorías antes:", sedes_sucias.nunique())

# TODO: encadená str.strip() y str.lower() sobre sedes_sucias
sedes_limpias = sedes_sucias.str.___().str.___()
print("categorías después:", sedes_limpias.nunique())
print()
print(sedes_limpias.tolist())

Ojo con esto, que es de los errores más frustrantes de los primeros días: los métodos de texto devuelven una columna nueva, no modifican la original. Si querés que la corrección quede, tenés que guardarla de vuelta en la columna: df["sede"] = df["sede"].str.strip().str.lower(). 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

print("media   :", round(crudo["bienestar"].mean(), 2))
print("faltantes:", int(crudo["bienestar"].isna().sum()))
media   : 4.7
faltantes: 250

La media salió sin ninguna advertencia. Para calcularla, las 250 respuestas de bienestar faltantes fueron descartadas por la librería, no por vos. 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.copy()
malos = mnar.index[mnar["bienestar"] <= 4]
ocultar = mnar.index.isin(malos[::2])
mnar.loc[ocultar, "bienestar"] = np.nan

print("faltantes  :", int(mnar['bienestar'].isna().sum()), f"({100*ocultar.mean():.0f} %)")
print("media real :", round(bienestar['bienestar'].mean(), 2))
print("media MNAR :", round(mnar['bienestar'].mean(), 2), " <- pandas no avisó nada")
faltantes  : 4986 (21 %)
media real : 4.7
media MNAR : 4.95  <- pandas no avisó nada

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 = salario[salario["anio"] == 2025]

q1, q3 = sal25["salario_mensual"].quantile([0.25, 0.75]) # Cuartiles 1 y 3
ric = q3 - q1 # Rango intercuartil
bajo, alto = q1 - 1.5 * ric, q3 + 1.5 * ric

extremos = sal25[(sal25["salario_mensual"] < bajo) | (sal25["salario_mensual"] > alto)]
print(f"umbrales (1,5 · RIC): {bajo/1000:.0f} mil  y  {alto/1000:.0f} mil")
print(f"salarios fuera de esos límites: {len(extremos)} de {len(sal25)}")
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.

resumen = bienestar.pivot_table(index="empleado_id", columns="fase", values="bienestar")
resumen["diferencia"] = resumen["intervencion"] - resumen["baseline"]

resumen = resumen.merge(empleados[["empleado_id", "grupo_fruta"]], on="empleado_id")
resumen.head(3)
empleado_id baseline intervencion diferencia grupo_fruta
0 1 4.55 5.15 0.60 Tratamiento
1 2 4.55 4.50 -0.05 Control
2 3 4.60 5.00 0.40 Tratamiento

pivot_table permite reordenar tus datos según una variable que usas para organizar las filas (los valores de empleado_id), otra para organizar las columnas (los valores de fase), y los valores que querés que se vean (el valor de bienestar de ese empleado en esa fase).

groupby 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).

resumen.groupby("grupo_fruta")["diferencia"].mean().round(3)
grupo_fruta
Control       -0.008
Tratamiento    0.354
Name: diferencia, dtype: float64

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 una columna de True/False, una por empleado tratado, y .sum() cuenta los True: cada uno vale 1. Es el mismo truco que usamos recién con duplicated().sum() y con isna().sum().

tratados = resumen[resumen["grupo_fruta"] == "Tratamiento"]

# TODO: ¿qué comparación deja afuera a los que mejoraron y a los que quedaron igual?
empeoraron = (tratados["diferencia"] ___ ___).sum()

print(f"{empeoraron} de {len(tratados)} empleados tratados empeoraron")

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.

if IN_COLAB:
    RUTA_HR = '/content/drive/MyDrive/analitica_de_datos/clase_02/hr_attrition.csv'
else:
    RUTA_HR = '../../../data/hr_attrition.csv'

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

3 rows × 35 columns

✏️ Consigna 6 (explorar)

value_counts() 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 = hr["Attrition"].value_counts()
print(salidas)
print()
# TODO: dividí por el total de filas de hr para pasarlo a proporción
print((salidas / ___).round(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 los dos métodos 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. El método que cuenta valores distintos es el mismo que usaste para ver que las cuatro “Mendoza” eran una sola.
  • faltantes: el método que los marca es el mismo que usamos con bienestar.
# valores distintos por columna, de menor a mayor
# TODO: completá el método que cuenta cuántos valores DISTINTOS tiene cada columna
print(hr.___().sort_values().head(3))
print()
# TODO: completá el método que marca los faltantes
print("faltantes en total:", int(hr.___().sum().sum()))

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 celdas de abajo los convierten en True/False. Después, acordate de la Consigna 5: el promedio de una columna 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["hace_extra"] = hr["OverTime"] == "Yes"
hr["se_fue"] = hr["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.groupby("___")["___"].mean().round(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 pd.read_csv(ruta) carga una tabla desde un archivo y devuelve un DataFrame
BASE + "archivo.csv" la ruta es carpeta más nombre; entre textos, + los pega
sep, na_values, dtype parámetros: afinan cómo se lee, sin cambiar qué se lee
Explorar shape, head(), dtypes las tres preguntas de siempre apenas cargás algo
método vs. atributo head() es algo que la tabla hace; dtypes algo que tiene
df["col"] vs. df[["a","b"]] una columna vs. una tabla más chica
df[mascara] filtra filas: adentro va una columna de True/False
.loc vs. .iloc etiqueta vs. posición; fila primero, columna después
describe(), value_counts() los ocho números y la tabla de frecuencias
Estructurar pd.merge(a, b, on="clave") une dos tablas. Siempre especificar on
Limpiar duplicated(), drop_duplicates() encontrar y sacar filas repetidas, después de ver de dónde salieron
replace(-999, np.nan) convertir códigos de no respuesta en faltantes de verdad
.str.strip(), .str.lower(), nunique() emparejar texto y contar categorías. Hay que reasignar el resultado
isna().sum() cuántos faltantes hay, que las operaciones descartan sin avisar
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
pivot_table(), groupby() reacomodar la tabla y resumir por grupo
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.