Capítulo 13 de 37 · intermedio
Regresión logística
Qué cubre este capítulo
Allá en el capítulo del threshold dibujamos una línea vertical sobre un solo feature y lo llamamos clasificador. Funcionó, fue honesto, y dejó siete de las ocho columnas tiradas en el piso. La regresión logística es el siguiente paso natural: conserva la frontera recta, pero deja que cada feature vote, y en vez de un sí/no duro en la línea, devuelve una probabilidad. Es el modelo al que recurro primero en cualquier problema de clasificación lineal, y la mayoría de las veces es el que termino poniendo en producción.
Lo construimos desde cero en NumPy — la sigmoide, el score lineal que envuelve, la pérdida de cross-entropy, el gradiente y el loop de descenso de gradiente que los une. Después dejamos que scikit-learn ajuste el mismo modelo y verificamos que los números coincidan. En el camino vas a poder ver el entrenamiento en vivo: una frontera de decisión deslizándose y rotando sobre un dataset 2-D mientras la curva de pérdida cae debajo de ella, cada frame un paso real de gradiente. Esta vez no hay forma cerrada, no hay argmax sobre una malla — un optimizador de verdad caminando cuesta abajo.
Los resultados corren sobre el mismo dataset de diabetes Pima de la semana 1, así que tenemos una referencia limpia. Un feature y un threshold sacaron 73% en aquel entonces. Ocho features y una sigmoide deberían hacerlo mejor. Veamos por cuánto.
Un poco de historia
La curva en el centro de todo esto es más vieja que la estadística como campo. Pierre François Verhulst escribió la función logística en 1838 para modelar el crecimiento poblacional — una curva en forma de S que arranca lento, acelera y luego se satura cuando se queda sin espacio. Necesitaba algo que viviera entre dos cotas y se moviera suavemente de una a la otra, que es exactamente la forma que quieres para una probabilidad.
La historia de la clasificación empieza un siglo después. Joseph Berkson, físico y estadístico en la Clínica Mayo, acuñó la palabra "logit" en 1944 — log-odds, la cosa en la que el modelo es realmente lineal — y pasó años argumentando que era una mejor herramienta que el probit, entonces dominante, para el trabajo de bioensayos. Tenía razón en su mayor parte, y el nombre se quedó. Luego, en 1958, David Cox planteó la regresión logística como un método general para resultados binarios, y Nelder y Wedderburn la integraron al marco de los modelos lineales generalizados en 1972. Ese linaje es la razón de que el modelo se sienta tan asentado: no es un truco de machine learning que dio la casualidad de funcionar, es un modelo estadístico con sesenta años de teoría detrás. Cuando la gente dice que la regresión logística es el clasificador lineal por defecto, ese es el peso detrás de la palabra defecto.
La intuición
Aquí hay un dataset pequeño en 2-D, dos clases, algo de traslape. Dos features en los ejes, color para la etiqueta verdadera. Este es el set de juguete con el que entrenaremos, para que de verdad puedas ver moverse la frontera.
Una línea recta puede separar en su mayoría estas dos nubes, igual que un solo corte separó en su mayoría la glucosa. La diferencia es que la línea ahora vive en dos dimensiones, así que puede inclinarse — no tiene que quedar perpendicular a un eje. En ocho dimensiones es un hiperplano que no puedes visualizar, pero la idea es idéntica: una frontera plana, orientada por un peso por feature.
La otra idea nueva es lo que pasa cerca de la línea. El clasificador de threshold era brutal — de un lado enfermo, del otro sano, sin puntos medios. La regresión logística lo suaviza. Lejos de la frontera, del lado positivo, dice "casi seguro clase 1"; lejos del otro lado, "casi seguro clase 0"; justo en la línea, "de verdad no sé, échalo a volado." Esa suavidad es todo el punto. Te da una probabilidad que puedes cortar donde quieras, y le da al entrenamiento algo suave que optimizar.
La matemática
Empieza con el score lineal. Para un vector de features con entradas, un vector de pesos y un sesgo :
Ese es el log-odds — el logit. Corre de a , lo cual no sirve como probabilidad, así que lo aplastamos con la sigmoide:
La sigmoide es la curva en S que escribió Verhulst. Mapea todo número real al intervalo , cruza en y se aplana hacia los extremos. La predicción del modelo es ese aplastamiento aplicado al score:
Para clasificar, corta la probabilidad en — lo cual, como la sigmoide cruza exactamente en , es lo mismo que preguntar si es positivo. La frontera es la superficie plana .
Ahora el entrenamiento. Necesitamos una pérdida que sea pequeña cuando está confiado y en lo correcto, y grande cuando está confiado y equivocado. Esa es la cross-entropy, también llamada log loss:
Cuando la verdad es solo sobrevive el primer término, y es — cero si dijiste , explotando conforme te acercas a . Cuando el segundo término hace lo mismo al revés. Un acierto confiado sale casi gratis; un error confiado sale carísimo.
No hay forma cerrada para los y que minimizan esto, así que descendemos por el gradiente. Y aquí está la recompensa de emparejar la sigmoide con la cross-entropy — el gradiente colapsa en algo limpio:
El error es simplemente , la brecha entre la probabilidad predicha y la verdad, y el gradiente es ese error ponderado por los features. La derivada de la sigmoide y la derivada del logaritmo se cancelan perfectamente; no sobrevive nada feo. El descenso de gradiente entonces repite una línea — — hasta que la pérdida deja de bajar.
Esa cancelación también responde una pregunta que la gente hace temprano: ¿por qué no usar simplemente error cuadrático, como en regresión lineal? Dos razones. El error cuadrático encima de una sigmoide es no convexo, así que el descenso puede atorarse en valles locales. Y castiga los errores confiados solo tibiamente, porque la sigmoide aplana el gradiente justo donde más necesitas una corrección fuerte. La cross-entropy aquí es convexa y sigue empujando duro contra los errores confiados. Usa la pérdida para la que el modelo fue construido.
En qué es bueno, en qué no
La ventaja es un paquete difícil de superar por el precio. Obtienes probabilidades calibradas, no solo etiquetas — cuando el modelo dice 0.7 lo dice en serio, lo cual importa muchísimo en el momento en que una decisión posterior depende de qué tan probable, no solo de si lo es. Obtienes pesos que puedes leer: cada es el cambio en log-odds por unidad del feature , así que el modelo te dice qué mediciones mueven la predicción y en qué dirección. Entrena rápido, apenas sufre overfitting con un poco de regularización, y es convexo, así que obtienes la misma respuesta cada vez. Como baseline fuerte en datos tabulares, es el número que tu gradient-boosted-lo-que-sea tiene que superar de verdad, no solo empatar.
El límite es el mismo que tenía el threshold, elevado una dimensión: la frontera sigue siendo plana. La regresión logística dibuja un hiperplano, y si la frontera verdadera se curva o las clases se envuelven una a la otra, un plano no puede seguirla. Puedes recuperar algo de eso a mano — agrega features polinomiales o de interacción y el plano se dobla en el espacio original — pero en ese punto ya estás haciendo el feature engineering que los modelos basados en árboles hacen gratis. También se toma el score lineal al pie de la letra, así que features con escalas descontroladas y outliers fuertes pueden arrastrar los pesos. Pasa a otro modelo cuando la señal sea genuinamente no lineal y prefieras que sea el modelo, y no tú, quien encuentre las interacciones.
Los datos
Los mismos pacientes de la semana 1: el dataset Pima Indians Diabetes, 768 mujeres, ocho mediciones cada una — glucosa, IMC, edad, presión arterial, etcétera — y una etiqueta que indica si fueron diagnosticadas con diabetes. La vez pasada usamos una columna. Esta vez entran las ocho.
Hay un paso de preprocesamiento que importa lo suficiente como para mencionarlo. Estos features viven en escalas completamente distintas: la glucosa pasa de 120, el score de pedigrí de diabetes ronda el 0.5, los conteos de embarazos son de un dígito. El descenso de gradiente con una sola tasa de aprendizaje no puede servir a rangos tan distintos — el paso que le queda bien a la glucosa es mil veces demasiado grande para el score de pedigrí. Así que primero estandarizamos: restamos la media de cada columna, dividimos entre su desviación estándar, usando estadísticas del set de entrenamiento únicamente y aplicando el mismo desplazamiento al set de prueba. Después de eso, cada feature queda con media 0 y varianza unitaria, y una sola tasa de aprendizaje funciona para todos. También hace comparables los pesos, algo que vamos a cobrar al final.
Constrúyelo, una función a la vez
Siete piezas pequeñas, de abajo hacia arriba, en el orden en que las escribirías en una terminal. Empieza con el aplastamiento, porque todo depende de él.
def sigmoid(z):
"""Squash any real number into (0, 1).
Written in the numerically stable branch form so a large negative z
never overflows exp(). For z >= 0 use 1 / (1 + e^-z); for z < 0 use
e^z / (1 + e^z). Both are the same function, algebraically.
"""
z = np.asarray(z, dtype=float)
out = np.empty_like(z)
pos = z >= 0
out[pos] = 1.0 / (1.0 + np.exp(-z[pos]))
ez = np.exp(z[~pos])
out[~pos] = ez / (1.0 + ez)
return out
Esa forma con ramas es deliberada. La versión de libro de texto
se desborda para muy negativos — exp de un número
positivo grande es inf — así que usamos la algebraicamente idéntica
siempre que es negativo. Misma curva, sin NaNs. Es el
tipo de cosa que nunca te muerde en datos de juguete y siempre te muerde en
datos reales.
Luego el score lineal, el log-odds antes de aplastarlo:
def logit(X, w, b):
"""The linear part: z = Xw + b.
X is (N, D), w is (D,), b is a scalar bias. Returns one score z per
row — the log-odds of class 1 before the sigmoid squashes it.
"""
return X @ w + b
Envuelve las dos piezas y tienes la salida de probabilidad del modelo:
def predict_proba(X, w, b):
"""P(y = 1 | x) for every row: sigmoid of the linear score."""
return sigmoid(logit(X, w, b))
Para convertir probabilidades en etiquetas, corta en 0.5:
def predict_proba(X, w, b):
"""P(y = 1 | x) for every row: sigmoid of the linear score."""
return sigmoid(logit(X, w, b))
Nota que el threshold es un parámetro, no una constante mágica. 0.5 es la elección natural, pero es la misma perilla que el corte de la semana 1 — muévela para intercambiar positivos perdidos por falsas alarmas cuando un error cuesta más que el otro.
Ahora la pérdida. Esto es lo que el entrenamiento minimiza:
def cross_entropy(X, y, w, b, eps=1e-12):
"""Mean binary cross-entropy (log loss) over the dataset.
For each row, -log(p) when the truth is 1 and -log(1 - p) when it's 0.
A confident, correct probability costs almost nothing; a confident,
wrong one costs a lot. Clipping keeps log() away from 0.
"""
p = predict_proba(X, w, b)
p = np.clip(p, eps, 1.0 - eps)
return float(-np.mean(y * np.log(p) + (1 - y) * np.log(1 - p)))
El clip mantiene a log lejos del cero cuando el modelo se pone gallito y
predice exactamente 0 o 1. Ahora el gradiente — la dirección que empeora la
pérdida, contra la cual daremos el paso:
def gradient(X, y, w, b):
"""Gradient of the mean cross-entropy w.r.t. w and b.
The algebra collapses to something clean: the error is just
(p - y), and the gradient is that error dotted with the features.
dw = X^T (p - y) / N, db = mean(p - y). No sigmoid derivative left
over — cross-entropy and the sigmoid were built for each other.
"""
p = predict_proba(X, w, b)
err = p - y # shape (N,)
dw = X.T @ err / len(y) # shape (D,)
db = float(np.mean(err)) # scalar
return dw, db
Mira qué poco hay ahí. Todo el gradiente es (p - y) en producto punto con los
features. Todo el cálculo de la sección de matemáticas se reduce a una resta y
una multiplicación de matrices, y no es coincidencia — es la sigmoide y la
cross-entropy cancelándose, la misma cancelación que hizo del error cuadrático
la elección equivocada.
Última pieza, el loop de entrenamiento. Arranca los pesos en cero y da pasos cuesta abajo:
def fit(X, y, lr=0.1, n_iter=300, record=False):
"""Train by batch gradient descent: step downhill on the log loss.
Start the weights at zero, and on every iteration compute the
gradient over the whole training set and take one step against it.
With `record=True` we also return the per-iteration (w, b, loss)
history so the chapter can animate the descent.
"""
N, D = X.shape
w = np.zeros(D)
b = 0.0
history = []
for t in range(n_iter):
if record:
history.append((w.copy(), b, cross_entropy(X, y, w, b)))
dw, db = gradient(X, y, w, b)
w -= lr * dw
b -= lr * db
if record:
history.append((w.copy(), b, cross_entropy(X, y, w, b)))
return w, b, history
return w, b
Descenso de gradiente por lotes, la versión simple: cada iteración usa todo el
set de entrenamiento para calcular un gradiente y dar un paso. La bandera
record guarda los pesos y la pérdida en cada iteración para que podamos
reproducir el descenso en un minuto. Esa reproducción es toda la razón por la
que el loop se ve como se ve.
Míralo trabajar
Este es el entrenamiento mismo, corriendo sobre el set de juguete 2-D para que cada punto sea visible. El panel superior son los datos; la línea naranja es la frontera de decisión actual, el lugar donde la probabilidad del modelo es exactamente 0.5. Un punto recibe un anillo rojo en el momento en que la frontera lo deja del lado equivocado. El panel inferior traza la pérdida de cross-entropy, un punto por iteración.
Dale play. Cada frame es un paso real de gradiente salido de fit — los mismos
pesos que produjo el código, sin suavizado. Reinicia y córrelo de nuevo las
veces que quieras.
Observa el primer movimiento. Los pesos empiezan en cero, así que no hay línea en absoluto y el modelo llama a todo una sola clase — la mitad de los puntos traen anillo rojo. Luego el gradiente entra en acción y aparece una frontera, tosca al principio, cortando el plano más o menos a la mitad. Desde ahí rota y se desliza, y los anillos rojos se desprenden por tandas conforme encuentra la orientación que separa las nubes. La curva de pérdida debajo cuenta la misma historia desde el otro lado: una caída empinada al principio mientras se corrigen los errores grandes, luego un largo planeo casi horizontal mientras afina detalles. Al final la frontera se ha asentado en el hueco entre las clases y la pérdida casi ha tocado fondo, aterrizando en 96% de accuracy en este set de juguete. Ese aplanamiento es la cara de la convergencia — el gradiente encogiéndose hacia cero porque queda poco por arreglar.
Lo único que el thresholding no podía hacer está justo ahí, en la inclinación de la línea. No es vertical, no es horizontal, está en ángulo — porque ambos features cargan señal y los pesos encontraron la mezcla. Ese ángulo es la dimensión extra de libertad que la regresión logística te compra sobre un solo corte.
Si quieres ver la función de aplastamiento por sí sola, aquí está: la sigmoide a lo largo del rango de scores que el modelo produce. Plana cerca de los extremos, empinada por el centro, cruzando 0.5 en cero.
La banda empinada del centro es la zona de incertidumbre cerca de la frontera; las colas planas son donde el modelo está confiado. Un punto lejos de la línea tiene un grande y se sienta en una cola, y por eso el modelo puede estar tan seguro de él.
La implementación completa
El archivo entero, de arriba abajo — el modelo, la pérdida, el gradiente, el loop, más los helpers de estandarización y accuracy que usan los traces. Este es el código que la animación de arriba realmente corrió:
"""Logistic regression, built from scratch.
A linear model that outputs a probability. Push the features through a
weighted sum, squash that sum to (0, 1) with the sigmoid, and call it
P(class = 1). Training is gradient descent on the cross-entropy loss — no
closed form, no library, just the gradient and a step size.
Pure NumPy (+ pandas for loading). 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: sigmoid
def sigmoid(z):
"""Squash any real number into (0, 1).
Written in the numerically stable branch form so a large negative z
never overflows exp(). For z >= 0 use 1 / (1 + e^-z); for z < 0 use
e^z / (1 + e^z). Both are the same function, algebraically.
"""
z = np.asarray(z, dtype=float)
out = np.empty_like(z)
pos = z >= 0
out[pos] = 1.0 / (1.0 + np.exp(-z[pos]))
ez = np.exp(z[~pos])
out[~pos] = ez / (1.0 + ez)
return out
# endregion
# region: logit
def logit(X, w, b):
"""The linear part: z = Xw + b.
X is (N, D), w is (D,), b is a scalar bias. Returns one score z per
row — the log-odds of class 1 before the sigmoid squashes it.
"""
return X @ w + b
# endregion
# region: predict_proba
def predict_proba(X, w, b):
"""P(y = 1 | x) for every row: sigmoid of the linear score."""
return sigmoid(logit(X, w, b))
# endregion
# region: predict
def predict(X, w, b, threshold=0.5):
"""Turn probabilities into 0/1 labels at a decision threshold.
0.5 is the natural cutoff — predict class 1 when it's more likely than
not — but the threshold is a knob you can move to trade the two kinds
of mistake, exactly like the cutoff in week 1.
"""
return (predict_proba(X, w, b) >= threshold).astype(int)
# endregion
# region: cross_entropy
def cross_entropy(X, y, w, b, eps=1e-12):
"""Mean binary cross-entropy (log loss) over the dataset.
For each row, -log(p) when the truth is 1 and -log(1 - p) when it's 0.
A confident, correct probability costs almost nothing; a confident,
wrong one costs a lot. Clipping keeps log() away from 0.
"""
p = predict_proba(X, w, b)
p = np.clip(p, eps, 1.0 - eps)
return float(-np.mean(y * np.log(p) + (1 - y) * np.log(1 - p)))
# endregion
# region: gradient
def gradient(X, y, w, b):
"""Gradient of the mean cross-entropy w.r.t. w and b.
The algebra collapses to something clean: the error is just
(p - y), and the gradient is that error dotted with the features.
dw = X^T (p - y) / N, db = mean(p - y). No sigmoid derivative left
over — cross-entropy and the sigmoid were built for each other.
"""
p = predict_proba(X, w, b)
err = p - y # shape (N,)
dw = X.T @ err / len(y) # shape (D,)
db = float(np.mean(err)) # scalar
return dw, db
# endregion
# region: fit
def fit(X, y, lr=0.1, n_iter=300, record=False):
"""Train by batch gradient descent: step downhill on the log loss.
Start the weights at zero, and on every iteration compute the
gradient over the whole training set and take one step against it.
With `record=True` we also return the per-iteration (w, b, loss)
history so the chapter can animate the descent.
"""
N, D = X.shape
w = np.zeros(D)
b = 0.0
history = []
for t in range(n_iter):
if record:
history.append((w.copy(), b, cross_entropy(X, y, w, b)))
dw, db = gradient(X, y, w, b)
w -= lr * dw
b -= lr * db
if record:
history.append((w.copy(), b, cross_entropy(X, y, w, b)))
return w, b, history
return w, b
# endregion
def accuracy(X, y, w, b, threshold=0.5):
"""Fraction of rows the model labels correctly at a threshold."""
return float((predict(X, w, b, threshold) == y).mean())
def standardize(X, mean=None, std=None):
"""Center and scale each column to mean 0, unit variance.
Gradient descent on raw features with wildly different ranges crawls;
standardizing puts every feature on the same footing so one learning
rate works for all of them. Returns the transformed X plus the mean
and std, so the exact same shift can be applied to test data.
"""
if mean is None:
mean = X.mean(axis=0)
if std is None:
std = X.std(axis=0)
std = np.where(std == 0, 1.0, std)
return (X - mean) / std, mean, std
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
No implementarías esto a mano en producción, y una vez que lo entiendes no lo
necesitas. La LogisticRegression de scikit-learn es el mismo modelo — score
lineal, sigmoide, cross-entropy — y te entrega los pesos ajustados y el sesgo
de la misma manera:
def sklearn_logreg(Xtr, ytr, Xte, yte):
"""Fit sklearn's LogisticRegression on standardized features.
Returns the learned weight vector, bias, and test accuracy. We hand it
the already-standardized arrays so it's judged on the same footing as
our from-scratch model.
"""
clf = LogisticRegression(max_iter=1000)
clf.fit(Xtr, ytr)
w = clf.coef_[0]
b = float(clf.intercept_[0])
acc = float(clf.score(Xte, yte))
return w, b, acc
Dos diferencias que vale la pena conocer. Primero, sklearn regulariza por
defecto: agrega una penalización L2 sobre los pesos, que los empuja hacia cero
y protege contra el overfitting. Nuestra versión desde cero no tiene
penalización alguna, lo cual está bien en estos datos pero es lo primero que
agregaría para cualquier cosa de mayor dimensión. Segundo, no usa descenso de
gradiente simple — el solver por defecto es lbfgs, un método cuasi-Newton que
usa la curvatura para dar pasos mucho más inteligentes, así que converge en
unas cuantas docenas de iteraciones donde nuestro descenso de paso fijo se toma
un par de miles. Mismo destino, camino más rápido. Le pasamos los arrays ya
estandarizados para que la comparación sea honesta — mismos features, mismo
escalado, mismo split.
Desde cero contra la librería
Divide los datos Pima 70/30 — 537 pacientes para entrenar, 231 apartados — estandariza con las estadísticas de entrenamiento, ajusta ambos modelos y califica sobre el set apartado. Aquí está el enfrentamiento, con el clasificador de threshold de la semana 1 incluido como barra de referencia:
Nuestra regresión logística desde cero saca 0.7662 en el set apartado. Scikit-learn saca 0.7662 — el mismo número a cuatro decimales, con un sesgo de -0.888 contra el -0.881 de sklearn, que es lo más cerca que dos optimizadores distintos sobre la misma pérdida convexa necesitan llegar jamás. La convexidad es la razón: hay un solo mínimo, y ambos caminos llegan a él. Esa coincidencia es el punto del ejercicio. Cuando tu gradiente escrito a mano aterriza en la respuesta de la librería, entendiste el modelo, no solo lo llamaste.
Ahora la referencia. El stump de la semana 1 — un feature, un threshold sobre la glucosa — saca 0.7316 en el mismo split. La regresión logística, con los ocho features y los mismos pacientes apartados, saca 0.7662. Tres puntos y medio no son una paliza, y eso también es honesto: la glucosa realmente es la señal dominante en este dataset, así que un modelo de un solo feature nunca iba a quedar en ridículo. Pero los features extra sí cargan peso real. Estandarizados, los tres más pesados son glucosa con +1.07, IMC con +0.85 y conteo de embarazos con +0.39 — todos empujando hacia una predicción de diabetes, en un orden que un clínico aprobaría con la cabeza. Ese es el dividendo de la interpretabilidad: el modelo no solo clasificó mejor, te dijo por qué, en unidades que puedes rankear.
Las mismas dos advertencias de la semana 1 siguen aplicando. Las clases están desbalanceadas — cerca de un tercio de estas pacientes son positivas — así que un modelo que adivinara "sin diabetes" para todas ya rebasaría el 65%, y la accuracy por sí sola adula a todos los modelos de la gráfica. Y el split apartado es lo que mantiene honestos estos números; la accuracy de entrenamiento se leería más alta y significaría menos. La regresión logística no escapa de esas trampas. Solo te da una mejor frontera y una probabilidad sobre la cual razonar una vez que estás dentro de ellas.
Conclusiones
La regresión logística es el clasificador lineal que uso por defecto, y creo
que tú también deberías. Es el siguiente peldaño honesto arriba del threshold:
conserva la frontera recta, deja que cada feature vote, obtén una probabilidad
calibrada en vez de una etiqueta pelona. En datos tabulares es el baseline que
un modelo más pesado tiene que superar genuinamente antes de ganarse su lugar —
y muchas veces no lo hace, que es exactamente lo que quieres descubrir en dos
segundos de fit, no dos semanas dentro de un proyecto.
Las tres cosas para llevarte. Probabilidades, no solo etiquetas: el 0.7
significa 0.7, que es lo que te permite fijar un threshold acorde al costo de
cada error en vez de aceptar 0.5 por defecto. Pesos legibles: estandariza los
features y los coeficientes rankean tu señal gratis, dirección incluida. Y la
lección del log-loss, que sobrevive a este capítulo — la sigmoide y la
cross-entropy fueron construidas como pareja, sus gradientes se cancelan en un
(p - y) limpio, y por eso no recurres al error cuadrático en un problema de
clasificación. Esa misma pérdida de cross-entropy y ese mismo gradiente limpio
son el motor debajo de las redes neuronales más adelante en el curso; una red
es en gran parte este modelo apilado y doblado hasta que la frontera por fin
puede curvarse. Que es lo único que la regresión logística no puede hacer, y la
razón por la que el curso continúa. Pero es donde la clasificación lineal
madura, y la mayoría de los días es todo lo que necesitas.