6 - Redes neuronales con TensorFlow y Keras¶
En el cuaderno anterior escribimos una red neuronal a mano, derivadas incluidas. Es lo que Ana llama nivel 0 en sus notas (sección 6.8): perfecto para entender, demasiado laborioso para redes reales. Aquí subimos los otros pisos de su "edificio":
- Nivel 1, diferenciación automática (TensorFlow): escribimos las operaciones y la biblioteca calcula todos los gradientes.
- Nivel 2, API de alto nivel (Keras): combinamos capas estándar en lugar de operaciones individuales.
- Nivel 3, modelos predefinidos (scikit-learn): una red neuronal como un modelo más de
fit/predict.
Con Keras construimos la red convolucional que diseña Ana para el proyecto de dermatología de Minermont (sección 6.4), capa a capa y parámetro a parámetro.
- Importar las bibliotecas.
- Nivel 1: diferenciación automática, y de nuevo el ejemplo de Marta.
- Nivel 2: XOR con Keras.
- Las imágenes de dermatología.
- La red convolucional del libro.
- Evaluación.
- Qué ve la red.
- Nivel 3: una red de scikit-learn con las mismas imágenes.
Paso 1: Importar las bibliotecas¶
Fijamos las semillas aleatorias y pedimos a TensorFlow operaciones deterministas, para que los resultados se puedan reproducir. En otra máquina o con una GPU los números aún pueden variar ligeramente.
import numpy as np
import matplotlib.pyplot as plt
import tensorflow as tf
import keras
from keras import layers
from sklearn.model_selection import train_test_split
from sklearn.neural_network import MLPClassifier
from sklearn.metrics import accuracy_score, recall_score, classification_report, ConfusionMatrixDisplay
keras.utils.set_random_seed(42)
tf.config.experimental.enable_op_determinism()
np.set_printoptions(precision=3, suppress=True)
print(f"TensorFlow {tf.__version__}, Keras {keras.__version__}")
TensorFlow 2.20.0, Keras 3.13.2
Paso 2: Nivel 1, diferenciación automática¶
TensorFlow representa cada cálculo como un grafo computacional: cada nodo es una variable, una entrada o una operación. Mientras una GradientTape está "grabando", TensorFlow recuerda el grafo y después puede aplicarle la regla de la cadena automáticamente. El ejemplo de Ana es $y = 3x + 2$, cuya derivada es 3:
x = tf.Variable(2.0)
with tf.GradientTape() as tape:
y = 3 * x + 2
print(f"y = {y.numpy()}, dy/dx = {tape.gradient(y, x).numpy()}")
y = 8.0, dy/dx = 3.0
Ahora la red de diagnóstico de Marta (sección 6.7), la que derivamos a mano en el cuaderno anterior. Solo escribimos el paso hacia adelante; los gradientes salen de la cinta:
x_in = tf.constant([0.5, 0.7])
W1 = tf.Variable([[0.8, 0.5], [0.3, 0.9], [1.2, 0.4]]); b1 = tf.Variable([0.1, -0.2, 0.0])
W2 = tf.Variable([[1.1, 0.3, 0.5], [0.2, 1.0, 0.7], [0.6, 0.4, 1.3]]); b2 = tf.Variable([0.0, 0.1, -0.1])
y_true = tf.constant([0.0, 1.0, 0.0]) # gripe
with tf.GradientTape() as tape:
hidden = tf.nn.relu(tf.linalg.matvec(W1, x_in) + b1)
probs = tf.nn.softmax(tf.linalg.matvec(W2, hidden) + b2)
loss = -tf.reduce_sum(y_true * tf.math.log(probs))
grads = dict(zip(['W1', 'b1', 'W2', 'b2'], tape.gradient(loss, [W1, b1, W2, b2])))
print(f"Probabilidades (resfriado, gripe, neumonía): {probs.numpy()}, pérdida {loss.numpy():.4f}")
for name, g in grads.items():
print(f"dL/d{name} =\n{g.numpy()}")
Probabilidades (resfriado, gripe, neumonía): [0.314 0.289 0.398], pérdida 1.2423 dL/dW1 = [[ 0.221 0.309] [-0.229 -0.321] [ 0.088 0.123]] dL/db1 = [ 0.441 -0.458 0.176] dL/dW2 = [[ 0.267 0.182 0.276] [-0.605 -0.413 -0.626] [ 0.338 0.231 0.35 ]] dL/db2 = [ 0.314 -0.711 0.398]
Las mismas probabilidades (0,314, 0,289, 0,398), la misma pérdida (1,2423) y los mismos gradientes que derivamos a mano en el cuaderno anterior, por ejemplo −0,605 y −0,321, los dos elementos que comprueba Marta. Pero aquí no hemos escrito ni una derivada: TensorFlow ha grabado las operaciones y les ha aplicado la regla de la cadena. Eso es lo que permite entrenar redes de cualquier arquitectura con millones de parámetros.
Paso 3: Nivel 2, XOR con Keras¶
Con Keras ya no escribimos operaciones: apilamos capas, elegimos una pérdida y un optimizador, y llamamos a fit. La red XOR del cuaderno anterior ocupa cuatro líneas. Usamos el optimizador Adam, una versión refinada del descenso del gradiente que adapta el paso de cada peso.
X_xor = np.array([[0, 0], [0, 1], [1, 0], [1, 1]], dtype='float32')
y_xor = np.array([0, 1, 1, 0], dtype='float32')
xor_model = keras.Sequential([
keras.Input(shape=(2,)),
layers.Dense(4, activation='tanh'),
layers.Dense(1, activation='sigmoid'),
])
xor_model.compile(optimizer=keras.optimizers.Adam(learning_rate=0.05), loss='binary_crossentropy')
history = xor_model.fit(X_xor, y_xor, epochs=300, verbose=0)
print(f"Pérdida final: {history.history['loss'][-1]:.4f}")
print(f"Probabilidades para (0,0), (0,1), (1,0), (1,1): {xor_model.predict(X_xor, verbose=0).ravel()}")
Pérdida final: 0.0029 Probabilidades para (0,0), (0,1), (1,0), (1,1): [0. 0.995 0.998 0.005]
La red resuelve XOR: probabilidades cercanas a 0 para (0, 0) y (1, 1), y cercanas a 1 para (0, 1) y (1, 0). Todo lo que escribimos a mano en el cuaderno anterior, el paso hacia adelante, la retropropagación y las actualizaciones, está ahora dentro de fit.
Paso 4: Las imágenes de dermatología¶
En el libro, los dermatólogos de Minermont necesitan ayuda para priorizar las lesiones cutáneas, y definir a mano características como la "simetría" o la "irregularidad del borde" resulta demasiado subjetivo. Un conjunto real de imágenes clínicas es confidencial, así que generamos imágenes sintéticas de 28×28 píxeles en escala de grises, el formato simplificado que usa Ana para explicar su red. Cada imagen es una lesión oscura sobre una piel más clara. Siguiendo las señales de alarma habituales, las lesiones benignas tienden a ser pequeñas, redondas y uniformes, y las malignas más grandes, asimétricas, con el borde irregular y el pigmento heterogéneo; las sospechosas quedan en medio. Las reglas se solapan a propósito, como en la realidad. Como en las consultas reales, las lesiones benignas son las más frecuentes.
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)
print("Imágenes por clase:", dict(zip(class_names, np.bincount(labels).tolist())))
fig, axes = plt.subplots(3, 6, figsize=(12, 6.5))
for row, c in enumerate(range(3)):
for ax, idx in zip(axes[row], np.where(labels == c)[0][:6]):
ax.imshow(images[idx], cmap='gray', vmin=0, vmax=1)
ax.axis('off')
axes[row, 0].set_title(class_names[c], loc='left')
plt.suptitle('Lesiones cutáneas sintéticas (28 x 28 píxeles)')
plt.show()
X_train, X_test, y_train, y_test = train_test_split(images[..., None], labels, test_size=0.25, random_state=42, stratify=labels)
Y_train = keras.utils.to_categorical(y_train, 3) # etiquetas one-hot para la salida softmax
print(f"Imágenes de entrenamiento: {len(y_train)}, de prueba: {len(y_test)}, forma de cada imagen: {X_train.shape[1:]}")
Imágenes por clase: {'benigna': 1828, 'sospechosa': 1036, 'maligna': 736}
Imágenes de entrenamiento: 2700, de prueba: 900, forma de cada imagen: (28, 28, 1)
Paso 5: La red convolucional del libro¶
La red de Ana, tal como la describe en la sección 6.4:
- Entrada de $28 \times 28 \times 1$ (un canal, escala de grises).
- Convolución con 16 filtros de $3 \times 3$ y ReLU. Cada filtro se desliza por la imagen buscando un patrón local.
- Max-pooling de $2 \times 2$: se queda con el mayor valor de cada ventana y reduce el tamaño a la mitad, a $14 \times 14$.
- Convolución con 32 filtros de $3 \times 3$, que combinan los patrones de la primera capa.
- Max-pooling de $2 \times 2$, hasta $7 \times 7 \times 32 = 1568$ valores.
- Aplanado (flatten) en una lista de 1568 números.
- Capa densa de 64 neuronas con ReLU.
- Salida de 3 neuronas con softmax: benigna, sospechosa y maligna.
padding='same' conserva el tamaño de la imagen en cada convolución, como en el libro. summary() enumera los parámetros de cada capa, que podemos comparar con la tabla del libro:
cnn = keras.Sequential([
keras.Input(shape=(28, 28, 1)),
layers.Conv2D(16, 3, padding='same', activation='relu'),
layers.MaxPooling2D(2),
layers.Conv2D(32, 3, padding='same', activation='relu'),
layers.MaxPooling2D(2),
layers.Flatten(),
layers.Dense(64, activation='relu'),
layers.Dense(3, activation='softmax'),
])
cnn.summary()
print(f"Parámetros totales: {cnn.count_params():,}")
Model: "sequential_1"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓ ┃ Layer (type) ┃ Output Shape ┃ Param # ┃ ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩ │ conv2d (Conv2D) │ (None, 28, 28, 16) │ 160 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ max_pooling2d (MaxPooling2D) │ (None, 14, 14, 16) │ 0 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ conv2d_1 (Conv2D) │ (None, 14, 14, 32) │ 4,640 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ max_pooling2d_1 (MaxPooling2D) │ (None, 7, 7, 32) │ 0 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ flatten (Flatten) │ (None, 1568) │ 0 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ dense_2 (Dense) │ (None, 64) │ 100,416 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ dense_3 (Dense) │ (None, 3) │ 195 │ └─────────────────────────────────┴────────────────────────┴───────────────┘
Total params: 105,411 (411.76 KB)
Trainable params: 105,411 (411.76 KB)
Non-trainable params: 0 (0.00 B)
Parámetros totales: 105,411
Los parámetros coinciden capa a capa con la tabla del libro: 160 en la primera convolución, 4.640 en la segunda, 100.416 en la capa densa y 195 en la salida, 105.411 en total. Casi todos están en la capa densa. Las convoluciones necesitan muy pocos, porque cada filtro pequeño se comparte por toda la imagen.
Entrenamos con la entropía cruzada categórica, como propone Ana, y el optimizador Adam. Apartamos el 20% de las imágenes de entrenamiento para validación: después de cada época (una pasada completa por las imágenes de entrenamiento) medimos la pérdida con ellas, y la parada temprana detiene el entrenamiento cuando deja de mejorar durante 4 épocas, quedándose con los mejores pesos. Es la misma idea que elegir la profundidad de un árbol con datos de validación: parar antes de memorizar.
cnn.compile(optimizer='adam', loss='categorical_crossentropy', metrics=['accuracy'])
early_stopping = keras.callbacks.EarlyStopping(monitor='val_loss', patience=4, restore_best_weights=True)
history = cnn.fit(X_train, Y_train, validation_split=0.2, epochs=40, batch_size=32, callbacks=[early_stopping], verbose=0)
h = history.history
best = int(np.argmin(h['val_loss']))
print(f"Épocas ejecutadas: {len(h['loss'])}, mejor época: {best + 1}")
print(f"Mejor época: exactitud en entrenamiento {h['accuracy'][best]:.3f}, en validación {h['val_accuracy'][best]:.3f}")
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')]:
ax.plot(np.arange(1, len(h[key]) + 1), h[key], 'o-', label='entrenamiento')
ax.plot(np.arange(1, len(h[key]) + 1), h['val_' + key], 's-', label='validación')
ax.axvline(best + 1, color='gray', linestyle='--')
ax.set_xlabel('Época')
ax.set_title(title)
ax.grid(True)
ax.legend()
plt.show()
Épocas ejecutadas: 12, mejor época: 8 Mejor época: exactitud en entrenamiento 0.919, en validación 0.900
La parada temprana terminó el entrenamiento tras 12 épocas y se quedó con los pesos de la época 8, la de menor pérdida de validación: una exactitud de 0,919 con las imágenes de entrenamiento y de 0,900 con las de validación. Las dos curvas se mantienen cerca, así que la red no ha memorizado las imágenes de entrenamiento.
Paso 6: Evaluación¶
Evaluamos una sola vez con las imágenes de prueba. En dermatología el número crítico es el recall de la clase maligna: cuántas lesiones malignas marca la red.
probabilities = cnn.predict(X_test, verbose=0)
y_pred = probabilities.argmax(axis=1)
print(f"Exactitud en prueba: {accuracy_score(y_test, y_pred):.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('Red convolucional con las imágenes de prueba')
plt.show()
Exactitud en prueba: 0.928
precision recall f1-score support
benigna 0.917 0.996 0.955 457
sospechosa 0.918 0.822 0.868 259
maligna 0.971 0.908 0.938 184
accuracy 0.928 900
macro avg 0.935 0.909 0.920 900
weighted avg 0.929 0.928 0.926 900
La red clasifica bien el 92,8% de las imágenes de prueba. Reconoce casi todas las lesiones benignas (recall de 0,996) y el 90,8% de las malignas, y cuando dice "maligna" acierta el 97,1% de las veces. La clase sospechosa, que queda entre las otras dos, es la más difícil (recall de 0,822). En una consulta real, el 9% aproximado de lesiones malignas que no se detectan es el número que más importa, y es el motivo por el que, como en el libro, el sistema solo ayuda a priorizar y el dermatólogo siempre toma la decisión.
La red no solo da una clase: softmax da una probabilidad para cada una, como el "benigna 0,12, sospechosa 0,21, maligna 0,67" del ejemplo de Ana. Estas son algunas imágenes de prueba con sus tres probabilidades:
examples = [np.where(y_test == c)[0][k] for c in range(3) for k in range(2)]
fig, axes = plt.subplots(1, 6, figsize=(16, 3.6))
for ax, i in zip(axes, examples):
ax.imshow(X_test[i, :, :, 0], cmap='gray', vmin=0, vmax=1)
ax.axis('off')
ax.set_title(f"real: {class_names[y_test[i]]}\n" + "\n".join(f"{n} {p:.2f}" for n, p in zip(class_names, probabilities[i])), fontsize=9)
plt.show()
Paso 7: Qué ve la red¶
Víctor pregunta en el libro cómo sabemos lo que detectan realmente los filtros. Podemos mirarlos directamente: estos son los 16 filtros de $3 \times 3$ que ha aprendido la primera capa convolucional, y los 16 mapas de características que producen para una lesión maligna (con qué fuerza responde cada filtro en cada posición).
filters = cnn.layers[0].get_weights()[0][:, :, 0, :] # 3 x 3 x 16
first_layer = keras.Model(cnn.inputs, cnn.layers[0].output)
image = X_test[np.where(y_test == 2)[0][0]]
maps = first_layer.predict(image[None], verbose=0)[0] # 28 x 28 x 16
fig, axes = plt.subplots(2, 17, figsize=(19, 2.8))
for row in range(2):
axes[row, 0].imshow(image[:, :, 0], cmap='gray', vmin=0, vmax=1)
for k in range(16):
axes[0, k + 1].imshow(filters[:, :, k], cmap='bwr')
axes[1, k + 1].imshow(maps[:, :, k], cmap='viridis')
for ax in axes.ravel():
ax.axis('off')
axes[0, 0].set_title('imagen')
axes[0, 1].set_title('los 16 filtros', loc='left')
axes[1, 1].set_title('sus 16 mapas de características', loc='left')
plt.show()
Nadie le ha dicho a la red qué buscar. Algunos mapas de características se encienden en el borde de la lesión, otros en el pigmento oscuro, en la piel más clara que la rodea o en la textura del interior. Como advierte Ana, es una imagen simplificada: los filtros no corresponden exactamente a conceptos humanos como "borde" o "textura", y las capas más profundas los combinan de formas mucho más difíciles de interpretar.
Paso 8: Nivel 3, una red de scikit-learn¶
En lo alto del edificio de Ana, MLPClassifier de scikit-learn es una red neuronal con la misma interfaz que cualquier otro modelo. Solo admite capas densas, así que cada imagen tiene que aplanarse en 784 números desde el principio, perdiendo su estructura espacial. Le damos una capa oculta de 64 neuronas, como la parte densa de la CNN:
mlp = MLPClassifier(hidden_layer_sizes=(64,), early_stopping=True, max_iter=300, random_state=0)
mlp.fit(X_train.reshape(len(X_train), -1), y_train)
mlp_pred = mlp.predict(X_test.reshape(len(X_test), -1))
mlp_params = sum(w.size for w in mlp.coefs_) + sum(b.size for b in mlp.intercepts_)
for name, pred, n_params in [('CNN (Keras)', y_pred, cnn.count_params()), ('MLP (scikit-learn)', mlp_pred, mlp_params)]:
rec = recall_score(y_test, pred, average=None)
print(f"{name:<20} parámetros {n_params:>7,} | exactitud {accuracy_score(y_test, pred):.3f} | "
f"recall benigna {rec[0]:.3f}, sospechosa {rec[1]:.3f}, maligna {rec[2]:.3f}")
CNN (Keras) parámetros 105,411 | exactitud 0.928 | recall benigna 0.996, sospechosa 0.822, maligna 0.908 MLP (scikit-learn) parámetros 50,435 | exactitud 0.670 | recall benigna 0.917, sospechosa 0.456, maligna 0.359
La red de scikit-learn, con 50.435 parámetros, solo llega a 0,670 y no detecta casi dos de cada tres lesiones malignas (recall de 0,359). La CNN llega a 0,928. La diferencia no está en el número de parámetros, ya que la mayoría de los de la CNN están en su capa densa, sino en la arquitectura. Al conservar la imagen como una cuadrícula y reutilizar los mismos filtros pequeños por todas partes, la CNN detecta bordes y texturas estén donde estén, que es justo el argumento de Ana a favor de las redes convolucionales.
Conclusión¶
- La diferenciación automática (TensorFlow) calcula los mismos gradientes que derivamos a mano, para cualquier red que escribamos: eso es lo que hizo práctico el aprendizaje profundo.
- Keras construye una red apilando capas. La CNN de Ana ocupa una docena de líneas, y sus 105.411 parámetros son exactamente los de la tabla del libro.
- Una red convolucional conserva la estructura espacial de la imagen: filtros pequeños, compartidos por toda la imagen, detectan patrones locales como bordes y texturas, y el pooling los hace robustos a pequeños desplazamientos. Con nuestras imágenes supera claramente a una red densa que ve los píxeles como una lista plana.
- Los datos de validación y la parada temprana controlan el sobreajuste, y la evaluación tiene que mirar cada clase, sobre todo las lesiones malignas que la red no detecta.
- Estas imágenes son sintéticas y muy sencillas. Como insiste el libro, un sistema real necesita datos propios abundantes, representativos y bien etiquetados, una validación cuidadosa y dermatólogos que siempre tomen la decisión final.