En 05-01 construimos nuestro primer clasificador (k-NN) para predecir si una entrega de Rutalia llegará tarde, y dejamos dos cabos sueltos: k-NN tiene límites serios, y la exactitud es una métrica que engaña. Esta lección resuelve ambos. Recorreremos el catálogo de clasificadores fundamentales —árboles de decisión, random forest, Naive Bayes y regresión logística—, implementando a mano la mecánica de cada uno (esto sigue siendo un curso de algoritmos: entropía, particiones recursivas, probabilidades condicionadas), y aprenderemos a evaluar en serio con matriz de confusión, precisión, recall, F1 y la curva ROC. El escenario sigue siendo el dataset canónico: 2000 entregas históricas, semilla 42, y una pregunta de negocio real para Rutalia: ¿qué entregas van a retrasarse, para poder avisar al cliente antes de que ocurra?

Contenido

  1. El problema de clasificación en Rutalia
  2. k-NN revisitado: sus límites
  3. Árboles de decisión: aprender preguntas
  4. Random forest: la sabiduría del bosque
  5. Naive Bayes: clasificar con probabilidades
  6. Regresión logística: el clasificador lineal
  7. Métricas en serio: cuando la exactitud engaña
  8. Tabla comparativa de clasificadores

El problema de clasificación en Rutalia

Clasificar es asignar una clase discreta a cada ejemplo. En nuestro dataset canónico hay dos problemas naturales:

  • Binario: retraso ∈ {0, 1}. ¿Avisamos al cliente C-1042 de que su paquete llegará tarde?
  • Multiclase: tipo de incidencia ∈ {ninguna, ausente, dirección errónea, paquete dañado}. La mecánica es idéntica (los algoritmos de esta lección generalizan a k clases); trabajaremos sobre el binario por claridad.

Partimos del pipeline de 05-01 (mismo código, misma semilla):

datos = generar_dataset()                     # 05-01: 2000 entregas, semilla 42
X_num = np.column_stack([datos["distancia_km"], datos["peso_kg"],
                         datos["hora_salida"], datos["dia_semana"]])
X = np.column_stack([X_num, one_hot(datos["zona"], ZONAS)])
y = datos["retraso"]
X_tr, X_te, y_tr, y_te = train_test_split_manual(X, y)

k-NN revisitado: sus límites

k-NN funcionó bien en 05-01, pero tiene dos problemas estructurales que conviene entender antes de buscar alternativas:

  • Coste por consulta O(n·d): no hay fase de entrenamiento, así que cada predicción recorre las n entregas históricas calculando d coordenadas. Con los "millones de registros" reales de Rutalia y miles de predicciones por hora, es inviable. (Existen estructuras espaciales —k-d trees, ball trees, primas de los tries de 01-04— que aceleran el caso de dimensión baja, pero no resuelven el fondo.)
  • La maldición de la dimensionalidad: en dimensión alta, la distancia euclídea pierde significado. Con d grande, casi todos los puntos quedan aproximadamente a la misma distancia entre sí, y "el vecino más cercano" deja de ser especialmente parecido. Nuestro one-hot ya nos subió a 13 dimensiones; con cientos de features, k-NN se degrada sin remedio.

La conclusión: queremos modelos que compriman los datos en una estructura pequeña durante el entrenamiento y luego predigan en O(profundidad) u O(d). Los siguientes lo hacen.

Árboles de decisión: aprender preguntas

Un árbol de decisión clasifica haciendo preguntas encadenadas sobre las features, como un diagnóstico:

flowchart TD
    A{"distancia_km > 4.2?"} -->|sí| B{"hora en punta?"}
    A -->|no| C{"zona = CEN?"}
    B -->|sí| D[RETRASO 0.91]
    B -->|no| E[puntual 0.72]
    C -->|sí| F{"distancia_km > 2.9?"}
    C -->|no| G[puntual 0.95]
    F -->|sí| H[RETRASO 0.66]
    F -->|no| I[puntual 0.88]

Lo notable: nadie escribió esas preguntas. El algoritmo las eligió de los datos. ¿Cómo? Midiendo cuánta "mezcla de clases" elimina cada pregunta candidata.

Impureza: entropía y Gini

Un nodo es puro si todas sus entregas son de la misma clase. Dos medidas estándar de impureza para un nodo con proporción p de retrasos:

Medida Fórmula Rango (binario) Notas
Entropía −p·log₂(p) − (1−p)·log₂(1−p) 0 (puro) a 1 (mitad y mitad) Origen en teoría de la información
Índice de Gini 2·p·(1−p) 0 (puro) a 0.5 Más barato de calcular; el defecto de sklearn

Ambas se comportan casi igual en la práctica. La ganancia de una pregunta es la impureza del padre menos la media ponderada de la impureza de los hijos que produce: el algoritmo elige, en cada nodo, la pregunta de máxima ganancia.

def gini(y):
    """Impureza de Gini de un conjunto de etiquetas binarias."""
    if len(y) == 0:
        return 0.0
    p = y.mean()
    return 2 * p * (1 - p)

def mejor_corte(X, y):
    """Busca la pareja (feature, umbral) que minimiza la impureza ponderada."""
    mejor = (None, None, gini(y))            # sin corte: impureza actual
    for j in range(X.shape[1]):              # cada feature...
        for umbral in np.unique(X[:, j]):    # ...y cada valor como umbral
            izq = X[:, j] <= umbral
            if izq.all() or (~izq).all():
                continue                     # corte inútil: no separa nada
            imp = (izq.mean() * gini(y[izq])
                   + (~izq).mean() * gini(y[~izq]))
            if imp < mejor[2]:
                mejor = (j, umbral, imp)
    return mejor                             # (feature, umbral, impureza)

Construcción recursiva: divide y vencerás

La construcción del árbol es divide y vencerás puro, el patrón de 01-03: resolver el nodo (elegir la mejor pregunta), partir los datos en dos y recursar en cada mitad. El caso base: nodo puro, sin cortes útiles, o profundidad máxima alcanzada.

def construir_arbol(X, y, profundidad_max=3, nivel=0):
    """Devuelve un árbol como dicts anidados. Hoja = probabilidad de retraso."""
    j, umbral, _ = mejor_corte(X, y)
    if nivel == profundidad_max or j is None or gini(y) == 0:
        return {"hoja": True, "p_retraso": y.mean(), "n": len(y)}
    izq = X[:, j] <= umbral
    return {"hoja": False, "feature": j, "umbral": umbral,
            "izq": construir_arbol(X[izq], y[izq], profundidad_max, nivel + 1),
            "der": construir_arbol(X[~izq], y[~izq], profundidad_max, nivel + 1)}

def predecir_arbol(nodo, x):
    """Baja por el árbol respondiendo las preguntas: O(profundidad)."""
    while not nodo["hoja"]:
        nodo = nodo["izq"] if x[nodo["feature"]] <= nodo["umbral"] else nodo["der"]
    return int(nodo["p_retraso"] > 0.5)

arbol = construir_arbol(X_tr, y_tr, profundidad_max=3)
acc = np.mean([predecir_arbol(arbol, x) == yv for x, yv in zip(X_te, y_te)])
print(f"Árbol (prof. 3): {acc:.3f}")

Observa las propiedades algorítmicas: entrenar cuesta O(d · n²) por nivel en esta versión ingenua (sklearn lo baja a O(d · n log n) preordenando cada columna, una idea directa de 04-02), pero predecir cuesta O(profundidad) — de recorrer 1600 entregas por consulta (k-NN) a responder 3 preguntas. Además el árbol no necesita escalado (compara contra umbrales, no calcula distancias) y es interpretable: puedes enseñarle el diagrama al jefe de operaciones de Rutalia y discutirlo.

Sobreajuste y poda

Un árbol sin límite de profundidad sigue partiendo hasta que cada hoja es pura... aunque tenga una sola entrega. Eso es memorizar el ruido: la curva en U de 05-01, otra vez. Antídotos:

  • Pre-poda: limitar profundidad_max, exigir un mínimo de ejemplos por hoja o una ganancia mínima para cortar. Es lo que hicimos (profundidad_max=3).
  • Post-poda: dejar crecer el árbol y luego eliminar las ramas que no mejoran el error de validación (más costoso, a veces mejor).

En sklearn: DecisionTreeClassifier(max_depth=3, min_samples_leaf=20). Prueba max_depth=None y compara train vs test: verás el sobreajuste en vivo.

Random forest: la sabiduría del bosque

Un árbol individual es inestable: cambia el 5% de los datos y puede salir un árbol distinto (alta varianza). La solución es sorprendente: entrenar muchos árboles distintos y hacerlos votar.

  • Bagging (bootstrap aggregating): cada árbol se entrena con una muestra aleatoria con reemplazo del entrenamiento (mismo tamaño n, pero con repetidos y ausentes). Cada árbol ve datos ligeramente distintos, así que comete errores distintos.
  • Subespacios aleatorios: en cada nodo, cada árbol solo considera un subconjunto aleatorio de features (típicamente √d). Esto descorrelaciona los árboles: sin ello, todos empezarían cortando por distancia_km y votarían casi lo mismo.

¿Por qué funciona promediar? El mismo motivo por el que la media de 100 mediciones ruidosas es más fiable que una: si los errores de los árboles son (parcialmente) independientes, al votar se cancelan. La varianza del promedio de T estimadores independientes es la individual dividida por T. Los árboles no son independientes del todo, pero el bagging y los subespacios los acercan a serlo — de ahí el empeño en descorrelacionarlos.

from sklearn.ensemble import RandomForestClassifier

rf = RandomForestClassifier(n_estimators=200, max_depth=None,
                            random_state=42).fit(X_tr, y_tr)
print(f"Random forest: {rf.score(X_te, y_te):.3f}")

Detalle elegante: cada árbol individual puede sobreajustar (profundidad libre), porque el promedio corrige la varianza. Se pierde la interpretabilidad del árbol único, pero rf.feature_importances_ aún dice qué features importan — en Rutalia verás dominar a distancia_km, hora_salida y las zonas congestionadas, coherente con la verdad oculta del generador.

Naive Bayes: clasificar con probabilidades

Cambio total de filosofía: en lugar de aprender fronteras, modelamos probabilidades. El teorema de Bayes nos da la probabilidad de retraso dadas las features:

P(retraso | x)  =  P(x | retraso) · P(retraso) / P(x)

El problema: estimar P(x | retraso) para cada combinación completa de features exigiría datos astronómicos. La suposición "naive" (ingenua) lo salva: asumir que las features son independientes entre sí dentro de cada clase, de modo que la probabilidad conjunta se factoriza en un producto de probabilidades individuales — cada una trivial de estimar contando.

Es falsa en general (en Rutalia, zona y distancia están correlacionadas), pero el clasificador solo necesita que la clase correcta obtenga el mayor producto, no probabilidades exactas. Por eso Naive Bayes funciona mejor de lo que su suposición merece.

Ejemplo con features categóricas (zona y franja horaria):

def entrenar_nb(zonas, franjas, y):
    """Estima las tablas de probabilidad por conteo, con suavizado de Laplace."""
    modelo = {}
    for c in (0, 1):
        m = (y == c)
        modelo[c] = {
            "prior": m.mean(),               # P(clase)
            # P(zona | clase): conteo + 1 (Laplace) para no dar probabilidad 0
            "p_zona": {z: (np.sum(zonas[m] == z) + 1) / (m.sum() + len(ZONAS))
                       for z in ZONAS},
            "p_franja": {f: (np.sum(franjas[m] == f) + 1) / (m.sum() + 3)
                         for f in ("manana", "punta", "tarde")},
        }
    return modelo

def predecir_nb(modelo, zona, franja):
    """Compara log-probabilidades (sumas, no productos: evita underflow)."""
    scores = {c: np.log(m["prior"]) + np.log(m["p_zona"][zona])
                 + np.log(m["p_franja"][franja])
              for c, m in modelo.items()}
    return max(scores, key=scores.get)

Dos detalles de ingeniería que son pura práctica algorítmica:

  • Suavizado de Laplace (+1 en los conteos): sin él, una combinación nunca vista en el entrenamiento (¿retraso en PAR de madrugada?) tendría probabilidad 0 y anularía todo el producto.
  • Log-probabilidades: multiplicar muchas probabilidades pequeñas provoca underflow numérico; sumar logaritmos es equivalente (el log es monótono) y estable.

Naive Bayes entrena en una sola pasada de conteo, O(n·d), predice en O(d) y funciona sorprendentemente bien con pocos datos y muchas features categóricas (su hábitat clásico: filtros de spam). En sklearn: CategoricalNB, GaussianNB (features continuas) o MultinomialNB (conteos).

Regresión logística: el clasificador lineal

El cuarto enfoque: una frontera lineal. Se calcula una puntuación z = w·x + b (combinación lineal de las features, como en la programación lineal de 02-01) y se convierte en probabilidad con la función sigmoide:

σ(z) = 1 / (1 + e^(−z))        σ(−∞)→0,  σ(0)=0.5,  σ(+∞)→1
def sigmoide(z):
    return 1 / (1 + np.exp(-z))

def predecir_logistica(w, b, x):
    p = sigmoide(x @ w + b)     # probabilidad de retraso
    return int(p > 0.5)

Geométricamente, w·x + b = 0 define un hiperplano que parte el espacio en dos: a un lado se predice retraso, al otro puntualidad. La distancia (con signo) al hiperplano gradúa la confianza vía la sigmoide. Es el clasificador lineal por excelencia: simple, rápido (O(d) por predicción), con coeficientes interpretables ("cada km añadido multiplica las odds de retraso por e^w₁").

¿Y de dónde salen w y b? Se aprenden minimizando una función de coste mediante descenso de gradiente — el algoritmo que desarrollaremos con todo detalle en 05-03. Aquí basta la idea: empezar con pesos aleatorios y ajustarlos iterativamente en la dirección que reduce el error. En sklearn: LogisticRegression() (necesita features escaladas, como todo modelo basado en w·x). Su límite es evidente: si la frontera real no es lineal (y la verdad multiplicativa de Rutalia no lo es), un hiperplano solo puede aproximarla — la salida de ese callejón son las redes neuronales de 05-04, que apilan muchas de estas unidades.

Métricas en serio: cuando la exactitud engaña

Llegamos a la deuda pendiente de 05-01. En nuestro dataset, ~85% de las entregas son puntuales. El clasificador "todo puntual" logra un 85% de exactitud siendo perfectamente inútil: no detecta ni un solo retraso, que es justo lo que Rutalia quiere detectar.

Matriz de confusión

Todo empieza por desglosar los cuatro resultados posibles:

Predicho: retraso Predicho: puntual
Real: retraso VP (verdadero positivo) FN (falso negativo) — retraso no avisado
Real: puntual FP (falso positivo) — aviso en falso VN (verdadero negativo)
def matriz_confusion(y_real, y_pred):
    vp = np.sum((y_real == 1) & (y_pred == 1))
    fn = np.sum((y_real == 1) & (y_pred == 0))
    fp = np.sum((y_real == 0) & (y_pred == 1))
    vn = np.sum((y_real == 0) & (y_pred == 0))
    return vp, fn, fp, vn

Precisión, recall y F1

  • Precisión = VP / (VP + FP): de los avisos de retraso que emitimos, ¿qué fracción era real? Mide el coste de los avisos en falso.
  • Recall (sensibilidad) = VP / (VP + FN): de los retrasos reales, ¿qué fracción detectamos? Mide los retrasos que se nos escapan.
  • F1 = media armónica de ambas = 2·P·R / (P + R). La armónica castiga el desequilibrio: si una de las dos es próxima a 0, el F1 se hunde aunque la otra sea 1.

El clasificador "todo puntual" queda retratado: recall = 0, F1 = 0, por mucho 85% de exactitud que exhiba. Y hay un trade-off inherente: bajar el umbral de decisión (avisar con p > 0.3 en vez de p > 0.5) sube el recall (cazas más retrasos) pero baja la precisión (más falsas alarmas). Cuál priorizar es una decisión de negocio: si el aviso es un SMS barato, Rutalia querrá recall alto; si dispara una compensación económica, precisión alta. La métrica se elige mirando el coste real de cada tipo de error — y la decisión final sobre qué hacer con cada cliente sigue siendo humana; el modelo solo prioriza.

Curva ROC y AUC (breve)

Los clasificadores probabilísticos permiten mover el umbral de 0 a 1. La curva ROC dibuja, para cada umbral, la tasa de verdaderos positivos frente a la de falsos positivos. El AUC (área bajo esa curva) resume la calidad global: 1.0 es perfecto, 0.5 es lanzar una moneda. Su lectura más útil: el AUC es la probabilidad de que el modelo puntúe más alto un retraso real que una entrega puntual, elegidos al azar. En sklearn: roc_auc_score(y_te, modelo.predict_proba(X_te)[:, 1]). Es la métrica de comparación estándar cuando aún no se ha fijado el umbral operativo.

Tabla comparativa de clasificadores

Criterio k-NN Árbol de decisión Random forest Naive Bayes Reg. logística
Entrenamiento Ninguno O(d·n log n) T árboles O(n·d), una pasada Iterativo (gradiente)
Predicción O(n·d) O(prof.) ✓ O(T·prof.) O(d) ✓ O(d) ✓
¿Necesita escalado? No No No
¿Frontera no lineal? Sí (a tramos) Limitada No
Interpretabilidad Baja Alta Media (importancias) Media Alta (coeficientes)
Riesgo de sobreajuste k pequeño Alto sin poda Bajo Bajo Bajo
Punto fuerte Simplicidad Explicable a negocio Precisión "de serie" Pocos datos, categóricas Base sólida y rápida

Regla práctica para datos tabulares como los de Rutalia: empieza con regresión logística como base (baseline), prueba un random forest (suele ser lo más fuerte sin ajuste fino) y usa el árbol simple cuando necesites explicar la decisión.

Errores Comunes y Consejos

  • Presumir de exactitud con clases desbalanceadas. El 85% de "todo puntual" es el suelo, no un logro. Mira siempre la matriz de confusión y el F1 de la clase minoritaria.
  • Dejar crecer el árbol sin límite. Error de train 0% y test mediocre = sobreajuste de libro. Limita profundidad o mínimo de ejemplos por hoja, y compara siempre train vs test.
  • Olvidar el suavizado en Naive Bayes. Una sola probabilidad 0 aniquila el producto entero. Laplace (+1) es una línea de código que evita predicciones absurdas.
  • Multiplicar probabilidades en lugar de sumar logs. Con decenas de features, el producto hace underflow a 0.0 silenciosamente. Trabaja siempre en espacio logarítmico.
  • Escalar para árboles / no escalar para logística. Los árboles comparan contra umbrales (el escalado les da igual); los modelos con w·x (logística, k-NN) lo exigen. Conocer la mecánica interna evita el error.
  • Consejo: fija el umbral de decisión según el coste de negocio de FP vs FN, no por el 0.5 por defecto. Es gratis y suele valer más que cambiar de algoritmo.

Ejercicios

  1. El árbol contra el bosque. Con el dataset canónico, entrena DecisionTreeClassifier con max_depth ∈ {2, 4, 8, None} y un RandomForestClassifier(n_estimators=200). Para cada modelo imprime exactitud en train y en test. ¿Con qué profundidad el árbol sobreajusta claramente? ¿El bosque sobreajusta aunque sus árboles tengan profundidad libre?

  2. La trampa de la exactitud, con números. Implementa el clasificador trivial "todo puntual" y compáralo con el random forest del ejercicio 1 usando: exactitud, precisión, recall y F1 (calculados con tu matriz_confusion). Redacta en una frase por qué Rutalia jamás debería desplegar el trivial pese a su exactitud.

  3. Mover el umbral. Con rf.predict_proba(X_te)[:, 1] obtén las probabilidades de retraso y evalúa precisión y recall para umbrales 0.3, 0.5 y 0.7. ¿Qué umbral elegirías si el aviso al cliente es un SMS gratuito? ¿Y si cada aviso dispara un descuento del 20%?

Soluciones

Ejercicio 1:

from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import RandomForestClassifier

for prof in [2, 4, 8, None]:
    a = DecisionTreeClassifier(max_depth=prof, random_state=42).fit(X_tr, y_tr)
    print(f"prof={str(prof):>4}  train={a.score(X_tr, y_tr):.3f}  test={a.score(X_te, y_te):.3f}")

rf = RandomForestClassifier(n_estimators=200, random_state=42).fit(X_tr, y_tr)
print(f"bosque      train={rf.score(X_tr, y_tr):.3f}  test={rf.score(X_te, y_te):.3f}")

Con max_depth=None el árbol clava el train (≈1.0) y pierde en test: sobreajuste claro; el hueco train−test crece con la profundidad. El bosque también roza 1.0 en train (sus árboles son profundos) pero mantiene el test alto: el promedio de árboles descorrelacionados absorbe la varianza que condena al árbol solitario.

Ejercicio 2:

pred_trivial = np.zeros_like(y_te)
pred_rf = rf.predict(X_te)

for nombre, pred in [("trivial", pred_trivial), ("bosque", pred_rf)]:
    vp, fn, fp, vn = matriz_confusion(y_te, pred)
    prec = vp / (vp + fp) if vp + fp else 0.0
    rec = vp / (vp + fn) if vp + fn else 0.0
    f1 = 2 * prec * rec / (prec + rec) if prec + rec else 0.0
    acc = (vp + vn) / len(y_te)
    print(f"{nombre}: acc={acc:.3f} prec={prec:.3f} recall={rec:.3f} F1={f1:.3f}")

El trivial ronda acc≈0.85 pero con recall=0 y F1=0: no detecta ningún retraso. El bosque tendrá menos margen en exactitud del que sugiere la intuición, pero un recall y F1 muy superiores. Frase: el trivial no avisa de ningún retraso, que es exactamente la única función del sistema; su exactitud solo refleja que los retrasos son raros.

Ejercicio 3:

probs = rf.predict_proba(X_te)[:, 1]
for u in (0.3, 0.5, 0.7):
    pred = (probs > u).astype(int)
    vp, fn, fp, vn = matriz_confusion(y_te, pred)
    print(f"umbral {u}: precisión={vp/(vp+fp):.3f}  recall={vp/(vp+fn):.3f}")

Con umbral 0.3 el recall sube (se cazan más retrasos) a costa de la precisión; con 0.7, al revés. SMS gratuito → falsas alarmas baratas → umbral bajo (0.3), prioriza recall. Descuento del 20% → cada FP cuesta dinero → umbral alto (0.7), prioriza precisión. La métrica correcta depende del coste real de cada error, no de la estadística.

Conclusión

Ya tienes el catálogo esencial de la clasificación y, más importante, su mecánica interna: los árboles aprenden preguntas maximizando la pureza con divide y vencerás; el bosque promedia árboles descorrelacionados para matar la varianza; Naive Bayes cuenta y multiplica probabilidades (en logaritmos, con Laplace); y la regresión logística traza un hiperplano y gradúa la confianza con la sigmoide. También sabes evaluar de verdad: la matriz de confusión, precisión/recall/F1 y el umbral como decisión de negocio, porque con un 85% de entregas puntuales la exactitud sola es humo. Nos queda una deuda técnica confesada: dijimos que los pesos de la regresión logística "se aprenden minimizando un coste con descenso de gradiente", sin explicar cómo. En 05-03 saldamos esa deuda: pasamos a predecir los minutos de entrega (regresión), y allí desarrollaremos el descenso de gradiente pieza a pieza — el algoritmo que, además, es el puente directo hacia las redes neuronales de 05-04.

© Copyright 2026. Todos los derechos reservados