#@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>
'''))Clase 2 · Análisis exploratorio y procesamiento de datos
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.
- Bajá los cuatro archivos desde la página de la clase:
nimbus_empleados.csv,nimbus_salario.csv,nimbus_bienestar_diario.csvyhr_attrition.csv. - En tu Drive, dentro de Mi unidad, creá la carpeta
analitica_de_datosy adentro otra llamadaclase_02. - 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.dtypesempleado_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).dtypeses 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 columna0 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 columnapor 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 sí 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.