Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

¡Atención! Aunque se llama regresión logística, la regresión logística es en realidad un método de clasificación.

Por Ariane Ducellier (2021)

🖥️ Diapositivas — Sesión 16 (mié 4 nov)

En este laboratorio vamos a hablar de:

  • Un método de clasificación simple: la regresión logística
  • El método de descenso de gradiente
  • La diferenciación automática
  • Una introducción a PyTorch

Regresión logística

Recuerde la regresión lineal:

y=b+xw+ϵy = b + x w + \epsilon

yy es un vector de longitud nn, xx es una matriz con nn filas y pp columnas, correspondientes a nn observaciones y pp características que se usan para explicar yy.

bb es un escalar. ww es un vector de longitud pp. ε es un vector de error aleatorio, de longitud nn. Es independiente de xx y tiene media cero.

Nuestro objetivo es encontrar los mejores valores de bb y ww de modo que los valores de y^=b+xw\hat{y} = b + x w queden tan cerca como sea posible de los valores reales yy.

En la regresión lineal, yy es una variable cuantitativa. ¿Qué pasa si yy es una variable cualitativa, por ejemplo y=0y = 0 para «no» y y=1y = 1 para «sí»?

Una manera de usar la regresión para resolver un problema de clasificación es modelar la probabilidad de que la variable yy tome el valor 1:

P(y=1)=b+xwP (y = 1) = b + x w

Una vez que hemos encontrado los mejores valores b^\hat{b} y w^\hat{w}, calculamos y^=b^+xw^\hat{y} = \hat{b} + x \hat{w}. Si y^0.5\hat{y} \geq 0.5, decidimos clasificar esta observación como y=1y = 1, es decir «sí». Si y^<0.5\hat{y} < 0.5, decidimos clasificar esta observación como y=0y = 0, es decir «no».

Hay un problema con este método. Quisiéramos tener 0P(y=1)10 \leq P (y = 1) \leq 1 porque es una probabilidad. Sin embargo, nada en esta formulación obliga a bb y ww a tomar valores tales que y^=b^+xw^\hat{y} = \hat{b} + x \hat{w} caiga siempre en [0,1][0, 1].

Para resolver este problema, podemos escribir en su lugar:

z=b+xwz = b + x w y P(y=1)=11+ezP (y = 1) = \frac{1}{1 + e^{-z}}

De esa manera, siempre tenemos 0P(y=1)10 \leq P (y = 1) \leq 1. Cuando b+xwb + x w se hace grande, P(y=1)P (y = 1) se acerca a 1, y el valor «sí» es cada vez más probable. Cuando b+xwb + x w se hace pequeño, P(y=1)P (y = 1) se acerca a 0, y el valor «no» es cada vez más probable.

¿Cómo encontramos los valores óptimos de bb y ww? Definimos la función de pérdida de entropía cruzada. Para una observación, la función de pérdida es:

L=(yilogy^i+(1yi)log(1y^i))\mathcal{L} = - \left(y_i \log \hat{y}_i + (1 - y_i) \log (1 - \hat{y}_i)\right) con y^i=11+e(b+xiTw)\hat{y}_i = \frac{1}{1 + e^{- (b + x_i^T w)}} donde xix_i es la ii-ésima fila de x.

Si la observación verdadera yiy_i es 1 («sí») y y^i=1\hat{y}_i = 1, la función de pérdida vale 0. Si y^i=0\hat{y}_i = 0, la función de pérdida tiende a infinito.

Si la observación verdadera yy es 0 («no») y y^i=0\hat{y}_i = 0, la función de pérdida vale 0. Si y^i=1\hat{y}_i = 1, la función de pérdida tiende a infinito.

Para las nn observaciones, escribimos:

L=i=1nLi\mathcal{L} = \sum_{i = 1}^n \mathcal{L}_i

Nuestro objetivo es entonces encontrar los valores de bb y ww que minimizan la función de pérdida. Note que, con esta formulación, L\mathcal{L} es siempre positiva.

Descenso de gradiente

Sabemos que el gradiente Lwj\frac{\partial \mathcal{L}}{\partial w_j} es positivo si la pérdida L\mathcal{L} aumenta cuando wjw_j aumenta. A la inversa, el gradiente Lwj\frac{\partial \mathcal{L}}{\partial w_j} es negativo si la pérdida L\mathcal{L} disminuye cuando wjw_j aumenta.

Para obtener valores cada vez más pequeños de la pérdida, en cada iteración tomamos:

wj(k+1)=wj(k)αLwjw_j^{(k + 1)} = w_j^{(k)} - \alpha \frac{\partial \mathcal{L}}{\partial w_j} para j=1,,pj = 1 , \cdots , p

b(k+1)=b(k)αLbb^{(k + 1)} = b^{(k)} - \alpha \frac{\partial \mathcal{L}}{\partial b}

Suponemos que el valor de α no es demasiado grande. Si el gradiente es positivo, el valor de wjw_j disminuirá en cada iteración, y el valor de la función de pérdida disminuirá. Si el gradiente es negativo, el valor de wjw_j aumentará en cada iteración, y el valor de la pérdida disminuirá.

Así que ahora todo lo que necesitamos hacer es calcular el gradiente de la función de pérdida.

Diferenciación automática

Hay tres maneras de calcular el gradiente. El primer método es usar la fórmula de la pérdida:

L(wj,b)=i=1nyilog(11+exp(bj=1pwjxi,j))+(1yi)log(111+exp(bj=1pwjxi,j))\mathcal{L} (w_j , b) = - \sum_{i = 1}^n y_i \log (\frac{1}{1 + \exp (- b - \sum_{j = 1}^p w_j x_{i,j})}) + (1 - y_i) \log (1 - \frac{1}{1 + \exp (- b - \sum_{j = 1}^p w_j x_{i,j})})

y calcular la fórmula exacta de las derivadas Lwj\frac{\partial \mathcal{L}}{\partial w_j} y Lb\frac{\partial \mathcal{L}}{\partial b}. Después solo hay que implementar la fórmula exacta en el código para calcular el gradiente.

Cuando la fórmula se complica más y más, se vuelve más y más probable cometer un error, ya sea en el cálculo de la fórmula de la derivada, ya sea en la implementación en el código.

El segundo método es calcular una aproximación del gradiente:

Lwj=L(wj+Δwj)L(wj)Δwj\frac{\partial \mathcal{L}}{\partial w_j} = \frac{\mathcal{L}(w_j + \Delta w_j) - \mathcal{L}(w_j)}{\Delta w_j}

Si se acumulan demasiadas aproximaciones, el método puede no funcionar muy bien y dar resultados inexactos.

El tercer método es usar la diferenciación automática. Si escribimos:

z=xiTw+b=fx(w,b)z = x_i^T w + b = f_x(w, b), σ=11+ez=g(z)\sigma = \frac{1}{1 + e^{-z}} = g(z) y L=(yilog(σ)+(1yi)log(1σ))=hy(σ)L = - (y_i \log(\sigma) + (1 - y_i) \log(1 - \sigma)) = h_y(\sigma), obtenemos:

Lwj=fwjg(z)h(σ)\frac{\partial L}{\partial w_j} = \frac{\partial f}{\partial w_j} g'(z) h'(\sigma)

Es muy fácil calcular la fórmula exacta de las derivadas:

fwj(w,b)=xi,j\frac{\partial f}{\partial w_j}(w, b) = x_{i,j}

g(z)=ez(1+ez)2g'(z) = \frac{e^{-z}}{(1 + e^{-z})^2}

h(σ)=yiσ+1yi1σh'(\sigma) = - \frac{y_i}{\sigma} + \frac{1 - y_i}{1 - \sigma}

Al calcular LL, necesitamos entonces mantener en memoria los valores de fwj(w,b)\frac{\partial f}{\partial w_j}(w, b), g(z)g'(z) y h(σ)h'(\sigma) para poder calcular el gradiente. Eso es lo que hace PyTorch.

Introducción a PyTorch

PyTorch (https://pytorch.org/) es un paquete de Python que le permite construir y entrenar redes neuronales. Se basa en la diferenciación automática.

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import pooch
import torch
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler

Los datos: potabilidad del agua

Usamos una tabla de mediciones de calidad del agua con un objetivo binario, Potability (0 = no potable, 1 = potable). Cada fila tiene 9 características numéricas (pH, dureza, sólidos disueltos, etcétera).

path = pooch.retrieve(
    url="https://raw.githubusercontent.com/UW-MLGEO/MLGeo-dataset/main/data/water_potability.csv",
    known_hash=None,
    fname="water_potability.csv",
    path=pooch.os_cache("mlgeo"),
)
data = pd.read_csv(path)

Algunas filas tienen valores faltantes. Las eliminamos y reiniciamos el índice de filas.

data = data.dropna()
data = data.reset_index(drop=True)
data.head()
Loading...
x = data.drop(columns=['Potability']).to_numpy()
y = data.Potability.to_numpy()
print(x.shape, y.shape)
(2011, 9) (2011,)

Divida antes de escalar

Apartamos el 30 % de las filas como conjunto de prueba antes de hacer cualquier otra cosa. Ajustamos el escalador solo sobre las filas de entrenamiento y lo aplicamos a ambos conjuntos: si el escalador viera las filas de prueba, información del conjunto de prueba se fugaría hacia el entrenamiento, y la evaluación final ya no sería honesta.

x_train, x_test, y_train_np, y_test_np = train_test_split(
    x, y, test_size=0.3, random_state=42, stratify=y)

scaler = StandardScaler().fit(x_train)
x_train = scaler.transform(x_train)
x_test = scaler.transform(x_test)
print(x_train.shape, x_test.shape)
(1407, 9) (604, 9)

Antes de entrenar nada, calcule el modelo de referencia (baseline) trivial: un «modelo» que siempre predice la clase más común. Todo clasificador que valga la pena conservar debe superar este número.

baseline = max(np.mean(y_train_np == 0), np.mean(y_train_np == 1))
print(f"Majority-class baseline accuracy: {baseline:.3f}")
Majority-class baseline accuracy: 0.597

Una observación, a mano

Vamos a calcular la pérdida correspondiente a la primera observación del conjunto de entrenamiento. En lugar de usar arreglos de Numpy para colocar nuestros datos y parámetros, vamos a usar tensores de torch, porque tienen propiedades que los arreglos de Numpy no tienen.

Estas son las características de la primera observación de entrenamiento:

x_i = torch.from_numpy(x_train[0, :])
x_i = x_i.float()
x_i
tensor([-0.0951, 0.0145, 2.0111, 0.8662, -0.3787, 0.7364, 0.1168, -1.5465, 0.6595])

Esta es la clase de la primera observación de entrenamiento:

y_i = float(y_train_np[0])
print(y_i)
1.0

Tomemos valores aleatorios para ww y bb. Al crear estas variables usamos la opción requires_grad=True porque más adelante querremos calcular el gradiente con respecto a ellas.

W = torch.rand(9, requires_grad=True)
B = torch.rand(1, requires_grad=True)

Definamos z=f(w,b)=xiTw+bz = f(w, b) = x_i^T w + b. Tenemos fwj=xi,j\frac{\partial f}{\partial w_j} = x_{i,j} y fb=1\frac{\partial f}{\partial b} = 1.

Por defecto, PyTorch solo conserva los gradientes de los tensores hoja como W y B. Los tensores intermedios como zz no conservan sus gradientes; llamar a retain_grad() le pide a PyTorch almacenarlos para que podamos inspeccionarlos después de la pasada hacia atrás (backward pass).

z = W.dot(x_i) + B
z.retain_grad()
z
tensor([1.7695], grad_fn=<AddBackward0>)

Definamos σ=g(z)=11+ez=g(f(w,b))=(gf)(w,b)\sigma = g(z) = \frac{1}{1 + e^{-z}} = g(f(w, b)) = (g \circ f) (w, b). Tenemos g(z)=ez(1+ez)2g'(z) = \frac{e^{-z}}{(1 + e^{-z})^2}.

sigma = 1.0 / (1.0 + torch.exp(- z))
sigma.retain_grad()
sigma
tensor([0.8544], grad_fn=<MulBackward0>)

Note que aquí usamos la función torch.exp en lugar de numpy.exp. Eso es porque numpy solo calcula el valor de exe^x pero no sabe que la derivada de exe^x es exe^x. Si queremos poder usar la diferenciación automática, necesitamos usar la función equivalente de torch, que calculará tanto σ(z)\sigma(z) como σz(z)\frac{\partial \sigma}{\partial z}(z). Este último valor será necesario cuando más adelante calculemos el gradiente.

Definamos L=h(σ)=(yilog(σ)+(1yi)log(1σ))=h(g(z))=h(g(f(w,b)))=(hgf)(w,b)L = h(\sigma) = - (y_i \log(\sigma) + (1 - y_i) \log(1 - \sigma)) = h(g(z)) = h(g(f(w, b))) = (h \circ g \circ f) (w, b). Tenemos L(σ)=(yiσ1yi1σ)L'(\sigma) = - (\frac{y_i}{\sigma} - \frac{1 - y_i}{1 - \sigma}).

L = - (y_i * torch.log(sigma) + (1 - y_i) * torch.log(1 - sigma))

Ahora podemos calcular el gradiente de la pérdida para una observación. Este comando calcula el gradiente de L con respecto a todas las variables que conservan un gradiente, es decir W, B, z y sigma, pero no devuelve el valor.

L.backward()

Tenemos Lσ=(yiσ1yi1σ)\frac{\partial L}{\partial \sigma} = - (\frac{y_i}{\sigma} - \frac{1 - y_i}{1 - \sigma}). Comparemos el resultado de PyTorch con la fórmula matemática exacta.

h_prime = - (y_i / sigma - (1 - y_i) / (1 - sigma))
print(sigma.grad.item(), h_prime.item())
-1.170426607131958 -1.170426607131958

Tenemos Lz=g(z)h(g(z))\frac{\partial L}{\partial z} = g'(z) h'(g(z)), es decir Lz=g(z)h(σ)\frac{\partial L}{\partial z} = g'(z) h'(\sigma). Comparemos el resultado de PyTorch con la fórmula matemática exacta.

g_prime = torch.exp(- z) / ((1 + torch.exp(- z)) ** 2.0)
print(z.grad.item(), (g_prime * h_prime).item())
-0.14561070501804352 -0.1456107348203659

Tenemos Lb=fbg(f(w,b))h(g(f(w,b)))\frac{\partial L}{\partial b} = \frac{\partial f}{\partial b} g'(f(w, b)) h'(g(f(w, b))), es decir Lb=fbg(z)h(σ)\frac{\partial L}{\partial b} = \frac{\partial f}{\partial b} g'(z) h'(\sigma). Del mismo modo, tenemos Lwj=fwjg(f(w,b))h(g(f(w,b)))\frac{\partial L}{\partial w_j} = \frac{\partial f}{\partial w_j} g'(f(w, b)) h'(g(f(w, b))), es decir Lwj=fwjg(z)h(σ)\frac{\partial L}{\partial w_j} = \frac{\partial f}{\partial w_j} g'(z) h'(\sigma). Comparemos los resultados de PyTorch con las fórmulas matemáticas exactas.

print(B.grad.item(), (1 * g_prime * h_prime).item())
-0.14561070501804352 -0.1456107348203659
print(W.grad)
print((x_i * g_prime * h_prime).detach())
tensor([ 0.0138, -0.0021, -0.2928, -0.1261,  0.0551, -0.1072, -0.0170,  0.2252,
        -0.0960])
tensor([ 0.0138, -0.0021, -0.2928, -0.1261,  0.0551, -0.1072, -0.0170,  0.2252,
        -0.0960])

Implementación de la regresión logística

Implementemos ahora la regresión logística usando todo el conjunto de entrenamiento.

X = torch.from_numpy(x_train).float()
Y = torch.from_numpy(y_train_np).float()

Podríamos programar a mano la regla de actualización w(k+1)=w(k)αL/ww^{(k+1)} = w^{(k)} - \alpha \, \partial \mathcal{L} / \partial w, y las ediciones anteriores de este libro lo hacían, con contabilidad de retain_grad en cada paso. torch.optim.SGD aplica exactamente esa regla de actualización, así que dejamos que se encargue de las actualizaciones de los parámetros y mantenemos explícita la fórmula de la pérdida. Escribimos la entropía cruzada binaria completa en lugar de llamar a torch.nn.BCELoss — el punto aquí es la transparencia, no la conveniencia.

Dos detalles prácticos:

  • Acotamos σ lejos de 0 y de 1 antes de tomar logaritmos, para que la pérdida nunca devuelva log(0).
  • Nos detenemos cuando el cambio relativo de la pérdida cae por debajo de 10-6, o después de 2000 iteraciones.
p = X.size()[1]
W = torch.zeros(p, requires_grad=True)
B = torch.zeros(1, requires_grad=True)
optimizer = torch.optim.SGD([W, B], lr=0.1)

max_iter = 2000
losses = []
for i in range(max_iter):
    optimizer.zero_grad()
    z = X @ W + B
    sigma = torch.sigmoid(z)
    sigma = torch.clamp(sigma, 1e-7, 1 - 1e-7)
    L = - (Y * torch.log(sigma) + (1 - Y) * torch.log(1 - sigma)).mean()
    L.backward()
    optimizer.step()
    losses.append(L.item())
    if i > 0 and abs(losses[-1] - losses[-2]) / abs(losses[-2]) < 1e-6:
        break

print(f"Stopped after {len(losses)} iterations, final training loss: {losses[-1]:.4f}")
Stopped after 155 iterations, final training loss: 0.6707

Grafique la curva de pérdida. Debería decrecer rápido al principio y luego aplanarse.

plt.plot(losses)
plt.xlabel('Iteration')
plt.ylabel('Mean cross-entropy loss')
plt.title('Gradient descent on the training set')
plt.grid(alpha=0.3)
plt.show()
<Figure size 640x480 with 1 Axes>

Evaluación sobre el conjunto de prueba

Ahora predecimos tanto sobre el conjunto de entrenamiento como sobre el conjunto de prueba. Las ediciones anteriores de esta lección evaluaban el modelo sobre los mismos datos con los que fue entrenado; la brecha entre los dos números de abajo es exactamente la razón por la que eso exagera la habilidad.

X_test_t = torch.from_numpy(x_test).float()
with torch.no_grad():
    proba_train = torch.sigmoid(X @ W + B).numpy()
    proba_test = torch.sigmoid(X_test_t @ W + B).numpy()

yhat_train = np.where(proba_train > 0.5, 1, 0)
yhat_test = np.where(proba_test > 0.5, 1, 0)

Calculemos ahora algunas métricas de clasificación a mano, sobre el conjunto de prueba. N_test es el número de observaciones de prueba.

y_test = y_test_np
N_test = len(y_test)
# True positive
tp = np.sum((y_test == 1) & (yhat_test == 1))
print(tp / N_test)
0.006622516556291391
# False negative
fn = np.sum((y_test == 1) & (yhat_test == 0))
print(fn / N_test)
0.3973509933774834
# False positive
fp = np.sum((y_test == 0) & (yhat_test == 1))
print(fp / N_test)
0.004966887417218543
# True negative
tn = np.sum((y_test == 0) & (yhat_test == 0))
print(tn / N_test)
0.5910596026490066
# Accuracy (percentage of correct classifications)
accuracy = (tp + tn) / (tp + tn + fp + fn)
print(accuracy)
0.597682119205298
# Recall (= sensitivity = percentage of positive values correctly classified)
recall = tp / (tp + fn)
print(recall)
0.01639344262295082
# Precision (= percentage of positive predictions that were correct)
precision = tp / (tp + fp)
print(precision)
0.5714285714285714
# F1
F1 = (2 * precision * recall) / (precision + recall)
print(F1)
0.03187250996015936

Ahora compare la exactitud de entrenamiento contra la exactitud de prueba, junto al modelo de referencia de clase mayoritaria.

train_accuracy = np.mean(yhat_train == y_train_np)
test_accuracy = np.mean(yhat_test == y_test)
print(f"Baseline (majority class): {baseline:.3f}")
print(f"Train accuracy:            {train_accuracy:.3f}")
print(f"Test accuracy:             {test_accuracy:.3f}")
Baseline (majority class): 0.597
Train accuracy:            0.606
Test accuracy:             0.598

La regresión logística apenas supera al modelo de referencia de clase mayoritaria en esta tabla, y la exhaustividad (recall) sobre la clase potable es baja. Ese es un resultado honesto: estas 9 características, combinadas linealmente, llevan poca señal sobre el objetivo. Un resultado casi nulo reportado contra un modelo de referencia es más útil que un score de entrenamiento inflado. Si alguien reporta solo una exactitud de entrenamiento, pregunte cuál era el modelo de referencia y qué dice un conjunto de prueba apartado.

¿Son honestas las probabilidades?

Todo lo anterior corta la salida de la sigmoide en el umbral 0.5 y califica las etiquetas resultantes. Pero el modelo produce una probabilidad, y en muchas aplicaciones la probabilidad misma es lo que se entrega. Un clasificador está calibrado cuando sus probabilidades declaradas coinciden con las frecuencias observadas: entre todas las muestras de prueba donde el modelo dice «70 % de probabilidad de potable», alrededor del 70 % deberían ser potables.

Dos herramientas lo miden sobre el conjunto de prueba:

  • Un diagrama de confiabilidad (sklearn.calibration.calibration_curve) agrupa las muestras en intervalos según la probabilidad predicha y grafica la fracción observada de positivos en cada intervalo contra la probabilidad predicha media. Un modelo calibrado sigue la diagonal.
  • El score de Brier es el error cuadrático medio de la probabilidad, 1Ni=1N(y^iyi)2\frac{1}{N} \sum_{i=1}^N (\hat{y}_i - y_i)^2. Más bajo es mejor. A diferencia de la exactitud, que solo ve de qué lado de 0.5 cae una predicción, el score de Brier castiga un 0.99 equivocado mucho más fuerte que un 0.55 equivocado.
from sklearn.calibration import calibration_curve
from sklearn.metrics import brier_score_loss

# Brier score by hand: the mean squared error of the probability
brier_logistic = np.mean((proba_test - y_test) ** 2)

frac_pos, mean_pred = calibration_curve(
    y_test, proba_test, n_bins=10, strategy='quantile')

plt.figure(figsize=(5, 5))
plt.plot([0, 1], [0, 1], 'k--', label='perfect calibration')
plt.plot(mean_pred, frac_pos, 'o-', label='logistic regression')
plt.xlabel('Mean predicted probability of potable')
plt.ylabel('Observed fraction potable')
plt.legend()
plt.grid(alpha=0.3)
plt.show()

print(f"Logistic regression  Brier: {brier_logistic:.3f}  "
      f"test accuracy: {test_accuracy:.3f}")
<Figure size 500x500 with 1 Axes>
Logistic regression  Brier: 0.243  test accuracy: 0.598

Las probabilidades del modelo abarcan solo de 0.27 a 0.57 aproximadamente: nunca afirma certeza sobre ninguna muestra, lo cual es consistente con la señal débil de estas características. La curva oscila alrededor de la diagonal, con la mayoría de los intervalos centrales por debajo de ella — donde el modelo dice 40 % de potable, la frecuencia observada está más cerca de 30–40 %, una sobreconfianza leve hacia la clase potable. Cada intervalo por cuantiles contiene unas 60 muestras de prueba, así que cada fracción observada lleva un ruido de muestreo de aproximadamente ±0.06, y solo las desviaciones mayores que eso merecen interpretación. Veredicto: aproximadamente honesto, débilmente informativo. Las probabilidades son utilizables, pero en su mayor parte reformulan la tasa base.

Un competidor confiado, y cómo repararlo

Un método predict_proba no es garantía de que los números que salen de él merezcan llamarse probabilidades. Tome un bosque aleatorio pequeño: 10 árboles profundos, cada uno de los cuales casi memoriza el conjunto de entrenamiento. Su «probabilidad» de potable es la fracción de árboles que votan potable, y con 10 árboles sobreajustados esas fracciones caen con facilidad en valores extremos como 0.9 o 1.0.

from sklearn.ensemble import RandomForestClassifier

rf = RandomForestClassifier(n_estimators=10, random_state=0)
rf.fit(x_train, y_train_np)
proba_rf = rf.predict_proba(x_test)[:, 1]

brier_rf = brier_score_loss(y_test, proba_rf)
acc_rf = np.mean((proba_rf > 0.5) == y_test)
print(f"Random forest  train accuracy: {rf.score(x_train, y_train_np):.3f}")
print(f"Random forest  test accuracy:  {acc_rf:.3f}   Brier: {brier_rf:.3f}")
Random forest  train accuracy: 0.974
Random forest  test accuracy:  0.619   Brier: 0.239

El bosque memoriza el conjunto de entrenamiento y aun así supera al modelo logístico en exactitud de prueba. Ahora verifique si sus probabilidades merecen confianza, y repárelas si no. Lo que está en juego es práctico: cuando un modelo le dice a una oficina de gestión del riesgo «80 % de probabilidad», alrededor del 80 % de esos casos deberían cumplirse, o el número no es un pronóstico sobre el que alguien pueda actuar. La reparación reetiqueta los scores del modelo usando frecuencias observadas: aparte algunas muestras de datos, registre con qué frecuencia las muestras con score cercano a 0.9 resultan de verdad potables, y corrija cada score a esa frecuencia observada. Solo se permiten correcciones que preserven el orden — si el bosque puntuó la muestra A por encima de la muestra B, la probabilidad corregida de A queda igual o por encima de la de B —, de modo que el ordenamiento de las muestras sobrevive; solo cambian los números adjuntos. CalibratedClassifierCV proporciona las muestras apartadas reajustando el bosque sobre pliegues de validación cruzada (las divisiones rotativas de la lección 3.8), y el nombre que scikit-learn le da al ajuste que preserva el orden es regresión isotónica (method='isotonic').

from sklearn.calibration import CalibratedClassifierCV

cal_rf = CalibratedClassifierCV(
    RandomForestClassifier(n_estimators=10, random_state=0),
    method='isotonic', cv=5)
cal_rf.fit(x_train, y_train_np)
proba_cal = cal_rf.predict_proba(x_test)[:, 1]

brier_cal = brier_score_loss(y_test, proba_cal)
acc_cal = np.mean((proba_cal > 0.5) == y_test)

plt.figure(figsize=(5, 5))
plt.plot([0, 1], [0, 1], 'k--', label='perfect calibration')
for proba, brier, label in [
        (proba_rf, brier_rf, 'raw forest'),
        (proba_cal, brier_cal, 'isotonic-calibrated forest')]:
    frac_pos, mean_pred = calibration_curve(y_test, proba, n_bins=10)
    plt.plot(mean_pred, frac_pos, 'o-', label=f'{label} (Brier {brier:.3f})')
plt.xlabel('Mean predicted probability of potable')
plt.ylabel('Observed fraction potable')
plt.legend()
plt.grid(alpha=0.3)
plt.show()

print(f"Raw forest         Brier: {brier_rf:.3f}   accuracy: {acc_rf:.3f}")
print(f"Calibrated forest  Brier: {brier_cal:.3f}   accuracy: {acc_cal:.3f}")
<Figure size 500x500 with 1 Axes>
Raw forest         Brier: 0.239   accuracy: 0.619
Calibrated forest  Brier: 0.220   accuracy: 0.664

La curva del bosque crudo cae muy por debajo de la diagonal a la derecha: donde afirma un 80 % o 90 % de probabilidad de potable, la frecuencia observada está cerca del 50 %. Miente con confianza exactamente en el rango sobre el que un usuario actuaría, y su exactitud de prueba no da ninguna pista de ello. La calibración isotónica baja el score de Brier y acerca la curva a la diagonal, aunque no la coloca sobre ella: el mapa de calibración se estima a partir de unas 1400 filas de entrenamiento y lleva su propio ruido de muestreo. La exactitud también mejora un poco, en parte porque CalibratedClassifierCV promedia los cinco bosques que ajusta a través de los pliegues — un pequeño bono de ensamble, no el punto del ejercicio.

En contextos de riesgo, la probabilidad es el entregable, no la etiqueta. Una ingeniera geotécnica reporta una probabilidad de licuefacción que entra en una verificación del reglamento de construcción; un gestor del agua actúa sobre una probabilidad de floración algal nociva; un mapa de llanura de inundación codifica probabilidades de excedencia de crecidas. La persona que fija el umbral de decisión — cuánta probabilidad justifica el costo de actuar — no suele ser la que entrenó el modelo, y tomará el número al pie de la letra. Un 90 % mal calibrado que en realidad significa 55 % traslada silenciosamente su error de modelado a la decisión de otra persona. Siempre que las probabilidades de un modelo alimenten un umbral que usted no controla, reporte un diagrama de confiabilidad y un score de Brier junto a la exactitud. El desacuerdo entre los miembros de un ensamble da una vista complementaria de la incertidumbre en la lección 3.9, y la misma verificación de confiabilidad se aplica a los ensambles profundos en la lección 4.5.

Apéndice

La regresión logística es un buen ejemplo para empezar a aprender sobre la diferenciación automática y PyTorch. Sin embargo, si de verdad quiere usar la regresión logística con su propio conjunto de datos, es mucho más fácil usar la función que ya existe en scikit-learn. Seguimos el mismo protocolo: ajustar sobre el conjunto de entrenamiento, reportar sobre el conjunto de prueba.

from sklearn.linear_model import LogisticRegression
from sklearn.metrics import precision_recall_fscore_support
model = LogisticRegression(random_state=0).fit(x_train, y_train_np)
model.coef_
array([[ 0.04513734, -0.02944814, 0.12027801, 0.05730839, -0.04010493, -0.07808411, 0.02579578, 0.00593486, 0.02137578]])
model.intercept_
array([-0.39579648])

Leer los coeficientes

Las características fueron estandarizadas, así que cada coeficiente es el cambio en el log-odds (el logaritmo de la razón de probabilidades) de la potabilidad por un aumento de una desviación estándar en esa característica, manteniendo fijas las demás. Exponenciarlo lo convierte en una razón de probabilidades (odds ratio): un coeficiente wjw_j multiplica la razón de probabilidades de potabilidad por ewje^{w_j} por cada desviación estándar de la característica jj. La celda de abajo lee de esta manera el coeficiente más grande.

Dos precauciones. Primera, el signo de un coeficiente describe el modelo ajustado, no la química del agua: con características correlacionadas y una señal tan débil, el ajuste puede asignarle un signo químicamente inverosímil a una característica mientras otra correlacionada absorbe el efecto contrario — la misma salvedad que la importancia de las características en la lección 3.7. Segunda, la LogisticRegression de sklearn aplica regularización L2 por defecto (C=1.0), así que sus coeficientes están encogidos hacia cero y difieren del ajuste sin penalización que entrenamos arriba por descenso de gradiente — solo ligeramente aquí, porque con unas 2000 muestras de entrenamiento la penalización por defecto es suave, pero la tabla de abajo muestra los dos vectores de coeficientes lado a lado para que usted verifique en lugar de suponer.

feature_names = data.drop(columns=['Potability']).columns
coefs = pd.DataFrame(
    {"gradient descent (no penalty)": W.detach().numpy(),
     "sklearn (L2, C=1.0)": model.coef_[0]},
    index=feature_names)
print(coefs.round(3))

top = coefs["sklearn (L2, C=1.0)"].abs().idxmax()
w_top = coefs.loc[top, "sklearn (L2, C=1.0)"]
print(f"\nOne standard deviation of {top} multiplies the odds of potability "
      f"by exp({w_top:.3f}) = {np.exp(w_top):.3f}")
                 gradient descent (no penalty)  sklearn (L2, C=1.0)
ph                                       0.043                0.045
Hardness                                -0.028               -0.029
Solids                                   0.117                0.120
Chloramines                              0.055                0.057
Sulfate                                 -0.040               -0.040
Conductivity                            -0.076               -0.078
Organic_carbon                           0.024                0.026
Trihalomethanes                          0.006                0.006
Turbidity                                0.021                0.021

One standard deviation of Solids multiplies the odds of potability by exp(0.120) = 1.128
yhat_sk = model.predict(x_test)
metrics = precision_recall_fscore_support(y_test, yhat_sk, average='binary')
(precision_sk, recall_sk, F1_sk) = (metrics[0], metrics[1], metrics[2])
print(precision_sk, recall_sk, F1_sk)
0.5714285714285714 0.01639344262295082 0.03187250996015936
print(f"sklearn train accuracy: {model.score(x_train, y_train_np):.3f}")
print(f"sklearn test accuracy:  {model.score(x_test, y_test):.3f}")
sklearn train accuracy: 0.606
sklearn test accuracy:  0.598