Capítulo 15 de 37 · intermedio
Regresión lasso
Qué cubre este capítulo
Lasso es regresión lineal que despide a sus propias features inútiles. Tomas la pérdida de mínimos cuadrados ordinarios y le agregas un término — una penalización sobre la suma de los valores absolutos de los pesos — y ese pequeño cambio hace que el modelo ponga algunos coeficientes en exactamente cero, no solo pequeños. Los ceros son el punto. Un ajuste de lasso no solo predice; te entrega una lista más corta de features y te dice que el resto no se ganó su lugar.
Ese es un trabajo distinto al de la regresión simple, y también distinto al de
ridge, el primo L2 contra el que vamos a estar comparando. Ridge encoge cada
coeficiente suavemente hacia cero y se detiene justo antes — nada llega nunca a
aterrizar en cero. Lasso, por la forma de la penalización L1, va tronando
coeficientes contra el cero uno por uno conforme subes la presión. Mismos datos,
mismo modelo lineal, comportamiento completamente distinto en la esquina. Lo
construimos desde cero con descenso por coordenadas y el operador de
soft-thresholding, vemos los coeficientes caer a cero con datos reales, y
comprobamos todo contra el Lasso de scikit-learn.
Los datos son el dataset de diabetes: 442 pacientes, diez mediciones de base cada uno, y un número que dice cuánto progresó la enfermedad un año después. Diez features es lo bastante pequeño para observar cada coeficiente por nombre, que es exactamente lo que queremos cuando la historia va de cuáles sobreviven.
Un poco de historia
Lasso es joven, para como van estas cosas. Robert Tibshirani lo presentó en 1996, en un paper titulado "Regression Shrinkage and Selection via the Lasso", y el nombre es un acrónimo que acuñó para lo que hace: Least Absolute Shrinkage and Selection Operator. Dos verbos en un solo método — encoge los coeficientes y selecciona entre ellos — y esa combinación era lo nuevo. Antes de lasso, la selección de features significaba procedimientos por pasos: agrega una variable, quita una variable, reajusta, repite, en una búsqueda discreta que era inestable y difícil de razonar. Lasso metió la selección dentro del ajuste mismo. Resuelves una sola optimización convexa y los coeficientes irrelevantes salen en cero, gratis.
La idea no cayó del cielo. El non-negative garrote de Leo Breiman (1995) rondaba el mismo objetivo un año antes, y la penalización L1 ya había aparecido en procesamiento de señales como basis pursuit por esas mismas fechas. Pero el planteamiento de Tibshirani — una restricción L1 sobre la regresión ordinaria, motivada como competidora directa de ridge — es el que pegó y detonó veinte años de trabajo posterior: least angle regression para calcular el path completo, la elastic net para cuando las features viajan en grupos correlacionados, el graphical lasso, y así. Si hoy haces ML aplicado y alguna vez has querido un modelo disperso, ese paper de 1996 es donde empieza el camino.
La intuición
Bajo lasso, cada coeficiente de un modelo lineal te cuesta algo. No es un costo que escale con qué tan bien predice la feature — es un peaje plano, lo mismo por unidad de peso, ya sea que el peso siga siendo grande o esté casi extinto. Esa planitud es todo el truco. Ridge te cobra el cuadrado del peso, así que conforme un coeficiente se encoge, el jalón de la penalización sobre él se desvanece hasta la nada, y el coeficiente se asienta en algún valor pequeño distinto de cero donde la fuerza que encoge y el jalón de los datos se equilibran. El jalón de lasso nunca se desvanece. Hasta el instante en que un peso toca cero, la penalización sigue jalando con toda su fuerza, así que para una feature que no está aportando lo suyo al ajuste, el cero gana de calle.
La imagen que la gente dibuja es la región de restricción. Acotar la suma de pesos absolutos forma un diamante (un cuadrado rotado en dos dimensiones, un politopo picudo en más); acotar la suma de cuadrados forma un círculo. El mejor ajuste está donde los contornos de la pérdida tocan por primera vez esa región, y un diamante tiene esquinas que sobresalen a lo largo de los ejes — los lugares donde algunas coordenadas son exactamente cero. Los contornos tienden a pegar en las esquinas. Un círculo no tiene esquinas, así que lo tocan por un lado, en un punto donde cada coordenada es pequeña pero distinta de cero. La geometría es toda la diferencia entre "pequeño" y "cero".
Esa es la promesa; aquí está el resultado en un problema de juguete donde conocemos la verdad. Generé 120 puntos a partir de doce features, pero solo cuatro de ellas mueven de verdad el objetivo — las otras ocho tienen coeficiente verdadero cero. Los mínimos cuadrados ordinarios no pueden saber eso, así que ajustan las doce, repartiéndole a cada feature de ruido un pequeño peso espurio. Lasso, con una penalización razonable, pone ocho de ellas en exactamente cero y se queda con las cuatro que importan. El azul es la verdad, y puedes ver a lasso aterrizar sobre ella mientras OLS embarra peso por todo el ruido.
Las matemáticas
Parte de la pérdida de mínimos cuadrados y apila los datos en con renglones y columnas. Lasso minimiza esa pérdida más una penalización L1 sobre los pesos:
Aquí es el vector de pesos, es el intercepto (nunca penalizado — no quieres encoger la línea base), y es la perilla que fija qué tan fuerte empuja la penalización. En esto es mínimos cuadrados ordinarios. Sube y los coeficientes se van jalando hacia cero; súbela lo suficiente y todos llegan. El único cambio respecto a ridge es el término de penalización: ridge usa , la norma L2 al cuadrado, y ese único intercambio es toda la diferencia de comportamiento entre los dos métodos.
El valor absoluto es lo que hace esto interesante y lo que lo hace incómodo. No es diferenciable en cero, así que no hay ecuación normal, no hay solución matricial de un solo golpe. Lo que nos salva es que si congelas todos los pesos menos uno, el problema en ese único peso tiene una respuesta cerrada y limpia. Fija todos los con , sea el residual con la feature excluida, y define su correlación con ese residual como
Entonces el peso que minimiza la pérdida a lo largo de la coordenada (con las features estandarizadas de modo que cada columna cumpla ) es el operador de soft-thresholding aplicado a :
Lee literalmente: jala hacia cero por , y si ese empujón lo llevaría más allá de cero, deténlo en cero. Cualquier cosa con correlación menor que en magnitud queda recortada a nada — ese es el tronido. El descenso por coordenadas es solo esta actualización, una feature a la vez, en ciclo hasta que nada se mueve. Como la pérdida es convexa, el orden no importa y converge al único mínimo global.
Para qué sirve, para qué no
Lasso se gana el pan cuando sospechas que la mayoría de tus features son peso muerto y prefieres que el modelo te diga cuáles en vez de adivinarlo tú. Obtienes un modelo disperso y legible de un solo ajuste convexo — sin búsqueda por pasos, sin ritual de p-values — y la dispersión vale dinero de verdad en producción. Menos coeficientes distintos de cero significa menos entradas que recolectar y servir al momento de predecir, un modelo que de verdad puedes explicarle a quien tenga que aprobarlo, y, cuando la señal verdadera realmente es dispersa, mejor generalización que aventarle todas las features a mínimos cuadrados. Es la misma idea L1 que regulariza a montones de modelos más grandes; aprenderla aquí con diez features es aprenderla en todas partes.
Donde se vuelve poco confiable es con features correlacionadas, y este es el golpe honesto en su contra. Cuando dos features cargan casi la misma información, lasso tiende a elegir una casi al azar y poner en cero la otra, y cuál elige puede voltearse con un cambio pequeño en los datos o en la semilla aleatoria. La selección es inestable aun cuando la predicción está bien, así que no leas "esta feature quedó en cero" como "esta feature no importa" — léelo como "de este grupo correlacionado se quedó una". También topa en features seleccionadas cuando tienes más features que muestras, y el encogimiento que aplica a las sobrevivientes las sesga hacia abajo. Esos límites son exactamente lo que la elastic net del próximo capítulo existe para arreglar, mezclando un poco de ridge para repartir el crédito entre grupos correlacionados en vez de forzar un ganador que se lleva todo.
Los datos
El dataset de diabetes viene incluido con scikit-learn, así que no hay nada que descargar. Cada uno de los 442 renglones es un paciente con diez features de base — edad, sexo, índice de masa corporal, presión arterial promedio, y seis mediciones de suero sanguíneo etiquetadas s1 a s6 — ya centradas y escaladas por quienes lo empaquetaron. El objetivo es una medida cuantitativa de la progresión de la enfermedad un año después de esa base, que va de 25 a 346. Es un problema de regresión genuino con señal modesta, lo que lo hace un buen banco de pruebas para lasso: suficientes features para que algunas sean plausiblemente redundantes, tan pocas que podemos nombrar cada una que el modelo conserva o descarta.
El índice de masa corporal es la feature por la que apostarías de entrada, y los datos están de acuerdo — los pacientes más pesados tienden a progresar más rápido. Aquí está el BMI contra el objetivo en los 442 pacientes. La tendencia es real y floja, el tipo de señal de la que un modelo lineal puede agarrar un pedazo pero no todo.
Constrúyelo, una función a la vez
NumPy puro, en el orden en que lo escribirías: el modelo, la métrica, el objetivo, el operador que hace el trabajo, y el loop que lo llama.
La predicción no cambia respecto a la regresión ordinaria — lasso cambia cómo elegimos los pesos, no lo que el modelo calcula con ellos:
def predict(X, w, b):
"""The linear model itself: yhat = Xw + b.
Identical to ordinary linear regression — lasso changes how we *choose* w,
not what the model computes. X is (n, d), w is (d,), b is a scalar, and the
result is (n,), one predicted number per row.
"""
return X @ w + b
R² es el número que de verdad vamos a reportar, la fracción de varianza que el modelo explica contra la línea base de siempre adivinar la media. La penalización nunca entra aquí; R² juzga predicciones, no la pérdida que optimizamos:
def r2_score(X, y, w, b):
"""Coefficient of determination: the fraction of variance we explain.
1 minus (our squared error / the squared error of always guessing the
mean). 1.0 is perfect, 0.0 is no better than the mean, negative is worse.
The penalty never enters here — R^2 judges predictions, not the objective.
"""
resid = y - predict(X, w, b)
ss_res = float(np.sum(resid ** 2))
ss_tot = float(np.sum((y - y.mean()) ** 2))
return 1.0 - ss_res / ss_tot
Ahora el objetivo, la cosa que el descenso por coordenadas está empujando hacia abajo. Error cuadrático medio escalado por un medio, más veces la suma de pesos absolutos — el término de ajuste a los datos y el peaje, escritos tal cual:
def lasso_objective(X, y, w, b, lam):
"""The thing we minimize: mean squared error plus the L1 penalty.
The first term is the usual data-fit, scaled by 1/2 so its gradient comes
out clean. The second, lambda * sum |w_j|, is the L1 penalty — it charges a
flat toll per unit of weight, the same toll no matter how big the weight
already is. That constant slope is what pins small coefficients to exactly
zero: unlike the squared L2 penalty, the pull toward zero doesn't fade as
the weight shrinks. lam = 0 gives plain least squares back.
"""
resid = predict(X, w, b) - y
mse = float(np.mean(resid ** 2)) / 2.0
penalty = lam * float(np.sum(np.abs(w)))
return mse + penalty
Aquí está el corazón del asunto. El operador de soft-thresholding son tres líneas y es el único lugar donde un coeficiente llega a ser exactamente cero. Encoge hacia cero por , recorta en cero, conserva el signo:
def soft_threshold(z, lam):
"""The soft-thresholding operator S_lambda(z) — lasso's beating heart.
It shrinks z toward zero by lam and clips anything smaller than lam to
exactly zero: S_lambda(z) = sign(z) * max(|z| - lam, 0). This is the
closed-form minimizer of (1/2)(w - z)^2 + lam*|w| over a single weight w,
and it's the one place the "snap to zero" happens. Every coordinate update
below is just this function applied to that coordinate's target value.
"""
return np.sign(z) * np.maximum(np.abs(z) - lam, 0.0)
Y el ajuste: recorre las coordenadas en ciclo, y para cada una quita su contribución actual del residual, mide cuánto sigue correlacionando esa feature con lo que queda, y fija su peso con el operador de arriba. Un acumulado mantiene barata cada actualización. Cuando una pasada completa no mueve nada, estamos en el mínimo:
def coordinate_descent(X, y, lam, n_iters=1000, tol=1e-9):
"""Fit lasso by cycling through one weight at a time.
Assumes X is standardized (each column centered, unit variance) so the
per-column scale is 1 and the intercept is simply the mean of y — a lasso
penalty is never applied to the intercept, so we fix it and fit the slopes
on the centered target. For each coordinate j we strip j's current
contribution out of the residual, compute rho_j = (1/n) x_j . r_j (how much
feature j still wants to explain), and set w_j = S_lambda(rho_j) / z_j. We
keep a running X @ w so each update is O(n), not O(nd), and stop when a full
sweep moves no weight by more than tol.
"""
n, d = X.shape
b = float(y.mean()) # intercept: unpenalized, exact for centered X
yc = y - b # center the target
z = (X ** 2).sum(axis=0) / n # per-column scale; == 1 for standardized X
w = np.zeros(d)
Xw = np.zeros(n) # running prediction X @ w, kept in sync
for _ in range(n_iters):
max_change = 0.0
for j in range(d):
r_j = yc - Xw + w[j] * X[:, j] # residual without feature j
rho = float(X[:, j] @ r_j) / n
w_new = soft_threshold(rho, lam) / z[j]
if w_new != w[j]:
Xw += (w_new - w[j]) * X[:, j] # keep the running fit current
max_change = max(max_change, abs(w_new - w[j]))
w[j] = w_new
if max_change < tol:
break
return w, b
Un detalle que importa más de lo que parece: las features tienen que estar estandarizadas primero. Lasso penaliza cada coeficiente con la misma , así que si una feature se mide en miles y otra en fracciones, la misma penalización significa cosas radicalmente distintas para cada una. Ponlas en una escala común y la penalización es justa. Ajustamos ese escalado solo con los datos de entrenamiento, para que nada se filtre desde el conjunto de prueba.
Míralo trabajar
Este es el path de lasso, y es la animación con la que hay que quedarse un rato. Ajusté el modelo cincuenta veces sobre los datos de entrenamiento, barriendo de pequeña a la izquierda de la malla a grande a la derecha — desde 0.049 hasta 49.1, el valor donde muere el último coeficiente. Cada cuadro es un reajuste real y completamente convergido, no una interpolación.
El panel superior son los diez coeficientes, una barra cada uno, por nombre. El panel inferior cuenta cuántos siguen distintos de cero. Observa lo que pasa conforme sube: las barras se encogen juntas, y luego uno por uno un coeficiente toca cero y se pone gris — ese es el tronido, el soft-threshold recortándolo fuera del modelo. El conteo del panel inferior baja un escalón cada vez que otra feature cae. Dale play, y luego regresa y avanza sobre el momento en que una barra cruza al gris.
Dos cosas que notar. Primero, el orden en que las features se van es información — las que aguantan más tiempo (índice de masa corporal, s5) son en las que el modelo más se apoya, y las que caen temprano apenas contribuían. Segundo, esto no es un encogimiento suave hacia cero y luego un aterrizaje delicado; es encoge, encoge, se fue. Un coeficiente es distinto de cero y luego, en una exacta, es cero y ahí se queda. Ridge te daría el encogimiento sin el "se fue" — cada barra seguiría acortándose para siempre y ninguna se pondría gris jamás. Las barras grises son la razón entera de echar mano de lasso.
La implementación completa
El archivo entero, sin librería, de arriba abajo — predict, R², el objetivo, el soft-threshold, y el descenso por coordenadas, más los helpers de estandarizar y cargar. Este es el código que la animación realmente corrió:
"""Lasso regression, built from scratch.
Lasso is ordinary least squares with one extra term bolted onto the loss: an
L1 penalty on the weights, lambda * sum |w_j|. That single change is the whole
story. Because the penalty has a corner at zero, the minimizer parks
coefficients at *exactly* zero once lambda gets big enough — the model selects
its own features by switching the useless ones off. Ridge (L2) shrinks weights
smoothly toward zero but never reaches it; lasso snaps them to zero one by one.
You can't get there with the normal equation — the absolute value isn't
differentiable at zero, so there's no clean formula for the minimum. The
workhorse is coordinate descent: hold every weight fixed but one, minimize the
loss over that one weight (which has a closed form, the soft-threshold), move to
the next, and cycle until nothing moves. Pure NumPy — no ML library in this
file. The `# region:` markers are what the chapter's include directives pull in.
"""
import numpy as np
import pandas as pd
# region: predict
def predict(X, w, b):
"""The linear model itself: yhat = Xw + b.
Identical to ordinary linear regression — lasso changes how we *choose* w,
not what the model computes. X is (n, d), w is (d,), b is a scalar, and the
result is (n,), one predicted number per row.
"""
return X @ w + b
# endregion
# region: r2
def r2_score(X, y, w, b):
"""Coefficient of determination: the fraction of variance we explain.
1 minus (our squared error / the squared error of always guessing the
mean). 1.0 is perfect, 0.0 is no better than the mean, negative is worse.
The penalty never enters here — R^2 judges predictions, not the objective.
"""
resid = y - predict(X, w, b)
ss_res = float(np.sum(resid ** 2))
ss_tot = float(np.sum((y - y.mean()) ** 2))
return 1.0 - ss_res / ss_tot
# endregion
# region: objective
def lasso_objective(X, y, w, b, lam):
"""The thing we minimize: mean squared error plus the L1 penalty.
The first term is the usual data-fit, scaled by 1/2 so its gradient comes
out clean. The second, lambda * sum |w_j|, is the L1 penalty — it charges a
flat toll per unit of weight, the same toll no matter how big the weight
already is. That constant slope is what pins small coefficients to exactly
zero: unlike the squared L2 penalty, the pull toward zero doesn't fade as
the weight shrinks. lam = 0 gives plain least squares back.
"""
resid = predict(X, w, b) - y
mse = float(np.mean(resid ** 2)) / 2.0
penalty = lam * float(np.sum(np.abs(w)))
return mse + penalty
# endregion
# region: soft_threshold
def soft_threshold(z, lam):
"""The soft-thresholding operator S_lambda(z) — lasso's beating heart.
It shrinks z toward zero by lam and clips anything smaller than lam to
exactly zero: S_lambda(z) = sign(z) * max(|z| - lam, 0). This is the
closed-form minimizer of (1/2)(w - z)^2 + lam*|w| over a single weight w,
and it's the one place the "snap to zero" happens. Every coordinate update
below is just this function applied to that coordinate's target value.
"""
return np.sign(z) * np.maximum(np.abs(z) - lam, 0.0)
# endregion
# region: coordinate_descent
def coordinate_descent(X, y, lam, n_iters=1000, tol=1e-9):
"""Fit lasso by cycling through one weight at a time.
Assumes X is standardized (each column centered, unit variance) so the
per-column scale is 1 and the intercept is simply the mean of y — a lasso
penalty is never applied to the intercept, so we fix it and fit the slopes
on the centered target. For each coordinate j we strip j's current
contribution out of the residual, compute rho_j = (1/n) x_j . r_j (how much
feature j still wants to explain), and set w_j = S_lambda(rho_j) / z_j. We
keep a running X @ w so each update is O(n), not O(nd), and stop when a full
sweep moves no weight by more than tol.
"""
n, d = X.shape
b = float(y.mean()) # intercept: unpenalized, exact for centered X
yc = y - b # center the target
z = (X ** 2).sum(axis=0) / n # per-column scale; == 1 for standardized X
w = np.zeros(d)
Xw = np.zeros(n) # running prediction X @ w, kept in sync
for _ in range(n_iters):
max_change = 0.0
for j in range(d):
r_j = yc - Xw + w[j] * X[:, j] # residual without feature j
rho = float(X[:, j] @ r_j) / n
w_new = soft_threshold(rho, lam) / z[j]
if w_new != w[j]:
Xw += (w_new - w[j]) * X[:, j] # keep the running fit current
max_change = max(max_change, abs(w_new - w[j]))
w[j] = w_new
if max_change < tol:
break
return w, b
# endregion
def standardize(X_train, X_other=None):
"""Center and scale to unit variance using the TRAIN statistics only.
Lasso penalizes every coefficient by the same lam, so the features have to
share a scale or the penalty means something different for each one. We fit
mu and sigma on train and apply them everywhere, so no test information
leaks into the transform.
"""
mu = X_train.mean(axis=0)
sd = X_train.std(axis=0)
sd[sd == 0] = 1.0
Xs = (X_train - mu) / sd
if X_other is None:
return Xs, mu, sd
return Xs, (X_other - mu) / sd, mu, sd
def load_data(path="../data/diabetes.csv"):
"""Diabetes: 442 patients, 10 baseline features, a one-year disease score.
Returns (X, y, feature_names). The target is a quantitative measure of
disease progression one year after baseline; the features are age, sex, BMI,
average blood pressure, and six blood-serum measurements.
"""
df = pd.read_csv(path)
target = "target"
features = [c for c in df.columns if c != target]
X = df[features].to_numpy(float)
y = df[target].to_numpy(float)
return X, y, features
La versión de librería
Nadie escribe a mano el descenso por coordenadas en producción, y una vez que lo
viste no te hace falta. sklearn.linear_model.Lasso minimiza el objetivo
idéntico — su alpha es nuestra — y corre por debajo el mismo
descenso por coordenadas con soft-thresholding, en Cython compilado.
Estandarizamos fuera del modelo y dejamos que él ajuste el intercepto, calcando
exactamente nuestro montaje:
def sklearn_fit(X_train, y_train, alpha):
"""Fit lasso with sklearn at a given alpha. Returns (weights, intercept) —
the twin of our coordinate_descent, so the coefficients line up one for one
and the same features come out at exactly zero."""
model = Lasso(alpha=alpha, fit_intercept=True, max_iter=100000, tol=1e-12)
model.fit(X_train, y_train)
return model.coef_, float(model.intercept_)
Lo único que hay que vigilar la primera vez que lo usas es que alpha y nuestra
coinciden solo porque ambos objetivos llevan el mismo
delante del error cuadrático. Las librerías difieren en ese
escalado — unas quitan el medio, unas dividen entre , otras no — y si algún
día portas una penalización entre dos de ellas y la dispersión se ve rara, esa
constante es el primer lugar donde revisar. Para la evaluación ajustamos en
train y reportamos R² y MSE de test:
def sklearn_score(X_train, y_train, X_test, y_test, alpha):
"""Fit on train, report test R^2 and test MSE — the face-off numbers."""
model = Lasso(alpha=alpha, fit_intercept=True, max_iter=100000, tol=1e-12)
model.fit(X_train, y_train)
pred = model.predict(X_test)
r2 = float(model.score(X_test, y_test))
mse = float(((pred - y_test) ** 2).mean())
return r2, mse
Con la penalización en la que aterrizamos abajo, los dos ajustes son indistinguibles — mismos coeficientes, mismas features en cero, coincidiendo a ocho decimales. Ese es el punto de construirlo: hay una sola solución de lasso para una dada, y un descenso por coordenadas correcto encuentra la misma que la librería.
Desde cero contra librería
¿Cuál ? Esa es la verdadera pregunta con lasso, y la respondes con datos con los que no entrenaste. Partí los 442 pacientes 60/20/20 — 265 para entrenar, 88 para validar, 89 apartados para la prueba final — ajusté el path completo sobre el conjunto de entrenamiento, y evalué cada sobre el conjunto de validación. La curva de abajo es el MSE de validación contra ; el punto marcado es el mínimo, en .
La curva baja y luego sube, que es la forma que quieres ver. Muy poca penalización a la izquierda y el modelo sobreajusta el ruido del entrenamiento; demasiada a la derecha y ya puso en cero features que necesitaba y subajusta. El fondo del valle es el intercambio que buscas. En esa , lasso conserva siete de las diez features y descarta tres — edad, s1 y s4 — poniendo sus coeficientes en exactamente cero. El índice de masa corporal sale como el más fuerte con 29.7, luego s5 con 21.3, con la presión arterial y s3 después; las tres descartadas están fuera, no pequeñas.
Ahora ajusta esa elegida de tres maneras sobre los datos de entrenamiento y evalúa sobre el conjunto de prueba apartado: mínimos cuadrados ordinarios con las diez features como línea base, nuestro lasso, y el de sklearn.
Dos barras son idénticas — nuestro lasso y el de sklearn aterrizan ambos en R²
de test 0.362, MSE de test 3169, los mismos coeficientes a ocho decimales. Y
aquí viene la parte que vale subrayar: el lasso, usando siete features, le gana
por poco a los mínimos cuadrados completos usando las diez (R² 0.339). Tiró tres
features y predijo un poco mejor a los pacientes apartados gracias a eso. Eso es
la regularización haciendo su trabajo — las tres que descartó estaban aportando
más ruido que señal, y recortarlas apretó el ajuste sobre datos que el modelo
nunca había visto. Las cifras exactas viven en results.json, regenerado cada
vez que el código cambia, para que la prosa no pueda desviarse de lo que corrió.
Verifica a las sobrevivientes con una gráfica de predicho contra real sobre el conjunto de prueba. Un modelo perfecto se sienta sobre la diagonal; la dispersión a su alrededor es la progresión de la enfermedad que estas diez mediciones de base no alcanzan a explicar.
La nube sigue la diagonal pero de manera floja, que es el retrato honesto de un dataset donde diez mediciones explican como un tercio de la varianza. Lasso no arregló ese techo — ningún modelo lineal lo hará — pero llegó ahí con tres entradas menos de las que necesitó el ajuste completo, que es el intercambio para el que existe.
Conclusiones
Echa mano de lasso cuando tengas más features de las que confías y quieras que el modelo haga el corte. Hace dos trabajos en un solo ajuste convexo — encoger y seleccionar — y la mitad de seleccionar es la que no puedes obtener de ridge ni de la regresión simple. En los datos de diabetes descartó tres de diez features y predijo un poco mejor gracias a eso; la victoria ahí no es la fracción de punto de R², es entregar un modelo con siete entradas en vez de diez y una respuesta limpia a "cuáles features importan de verdad". Esa respuesta vale más en la mayoría de las salas que el accuracy.
Pero confía más en la dispersión que en la selección específica. La inestabilidad con features correlacionadas es real, y yo he visto a un lasso intercambiar cuál de dos features gemelas conserva entre dos corridas sobre datos casi iguales. Cuando tus features viajan en grupos correlacionados — y las features reales casi siempre lo hacen — la costumbre de lasso de coronar a una y poner en cero al resto es arbitraria de una forma que te va a morder si la sobreinterpretas. Ese es precisamente el hueco que la elastic net cierra en el próximo capítulo, mezclando la penalización L1 que te da los ceros con la L2 que reparte el crédito dentro de un grupo. Ridge encoge, lasso selecciona, la elastic net hace ambas; el soft-threshold que construiste aquí es la pieza sobre la que está construida la tercera. Apréndelo con diez features con nombre que puedes ver tronar a cero, porque el mismo operador está haciendo el mismo trabajo dentro de modelos mucho más grandes donde no puedes.