#@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 4 · Introducción al aprendizaje supervisado
Analítica de Datos · Maestría en Ciencias del Comportamiento · Universidad de San Andrés
29/08/2026
La pregunta es ¿quién va a renunciar en Nimbus?. La vamos a responder con el modelo de machine learning más sencillo que existe: K vecinos más cercanos (KNN). No hace falta escribir nada, alcanza con seguir las celdas y mirar los números que salen.
La idea que queremos ver:
El acierto sobre los datos de entrenamiento miente. Un modelo que acierta casi todo lo que ya vio puede ser el peor a la hora de predecir lo que no vio.
El recorrido tiene cuatro pasos:
| # | Paso | Qué hacemos |
|---|---|---|
| 1 | Armar la tabla | unir las tablas de Nimbus, como en la Clase 2 |
| 2 | Partir | separar entrenamiento y testeo, en una línea |
| 3 | KNN | K = 1 contra K = 11, y los cuatro números que importan |
| 4 | Todos los K a la vez | la curva completa, que es la figura de las slides |
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.
1. Armar la tabla
Nimbus tiene una tabla nueva, nimbus_rrhh.csv: una fila por empleado con tres señales de comportamiento del último tiempo (faltas del mes, weeklys a las que no fue, minutos con la cámara prendida) y la columna que hoy queremos predecir, renuncia (Sí/No).
Los datos se leen por URL: no hay que bajar ni montar nada.
Un repaso de lo que hace la celda de abajo, que es la misma receta de la Clase 2:
import pandas as pd: trae la librería pandas y la apodapd, así después escribimospd.read_csven vez depandas.read_csv. Lo mismo connumpy(np) ymatplotlib(plt).pd.read_csv(ruta): lee un archivo CSV y devuelve un DataFrame, una tabla..shape: devuelve (filas,columnas). Es el chequeo más rápido de que cargó lo que esperábamos..head(): muestra las primeras cinco filas.
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import os
SEED = 42 # esto es para que todos veamos exactamente los mismos resultados, independientemente de cuantas veces lo corramos
BASE = "https://raw.githubusercontent.com/tomdamelio/analitica_de_datos_alumnos/main/data/toy-nimbus/"
if os.path.isdir("../../../data/toy-nimbus"):
BASE = "../../../data/toy-nimbus/"
# BASE + "nombre.csv" pega la carpeta y el nombre del archivo en una sola ruta.
empleados = pd.read_csv(BASE + "nimbus_empleados.csv")
salario = pd.read_csv(BASE + "nimbus_salario.csv")
rrhh = pd.read_csv(BASE + "nimbus_rrhh.csv")
print("empleados:", empleados.shape, "| salario:", salario.shape, "| rrhh:", rrhh.shape)
rrhh.head()Tres tablas: empleados y rrhh tienen una fila por empleado (600 filas cada una), pero salario tiene una fila por empleado y año (1.800 filas: 600 empleados por tres años). Si la uniéramos así nomás, cada empleado aparecería tres veces. Así que primero nos quedamos con el salario de 2025, y recién después unimos todo por empleado_id, que es exactamente el merge de la Clase 2.
Paso a paso:
salario["anio"] == 2025pregunta, fila por fila, si el año es 2025. Ponerlo entre corchetes (salario[...]) se queda con las filas donde la respuesta esTrue..drop(columns="anio")saca la columnaanio, que ya no aporta nada (es 2025 en todas)..merge(otra_tabla, on="empleado_id")pega las columnas de la otra tabla, emparejando las filas que tienen el mismoempleado_id. Lo hacemos dos veces, una por tabla.
# 1 y 2: solo el salario de 2025, sin la columna del año
salario_2025 = salario[salario["anio"] == 2025].drop(columns="anio")
print("salario_2025:", salario_2025.shape)
# 3: unir, de a una tabla por vez
datos = empleados.merge(salario_2025, on="empleado_id") # unimos con salario_2025
datos = datos.merge(rrhh, on="empleado_id") # unimos con rrhh
print("la tabla unida:", datos.shape)
datos.head()Antes de predecir nada, un número que hay que tener a mano es cuánta gente renuncia. Si casi nadie se va, un modelo que diga “no renuncia nadie” acierta casi siempre sin haber aprendido nada. Ese es el piso contra el que hay que comparar cualquier modelo.
value_counts() cuenta cuántas veces aparece cada valor de la columna. Con normalize=True devuelve proporciones en vez de conteos. El resultado es una tablita con una fila por valor (No, Si), y proporcion["No"] saca de ahí el número de la fila No.
proporcion = datos["renuncia"].value_counts(normalize=True).round(3)
print(proporcion)
base = proporcion["No"]
print()
print(f"Piso: si predigo 'No' para todos, acierto el {base:.1%}") # :.1% lo muestra como porcentaje con un decimal2. Partir
Para saber si el modelo predice, hay que examinarlo con datos que no vio. Así que antes de entrenar nada vamos a partir la tabla y ocultar al modelo una parte: el 70 % es el conjunto de entrenamiento (con eso aprende) y el 30 % es el conjunto de testeo (con eso lo examinamos).
Dos decisiones que tomamos:
stratify: que la proporción de renuncias sea la misma en las dos mitades, así ninguna queda con más renunciantes que la otra por azar.random_state: la partición es al azar, es decir que cualquier punto tiene la misma probabilidad de ser asignado al conjunto de entrenamiento que al de test (siempre y cuando se mantenga la proporción que pedimos enstratify). Con la semilla fija todos obtenemos la misma división de datos. Guardá esta idea, porque la retomamos al final.
Para que el modelo se pueda gráficar en un plano 2D, usamos solo dos predictores: faltas_mes y minutos_camara_weekly. Son las dos variables de las slides.
Dos convenciones de nombres que vas a ver en todo el material de machine learning, no solo acá:
X(mayúscula) es la tabla con los predictores: lo que el modelo puede mirar. Mayúscula porque es una tabla con varias columnas.y(minúscula) es la variable objetivo: lo que el modelo tiene que adivinar. Minúscula porque es una sola columna.
Para la división usamos la función train_test_split. Esta recibe X e y y devuelve cuatro cosas, siempre en este orden: los predictores de entrenamiento, los de testeo, el objetivo de entrenamiento y el de testeo. Por eso a la izquierda del = hay cuatro nombres separados por comas.
from sklearn.model_selection import train_test_split
X = datos[["faltas_mes", "minutos_camara_weekly"]] # lo que el modelo puede mirar (corchetes dobles: una lista de columnas)
y = datos["renuncia"] # lo que tiene que adivinar (una sola columna)
X_train, X_test, y_train, y_test = train_test_split(
X, y,
test_size=0.3, # el 30 % va a testeo
stratify=y, # misma proporción de renuncias en los dos conjuntos
random_state=SEED) # la semilla, así la partición es la misma para todos siempre
print("entrenamiento:", len(X_train), "empleados")
print("testeo :", len(X_test), "empleados")
print()
# y_train == 'Si' da True/False por empleado; el promedio de True/False es la proporción de True.
print("renuncias en entrenamiento:", f"{(y_train == 'Si').mean():.1%}")
print("renuncias en testeo :", f"{(y_test == 'Si').mean():.1%}")# Así quedó el conjunto de entrenamiento: solo 420 empleados,
# elegidos al azar (fijate en el índice: no va de 0 en adelante ni de a uno).
X_train.head()3. KNN: K = 1 contra K = 11
KNN no estima ningún parámetro nuevo. Para predecir a un empleado nuevo, busca los K empleados más parecidos del conjunto de entrenamiento y vota: si la mayoría renunció, predice que renuncia.
“Parecido” es distancia geométrica, y ahí está la trampa de las slides: los minutos de cámara van de 0 a 45 y las faltas de 0 a 10, así que medidos crudos los minutos deciden solos. La solución es estandarizar: poner las dos variables en la misma escala antes de medir. Se hace una vez, con StandardScaler (retomaremos el tema en sí mismo en la Clase 6).
Estandarizar una columna es restarle su promedio y dividirla por su desvío. Después de eso, cada valor dice “cuántos desvíos por encima o por debajo del promedio está este empleado”, y esa unidad es la misma para faltas y para minutos.
Un detalle que importa: el promedio y el desvío se calculan solo con el conjunto de entrenamiento (escalador.fit(X_train)), y después se aplican a los dos conjuntos con .transform(). Si el escalador tomara en cuenta al conjunto de testeo, ya no sería un examen limpio, porque estamos tomando información del conjunto de prueba.
from sklearn.preprocessing import StandardScaler
escalador = StandardScaler()
escalador.fit(X_train) # calcula promedio y desvío de cada columna, mirando SOLO entrenamiento
X_train_esc = escalador.transform(X_train) # aplica (valor - promedio) / desvío a cada columna
X_test_esc = escalador.transform(X_test) # con el MISMO promedio y desvío, sin recalcular
# Un empleado de ejemplo, antes y después: el primero del conjunto de entrenamiento.
print("antes | faltas:", X_train.iloc[0, 0], " minutos de cámara:", X_train.iloc[0, 1])
print("después | faltas:", round(X_train_esc[0, 0], 2), " minutos de cámara:", round(X_train_esc[0, 1], 2))Antes, ese empleado tenía “0 faltas y 42 minutos de cámara”, dos números en escalas distintas que no se pueden comparar entre sí. Después tiene “un desvío por debajo del promedio de faltas, 1,3 desvíos por encima del promedio de cámara”: dos números en la misma unidad. Eso es lo único que hace la estandarización.
(El resultado de .transform() ya no es un DataFrame sino una matriz de números, sin nombres de columna. No importa: el modelo solo necesita los números.)
K = 1
Un solo vecino. Para cada empleado, el modelo asigna lo que hizo el empleado más parecido.
Entrenar y evaluar cualquier modelo de scikit-learn son siempre los mismos tres pasos, y vale la pena aprenderse el ritmo porque es idéntico para regresión lineal, árboles o lo que venga en las próximas clases:
- Crear el modelo:
KNeighborsClassifier(n_neighbors=1). Todavía no vio ningún dato. Solo le dijimos qué tipo de modelo es y con qué configuración (acá, un vecino). - Entrenarlo con
.fit(X_train_esc, y_train): le mostramos los predictores y las respuestas del conjunto de entrenamiento. Para KNN, “entrenar” es simplemente guardar esos 420 empleados para después buscar vecinos entre ellos. - Medir el acierto con
.score(X, y): el modelo predice la renuncia de cada empleado deX, compara con la respuesta verdaderay, y devuelve la proporción de aciertos.
El paso 3 lo hacemos dos veces: sobre los datos que vio y sobre los que no vio.
from sklearn.neighbors import KNeighborsClassifier
# 1. crear el modelo: KNN con un solo vecino
knn_1 = KNeighborsClassifier(n_neighbors=1)
# 2. entrenarlo, SOLO con entrenamiento
knn_1.fit(X_train_esc, y_train)
# 3. medir el acierto, dos veces
acierto_train_1 = knn_1.score(X_train_esc, y_train) # sobre lo que vio
acierto_test_1 = knn_1.score(X_test_esc, y_test) # sobre lo que NO vio
print(f"K = 1 | entrenamiento: {acierto_train_1:.1%} | testeo: {acierto_test_1:.1%}")Qué hay adentro de ese .score()
.score() es un atajo. Por debajo hace dos cosas que podemos hacer a mano para que no quede como magia: primero predice con .predict(), y después compara con la verdad. Mirá los primeros cinco empleados del conjunto de testeo:
prediccion = knn_1.predict(X_test_esc) # lo que el modelo dice de cada empleado de testeo
comparacion = pd.DataFrame({"verdad": y_test.values, "predicción": prediccion})
print(comparacion.head())
# Acertó donde las dos columnas coinciden. La proporción de coincidencias es el acierto:
print()
print("acierto calculado a mano:", f"{(comparacion['verdad'] == comparacion['predicción']).mean():.1%}")
print("acierto según .score() :", f"{acierto_test_1:.1%}")Casi 98 % en el entrenamiento y 88 % por fuera. Diez puntos de diferencia, lo que indica que el modelo aprendió el ruido de sus 420 empleados, no la relación real, y eso no le sirve con los 180 que nunca vio. Esto es sobreajuste. Solamente te das cuenta si comparás los porcentajes de acierto entre el conjunto de entrenamiento y el de prueba.
(Fijate: Casi 98 % y no 100 % porque hay empleados con exactamente las mismas faltas y los mismos minutos que renunciaron y otros que no. Ahí ni copiar sirve.)
K = 11
Antes de correr la celda, anotá una predicción: con once vecinos en vez de uno, ¿el acierto en entrenamiento va a subir o bajar? ¿Y el de testeo? ¡No vale hacer trampa e ir a las slides!
Los mismos tres pasos, cambiando un solo número. Ahora ningún empleado único asigna sobre otro. Lo que diga el modelo es lo que hacen los once más parecidos.
# Los mismos tres pasos. Lo único que cambia es n_neighbors acá.
knn_11 = KNeighborsClassifier(n_neighbors=11)
knn_11.fit(X_train_esc, y_train)
acierto_train_11 = knn_11.score(X_train_esc, y_train)
acierto_test_11 = knn_11.score(X_test_esc, y_test)
print(f"K = 11 | entrenamiento: {acierto_train_11:.1%} | testeo: {acierto_test_11:.1%}")Los cuatro números, juntos:
| Entrenamiento | Testeo | |
|---|---|---|
| K = 1 | 97,9 % | 87,8 % |
| K = 11 | 91,9 % | 91,1 % |
Con K = 1 el acierto por dentro es más alto y el acierto por fuera es más bajo. El modelo que se ve mejor por dentro es el peor por fuera. Si hubiéramos elegido mirando solo el acierto en entrenamiento, nos quedábamos con el modelo equivocado.
Y una segunda lectura, contra el piso de la sección 1: el 83 % lo conseguía cualquiera diciendo “no renuncia nadie”. Lo que el modelo aporta de verdad son los ocho puntos que hay entre ese 83 % y el 91 %, no los 91 % en sí mismos.
4. Todos los K a la vez
Lo que hicimos con dos valores de K se puede hacer con muchos: los mismos tres pasos, repetidos en un ciclo for para una lista de valores de K, guardando los dos aciertos de cada uno. Las dos curvas en una figura son la curva de las slides, armada con nuestros datos.
Cómo leer el ciclo de la celda de abajo:
valores_kes una lista: los valores de K que queremos probar, entre corchetes y separados por coma.for k in valores_k:repite el bloque indentado una vez por cada valor de la lista, y en cada vueltakvale el siguiente número.- Adentro del bloque van los tres pasos de siempre (crear, entrenar, medir), y al final
filas.append(...)agrega a la listafilasun renglón con el K y sus dos aciertos. - Al salir del ciclo,
pd.DataFrame(filas)convierte esa lista de renglones en una tabla..Thace que las columnas sean las filas, y las filas las columnas. Entonces, los K quedan acá como columnas. Solo lo hacemos para que entre en pantalla.
valores_k = [1, 2, 3, 5, 7, 11, 15, 21, 31, 51, 75, 101, 151]
filas = []
for k in valores_k:
knn = KNeighborsClassifier(n_neighbors=k)
knn.fit(X_train_esc, y_train)
filas.append({"K": k,
"entrenamiento": knn.score(X_train_esc, y_train),
"testeo": knn.score(X_test_esc, y_test)})
resultados = pd.DataFrame(filas).set_index("K")
mejor_k = resultados["testeo"].idxmax() # idxmax: el K (índice) donde el acierto de testeo es máximo
print("mejor K según testeo:", mejor_k, f"({resultados.loc[mejor_k, 'testeo']:.1%})")
resultados.round(3).T#@title La figura (solo ejecutar; el código del gráfico no es el tema de hoy) {display-mode: "form"}
NAVY, TERRACOTA, GRIS = "#00529B", "#C0492F", "#9FB0BD"
fig, ax = plt.subplots(figsize=(8, 4.5), dpi=150)
ax.plot(resultados.index, resultados["entrenamiento"], "o-", color=NAVY, label="Entrenamiento")
ax.plot(resultados.index, resultados["testeo"], "o-", color=TERRACOTA, label="Testeo")
ax.axhline(base, color=GRIS, linestyle="--", label=f"Piso: 'no renuncia nadie' ({base:.0%})")
ax.axvline(mejor_k, color=TERRACOTA, alpha=0.3)
ax.annotate(f"mejor K = {mejor_k}", (mejor_k, resultados.loc[mejor_k, "testeo"]),
xytext=(mejor_k * 1.6, 0.95), color=TERRACOTA, fontsize=10,
arrowprops=dict(arrowstyle="-", color=TERRACOTA, alpha=0.5))
ax.set_xscale("log")
ax.set_xticks(valores_k)
ax.set_xticklabels(valores_k, fontsize=8)
ax.set_xlabel("K (cantidad de vecinos) · hacia la izquierda, más flexible")
ax.set_ylabel("Acierto")
ax.set_ylim(0.8, 1.0)
ax.set_title("El acierto en entrenamiento siempre favorece al K más chico; el de testeo no",
fontsize=11, loc="left")
ax.legend(frameon=False, loc="upper right")
for lado in ("top", "right"):
ax.spines[lado].set_visible(False)
ax.grid(axis="y", alpha=0.25)
# La figura se guarda en la carpeta de figuras de la clase (o en ./figuras, en Colab).
CARPETA_FIG = "../assets/figures" if os.path.isdir("../assets/figures") else "figuras"
os.makedirs(CARPETA_FIG, exist_ok=True)
fig.savefig(f"{CARPETA_FIG}/clase04_knn_curva_k.png", bbox_inches="tight")
plt.show()Tres cosas para leer en la figura:
- La curva de entrenamiento baja sin parar a medida que K crece. Ningún dato de entrenamiento va a preferir un modelo menos flexible: cuanto más se pega el modelo a los datos, mejor se ve por dentro. Por eso ese número no sirve para elegir.
- La curva de testeo tiene forma de U invertida: sube, llega a un máximo cerca de K = 11 y después vuelve a bajar. A la izquierda el modelo copia el ruido (sobreajuste); a la derecha vota tanta gente que ya no distingue a nadie (subajuste).
- Con K = 151 las dos curvas se juntan en el piso: 151 vecinos sobre 420 es votar con un tercio de la empresa, y la mayoría siempre dice “no renuncia”. El modelo dejó de modelar.
Para cerrar: ¿y si nos tocó una partición con suerte?
Partimos la tabla al azar una sola vez, con random_state=42. Con otra semilla nos habrían tocado otros 180 empleados en el examen, y el 91,1 % sería otro número. ¿Ese 91,1 % es el desempeño del modelo, o el del modelo con la suerte que tuvimos hoy?
Esa es la pregunta con la que volvemos a las slides.
Vocabulario de hoy
| Palabra | Qué significa | Dónde apareció en el código |
|---|---|---|
| Predictores | las columnas que el modelo puede mirar | X |
| Variable objetivo | lo que el modelo tiene que adivinar | y |
| Conjunto de entrenamiento | los datos con los que el modelo aprende | X_train, y_train |
| Conjunto de testeo | los datos apartados para examinarlo | X_test, y_test |
| Estandarizar | poner todas las columnas en la misma escala | StandardScaler |
| Entrenar / ajustar | mostrarle los datos de entrenamiento al modelo | .fit() |
| Predecir | pedirle al modelo una respuesta para datos nuevos | .predict() |
| Acierto | proporción de predicciones correctas | .score() |
| Sobreajuste | acertar mucho por dentro y poco por fuera | K = 1 |
| Piso | lo que acierta un modelo que no aprendió nada | 83 % |
| Paso | Qué usamos | En una línea |
|---|---|---|
| Armar la tabla | merge |
la misma unión de la Clase 2, ahora con nimbus_rrhh.csv |
| Partir | train_test_split |
apartar el 30 % antes de entrenar, estratificado, con semilla |
| KNN | StandardScaler + KNeighborsClassifier(n_neighbors=k) · .fit() · .score() |
estandarizar, entrenar y medir el acierto |
| Curva | un ciclo sobre k |
entrenamiento baja siempre; testeo tiene un máximo |