6 - Una red neuronal convolucional desde cero¶
En el capítulo 6, Ana explica al equipo de Minermont la red que podría ayudar a los dermatólogos a priorizar las lesiones cutáneas: una red neuronal convolucional (CNN) que recibe una imagen de 28×28 en escala de grises y da una probabilidad para cada clase, benigna, sospechosa o maligna. El cuaderno Redes neuronales con TensorFlow y Keras la construye con unas pocas líneas de Keras. Aquí la escribimos desde cero, solo con NumPy, para ver lo que esconden esas líneas:
- Importar las bibliotecas.
- La convolución a mano, con el ejemplo del libro.
- El relleno (padding) y la convolución como un producto de matrices.
- El max-pooling.
- Las capas y su paso hacia atrás.
- La red del libro: 105.411 parámetros.
- Comprobar los gradientes.
- Las imágenes de dermatología.
- Entrenamiento con descenso del gradiente por mini-lotes.
- Evaluación con las imágenes de prueba.
- Lo que han aprendido los filtros.
Paso 1: Importar las bibliotecas¶
La red solo usa NumPy. scikit-learn divide los datos y dibuja la matriz de confusión, como en los demás cuadernos.
import time
import numpy as np
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report, ConfusionMatrixDisplay
Paso 2: La convolución a mano¶
La primera figura de Ana: un kernel de 3×3 se desliza sobre una entrada de 5×5, píxel a píxel (paso o stride 1). En cada posición multiplica la ventana por el kernel, elemento a elemento, y suma los nueve productos. Esa suma es un valor del mapa de características. Sin relleno, una ventana de 3×3 cabe en 3×3 posiciones de una entrada de 5×5.
book_input = np.array([[1, 3, 0, 2, 1],
[0, 2, 1, 3, 0],
[2, 1, 3, 1, 2],
[1, 0, 2, 0, 1],
[3, 1, 0, 2, 3]])
book_kernel = np.array([[0, 1, 0],
[1, 1, 1],
[0, 1, 0]])
def convolve(image, kernel):
"""Desliza el kernel sobre la imagen (sin relleno, paso 1): una salida por posición."""
k = kernel.shape[0]
rows, cols = image.shape[0] - k + 1, image.shape[1] - k + 1
feature_map = np.zeros((rows, cols))
for i in range(rows):
for j in range(cols):
window = image[i:i + k, j:j + k]
feature_map[i, j] = np.sum(window * kernel)
return feature_map
print("Primera ventana:\n", book_input[:3, :3])
print("Ventana x kernel, elemento a elemento:\n", book_input[:3, :3] * book_kernel)
print("Suma:", np.sum(book_input[:3, :3] * book_kernel))
print("\nMapa de características (3 x 3):\n", convolve(book_input, book_kernel))
Primera ventana: [[1 3 0] [0 2 1] [2 1 3]] Ventana x kernel, elemento a elemento: [[0 3 0] [0 2 1] [0 1 0]] Suma: 7 Mapa de características (3 x 3): [[7. 9. 7.] [8. 8. 9.] [5. 5. 6.]]
La primera ventana da 3 + 2 + 1 + 1 = 7, y el mapa de características completo es el de la figura de Ana. Este kernel es una cruz: suma cada píxel y sus cuatro vecinos, así que cada valor del mapa dice cuánta intensidad hay en ese vecindario en forma de cruz de la entrada. Una red usa la misma operación, pero no elige los kernels: los aprende.
Paso 3: El relleno y la convolución como un producto de matrices¶
La red del libro usa padding='same': un borde de ceros alrededor de la imagen, para que el mapa de características conserve el tamaño de la entrada (28×28 sigue siendo 28×28). Y una capa real no aplica un kernel con un doble bucle, sino muchos kernels a muchas imágenes. El truco habitual, llamado im2col, copia cada ventana de 3×3 en una fila de una matriz. Así, toda la capa, todas las posiciones, todas las imágenes y todos los filtros, es un único producto de matrices: (ventanas × 9·canales) por (9·canales × filtros). Lo escribimos para imágenes con cualquier número de canales, guardadas como (imágenes, alto, ancho, canales), como en Keras.
def im2col(x, k=3):
"""x: (n, h, w, c) -> una fila por posición de salida con sus k*k*c valores de entrada (relleno 'same')."""
n, h, w, c = x.shape
p = k // 2
padded = np.pad(x, ((0, 0), (p, p), (p, p), (0, 0))) # un borde de ceros
cols = np.empty((n, h, w, k, k, c), dtype=x.dtype)
for i in range(k):
for j in range(k):
cols[:, :, :, i, j, :] = padded[:, i:i + h, j:j + w, :]
return cols.reshape(n * h * w, k * k * c)
x = book_input[None, :, :, None].astype(float) # 1 imagen, 5 x 5, 1 canal
rows = im2col(x)
print(f"Matriz im2col: {rows.shape[0]} ventanas x {rows.shape[1]} valores")
same = (rows @ book_kernel.reshape(9, 1)).reshape(5, 5)
print("Mapa de características con relleno 'same' (5 x 5):\n", same)
print("Su centro es igual al mapa sin relleno:", np.array_equal(same[1:4, 1:4], convolve(book_input, book_kernel)))
Matriz im2col: 25 ventanas x 9 valores Mapa de características con relleno 'same' (5 x 5): [[4. 6. 6. 6. 3.] [5. 7. 9. 7. 6.] [4. 8. 8. 9. 4.] [6. 5. 5. 6. 6.] [5. 4. 5. 5. 6.]] Su centro es igual al mapa sin relleno: True
Con un borde de ceros, el mapa tiene 25 valores, uno por píxel, y su centro de 3×3 es exactamente el mapa del paso 2. Los valores del borde son más pequeños, porque parte de la cruz cae sobre los ceros. im2col ha convertido las 25 ventanas en una matriz de 25×9, y un único producto de matrices las ha calculado todas. En la primera capa de la red, el mismo producto procesa 32 imágenes × 784 posiciones y 16 filtros a la vez.
Paso 4: El max-pooling¶
Después de cada convolución, la red del libro aplica un max-pooling de 2×2 con paso 2: recorre cada mapa con una ventana de 2×2 que salta 2 píxeles cada vez, sin solaparse, y se queda solo con el valor más grande. El mapa se reduce a la mitad en cada dirección. Lo aplicamos a la esquina superior izquierda de 4×4 del mapa de características anterior, y además recordamos dónde estaba cada máximo, porque el paso hacia atrás lo necesitará.
def pool_forward(x):
"""Max-pooling de 2 x 2 con paso 2. Devuelve los mapas reducidos y la posición de cada máximo (de 0 a 3)."""
n, h, w, c = x.shape
windows = x.reshape(n, h // 2, 2, w // 2, 2, c).transpose(0, 1, 3, 5, 2, 4).reshape(n, h // 2, w // 2, c, 4)
where = windows.argmax(axis=-1)
return np.take_along_axis(windows, where[..., None], axis=-1)[..., 0], where
corner = same[:4, :4]
pooled, where = pool_forward(corner[None, :, :, None])
print("Esquina de 4 x 4 del mapa de características:\n", corner)
print("Tras el max-pooling de 2 x 2:\n", pooled[0, :, :, 0])
Esquina de 4 x 4 del mapa de características: [[4. 6. 6. 6.] [5. 7. 9. 7.] [4. 8. 8. 9.] [6. 5. 5. 6.]] Tras el max-pooling de 2 x 2: [[7. 9.] [8. 9.]]
Cada bloque de 2×2 conserva su máximo, 7, 9, 8 y 9, y el mapa de 4×4 se convierte en uno de 2×2: una cuarta parte de los valores, con la respuesta más fuerte de cada bloque. Si el patrón se desplaza un píxel dentro de un bloque, el resultado no cambia.
Paso 5: Las capas y su paso hacia atrás¶
Para entrenar, cada capa necesita dos funciones. El paso hacia delante calcula su salida. El paso hacia atrás recibe el gradiente de la pérdida respecto a esa salida y devuelve el gradiente respecto a su entrada y a sus pesos: es la retropropagación, la regla de la cadena aplicada capa a capa desde la pérdida hasta la imagen.
- Convolución. El paso hacia delante es el producto de matrices del paso 3 más un sesgo por filtro. Hacia atrás, el gradiente de los pesos es la matriz im2col traspuesta por el gradiente que llega, y el gradiente de la entrada vuelve por las mismas ventanas (col2im), sumándose donde las ventanas se solapan.
- ReLU, $\max(0, z)$, deja pasar el gradiente donde $z > 0$ y lo bloquea en el resto.
- Max-pooling no tiene pesos. Su gradiente va entero a la posición que tenía el máximo, y las otras tres reciben cero.
- Capa densa, $\mathbf{a}\,W + \mathbf{b}$, como en el cuaderno del perceptrón.
- Softmax con entropía cruzada categórica. Como en el cuaderno multiclase del capítulo 4, el gradiente respecto a las puntuaciones es la probabilidad menos la etiqueta one-hot, $\hat{\mathbf{y}} - \mathbf{y}$.
def col2im(d_rows, shape, k=3):
"""Paso hacia atrás de im2col: devuelve el gradiente de cada ventana a sus píxeles, sumando los solapamientos."""
n, h, w, c = shape
p = k // 2
d_rows = d_rows.reshape(n, h, w, k, k, c)
d_padded = np.zeros((n, h + 2 * p, w + 2 * p, c), dtype=d_rows.dtype)
for i in range(k):
for j in range(k):
d_padded[:, i:i + h, j:j + w, :] += d_rows[:, :, :, i, j, :]
return d_padded[:, p:-p, p:-p, :]
def conv_forward(x, W, b):
n, h, w, _ = x.shape
rows = im2col(x)
return (rows @ W.reshape(-1, W.shape[-1]) + b).reshape(n, h, w, -1), rows
def conv_backward(d_out, rows, x_shape, W):
d = d_out.reshape(-1, W.shape[-1])
dW = (rows.T @ d).reshape(W.shape)
db = d.sum(axis=0)
dx = col2im(d @ W.reshape(-1, W.shape[-1]).T, x_shape)
return dx, dW, db
def pool_backward(d_out, where, shape):
n, h, w, c = shape
d_windows = np.zeros((n, h // 2, w // 2, c, 4), dtype=d_out.dtype)
np.put_along_axis(d_windows, where[..., None], d_out[..., None], axis=-1) # solo el máximo recibe el gradiente
return d_windows.reshape(n, h // 2, w // 2, c, 2, 2).transpose(0, 1, 4, 2, 5, 3).reshape(n, h, w, c)
def softmax(scores):
scores = scores - scores.max(axis=1, keepdims=True)
e = np.exp(scores)
return e / e.sum(axis=1, keepdims=True)
def cross_entropy(probs, y):
return -np.mean(np.log(probs[np.arange(len(y)), y] + 1e-12))
Paso 6: La red del libro: 105.411 parámetros¶
Los ocho pasos de Ana: entrada de 28×28×1, convolución con 16 filtros de 3×3 y ReLU, max-pooling a 14×14, convolución con 32 filtros de 3×3 y ReLU, max-pooling a 7×7, aplanado en 1.568 números, capa densa de 64 neuronas con ReLU y 3 salidas con softmax. Los pesos empiezan con valores aleatorios con la escala que propuso He para capas ReLU (desviación típica $\sqrt{2 / \text{entradas}}$), y los sesgos a cero. forward guarda todo lo que necesitará el paso hacia atrás.
def init_network(seed=0, dtype=np.float32):
rng = np.random.default_rng(seed)
def he(shape, inputs):
return rng.normal(0, np.sqrt(2 / inputs), shape).astype(dtype)
return {'W1': he((3, 3, 1, 16), 9), 'b1': np.zeros(16, dtype), # conv 1: 16 filtros 3 x 3 x 1
'W2': he((3, 3, 16, 32), 144), 'b2': np.zeros(32, dtype), # conv 2: 32 filtros 3 x 3 x 16
'W3': he((1568, 64), 1568), 'b3': np.zeros(64, dtype), # densa: 7 * 7 * 32 = 1568 entradas
'W4': he((64, 3), 64), 'b4': np.zeros(3, dtype)} # salida: 3 clases
def forward(net, x):
z1, rows1 = conv_forward(x, net['W1'], net['b1']); a1 = np.maximum(z1, 0); m1, where1 = pool_forward(a1)
z2, rows2 = conv_forward(m1, net['W2'], net['b2']); a2 = np.maximum(z2, 0); m2, where2 = pool_forward(a2)
flat = m2.reshape(len(x), -1) # aplanado
z3 = flat @ net['W3'] + net['b3']; a3 = np.maximum(z3, 0)
probs = softmax(a3 @ net['W4'] + net['b4'])
return probs, (x, z1, rows1, a1, m1, where1, z2, rows2, a2, where2, m2, flat, z3, a3)
def backward(net, cache, probs, y):
x, z1, rows1, a1, m1, where1, z2, rows2, a2, where2, m2, flat, z3, a3 = cache
grads = {}
d4 = probs.copy()
d4[np.arange(len(y)), y] -= 1 # probabilidad menos etiqueta one-hot
d4 /= len(y)
grads['W4'], grads['b4'] = a3.T @ d4, d4.sum(axis=0)
d3 = (d4 @ net['W4'].T) * (z3 > 0) # hacia atrás por la ReLU
grads['W3'], grads['b3'] = flat.T @ d3, d3.sum(axis=0)
d_m2 = (d3 @ net['W3'].T).reshape(m2.shape) # deshacer el aplanado
d_z2 = pool_backward(d_m2, where2, a2.shape) * (z2 > 0)
d_m1, grads['W2'], grads['b2'] = conv_backward(d_z2, rows2, m1.shape, net['W2'])
d_z1 = pool_backward(d_m1, where1, a1.shape) * (z1 > 0)
_, grads['W1'], grads['b1'] = conv_backward(d_z1, rows1, x.shape, net['W1'])
return grads
net = init_network()
layers = [('Conv 1 (16 filtros 3x3)', ['W1', 'b1']), ('MaxPooling 1', []), ('Conv 2 (32 filtros 3x3)', ['W2', 'b2']),
('MaxPooling 2', []), ('Aplanado', []), ('Densa (64)', ['W3', 'b3']), ('Salida (3, softmax)', ['W4', 'b4'])]
for name, keys in layers:
print(f"{name:<24}{sum(net[k].size for k in keys):>8,}")
print(f"{'Total':<24}{sum(v.size for v in net.values()):>8,}")
Conv 1 (16 filtros 3x3) 160 MaxPooling 1 0 Conv 2 (32 filtros 3x3) 4,640 MaxPooling 2 0 Aplanado 0 Densa (64) 100,416 Salida (3, softmax) 195 Total 105,411
Los mismos 160, 4.640, 100.416 y 195 que en la tabla del libro, 105.411 en total. La compartición de pesos se ve en estos números: la primera convolución mira toda la imagen con solo 160 parámetros, porque los mismos 16 filtros pequeños se reutilizan en todas las posiciones, mientras que la capa densa necesita 100.416, porque cada una de sus 1.568 entradas está conectada con cada una de sus 64 neuronas.
Paso 7: Comprobar los gradientes¶
Un error en un paso hacia atrás no produce ningún mensaje de error: la red simplemente aprende mal. Por eso, antes de entrenar, comparamos cada gradiente analítico con uno numérico, que se obtiene moviendo un único peso $\varepsilon$ hacia arriba y hacia abajo y midiendo cómo cambia la pérdida: $\frac{L(w + \varepsilon) - L(w - \varepsilon)}{2\varepsilon}$. Usamos doble precisión, cuatro imágenes y cinco pesos al azar de cada matriz. Para la comprobación, los sesgos toman pequeños valores aleatorios: exactamente en cero, algunas unidades caen justo en el pico de la ReLU, donde la derivada no está definida y los dos métodos pueden discrepar con toda legitimidad.
check_rng = np.random.default_rng(1)
check_net = init_network(seed=1, dtype=np.float64)
for key in ['b1', 'b2', 'b3', 'b4']:
check_net[key] = check_rng.normal(0, 0.1, check_net[key].shape)
check_x = check_rng.random((4, 28, 28, 1))
check_y = np.array([0, 1, 2, 1])
probs, cache = forward(check_net, check_x)
analytic = backward(check_net, cache, probs, check_y)
eps, worst = 1e-6, 0.0
for key, W in check_net.items():
for _ in range(5):
idx = tuple(check_rng.integers(0, s) for s in W.shape)
original = W[idx]
W[idx] = original + eps; loss_plus = cross_entropy(forward(check_net, check_x)[0], check_y)
W[idx] = original - eps; loss_minus = cross_entropy(forward(check_net, check_x)[0], check_y)
W[idx] = original
numerical = (loss_plus - loss_minus) / (2 * eps)
relative = abs(numerical - analytic[key][idx]) / max(abs(numerical) + abs(analytic[key][idx]), 1e-12)
worst = max(worst, relative)
print(f"Mayor diferencia relativa entre los gradientes analíticos y los numéricos: {worst:.1e}")
Mayor diferencia relativa entre los gradientes analíticos y los numéricos: 9.5e-08
La mayor diferencia relativa es 9,5e-08, del tamaño de los errores de redondeo: los pasos hacia atrás son correctos. Un error en el paso hacia atrás de una capa aparecería en los gradientes de sus pesos y de todas las capas anteriores, que se calculan a partir de él.
Paso 8: Las imágenes de dermatología¶
Las mismas lesiones sintéticas que en el cuaderno de Keras, con el mismo generador, la misma semilla y la misma división, para poder comparar los resultados: 3.600 imágenes de 28×28 píxeles, el 75% para entrenamiento y el 25% para la prueba. Como hace validation_split=0.2 en Keras, el último 20% de las imágenes de entrenamiento se reserva para validación.
def make_lesions(n, seed, shares=(0.5, 0.3, 0.2)):
"""Imágenes sintéticas de 28x28 en escala de grises de lesiones cutáneas: 0 = benigna, 1 = sospechosa, 2 = maligna."""
rng = np.random.default_rng(seed)
labels = rng.choice(3, n, p=shares)
yy, xx = np.mgrid[0:28, 0:28].astype(float)
images = np.empty((n, 28, 28), dtype=np.float32)
# las reglas ocultas: tamaño (r), irregularidad del borde (irr), pigmento heterogéneo (het),
# lóbulo extra asimétrico (lobe) y lo difuminado que está el borde (soft); los rangos se solapan a propósito
params = {0: dict(r=(4.0, 7.0), irr=(0.0, 0.10), het=(0.0, 0.06), lobe=0.05, soft=(1.2, 2.0)),
1: dict(r=(5.0, 8.5), irr=(0.07, 0.20), het=(0.04, 0.12), lobe=0.35, soft=(0.8, 1.6)),
2: dict(r=(6.0, 10.0), irr=(0.15, 0.32), het=(0.10, 0.22), lobe=0.75, soft=(0.5, 1.2))}
for i, c in enumerate(labels):
p = params[c]
cx, cy = rng.uniform(11, 17, 2)
r0, irr = rng.uniform(*p['r']), rng.uniform(*p['irr'])
het, soft = rng.uniform(*p['het']), rng.uniform(*p['soft'])
theta, dist = np.arctan2(yy - cy, xx - cx), np.hypot(xx - cx, yy - cy)
radius = r0 * (1 + sum(irr / k ** 0.5 * rng.uniform(0.5, 1.0) * np.cos(k * theta + rng.uniform(0, 2 * np.pi))
for k in range(2, 7)))
mask = 1 / (1 + np.exp(-(radius - dist) / soft))
if rng.random() < p['lobe']:
a = rng.uniform(0, 2 * np.pi)
lx, ly = cx + 0.8 * r0 * np.cos(a), cy + 0.8 * r0 * np.sin(a)
mask = np.maximum(mask, 1 / (1 + np.exp(-(0.55 * r0 - np.hypot(xx - lx, yy - ly)) / soft)))
pigment = rng.uniform(0.45, 0.6) + het * rng.normal(0, 1, (28, 28))
for _ in range(3): # manchas más oscuras y más claras
bx, by = cx + rng.normal(0, r0 / 2), cy + rng.normal(0, r0 / 2)
pigment += het * 2.5 * rng.choice([-1, 1]) * np.exp(-((xx - bx) ** 2 + (yy - by) ** 2) / (2 * (r0 / 3) ** 2))
skin = 0.82 + 0.04 * (xx - 14) / 14 * rng.normal() + 0.03 * rng.normal(0, 1, (28, 28))
images[i] = np.clip(skin - mask * pigment, 0, 1)
return images, labels
class_names = ['benigna', 'sospechosa', 'maligna']
images, labels = make_lesions(3600, seed=0)
X_train, X_test, y_train, y_test = train_test_split(images[..., None], labels, test_size=0.25, random_state=42, stratify=labels)
split = int(len(y_train) * 0.8)
X_fit, y_fit, X_val, y_val = X_train[:split], y_train[:split], X_train[split:], y_train[split:]
print(f"Entrenamiento: {len(y_fit)} imágenes, validación: {len(y_val)}, prueba: {len(y_test)}")
fig, axes = plt.subplots(3, 6, figsize=(12, 6.5))
for c in range(3):
for ax, idx in zip(axes[c], np.where(y_fit == c)[0][:6]):
ax.imshow(X_fit[idx, :, :, 0], cmap='gray', vmin=0, vmax=1)
ax.axis('off')
axes[c, 0].set_title(class_names[c], loc='left')
plt.suptitle('Lesiones cutáneas sintéticas (28 x 28 píxeles)')
plt.show()
Entrenamiento: 2160 imágenes, validación: 540, prueba: 900
Paso 9: Entrenamiento con descenso del gradiente por mini-lotes¶
El cuaderno de Keras usa el optimizador Adam. Aquí usamos el algoritmo del capítulo 2, el descenso del gradiente por mini-lotes: lotes de 32 imágenes, barajados al principio de cada época, y la actualización $w := w - \alpha \nabla L$ para cada uno de los 105.411 parámetros. Después de cada época medimos la pérdida con las imágenes de validación. Si no ha mejorado, la tasa de aprendizaje se reduce a la mitad, como en el cuaderno del descenso del gradiente del capítulo 2; si no ha mejorado durante 4 épocas seguidas, el entrenamiento se detiene. Como máximo 20 épocas, empezando con $\alpha = 0{,}05$, y nos quedamos con los pesos de la época con la menor pérdida de validación (parada temprana).
def predict_probs(net, x, batch=500):
return np.concatenate([forward(net, x[i:i + batch])[0] for i in range(0, len(x), batch)])
net = init_network(seed=0)
learning_rate, batch_size, patience = 0.05, 32, 4
order_rng = np.random.default_rng(0)
history = {'loss': [], 'val_loss': [], 'accuracy': [], 'val_accuracy': [], 'learning_rate': []}
best_loss, best_net, best_epoch, waited = np.inf, None, 0, 0
start = time.time()
for epoch in range(1, 21):
order = order_rng.permutation(len(y_fit))
for first in range(0, len(order), batch_size):
batch = order[first:first + batch_size]
probs, cache = forward(net, X_fit[batch])
grads = backward(net, cache, probs, y_fit[batch])
for key in net:
net[key] -= learning_rate * grads[key]
p_fit, p_val = predict_probs(net, X_fit), predict_probs(net, X_val)
history['loss'].append(cross_entropy(p_fit, y_fit)); history['val_loss'].append(cross_entropy(p_val, y_val))
history['accuracy'].append(np.mean(p_fit.argmax(1) == y_fit)); history['val_accuracy'].append(np.mean(p_val.argmax(1) == y_val))
history['learning_rate'].append(learning_rate)
if history['val_loss'][-1] < best_loss:
best_loss, best_net, best_epoch, waited = history['val_loss'][-1], {k: v.copy() for k, v in net.items()}, epoch, 0
else:
waited += 1
learning_rate /= 2
if waited == patience:
break
print(f"Épocas ejecutadas: {epoch}, mejor época: {best_epoch}, tiempo de entrenamiento: {time.time() - start:.0f} s")
print(f"Mejor época: exactitud en entrenamiento {history['accuracy'][best_epoch - 1]:.3f}, "
f"en validación {history['val_accuracy'][best_epoch - 1]:.3f}, pérdida de validación {best_loss:.4f}")
print("Tasa de aprendizaje en cada época:", ", ".join(f"{rate:g}" for rate in history['learning_rate']))
fig, axes = plt.subplots(1, 2, figsize=(12, 4))
for ax, key, title in [(axes[0], 'loss', 'Pérdida (entropía cruzada categórica)'), (axes[1], 'accuracy', 'Exactitud')]:
epochs = range(1, len(history[key]) + 1)
ax.plot(epochs, history[key], 'o-', label='entrenamiento')
ax.plot(epochs, history['val_' + key], 's-', label='validación')
ax.axvline(best_epoch, color='gray', ls='--', label='mejor época')
ax.set_xticks([1, 5, 10, 15, 20]); ax.set_xlabel('época'); ax.set_title(title); ax.legend(); ax.grid(True)
plt.show()
net = best_net
Épocas ejecutadas: 20, mejor época: 20, tiempo de entrenamiento: 21 s Mejor época: exactitud en entrenamiento 0.948, en validación 0.931, pérdida de validación 0.1876 Tasa de aprendizaje en cada época: 0.05, 0.05, 0.05, 0.05, 0.05, 0.025, 0.025, 0.0125, 0.0125, 0.00625, 0.00625, 0.003125, 0.0015625, 0.0015625, 0.0015625, 0.0015625, 0.0015625, 0.00078125, 0.000390625, 0.000390625
La pérdida baja rápido en las primeras épocas, con un par de repuntes en la pérdida de validación, en las épocas 5 y 7, mientras la tasa de aprendizaje es todavía grande. Cada vez que la pérdida de validación no mejora, la tasa se reduce a la mitad, y a partir de la época 10 las curvas se estabilizan. La parada temprana no llega a activarse: la pérdida de validación sigue mejorando, muy poco, hasta la época 20, que es la mejor, con una exactitud de 0,948 en entrenamiento, de 0,931 en validación y una pérdida de validación de 0,1876. Las dos curvas se mantienen cerca, así que la red no ha memorizado las imágenes de entrenamiento. En un portátil, todo el entrenamiento dura segundos; con imágenes y redes más grandes duraría mucho más, y por eso Ana recomienda las GPU.
Paso 10: Evaluación con las imágenes de prueba¶
Evaluamos una sola vez con las 900 imágenes de prueba. Como en el cuaderno de Keras, el número que más importa es el recall de la clase maligna.
test_probs = predict_probs(net, X_test)
y_pred = test_probs.argmax(axis=1)
print(f"Exactitud en prueba: {np.mean(y_pred == y_test):.3f}\n")
print(classification_report(y_test, y_pred, target_names=class_names, digits=3))
ConfusionMatrixDisplay.from_predictions(y_test, y_pred, display_labels=class_names, cmap='Blues')
plt.xlabel('Clase predicha'); plt.ylabel('Clase real'); plt.title('CNN desde cero con las imágenes de prueba')
plt.show()
Exactitud en prueba: 0.926
precision recall f1-score support
benigna 0.954 0.961 0.957 457
sospechosa 0.869 0.873 0.871 259
maligna 0.933 0.913 0.923 184
accuracy 0.926 900
macro avg 0.919 0.915 0.917 900
weighted avg 0.926 0.926 0.926 900
La exactitud en prueba es 0,926. Las lesiones malignas tienen un recall de 0,913: 168 de las 184 se marcan como malignas y las otras 16 como sospechosas, así que ninguna se toma por benigna, y tampoco se toma ninguna benigna por maligna. La clase sospechosa, en medio, es la más difícil, con un recall de 0,873. Para este problema bastan 105.411 parámetros entrenados con el descenso del gradiente simple del capítulo 2.
Paso 11: Lo que han aprendido los filtros¶
Nadie ha programado los filtros: empezaron siendo números aleatorios. Debajo están los 16 kernels de la primera capa tras el entrenamiento y los 16 mapas de características que producen para una lesión maligna de prueba (después de la ReLU). Los píxeles claros de un mapa indican una respuesta fuerte.
malignant = np.where((y_test == 2) & (y_pred == 2))[0][0]
maps = np.maximum(conv_forward(X_test[malignant:malignant + 1], net['W1'], net['b1'])[0][0], 0)
fig, axes = plt.subplots(2, 17, figsize=(17, 2.6))
axes[0, 0].imshow(X_test[malignant, :, :, 0], cmap='gray', vmin=0, vmax=1)
axes[0, 0].set_title('imagen', fontsize=9)
axes[1, 0].axis('off')
for f in range(16):
axes[0, f + 1].imshow(net['W1'][:, :, 0, f], cmap='gray')
axes[1, f + 1].imshow(maps[:, :, f], cmap='gray')
for ax in axes.ravel():
ax.set_xticks([]); ax.set_yticks([])
axes[0, 1].set_ylabel('kernel'); axes[1, 1].set_ylabel('mapa')
plt.suptitle('Primera capa convolucional: los 16 kernels aprendidos y sus mapas de características')
plt.show()
Los kernels no se parecen a nada que diseñaría una persona, y los mapas muestran por qué aun así son útiles: unos resaltan la lesión, otros la piel que la rodea o partes de su borde, y unos pocos quedan casi negros para esta imagen. La segunda capa combina estos 16 mapas en 32 nuevos, y la capa densa los convierte en las tres probabilidades. Como advierte Ana, es la forma propia que tiene la red de ver: a qué responde cada filtro no tiene por qué coincidir con nuestra idea humana de bordes y texturas.
Conclusión¶
- Una convolución es un kernel pequeño que se desliza sobre la imagen y suma productos. Con im2col, una capa entera se convierte en un solo producto de matrices.
- El max-pooling reduce los mapas a la mitad quedándose con el valor más grande de cada ventana de 2×2, y hacia atrás envía el gradiente solo a ese valor.
- La retropropagación es la regla de la cadena, capa a capa. Una comprobación numérica del gradiente es la forma de saber que está bien antes de entrenar.
- La red del libro tiene 105.411 parámetros, casi todos ellos, 100.416, en la capa densa. Entrenada con el descenso del gradiente por mini-lotes simple y una tasa de aprendizaje que se reduce a la mitad cuando la pérdida de validación se estanca, clasifica las lesiones sintéticas más o menos igual de bien que la versión de Keras.
- Nadie diseña los filtros: salen del descenso del gradiente y, como advierte Ana, lo que detectan no tiene por qué coincidir con nuestra idea humana de bordes y texturas.