Curso de ML EN

Capítulo 5 de 37 · básico

El clasificador más simple: un umbral

Qué cubre este capítulo

Antes de lanzarte por una red neuronal, deberías saber cuánto puntúa el modelo más tonto posible sobre tus datos. Ese número es la vara que todo lo demás tiene que superar, y la mayoría de las veces nadie lo mide. Por eso empezamos todo el curso aquí, con un clasificador tan simple que cabe en una línea: elige un número, traza una línea, y declara enfermo a todo el que quede arriba de la línea y sano a todo el que quede abajo.

Lo construimos a mano en NumPy, una función a la vez, y luego dejamos que scikit-learn construya exactamente lo mismo y comprobamos que los números coinciden. En el camino vas a ver el "entrenamiento" en acción — el corte se desliza sobre datos reales de pacientes y ves cómo la accuracy sube y baja con él. Sin magia, sin gradientes todavía, solo una búsqueda sobre un número. Entiende esto y el resto del curso son variaciones sobre el mismo tema.

Los datos son el conjunto Pima Indians Diabetes: 768 pacientes, ocho mediciones cada uno, y una etiqueta que dice si fueron diagnosticados con diabetes. Por ahora solo usaremos una de las ocho columnas — la glucosa en sangre.

Un poco de historia

Poner un umbral sobre una sola medición es más viejo que el machine learning y más viejo que las computadoras. La medicina lleva un siglo funcionando así: una glucosa en ayunas por encima de cierto corte significa diabetes, una presión arterial por encima de otro significa hipertensión. La Organización Mundial de la Salud todavía define la diabetes con una línea fija de glucosa. Alguien eligió ese número mirando los resultados clínicos, que es exactamente lo que estamos a punto de hacer, solo que con una computadora haciendo la mirada.

Del lado de la estadística, Ronald Fisher formalizó la idea en 1936 con el análisis discriminante lineal: proyecta los datos sobre un eje y luego divídelos con un umbral. Y en machine learning el umbral de una sola feature tiene nombre — decision stump, un árbol de decisión podado hasta dejarle un solo split. Parece un juguete, pero resultó importar. AdaBoost, de Freund y Schapire (1997), apila cientos de estos stumps en uno de los clasificadores más fuertes de su época. Todo árbol de decisión que entrenes en tu vida también empieza su vida como un stump: el split de la raíz es exactamente esto. Así que lo que construimos hoy es a la vez el modelo útil más débil y el átomo con el que se arman los modelos más grandes.

La intuición

Mira una gráfica de lecturas de glucosa, coloreadas según quién realmente tenía diabetes. Los dos grupos se traslapan, pero no están uno encima del otro — las lecturas de los diabéticos se cargan hacia arriba. Si se cargaran hacia arriba perfectamente, sin traslape, una línea vertical los separaría y habríamos terminado. No es así, de modo que ninguna línea es perfecta. El trabajo es encontrar la línea que se equivoque la menor cantidad de veces.

Ese es el modelo completo. Una feature en el eje x, una línea vertical, un pronóstico de "diabético" para todo lo que quede a su derecha. "Entrenar" significa probar líneas hasta encontrar la mejor. No hay nada más, y ese es el punto — todo lo más difícil de este curso es una forma más inteligente de trazar una frontera cuando una línea recta en una dimensión no alcanza.

Aquí están los datos reales. Lecturas de glucosa para ambos grupos, dibujadas como distribuciones suavizadas para que veas el traslape y la inclinación:

La joroba azul (sin diabetes) tiene su pico alrededor de 100, la morada (diabetes) queda bastante a la derecha. Donde se traslapan es donde un umbral tiene que tomar decisiones difíciles, y donde se nos fuga la accuracy.

Las matemáticas

Sea gig_i la lectura de glucosa del paciente ii y θ\theta el corte. La predicción es un solo indicador:

y^i(θ)=1 ⁣[giθ]\hat{y}_i(\theta) = \mathbb{1}\!\left[\, g_i \ge \theta \,\right]

Uno si la glucosa está en el corte o por encima, cero en caso contrario. La accuracy es simplemente qué tan seguido eso coincide con la etiqueta verdadera yiy_i a lo largo de los NN pacientes:

acc(θ)=1Ni=1N1 ⁣[y^i(θ)=yi]\mathrm{acc}(\theta) = \frac{1}{N} \sum_{i=1}^{N} \mathbb{1}\!\left[\, \hat{y}_i(\theta) = y_i \,\right]

Entrenar es elegir el corte que la maximiza. Solo buscamos en el rango donde la glucosa realmente varía, de 75 a 200:

θ=argmaxθ{75,,200}acc(θ)\theta^{*} = \arg\max_{\theta \,\in\, \{75, \dots, 200\}} \mathrm{acc}(\theta)

Ese es todo el algoritmo de aprendizaje — un argmax sobre un entero. Para ver de dónde sale la accuracy y a dónde se va, contamos los resultados en una matriz de confusión de 2×2:

Cjk={i:y^i=j,  yi=k}C_{jk} = \bigl|\{\, i : \hat{y}_i = j,\; y_i = k \,\}\bigr|

La fila jj es lo que predijimos, la columna kk es la verdad. La diagonal son los pacientes que acertamos. Fuera de la diagonal están las dos maneras de equivocarse, y no son el mismo tipo de error — uno es un diabético que se nos escapó, el otro una falsa alarma. Guarda esa idea; regresa cuando veamos la matriz moverse.

En qué es bueno, en qué no

El lado bueno es real. Es interpretable al punto de ser obvio — el modelo es un número que un doctor puede leer y discutir. Se entrena en milisegundos, casi no necesita datos para estimar un solo corte, y es imposible hacer overfitting con un solo umbral. Como baseline es honesto: si tu modelo sofisticado no puede ganarle a una línea sobre una feature, el modelo sofisticado no está aprendiendo nada.

El lado malo es igual de real. Ve una sola feature e ignora las otras siete, así que cualquier señal que viva en una combinación de mediciones le resulta invisible. La frontera está alineada a los ejes y es recta — un solo corte, perpendicular a un eje, sin curvas, sin interacciones. En datos donde las clases se enroscan una alrededor de la otra o dependen de dos features a la vez, un stump está indefenso. Es un piso, no un techo. El resto del curso se trata de despegar de ese piso.

Constrúyelo, una función a la vez

Todo el asunto son seis funciones cortas. Cada una hace exactamente un trabajo, y cada una se apoya en la anterior. Este es el mismo orden en que lo escribiría en una terminal: primero la pieza más pequeña que se pueda probar, luego envolverla.

La regla de decisión en sí. Todo lo demás es contabilidad alrededor de esta línea:

def simple_pred_vec(g, theta):
    """The entire decision rule: diabetic when glucose >= theta.

    Both arguments broadcast, so g can be one reading or a whole column,
    and theta one cutoff or a column of candidate cutoffs.
    """
    return g >= theta

Como esa comparación hace broadcasting, la misma función maneja una lectura contra un corte, o una columna entera de lecturas contra una columna entera de cortes candidatos. En un minuto nos apoyamos fuerte en eso. Primero, aplícala sobre un dataframe de pacientes:

def simple_pred_vec(g, theta):
    """The entire decision rule: diabetic when glucose >= theta.

    Both arguments broadcast, so g can be one reading or a whole column,
    and theta one cutoff or a column of candidate cutoffs.
    """
    return g >= theta

Reacomodar theta como columna y la glucosa como fila convierte una comparación en una malla completa (T, N) — cada corte candidato evaluado contra cada paciente de una vez. Ahora la accuracy es una línea: compara esa malla contra la verdad y promedia sobre los pacientes.

def simple_acc(df, theta):
    """Fraction of patients the rule gets right, for each candidate theta."""
    y = df["Outcome"].to_numpy()          # true labels, shape (N,)
    pred = simple_pred(df, theta)         # shape (T, N)
    return (pred == y).mean(axis=1)       # shape (T,)

Con la accuracy en la mano, "entrenar" es una búsqueda. Aquí está de la forma obvia, con un loop sobre cada corte entero:

def best_theta_loopy(df):
    """Brute force: try every integer cutoff from 75 to 200, keep the best.

    Ties go to the first (lowest) cutoff that reaches the best accuracy.
    """
    best_t, best_a = None, -1.0
    for t in range(75, 201):
        a = float(simple_acc(df, t)[0])
        if a > best_a:
            best_t, best_a = t, a
    return best_t, best_a

Eso funciona y es legible, y para 126 cortes es más que rápido. Pero ya construimos simple_acc para evaluar todos los cortes de un jalón, así que el loop está haciendo trabajo que NumPy preferiría hacer solo. Misma respuesta, sin loop de Python:

def best_theta_loopy(df):
    """Brute force: try every integer cutoff from 75 to 200, keep the best.

    Ties go to the first (lowest) cutoff that reaches the best accuracy.
    """
    best_t, best_a = None, -1.0
    for t in range(75, 201):
        a = float(simple_acc(df, t)[0])
        if a > best_a:
            best_t, best_a = t, a
    return best_t, best_a

Ambas versiones devuelven el mismo corte y la misma accuracy — argmax rompe empates con el primer máximo, exactamente como el loop que se queda con el primer mejor que ve. La versión vectorizada es la que te quedas. Agarrarle el modo a convertir un loop en un broadcast es la mitad de lo que hace que NumPy valga la pena, y es un hábito que rinde en todos los capítulos que vienen.

Última pieza: la accuracy es un número, y un número esconde en qué te estás equivocando. La matriz de confusión lo abre en canal.

def simple_confusion(df, theta):
    """The 2x2 confusion matrix for the rule at a given cutoff.

    M[i, j] counts patients *predicted* i whose *true* label is j
    (0 = no diabetes, 1 = diabetes). So M[0, 1] is the missed diabetics
    (false negatives) and M[1, 0] the false alarms (false positives).
    """
    y = df["Outcome"].to_numpy()
    pred = simple_pred(df, theta)[0].astype(int)
    M = np.zeros((2, 2), dtype=int)
    for i in range(2):
        for j in range(2):
            M[i, j] = int(((pred == i) & (y == j)).sum())
    return M

Míralo trabajar

Esta es la búsqueda misma, corriendo sobre un conjunto de juguete pequeño y limpio para que de verdad puedas verla — unos 60 pacientes, dos grupos, una dimensión. La línea naranja es el corte actual. Los puntos reciben un anillo rojo en el momento en que el corte los clasifica mal. El panel inferior traza la accuracy conforme el corte se mueve, para que veas llegar el pico.

Presiona play. Esto es best_theta_loopy con el loop frenado a velocidad humana — cada cuadro es una iteración real, evaluando un corte real. Reinícialo y córrelo de nuevo las veces que quieras.

Fíjate qué pasa en los extremos. Con el corte hasta la izquierda, todos son predichos como diabéticos, así que cada punto sano trae un anillo rojo. Hasta la derecha, nadie lo es, y entonces se encienden los puntos enfermos. La curva de accuracy está baja en ambos extremos y se joroba en medio, y la cima de esa joroba es el corte que nos quedamos.

Ahora la misma búsqueda sobre los datos reales de 768 pacientes, vista a través de la matriz de confusión. El mismo barrido, pero en lugar de puntos esto muestra los cuatro conteos — sanos correctos, diabéticos correctos, y los dos tipos de error — actualizándose en vivo conforme el corte se mueve.

Este es el trade-off del que un umbral no puede escapar, y vale la pena detenerse a pensarlo. Desliza el corte hacia abajo y atrapas a casi todos los diabéticos — los casos perdidos (celda superior derecha) caen hacia cero — pero también le pones bandera a un montón de gente sana (la celda inferior izquierda se infla). Deslízalo hacia arriba y las falsas alarmas desaparecen mientras los diabéticos perdidos se acumulan. No hay configuración que anule ambos. Dónde pones la línea es una decisión sobre qué error prefieres cometer, y en medicina esa no es una pregunta de matemáticas. La accuracy sola no te lo va a decir; la matriz sí.

Aquí está la curva de accuracy sobre el dataset completo, con el corte ganador marcado. El pico es ancho y un poco plano arriba, que es la forma visual de decir que un stump es un instrumento romo — toda una banda de cortes puntúa más o menos igual.

La implementación completa

Seis funciones, sin librería, de arriba a abajo. Este es el archivo que las animaciones de arriba realmente corrieron:

"""The simplest classifier: a glucose threshold, built from scratch.

Predict diabetic when a patient's blood glucose is at or above a cutoff
theta; the whole "training" is a brute-force search for the cutoff with the
highest accuracy. Pure NumPy — no ML library anywhere in this file.

Every function below appears in the chapter one step at a time (the
`# region:` markers are what the book's include directives pull in).
"""

import numpy as np
import pandas as pd


# region: simple_pred_vec
def simple_pred_vec(g, theta):
    """The entire decision rule: diabetic when glucose >= theta.

    Both arguments broadcast, so g can be one reading or a whole column,
    and theta one cutoff or a column of candidate cutoffs.
    """
    return g >= theta
# endregion


# region: simple_pred
def simple_pred(df, theta):
    """Apply the rule to every patient in the dataframe.

    theta may be a scalar or a vector of T candidate cutoffs. The result is
    a (T, N) boolean array: one row of predictions per candidate, produced
    by a single broadcast comparison — no loops.
    """
    g = df["Glucose"].to_numpy()                              # shape (N,)
    thetas = np.atleast_1d(np.asarray(theta)).reshape(-1, 1)  # shape (T, 1)
    return simple_pred_vec(g, thetas)                         # shape (T, N)
# endregion


# region: simple_acc
def simple_acc(df, theta):
    """Fraction of patients the rule gets right, for each candidate theta."""
    y = df["Outcome"].to_numpy()          # true labels, shape (N,)
    pred = simple_pred(df, theta)         # shape (T, N)
    return (pred == y).mean(axis=1)       # shape (T,)
# endregion


# region: best_theta_loopy
def best_theta_loopy(df):
    """Brute force: try every integer cutoff from 75 to 200, keep the best.

    Ties go to the first (lowest) cutoff that reaches the best accuracy.
    """
    best_t, best_a = None, -1.0
    for t in range(75, 201):
        a = float(simple_acc(df, t)[0])
        if a > best_a:
            best_t, best_a = t, a
    return best_t, best_a
# endregion


# region: best_theta
def best_theta(df):
    """The same search with the loop pushed into NumPy.

    One (126, N) comparison scores every cutoff at once; argmax picks the
    winner. np.argmax returns the FIRST maximum, so ties break exactly like
    the loop above.
    """
    thetas = np.arange(75, 201)
    acc = simple_acc(df, thetas)
    i = int(np.argmax(acc))
    return int(thetas[i]), float(acc[i])
# endregion


# region: simple_confusion
def simple_confusion(df, theta):
    """The 2x2 confusion matrix for the rule at a given cutoff.

    M[i, j] counts patients *predicted* i whose *true* label is j
    (0 = no diabetes, 1 = diabetes). So M[0, 1] is the missed diabetics
    (false negatives) and M[1, 0] the false alarms (false positives).
    """
    y = df["Outcome"].to_numpy()
    pred = simple_pred(df, theta)[0].astype(int)
    M = np.zeros((2, 2), dtype=int)
    for i in range(2):
        for j in range(2):
            M[i, j] = int(((pred == i) & (y == j)).sum())
    return M
# endregion


def load_data(path="../data/diabetes.csv"):
    """Pima Indians Diabetes dataset: 768 patients, 8 features, Outcome."""
    return pd.read_csv(path)

La versión de librería

Nadie escribe a mano una búsqueda de umbral en la práctica, y tú tampoco deberías una vez que la entiendes. Un decision stump — un árbol con max_depth=1 — es el mismo modelo, y scikit-learn te ajusta uno:

def stump_glucose(train_df, test_df):
    """A depth-1 tree on the single Glucose column — the library twin of
    our hand-built rule. Returns the learned cutoff and test accuracy."""
    stump = DecisionTreeClassifier(max_depth=1, random_state=0)
    stump.fit(train_df[["Glucose"]], train_df["Outcome"])
    cutoff = float(stump.tree_.threshold[0])
    acc = float(stump.score(test_df[["Glucose"]], test_df["Outcome"]))
    return cutoff, acc

Una diferencia bajo el capó: nosotros buscamos el corte que maximiza la accuracy, sklearn busca el que minimiza la impureza de Gini. Objetivo distinto, pero en estos datos aterrizan esencialmente en la misma línea. La otra diferencia es más interesante. Dale al stump las ocho features en lugar de solo la glucosa y él solito elegirá con cuál feature hacer el split:

def stump_all_features(train_df, test_df):
    """Give the stump all 8 features and let it choose the split itself.
    Returns the feature it picked, the cutoff, and test accuracy."""
    stump = DecisionTreeClassifier(max_depth=1, random_state=0)
    stump.fit(train_df[FEATURES], train_df["Outcome"])
    feature = FEATURES[int(stump.tree_.feature[0])]
    cutoff = float(stump.tree_.threshold[0])
    acc = float(stump.score(test_df[FEATURES], test_df["Outcome"]))
    return feature, cutoff, acc

Ahí el stump se gana su lugar como diagnóstico. La feature que elige es la medición más predictiva de todo el conjunto, rankeada gratis. En estos datos elige la glucosa — que es la confirmación silenciosa de que nuestra corazonada de una sola feature era la feature correcta desde el principio.

Desde cero contra librería

Divide los datos 70/30, ajusta sobre la mitad de entrenamiento, evalúa sobre la mitad apartada — tanto nuestra búsqueda como el stump de sklearn. Aquí está el cara a cara:

Las barras quedan una encima de la otra, que es el resultado que quieres. Nuestro argmax escrito a mano y el stump de Gini de sklearn alcanzan la misma accuracy de test con cortes separados por un pelo, y darle al stump las ocho features no mueve el número — solo reconfirma que la glucosa era la que importaba. Las cifras exactas de las que se dibuja la gráfica viven en results.json, regenerado cada vez que el código cambia, para que la prosa y la imagen no puedan alejarse de lo que el código realmente hace.

Dos advertencias honestas sobre esa accuracy. Sobre el dataset completo el mejor corte puntúa alrededor de 75%, que suena decente hasta que notas que adivinar "sin diabetes" para todos puntúa alrededor de 65% — las clases están desbalanceadas, así que el piso está más arriba del 50%. Y la accuracy sobre los datos con los que entrenaste es optimista; por eso el cara a cara de arriba usa una división apartada. Ambas son lecciones que el stump te enseña barato y que pagarás caro si las aprendes después con un modelo más grande.

Conclusiones

Ajusta el stump primero. Siempre. Antes del gradient boosting, antes de la red, antes de todo — dedícale los dos segundos a averiguar cuánto puntúa una línea sobre una feature, porque ese es el número que tu modelo real tiene que superar para justificar su existencia. He visto demasiados pipelines enviar a producción un modelo complicado que un simple umbral habría igualado, y nadie lo revisó porque nadie midió el piso.

El stump te paga de tres maneras. Es tu baseline honesto. Es un rankeador de features gratis — dale todas las columnas y el split que elige es tu señal más predictiva, sin código extra. Y es el átomo que el resto del curso ensambla: haz boosting con un montón de stumps y obtienes AdaBoost, apila splits en un árbol y obtienes un bosque. Recurre a algo más pesado cuando un solo corte recto en una dimensión deje accuracy sobre la mesa — cuando la señal viva en una combinación de features, o la frontera necesite curvarse. Eso es la mayoría de los problemas reales, y por eso el curso continúa. Pero empieza aquí, porque el piso es donde descubres si de verdad estás subiendo.