# Identidad visual de la materia (ejecutar y seguir)
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; }
</style>")Clase 2: Analisis exploratorio y procesamiento de datos
Analitica de Datos, Maestria en Ciencias del Comportamiento, Universidad de San Andres, Primavera 2026
Lenguaje: R. Esta es la version R, equivalente y validada, de la notebook Python de la clase: mismo dataset, mismos pasos, mismos resultados. Usala si te sentis mas comodo/a en R; el dictado sigue la version Python.
Objetivo. Recorrer el analisis exploratorio (EDA) completo de la clase con el R que ya conoces (tidyverse), articulando lo visto en Estadistica con las practicas que piden los proyectos de machine learning.
Al terminar esta clase vas a poder:
- cargar un dataset real desde la nube y explorarlo con
readrydplyr; - calcular descriptivos, filtrar, ordenar, agrupar y resumir;
- detectar y tratar duplicados, valores faltantes y outliers, justificando cada decision;
- crear variables nuevas con sentido sustantivo (feature engineering);
- mapear cada verbo de
dplyra su equivalente en pandas, para moverte entre lenguajes.
El codigo esta casi completo, los ejercicios tienen huecos para pensar, y las soluciones estan disponibles.
Contenidos de hoy
| # | Tema | La idea en una linea |
|---|---|---|
| 1 | El R que ya sabes, como punto de partida | tu tidyverse es el mapa para leer pandas |
| 2 | Cargar datos desde la nube | un tibble es una tabla con superpoderes |
| 3 | Exploracion inicial y descriptivos | antes de modelar, mirar; antes de mirar, resumir |
| 4 | Procesamiento con dplyr | filtrar, ordenar y agrupar son el 80% del trabajo real |
| 5 | Faltantes y duplicados | primero detectar y entender, despues decidir |
| 6 | Outliers | atipico no es sinonimo de error |
| 7 | Agrupar para comparar y feature engineering | las mejores variables se crean con conocimiento del dominio |
| 8 | Terminologia | limpieza, procesamiento y preprocesamiento no son lo mismo |
| Autoevaluacion, Cierre |
Tip: hace click en cualquier tema para saltar directo. Los ejercicios estan intercalados en la seccion que les corresponde.
Donde se ubica esta clase en la materia
En la Clase 1 pusiste en marcha Google Colab. Hoy recorres, de punta a punta, la primera etapa de todo proyecto de datos: el analisis exploratorio. Esta version esta escrita en el R que ya usaste en Estadistica y Metodologia, con una mision extra: cada operacion lleva al lado su equivalente en pandas, para que la version Python nunca te resulte ajena.
Sobre que se apoya. Los descriptivos y la lectura de distribuciones de Estadistica, y tu manejo de tidyverse.
A que habilita. La Clase 3 (visualizacion) parte del dataset que hoy dejamos explorado y limpio. Las clases 4 a 10 reutilizan este dataset y las decisiones de limpieza que tomes hoy.
| se apoya en | esta clase | habilita |
|---|---|---|
| Estadistica descriptiva; tidyverse; Colab (Clase 1) | EDA completo: cargar, explorar, limpiar, transformar | Visualizacion (Clase 3); modelado supervisado y no supervisado (Clases 4 a 10) |
El hilo de hoy
Una pregunta de people analytics guia toda la clase: quienes dejan la empresa y por que. Repaso de los verbos de dplyr \(\to\) cargar el dataset real de rotacion de personal \(\to\) primeros descriptivos \(\to\) filtrar, ordenar y agrupar \(\to\) detectar duplicados y faltantes \(\to\) decidir que hacer con los outliers \(\to\) crear variables nuevas que capturen el fenomeno \(\to\) una tabla lista para visualizar (Clase 3) y modelar (Clase 4).
# Setup: librerias, semilla, estilo de figuras y helpers de la materia
suppressPackageStartupMessages(library(tidyverse))
SEED <- 42
set.seed(SEED)
# Paleta semantica de la materia (misma que la version Python y el sitio)
COL <- c(
primary = "#00529B", # azul institucional: la serie principal
accent = "#C0492F", # terracota: el contraste
good = "#1F7A4D", # verde: correcto / esperado
bad = "#B4232E", # rojo: problema / alerta
muted = "#7E9EBB", # azul apagado: geometria secundaria
ink = "#122535", # tinta: texto y ejes
grey = "#9FB0BD" # gris azulado: grillas
)
theme_set(
theme_minimal(base_size = 12) +
theme(plot.title = element_text(face = "bold", size = 12, colour = COL[["ink"]]),
panel.grid.minor = element_blank())
)
options(repr.plot.width = 7, repr.plot.height = 4)
# En Colab las figuras se guardan en ./figures; al renderizar el sitio, en assets de la clase
en_colab <- file.exists("/content")
fig_dir <- if (en_colab) "figures" else "../assets/figures"
dir.create(fig_dir, showWarnings = FALSE, recursive = TRUE)
guardar_figura <- function(nombre, plot = last_plot()) {
# Sufijo _r: la figura equivalente de la version Python es la canonica del sitio
ggsave(file.path(fig_dir, paste0(nombre, "_r.png")), plot = plot,
dpi = 150, width = 7, height = 4, bg = "white")
}
# URL publica y estable del dataset (repo oficial de IBM); la cargamos en la seccion 2
URL_DATOS <- "https://raw.githubusercontent.com/IBM/employee-attrition-aif360/master/data/emp_attrition.csv"
cat("Setup listo. Semilla fija en", SEED, "\n")Setup listo. Semilla fija en 42
## 1. El R que ya sabes, como punto de partida
La idea. No arrancas de cero: los verbos de dplyr que usaste en Estadistica son exactamente las operaciones que todo EDA necesita, en cualquier lenguaje. Esta seccion repasa las piezas basicas con ejemplos de people analytics y te deja el diccionario R \(\leftrightarrow\) Python que vamos a usar toda la materia.
| en R (tidyverse) | en Python (pandas) | que hace |
|---|---|---|
x <- 42 |
x = 42 |
asignar una variable |
c(29, 41, 35) |
[29, 41, 35] |
coleccion ordenada |
list(nombre = "Ana") |
{"nombre": "Ana"} |
coleccion con nombres |
select(df, a, b) |
df[["a", "b"]] |
elegir columnas |
filter(df, cond) |
df[cond] |
filtrar filas |
arrange(df, desc(a)) |
df.sort_values("a", ascending=False) |
ordenar |
mutate(df, c = a/b) |
df["c"] = df["a"] / df["b"] |
crear columna |
group_by() + summarise() |
df.groupby("g")["x"].mean() |
agrupar y resumir |
Una diferencia para tener a mano: R cuenta desde 1 y Python desde 0. edades[1] es el primer elemento en R y el segundo en Python.
Las piezas basicas, en miniatura
Antes del dataset real, la logica de un analisis en cuatro objetos: un vector, una lista nombrada (el “diccionario” de R), un bucle y una funcion.
# Un vector: edades de un equipo de 6 personas
edades <- c(29, 41, 35, 52, 27, 38)
cat("Primera edad:", edades[1], "| ultima:", edades[length(edades)], "| cuantas:", length(edades), "\n")
# Una lista nombrada: UNA empleada con sus atributos (el "diccionario" de Python)
empleada <- list(nombre = "Ana", edad = 29, ingreso = 3200.0, horas_extra = TRUE)
cat("Edad de Ana:", empleada$edad, "\n")
# Una tabla en miniatura: en R lo natural es un tibble
equipo <- tibble(
nombre = c("Ana", "Bruno", "Carla", "Dario"),
ingreso = c(3200, 5100, 2800, 6900),
renuncio = c(TRUE, FALSE, TRUE, FALSE)
)
# Bucle y condicional: cuantas personas renunciaron
renuncias <- 0
for (i in seq_len(nrow(equipo))) {
if (equipo$renuncio[i]) {
renuncias <- renuncias + 1
}
}
cat("Renuncias en el equipo:", renuncias, "de", nrow(equipo), "\n")
# La misma logica, empaquetada en una funcion reutilizable
tasa_de_renuncia <- function(tabla) {
mean(tabla$renuncio) # la media de un vector logico es una proporcion
}
tasa_equipo <- tasa_de_renuncia(equipo)
cat("Tasa de renuncia del equipo:", tasa_equipo, "\n")
# Verificacion: cada afirmacion de esta clase se comprueba con stopifnot()
stopifnot(edades[1] == 29, renuncias == 2, tasa_equipo == 0.5)Primera edad: 29 | ultima: 38 | cuantas: 6
Edad de Ana: 29
Renuncias en el equipo: 2 de 4
Tasa de renuncia del equipo: 0.5
### Ejercicio 1. Fundamentos: clasificar ingresos
Escribi una funcion clasificar_ingreso(ingreso) que devuelva "bajo" si el ingreso mensual es menor a 3000, "medio" si esta entre 3000 y 10000 (sin incluir), y "alto" si es 10000 o mas. Despues usala en un for para clasificar los ingresos de ingresos_muestra y contar cuantos hay de cada banda en el vector nombrado conteo.
Pista: conteo[[banda]] <- conteo[[banda]] + 1 suma 1 a la banda que corresponda.
clasificar_ingreso <- function(ingreso) {
if (ingreso < 3000) {
"bajo"
} else if (ingreso < 10000) {
"medio"
} else {
"alto"
}
}
ingresos_muestra <- c(2094, 5993, 2911, 19999, 4919, 2670, 13245)
conteo <- c(bajo = 0, medio = 0, alto = 0)
for (ingreso in ingresos_muestra) {
banda <- clasificar_ingreso(ingreso)
conteo[[banda]] <- conteo[[banda]] + 1
}
print(conteo)
stopifnot(conteo[["bajo"]] == 3, conteo[["medio"]] == 2, conteo[["alto"]] == 2)
cat("Ejercicio 1 correcto\n") bajo medio alto
3 2 2
Ejercicio 1 correcto
## 2. Cargar datos: de un archivo en la nube a un tibble
La idea. Todo analisis empieza trayendo los datos a memoria. readr::read_csv() convierte un CSV (local o en la nube) en un tibble, igual que pd.read_csv() en Python.
Nuestro dataset es HR Employee Attrition: 1470 empleados ficticios creados por cientificos de datos de IBM para estudiar la rotacion de personal (attrition: si la persona dejo la empresa). Es EL problema clasico de people analytics. Lo cargamos directo desde el repositorio publico oficial de IBM.
Para otros formatos el patron es identico: readxl::read_excel() para planillas, arrow::read_parquet() para el formato columnar de datos grandes, y write_csv() y compania para exportar.
# Cargar el CSV desde la URL publica (funciona en Colab sin descargar nada a mano)
df <- read_csv(URL_DATOS, show_col_types = FALSE)
cat("Dimensiones (filas, columnas):", dim(df), "\n")
head(df, 3)Dimensiones (filas, columnas): 1470 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 |
# Verificacion: el dataset tiene 1470 empleados y 35 variables
stopifnot(nrow(df) == 1470, ncol(df) == 35, "Attrition" %in% names(df))Que tipo tiene cada columna
glimpse() resume nombre, tipo y primeros valores de cada columna (es el df.info() de pandas). Leer esta salida es el primer chequeo de calidad: una variable numerica que aparece como texto es sintoma de datos sucios.
glimpse(df)Rows: 1,470 Columns: 35 $ Age <dbl> 41, 49, 37, 33, 27, 32, 59, 30, 38, 36, 35, 2… $ Attrition <chr> "Yes", "No", "Yes", "No", "No", "No", "No", "… $ BusinessTravel <chr> "Travel_Rarely", "Travel_Frequently", "Travel… $ DailyRate <dbl> 1102, 279, 1373, 1392, 591, 1005, 1324, 1358,… $ Department <chr> "Sales", "Research & Development", "Research … $ DistanceFromHome <dbl> 1, 8, 2, 3, 2, 2, 3, 24, 23, 27, 16, 15, 26, … $ Education <dbl> 2, 1, 2, 4, 1, 2, 3, 1, 3, 3, 3, 2, 1, 2, 3, … $ EducationField <chr> "Life Sciences", "Life Sciences", "Other", "L… $ EmployeeCount <dbl> 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, … $ EmployeeNumber <dbl> 1, 2, 4, 5, 7, 8, 10, 11, 12, 13, 14, 15, 16,… $ EnvironmentSatisfaction <dbl> 2, 3, 4, 4, 1, 4, 3, 4, 4, 3, 1, 4, 1, 2, 3, … $ Gender <chr> "Female", "Male", "Male", "Female", "Male", "… $ HourlyRate <dbl> 94, 61, 92, 56, 40, 79, 81, 67, 44, 94, 84, 4… $ JobInvolvement <dbl> 3, 2, 2, 3, 3, 3, 4, 3, 2, 3, 4, 2, 3, 3, 2, … $ JobLevel <dbl> 2, 2, 1, 1, 1, 1, 1, 1, 3, 2, 1, 2, 1, 1, 1, … $ JobRole <chr> "Sales Executive", "Research Scientist", "Lab… $ JobSatisfaction <dbl> 4, 2, 3, 3, 2, 4, 1, 3, 3, 3, 2, 3, 3, 4, 3, … $ MaritalStatus <chr> "Single", "Married", "Single", "Married", "Ma… $ MonthlyIncome <dbl> 5993, 5130, 2090, 2909, 3468, 3068, 2670, 269… $ MonthlyRate <dbl> 19479, 24907, 2396, 23159, 16632, 11864, 9964… $ NumCompaniesWorked <dbl> 8, 1, 6, 1, 9, 0, 4, 1, 0, 6, 0, 0, 1, 0, 5, … $ Over18 <chr> "Y", "Y", "Y", "Y", "Y", "Y", "Y", "Y", "Y", … $ OverTime <chr> "Yes", "No", "Yes", "Yes", "No", "No", "Yes",… $ PercentSalaryHike <dbl> 11, 23, 15, 11, 12, 13, 20, 22, 21, 13, 13, 1… $ PerformanceRating <dbl> 3, 4, 3, 3, 3, 3, 4, 4, 4, 3, 3, 3, 3, 3, 3, … $ RelationshipSatisfaction <dbl> 1, 4, 2, 3, 4, 3, 1, 2, 2, 2, 3, 4, 4, 3, 2, … $ StandardHours <dbl> 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 8… $ StockOptionLevel <dbl> 0, 1, 0, 0, 1, 0, 3, 1, 0, 2, 1, 0, 1, 1, 0, … $ TotalWorkingYears <dbl> 8, 10, 7, 8, 6, 8, 12, 1, 10, 17, 6, 10, 5, 3… $ TrainingTimesLastYear <dbl> 0, 3, 3, 3, 3, 2, 3, 2, 2, 3, 5, 3, 1, 2, 4, … $ WorkLifeBalance <dbl> 1, 3, 3, 3, 3, 2, 2, 3, 3, 2, 3, 3, 2, 3, 3, … $ YearsAtCompany <dbl> 6, 10, 0, 8, 2, 7, 1, 1, 9, 7, 5, 9, 5, 2, 4,… $ YearsInCurrentRole <dbl> 4, 7, 0, 7, 2, 7, 0, 0, 7, 7, 4, 5, 2, 2, 2, … $ YearsSinceLastPromotion <dbl> 0, 1, 0, 3, 2, 3, 0, 0, 1, 7, 0, 0, 4, 1, 0, … $ YearsWithCurrManager <dbl> 5, 7, 0, 0, 2, 6, 0, 0, 8, 7, 3, 8, 3, 2, 3, …
# Cuantas columnas numericas y cuantas de texto hay
numericas <- names(df)[sapply(df, is.numeric)]
texto <- names(df)[sapply(df, is.character)]
cat(length(numericas), "columnas numericas |", length(texto), "columnas de texto\n")
cat("De texto:", paste(texto, collapse = ", "), "\n")
stopifnot(length(numericas) == 26, length(texto) == 9)26 columnas numericas | 9 columnas de texto
De texto: Attrition, BusinessTravel, Department, EducationField, Gender, JobRole, MaritalStatus, Over18, OverTime
Importar y exportar: CSV, Excel, parquet
Cargar y guardar son simetricas: por cada lectura hay una escritura. En R:
| formato | leer | guardar | cuando conviene |
|---|---|---|---|
| CSV | readr::read_csv |
readr::write_csv |
texto plano, universal |
| Excel | readxl::read_excel |
writexl::write_xlsx |
planillas del negocio |
| parquet | arrow::read_parquet |
arrow::write_parquet |
columnar: rapido y liviano para datos grandes |
read_csv tiene parametros para archivos poco prolijos: delim, na, col_types, skip, locale (separador decimal, encoding).
# En Colab (runtime R) instala los que falten; en local ya estan
for (p in c("readxl", "writexl", "arrow")) if (!requireNamespace(p, quietly = TRUE)) install.packages(p)
suppressPackageStartupMessages({ library(readxl); library(writexl); library(arrow) })
carpeta <- tempdir()
# Guardar en tres formatos
write_csv(df, file.path(carpeta, "hr.csv"))
write_xlsx(df, file.path(carpeta, "hr.xlsx"))
write_parquet(df, file.path(carpeta, "hr.parquet"))
# Y volver a leerlos: la tabla vuelve identica en forma
df_csv <- read_csv(file.path(carpeta, "hr.csv"), show_col_types = FALSE)
df_xlsx <- read_excel(file.path(carpeta, "hr.xlsx"))
df_pq <- read_parquet(file.path(carpeta, "hr.parquet"))
cat("shapes tras el ida y vuelta:", dim(df_csv), "|", dim(df_xlsx), "|", dim(df_pq), "\n")
stopifnot(all(dim(df_csv) == dim(df)), all(dim(df_xlsx) == dim(df)), all(dim(df_pq) == dim(df)))shapes tras el ida y vuelta: 1470 35 | 1470 35 | 1470 35
## 3. Exploracion inicial y descriptivos
La idea. Antes de cualquier modelo, numeros que resuman: cuantos casos hay, como se distribuye la variable de interes, que pinta tienen las demas. summary() calcula los descriptivos que ya conoces; count() cuenta categorias.
Predeci antes de correr la proxima celda: en una distribucion de ingresos, la media suele ser mayor, menor o igual que la mediana?
Ver la respuesta
Mayor. Los ingresos casi siempre tienen asimetria a derecha: una minoria gana mucho y arrastra la media hacia arriba, mientras la mediana se queda con la mayoria. Por eso para ingresos se reporta mediana. Confirmalo en la celda siguiente.
# Descriptivos de las variables centrales de hoy
df %>%
select(Age, MonthlyIncome, YearsAtCompany, DistanceFromHome) %>%
summary() Age MonthlyIncome YearsAtCompany DistanceFromHome
Min. :18.00 Min. : 1009 Min. : 0.000 Min. : 1.000
1st Qu.:30.00 1st Qu.: 2911 1st Qu.: 3.000 1st Qu.: 2.000
Median :36.00 Median : 4919 Median : 5.000 Median : 7.000
Mean :36.92 Mean : 6503 Mean : 7.008 Mean : 9.193
3rd Qu.:43.00 3rd Qu.: 8379 3rd Qu.: 9.000 3rd Qu.:14.000
Max. :60.00 Max. :19999 Max. :40.000 Max. :29.000
# La variable objetivo: cuantos se fueron?
conteo_attrition <- df %>% count(Attrition)
print(conteo_attrition)
tasa_attrition <- mean(df$Attrition == "Yes")
mediana_ingreso <- median(df$MonthlyIncome)
media_ingreso <- mean(df$MonthlyIncome)
cat("Tasa de rotacion:", round(tasa_attrition * 100, 1), "% | ingreso mediano: $",
mediana_ingreso, "| ingreso medio: $", round(media_ingreso), "\n")
# Verificaciones (calculo independiente del conteo)
stopifnot(
conteo_attrition$n[conteo_attrition$Attrition == "Yes"] == 237,
conteo_attrition$n[conteo_attrition$Attrition == "No"] == 1233,
abs(tasa_attrition - 237/1470) < 1e-12,
mediana_ingreso == 4919,
media_ingreso > mediana_ingreso # asimetria a derecha, como predijiste
)# A tibble: 2 × 2 Attrition n <chr> <int> 1 No 1233 2 Yes 237 Tasa de rotacion: 16.1 % | ingreso mediano: $ 4919 | ingreso medio: $ 6503
# Primera figura: la distribucion del ingreso mensual
p <- ggplot(df, aes(x = MonthlyIncome)) +
geom_histogram(bins = 40, fill = COL[["primary"]], colour = "white") +
geom_vline(xintercept = mediana_ingreso, colour = COL[["accent"]], linewidth = 1) +
geom_vline(xintercept = media_ingreso, colour = COL[["accent"]], linewidth = 1, linetype = "dotted") +
annotate("text", x = mediana_ingreso, y = 205, label = "mediana: $4,919",
hjust = -0.05, colour = COL[["accent"]], size = 3.4) +
annotate("text", x = media_ingreso, y = 185, label = "media: $6,503",
hjust = -0.05, colour = COL[["accent"]], size = 3.4) +
labs(title = "La mitad de los empleados gana menos de $4,919: la media la infla la cola derecha",
x = "ingreso mensual (USD)", y = "cantidad de empleados")
guardar_figura("clase02_distribucion_ingreso", p)
p
## 4. Procesamiento con dplyr: filtrar, seleccionar, ordenar, agrupar
La idea. El 80% del trabajo real con datos son los verbos que ya usaste mil veces: select, filter, arrange, rename, mutate, group_by + summarise. Hoy los aplicamos con ojo de EDA: cada operacion responde una pregunta sustantiva sobre la rotacion.
# Elegir columnas: la vista minima del problema
vista <- df %>% select(Age, Department, JobRole, MonthlyIncome, OverTime, Attrition)
head(vista, 3)| Age | Department | JobRole | MonthlyIncome | OverTime | Attrition |
|---|---|---|---|---|---|
| <dbl> | <chr> | <chr> | <dbl> | <chr> | <chr> |
| 41 | Sales | Sales Executive | 5993 | Yes | Yes |
| 49 | Research & Development | Research Scientist | 5130 | No | No |
| 37 | Research & Development | Laboratory Technician | 2090 | Yes | Yes |
# Filtrar filas: quienes hacen horas extra
con_horas_extra <- df %>% filter(OverTime == "Yes")
cat("Hacen horas extra:", nrow(con_horas_extra), "de", nrow(df), "\n")
# Ordenar: los 3 ingresos mas altos de ese grupo
con_horas_extra %>%
arrange(desc(MonthlyIncome)) %>%
select(JobRole, MonthlyIncome, Attrition) %>%
head(3)Hacen horas extra: 416 de 1470
| JobRole | MonthlyIncome | Attrition |
|---|---|---|
| <chr> | <dbl> | <chr> |
| Manager | 19859 | Yes |
| Manager | 19847 | No |
| Research Director | 19701 | No |
# Verificacion del filtro: 416 personas hacen horas extra
stopifnot(nrow(con_horas_extra) == 416, all(con_horas_extra$OverTime == "Yes"))Seleccionar por etiqueta o por posicion (el loc/iloc de R)
En pandas se usa df.loc[filas, columnas] (por etiqueta) e df.iloc[i, j] (por posicion). En R:
- por nombre:
df$Ageodf[["Age"]](una columna sola es un vector, el bloque 1D, como laSeriesde pandas), ydf[condicion, c("JobRole", "MonthlyIncome")]para filas + columnas. - por posicion:
df[i, j],df[1:5, 1:3],slice(df, 1:5). Ojo: R cuenta desde 1 (pandas, desde 0).
# Por nombre: una columna es un vector (el "Series" de R)
col_edad <- df$Age
cat("clase:", class(col_edad), "| primeras edades:", head(col_edad, 3), "\n")
# filas por condicion + columnas por nombre, en un paso
df[df$OverTime == "Yes", c("JobRole", "MonthlyIncome")] %>% head(3)clase: numeric | primeras edades: 41 49 37
| JobRole | MonthlyIncome |
|---|---|
| <chr> | <dbl> |
| Sales Executive | 5993 |
| Laboratory Technician | 2090 |
| Research Scientist | 2909 |
# Por posicion (fila, columna); en R el conteo arranca en 1, no en 0
cat("primera columna, primer valor:", df[[1]][1], "\n")
bloque <- df[1:5, 1:3] # 5 primeras filas, 3 primeras columnas
cat("bloque:", dim(bloque), "\n")
stopifnot(is.numeric(col_edad), identical(col_edad, df$Age), df[[1]][1] == df$Age[1])primera columna, primer valor: 41
bloque: 5 3
Limpieza estructural: columnas sin informacion y duplicados
Tres columnas de este dataset valen lo mismo en las 1470 filas: no distinguen a nadie y conviene sacarlas. n_distinct() las delata. De paso chequeamos duplicados exactos con duplicated(): aca no hay, pero en datos reales son moneda corriente.
# Detectar columnas constantes: un solo valor distinto en toda la columna
constantes <- names(df)[sapply(df, n_distinct) == 1]
cat("Columnas constantes:", paste(constantes, collapse = ", "), "\n")
# Sacarlas
df <- df %>% select(-all_of(constantes))
cat("Quedan", ncol(df), "columnas\n")
# Duplicados exactos
cat("Filas duplicadas:", sum(duplicated(df)), "\n")
stopifnot(
setequal(constantes, c("EmployeeCount", "Over18", "StandardHours")),
ncol(df) == 32,
sum(duplicated(df)) == 0
)Columnas constantes: EmployeeCount, Over18, StandardHours
Quedan 32 columnas
Filas duplicadas: 0
# Agrupar y resumir: ingreso mediano por departamento
ingreso_por_area <- df %>%
group_by(Department) %>%
summarise(mediana_ingreso = median(MonthlyIncome)) %>%
arrange(mediana_ingreso)
print(ingreso_por_area)
stopifnot(
ingreso_por_area$mediana_ingreso[ingreso_por_area$Department == "Human Resources"] == 3886,
ingreso_por_area$mediana_ingreso[ingreso_por_area$Department == "Research & Development"] == 4374,
ingreso_por_area$mediana_ingreso[ingreso_por_area$Department == "Sales"] == 5754.5
)# A tibble: 3 × 2 Department mediana_ingreso <chr> <dbl> 1 Human Resources 3886 2 Research & Development 4374 3 Sales 5754.
### Ejercicio 2. dplyr: donde se concentra la rotacion?
Calcula la tasa de attrition por puesto (JobRole) y mostra los 3 puestos con mayor rotacion. La receta: crea la columna logica AttritionBool con Attrition == "Yes" y usa que la media de un vector logico es una proporcion. Despues agrupa por JobRole, resumi y ordena de mayor a menor.
Pista: group_by(JobRole) %>% summarise(tasa = mean(AttritionBool)).
df <- df %>% mutate(AttritionBool = Attrition == "Yes")
tasa_por_puesto <- df %>%
group_by(JobRole) %>%
summarise(tasa = mean(AttritionBool)) %>%
arrange(desc(tasa))
print(head(tasa_por_puesto, 3))
stopifnot(
tasa_por_puesto$JobRole[1] == "Sales Representative",
abs(tasa_por_puesto$tasa[1] - 0.3976) < 1e-3
)
cat("Ejercicio 2 correcto\n")# A tibble: 3 × 2 JobRole tasa <chr> <dbl> 1 Sales Representative 0.398 2 Laboratory Technician 0.239 3 Human Resources 0.231 Ejercicio 2 correcto
Casi 4 de cada 10 representantes de ventas se van: cuando en la Clase 4 predigas attrition, el puesto va a ser una de las variables con mas señal. Eso es lo que busca el EDA: saber donde mirar antes de modelar.
## 5. Datos faltantes y duplicados: detectar antes de decidir
La idea. Los datos reales llegan sucios: encuestas a medio responder, sensores que se apagan, exportaciones que duplican filas. La regla profesional es primero detectar y entender el patron, despues decidir; borrar a ciegas puede sesgar el analisis.
Nota de laboratorio. Este dataset viene limpio de fabrica, asi que para practicar vamos a ensuciar una copia con reglas fijas y a la vista (nada de azar: son las mismas filas que se ensucian en la version Python, para que puedas comparar). Todo el analisis sustantivo de las otras secciones usa siempre los datos originales.
# Construir la copia "sucia": duplicados + faltantes con reglas deterministas
df_sucio <- bind_rows(df, head(df, 5)) # 5 filas repetidas al final
cat("Filas con duplicados:", nrow(df_sucio), "| duplicados detectados:", sum(duplicated(df_sucio)), "\n")
df_sucio <- distinct(df_sucio)
cat("Tras eliminar duplicados:", nrow(df_sucio), "filas\n")
# Reglas fijas: se pierden datos segun el numero de legajo (EmployeeNumber)
df_sucio$DistanceFromHome[df_sucio$EmployeeNumber %% 20 == 0] <- NA
df_sucio$JobSatisfaction[df_sucio$EmployeeNumber %% 31 == 0] <- NA
stopifnot(nrow(df_sucio) == 1470)Filas con duplicados: 1475 | duplicados detectados: 5
Tras eliminar duplicados: 1470 filas
# Detectar: cuantos faltantes hay, y donde
faltantes <- colSums(is.na(df_sucio))
print(faltantes[faltantes > 0])
cat("Total de celdas faltantes:", sum(is.na(df_sucio)), "\n")
stopifnot(
sum(is.na(df_sucio$DistanceFromHome)) == 77,
sum(is.na(df_sucio$JobSatisfaction)) == 44,
sum(is.na(df_sucio)) == 121
)DistanceFromHome JobSatisfaction
77 44
Total de celdas faltantes: 121
Decidir: eliminar o imputar
Dos familias de estrategias, cada una con su costo:
- Eliminar (
drop_na()): simple y honesta, pero tira informacion; con 77 + 44 faltantes perderias hasta 121 filas de 1470, un 8%. - Imputar (rellenar con un valor razonable): para una variable numerica asimetrica como la distancia, la mediana (robusta a outliers); para una ordinal como la satisfaccion, la moda.
La eleccion depende de cuanto falta, de por que falta y de que vas a hacer despues. En la Clase 4 vas a ver la version para machine learning: la imputacion se aprende solo del set de entrenamiento, para no filtrar informacion del test.
# Imputar: mediana para la distancia, moda para la satisfaccion
mediana_dist <- median(df_sucio$DistanceFromHome, na.rm = TRUE)
moda_satisf <- as.numeric(names(which.max(table(df_sucio$JobSatisfaction))))
cat("Mediana de distancia:", mediana_dist, "| moda de satisfaccion:", moda_satisf, "\n")
df_imputado <- df_sucio %>%
mutate(
DistanceFromHome = coalesce(DistanceFromHome, mediana_dist), # coalesce = fillna
JobSatisfaction = coalesce(JobSatisfaction, moda_satisf)
)
cat("Faltantes despues de imputar:", sum(is.na(df_imputado)), "\n")
stopifnot(
mediana_dist == 7, moda_satisf == 4,
sum(is.na(df_imputado)) == 0,
median(df_imputado$DistanceFromHome) == 7 # la mediana no se movio
)Mediana de distancia: 7 | moda de satisfaccion: 4
Faltantes despues de imputar: 0
### Ejercicio 3. Faltantes: imputacion por grupo
Imputar con la mediana global es razonable, pero se puede afinar: conviene imputar cada faltante con la mediana de su propio departamento. Completa la imputacion por grupo: dentro de group_by(Department), la median(DistanceFromHome, na.rm = TRUE) que calcules en mutate() es la del grupo de cada fila.
Pista: group_by(Department) %>% mutate(dist_imputada = coalesce(DistanceFromHome, median(DistanceFromHome, na.rm = TRUE))).
df_grupo <- df_sucio %>%
group_by(Department) %>%
mutate(dist_imputada = coalesce(DistanceFromHome, median(DistanceFromHome, na.rm = TRUE))) %>%
ungroup()
cat("Faltantes antes:", sum(is.na(df_sucio$DistanceFromHome)),
"| despues:", sum(is.na(df_grupo$dist_imputada)), "\n")
stopifnot(sum(is.na(df_grupo$dist_imputada)) == 0,
median(df_grupo$dist_imputada) == 7)
cat("Ejercicio 3 correcto\n")Faltantes antes: 77 | despues: 0
Ejercicio 3 correcto
## 6. Outliers: atipico no es sinonimo de error
La idea. Un outlier es una observacion muy alejada del resto. La regla practica mas usada es la regla del IQR: es atipico lo que queda a mas de \(1.5 \times IQR\) de los cuartiles,
\[ x > Q_3 + 1.5 \times IQR \qquad \text{o} \qquad x < Q_1 - 1.5 \times IQR \]
donde \(Q_1\) y \(Q_3\) son el primer y tercer cuartil e \(IQR = Q_3 - Q_1\) es el rango intercuartilico, el ancho de la “caja central” que concentra la mitad de los datos.
La trampa clasica. Detectar un outlier no autoriza a borrarlo. Un valor atipico puede ser un error de carga (una edad de 340) o un caso real e informativo (un director que gana 5 veces la mediana). La regla: detectar con estadistica, decidir con conocimiento del dominio.
# Regla del IQR sobre el ingreso mensual
q1 <- quantile(df$MonthlyIncome, 0.25)
q3 <- quantile(df$MonthlyIncome, 0.75)
iqr <- q3 - q1
limite_superior <- q3 + 1.5 * iqr
cat("Q1 =", q1, "| Q3 =", q3, "| IQR =", iqr, "| limite superior =", limite_superior, "\n")
outliers <- df %>% filter(MonthlyIncome > limite_superior)
cat("Ingresos atipicos:", nrow(outliers), "de", nrow(df), "\n")
# Quienes son? Miramos su nivel jerarquico (JobLevel va de 1 a 5)
print(outliers %>% count(JobLevel))
# Verificacion: son 114 y TODOS tienen nivel 4 o 5: gerentes y directores reales, no errores
stopifnot(q1 == 2911, q3 == 8379, limite_superior == 16581,
nrow(outliers) == 114, min(outliers$JobLevel) >= 4)Q1 = 2911 | Q3 = 8379 | IQR = 5468 | limite superior = 16581 Ingresos atipicos: 114 de 1470 # A tibble: 2 × 2 JobLevel n <dbl> <int> 1 4 45 2 5 69
# El boxplot dibuja exactamente la regla del IQR: caja entre Q1 y Q3, atipicos como puntos
p <- ggplot(df, aes(x = MonthlyIncome, y = "")) +
geom_boxplot(fill = COL[["muted"]], colour = COL[["ink"]],
outlier.colour = COL[["bad"]], outlier.alpha = 0.6, width = 0.4) +
labs(title = "Los 114 ingresos atipicos no son errores: son los niveles jerarquicos 4 y 5",
x = "ingreso mensual (USD)", y = NULL)
guardar_figura("clase02_outliers_ingreso", p)
p
Que tan estricto es tu umbral?
El 1.5 de la regla es una convencion, no una ley. Mira como cambia la cantidad de empleados señalados como atipicos segun el multiplicador k que elijas (en la version Python este experimento es un control deslizante). La leccion: “outlier” depende del umbral, y esa eleccion hay que reportarla.
# Cuantos outliers quedan señalados con cada umbral k
tibble(k = c(1.0, 1.5, 2.0, 2.5, 3.0)) %>%
rowwise() %>%
mutate(outliers = sum(df$MonthlyIncome > q3 + k * iqr)) %>%
ungroup()| k | outliers |
|---|---|
| <dbl> | <int> |
| 1.0 | 146 |
| 1.5 | 114 |
| 2.0 | 31 |
| 2.5 | 0 |
| 3.0 | 0 |
## 7. Agrupar para comparar y feature engineering
La idea. Las preguntas interesantes casi siempre comparan grupos: se van mas los que hacen horas extra? los que viajan seguido? El combo group_by + media de una columna logica responde cualquier “tasa por grupo” en una linea. Y cuando ninguna columna existente captura el fenomeno, se crea una variable nueva con sentido sustantivo: eso es feature engineering, y la justificacion viene del dominio, no del algoritmo.
# La comparacion estrella de la clase: attrition segun horas extra
tasa_por_horas_extra <- df %>%
group_by(OverTime) %>%
summarise(tasa = mean(AttritionBool))
print(tasa_por_horas_extra %>% mutate(tasa_pct = round(tasa * 100, 1)))
stopifnot(
abs(tasa_por_horas_extra$tasa[tasa_por_horas_extra$OverTime == "No"] - 0.1044) < 1e-3,
abs(tasa_por_horas_extra$tasa[tasa_por_horas_extra$OverTime == "Yes"] - 0.3053) < 1e-3
)# A tibble: 2 × 3 OverTime tasa tasa_pct <chr> <dbl> <dbl> 1 No 0.104 10.4 2 Yes 0.305 30.5
# Figura: la brecha en una imagen
datos_fig <- tasa_por_horas_extra %>%
mutate(grupo = if_else(OverTime == "Yes", "Con horas extra", "Sin horas extra"),
pct = tasa * 100)
p <- ggplot(datos_fig, aes(x = grupo, y = pct, fill = grupo)) +
geom_col(width = 0.55, show.legend = FALSE) +
geom_text(aes(label = paste0(round(pct, 1), "%")), vjust = -0.5, fontface = "bold",
colour = COL[["ink"]]) +
scale_fill_manual(values = c("Con horas extra" = COL[["accent"]],
"Sin horas extra" = COL[["muted"]])) +
scale_y_continuous(limits = c(0, 36)) +
labs(title = "Con horas extra, la rotacion casi se triplica: 10.4% contra 30.5%",
x = NULL, y = "% que dejo la empresa")
guardar_figura("clase02_attrition_horas_extra", p)
p
# Otra comparacion con señal: viajar seguido por trabajo
tasa_por_viaje <- df %>%
group_by(BusinessTravel) %>%
summarise(tasa = mean(AttritionBool)) %>%
arrange(tasa)
print(tasa_por_viaje %>% mutate(tasa_pct = round(tasa * 100, 1)))
stopifnot(
abs(tasa_por_viaje$tasa[tasa_por_viaje$BusinessTravel == "Travel_Frequently"] - 0.2491) < 1e-3,
abs(tasa_por_viaje$tasa[tasa_por_viaje$BusinessTravel == "Non-Travel"] - 0.0800) < 1e-3
)# A tibble: 3 × 3 BusinessTravel tasa tasa_pct <chr> <dbl> <dbl> 1 Non-Travel 0.08 8 2 Travel_Rarely 0.150 15 3 Travel_Frequently 0.249 24.9
Crear variables con sentido: dos ejemplos
Ingreso por año de experiencia. Dos personas pueden ganar lo mismo con trayectorias muy distintas: 3000 dolares con 2 años de experiencia es una cosa, con 20 años es otra. El cociente captura esa diferencia (sumamos 1 al denominador para que quien recien empieza no divida por cero).
Tramos de edad. Para comparar generaciones conviene discretizar la edad con cut(), que corta una variable continua en intervalos con etiqueta.
# Feature 1: ingreso mensual por año de experiencia total
# Feature 2: tramos de edad
df <- df %>%
mutate(
IngresoPorAnioExp = MonthlyIncome / (TotalWorkingYears + 1),
TramoEdad = cut(Age, breaks = c(17, 30, 40, 50, 61),
labels = c("18-30", "31-40", "41-50", "51-60"))
)
tasa_por_tramo <- df %>%
group_by(TramoEdad) %>%
summarise(tasa = mean(AttritionBool))
print(tasa_por_tramo %>% mutate(tasa_pct = round(tasa * 100, 1)))
stopifnot(
abs(median(df$IngresoPorAnioExp) - 549.22) < 0.01,
sum(is.na(df$TramoEdad)) == 0, # todos los 1470 caen en un tramo
tasa_por_tramo$tasa[tasa_por_tramo$TramoEdad == "18-30"] >
tasa_por_tramo$tasa[tasa_por_tramo$TramoEdad == "41-50"] # los mas jovenes rotan mas
)# A tibble: 4 × 3 TramoEdad tasa tasa_pct <fct> <dbl> <dbl> 1 18-30 0.259 25.9 2 31-40 0.137 13.7 3 41-50 0.106 10.6 4 51-60 0.126 12.6
### Ejercicio 4. Feature engineering: el costo de viajar seguido
Crea la variable logica ViajaFrecuente (si BusinessTravel es "Travel_Frequently") y calcula la tasa de attrition de quienes viajan frecuente contra el resto. Te tiene que dar, aproximadamente, 24.9% contra 14.1%.
Pista: es el mismo patron del Ejercicio 2: columna logica + group_by + summarise con mean.
df <- df %>% mutate(ViajaFrecuente = BusinessTravel == "Travel_Frequently")
tasa_viaje_frec <- df %>%
group_by(ViajaFrecuente) %>%
summarise(tasa = mean(AttritionBool))
print(tasa_viaje_frec %>% mutate(tasa_pct = round(tasa * 100, 1)))
stopifnot(
abs(tasa_viaje_frec$tasa[tasa_viaje_frec$ViajaFrecuente] - 0.2491) < 1e-3,
abs(tasa_viaje_frec$tasa[!tasa_viaje_frec$ViajaFrecuente] - 0.1408) < 1e-3
)
cat("Ejercicio 4 correcto\n")# A tibble: 2 × 3 ViajaFrecuente tasa tasa_pct <lgl> <dbl> <dbl> 1 FALSE 0.141 14.1 2 TRUE 0.249 24.9 Ejercicio 4 correcto
## 8. Terminologia: hablar con precision
Hoy hiciste, sin nombrarlas, casi todas las etapas del vocabulario tecnico del programa. El mapa, para que cada termino te quede asociado a algo que ya hiciste:
| termino | que es | donde lo hiciste hoy |
|---|---|---|
| datos crudos | la fuente original, intacta | el CSV de IBM recien cargado |
| limpieza | corregir errores, duplicados, tipos | columnas constantes, duplicados (secciones 4 y 5) |
| procesamiento | manipular: filtrar, agrupar, resumir | filter, group_by (secciones 4 y 7) |
| preprocesamiento | lo que pide el algoritmo (estandarizar, codificar, imputar) | la imputacion (seccion 5); el resto llega en la Clase 4 |
| feature engineering | crear variables con sentido sustantivo | IngresoPorAnioExp, TramoEdad, ViajaFrecuente (seccion 7) |
| feature selection | elegir que variables entran al modelo | todavia no: llega con los modelos (Clases 4 a 7) |
La distincion no es pedanteria: cuando en la Clase 4 hablemos de data leakage vas a ver que confundir preprocesamiento con procesamiento puede invalidar un modelo entero.
Cuatro preguntas que cruzan toda la clase. Comprometete con una respuesta antes de abrir cada solucion.
1. En R, edades[1] devuelve el primer elemento. Que devuelve edades[1] en Python si edades = [29, 41, 35]?
Ver respuesta
41. Python cuenta desde 0: edades[0] es 29 y edades[1] es 41. Es el tropiezo mas comun al cruzar de R a Python; tenelo presente cuando leas la version Python de esta clase.
2. La media del ingreso (6503) es mayor que la mediana (4919). Que te dice eso de la distribucion, y cual de las dos reportarias?
Ver respuesta
Asimetria a derecha: pocos ingresos muy altos arrastran la media hacia arriba. Para describir el ingreso “tipico” conviene la mediana, que no se deja arrastrar por la cola.
3. Encontraste 114 ingresos por encima de \(Q_3 + 1.5 \times IQR\). Corresponde eliminarlos antes de modelar?
Ver respuesta
No sin investigarlos. En este dataset todos tienen nivel jerarquico 4 o 5: son gerentes y directores reales, informacion legitima, no errores de carga. Detectar es estadistica; decidir es conocimiento del dominio.
4. Para responder “que departamento tiene mayor tasa de rotacion” en una linea de dplyr, que combinacion usas?
Ver respuesta
Columna logica + agrupar + media: df %>% group_by(Department) %>% summarise(tasa = mean(AttritionBool)). La media de un vector logico es una proporcion; ordenando el resultado ya tenes el ranking (da Sales con 20.6%).
Lo que ya podes hacer. Cargar un CSV desde la nube; explorar un dataset con dim, glimpse, summary y count; filtrar, ordenar, renombrar y agrupar; detectar y tratar duplicados, faltantes (eliminar o imputar, global o por grupo) y outliers (regla del IQR mas criterio de dominio); y crear variables nuevas con sentido sustantivo. Es la misma caja de herramientas de la version Python: dos lenguajes, un solo oficio.
El hallazgo sustantivo de hoy, por si te lo llevas en una frase: en esta empresa la rotacion se concentra en la gente que hace horas extra (30.5% contra 10.4%), viaja seguido (24.9%) y esta en ventas (39.8% entre representantes). En la Clase 4 vamos a intentar predecirla.
Para seguir.
- Wickham y Grolemund, R para Ciencia de Datos, caps. 4 a 6: la referencia directa de lo que usaste hoy (gratis online, en español).
- McKinney, Python for Data Analysis, caps. 5 a 7, para el espejo en Python (gratis online).
- La notebook Python de esta clase, en el sitio de la materia.
Apendice: Machete dplyr y su espejo pandas
| tarea | en R | en pandas |
|---|---|---|
| cargar CSV desde URL | read_csv(url) |
pd.read_csv(url) |
| dimensiones / tipos | dim(df), glimpse(df) |
df.shape, df.info() |
| contar categorias | count(df, col) |
df["col"].value_counts() |
| elegir columnas | select(df, a, b) |
df[["a", "b"]] |
| filtrar filas | filter(df, cond) |
df[cond] |
| ordenar | arrange(df, desc(a)) |
df.sort_values("a", ascending=False) |
| duplicados | sum(duplicated(df)), distinct(df) |
df.duplicated().sum(), df.drop_duplicates() |
| faltantes | colSums(is.na(df)), coalesce(x, v) |
df.isna().sum(), df["c"].fillna(v) |
| tasa por grupo | group_by %>% summarise(mean(bool)) |
df.groupby("g")["bool"].mean() |
| crear variable | mutate(df, c = a/b) |
df["c"] = df["a"] / df["b"] |
| discretizar | cut(x, breaks, labels) |
pd.cut(x, bins, labels) |
Correr / compartir esta notebook. Abrila en Google Colab con el boton del inicio y elegi Entorno de ejecucion → Ejecutar todo; el runtime de R de Colab ya trae tidyverse instalado.
Datos: IBM HR Analytics Employee Attrition (datos ficticios creados por IBM), servidos desde el repositorio oficial de IBM. Cada numero de esta pagina se recalcula y verifica con stopifnot() al ejecutar.