6 - El perceptrón y las redes neuronales multicapa, desde cero¶
En el capítulo 6, el equipo de Minermont conoce las redes neuronales en AIA. Alma empieza por la más sencilla, el perceptrón de Rosenblatt, y por su famosa limitación, y después muestra cómo apilar neuronas en capas y entrenarlas con retropropagación (backpropagation) permite superarla. En este cuaderno lo construimos todo con NumPy, sin ninguna biblioteca de aprendizaje profundo, como en el "nivel 0" de Ana (sección 6.8):
- Importar las bibliotecas.
- El perceptrón y su regla de aprendizaje.
- El problema XOR.
- Una red multicapa que resuelve XOR, entrenada con retropropagación.
- Las funciones de activación.
- El ejemplo completo de Marta: un paso hacia adelante, un paso hacia atrás y una actualización (sección 6.7).
- Comprobar la retropropagación con derivadas numéricas.
Paso 1: Importar las bibliotecas¶
import numpy as np
import matplotlib.pyplot as plt
np.set_printoptions(precision=3, suppress=True)
Paso 2: El perceptrón¶
Un perceptrón calcula una suma ponderada de sus entradas más un sesgo y la pasa por una función escalón:
$$z = w \cdot x + b \qquad\qquad \hat{y} = \begin{cases} 1 & \text{si } z \ge 0 \\ 0 & \text{si } z < 0 \end{cases}$$
Como observa Marta en el libro, $z$ es la misma fórmula que la regresión lineal, y el escalón es una versión brusca de la sigmoide de la regresión logística. La regla de aprendizaje de Rosenblatt recorre los ejemplos uno a uno y, solo cuando la predicción es incorrecta, mueve los pesos hacia la respuesta correcta:
$$w \leftarrow w + \eta\,(y - \hat{y})\,x \qquad\qquad b \leftarrow b + \eta\,(y - \hat{y})$$
La probamos con las funciones lógicas AND y OR, cuyas tablas de verdad son nuestros cuatro ejemplos de entrenamiento:
X = np.array([[0, 0], [0, 1], [1, 0], [1, 1]], dtype=float)
targets = {'AND': np.array([0, 0, 0, 1]), 'OR': np.array([0, 1, 1, 1]), 'XOR': np.array([0, 1, 1, 0])}
def train_perceptron(X, y, learning_rate=0.1, epochs=20):
w, b, errors = np.zeros(X.shape[1]), 0.0, []
for _ in range(epochs):
mistakes = 0
for xi, yi in zip(X, y):
y_hat = int(w @ xi + b >= 0)
update = learning_rate * (yi - y_hat) # cero cuando la predicción acierta
w, b = w + update * xi, b + update
mistakes += int(update != 0)
errors.append(mistakes)
return w, b, errors
def predict_perceptron(X, w, b):
return (X @ w + b >= 0).astype(int)
perceptrons = {}
for gate in ['AND', 'OR']:
w, b, errors = train_perceptron(X, targets[gate])
perceptrons[gate] = (w, b)
print(f"{gate}: w = {w}, b = {b:.1f}, predicciones {predict_perceptron(X, w, b)}, errores por época {errors[:6]}")
AND: w = [0.2 0.1], b = -0.2, predicciones [0 0 0 1], errores por época [2, 3, 3, 0, 0, 0] OR: w = [0.1 0.1], b = -0.1, predicciones [0 1 1 1], errores por época [2, 2, 1, 0, 0, 0]
Las dos funciones se aprenden en pocas pasadas: a partir de la cuarta época ya no hay errores, y los pesos definen una recta que separa los unos de los ceros.
Paso 3: El problema XOR¶
XOR es la bombilla con dos interruptores de Alma: se enciende cuando exactamente un interruptor está encendido y se apaga cuando los dos están en la misma posición. Entrenemos el mismo perceptrón con ella:
w_xor, b_xor, errors_xor = train_perceptron(X, targets['XOR'], epochs=20)
print(f"XOR: predicciones {predict_perceptron(X, w_xor, b_xor)} (objetivo {targets['XOR']})")
print(f"Errores por época: {errors_xor}")
fig, axes = plt.subplots(1, 3, figsize=(15, 4.5))
for ax, gate in zip(axes, ['AND', 'OR', 'XOR']):
y = targets[gate]
ax.scatter(*X[y == 0].T, marker='^', s=150, color='tab:red', label='0 (apagada)')
ax.scatter(*X[y == 1].T, marker='o', s=150, color='tab:blue', label='1 (encendida)')
if gate in perceptrons:
w, b = perceptrons[gate]
xs = np.linspace(-0.5, 1.5, 2)
ax.plot(xs, -(w[0] * xs + b) / w[1], 'k--', label='frontera del perceptrón')
ax.set_title(f'{gate}: basta con una recta')
else:
ax.set_title('XOR: ninguna recta separa las clases')
ax.set_xlim(-0.5, 1.5)
ax.set_ylim(-0.5, 1.5)
ax.set_xlabel('$x_1$ (interruptor 1)')
ax.set_ylabel('$x_2$ (interruptor 2)')
ax.legend(loc='upper right', fontsize=8)
plt.show()
XOR: predicciones [1 1 0 0] (objetivo [0 1 1 0]) Errores por época: [3, 3, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4]
El perceptrón nunca deja de equivocarse: a partir de la tercera época corrige sus pesos con los cuatro ejemplos en cada época, y los pesos dan vueltas sin asentarse. El gráfico muestra por qué: los dos puntos "encendida" están en esquinas opuestas, y también los dos "apagada", así que ninguna recta puede dejar cada pareja en su propio lado. Es la limitación que señalaron Minsky y Papert en 1969.
Paso 4: Una red multicapa para XOR¶
La solución es añadir una capa oculta con una activación no lineal. Nuestra red tiene 2 entradas, 2 neuronas ocultas con la tangente hiperbólica y 1 neurona de salida con la sigmoide, que da la probabilidad de que la bombilla esté encendida:
$$a^{(1)} = \tanh\!\left(W^{(1)} x + b^{(1)}\right) \qquad\qquad \hat{y} = \sigma\!\left(W^{(2)} a^{(1)} + b^{(2)}\right)$$
El coste es la pérdida logarítmica (entropía cruzada binaria) del capítulo 3. Para entrenarla necesitamos el gradiente del coste respecto a cada peso, y eso es lo que calcula la retropropagación: la regla de la cadena, aplicada desde la salida hacia la entrada. Para esta red:
- Error de la salida: $\delta^{(2)} = \hat{y} - y$ (la misma expresión sencilla que en la regresión logística).
- Error de la capa oculta: $\delta^{(1)} = \left((W^{(2)})^\top \delta^{(2)}\right) \odot \left(1 - (a^{(1)})^2\right)$, porque la derivada de $\tanh$ es $1 - \tanh^2$.
- Gradientes: $\frac{\partial J}{\partial W^{(l)}} = \delta^{(l)}\,(a^{(l-1)})^\top$ y $\frac{\partial J}{\partial b^{(l)}} = \delta^{(l)}$, promediados sobre los ejemplos.
La entrenamos con descenso del gradiente desde varios pesos iniciales aleatorios:
def sigmoid(z):
return 1 / (1 + np.exp(-z))
def forward_xor(X, params):
W1, b1, W2, b2 = params
A1 = np.tanh(X @ W1.T + b1) # capa oculta (4 ejemplos x 2 neuronas)
P = sigmoid(A1 @ W2.T + b2).ravel() # probabilidad de salida
return A1, P
def train_xor_network(seed, hidden=2, learning_rate=0.5, epochs=5000):
rng = np.random.default_rng(seed)
params = [rng.normal(0, 1, (hidden, 2)), np.zeros(hidden), rng.normal(0, 1, (1, hidden)), np.zeros(1)]
y = targets['XOR'].astype(float)
losses = []
for _ in range(epochs):
W1, b1, W2, b2 = params
A1, P = forward_xor(X, params)
losses.append(-np.mean(y * np.log(P) + (1 - y) * np.log(1 - P)))
d2 = (P - y)[:, None] / len(y) # error de la salida
d1 = (d2 @ W2) * (1 - A1 ** 2) # error de la capa oculta, hacia atrás por W2 y tanh
grads = [d1.T @ X, d1.sum(axis=0), d2.T @ A1, d2.sum(axis=0)]
params = [p - learning_rate * g for p, g in zip(params, grads)]
return params, losses
runs = {}
for seed in range(6):
params, losses = train_xor_network(seed)
_, P = forward_xor(X, params)
runs[seed] = (params, losses)
solved = np.array_equal((P >= 0.5).astype(int), targets['XOR'])
print(f"Comienzo {seed}: coste final {losses[-1]:.4f}, probabilidades {P.round(3)} -> {'resuelto' if solved else 'atascado'}")
Comienzo 0: coste final 0.3471, probabilidades [0. 0.999 0.5 0.5 ] -> atascado Comienzo 1: coste final 0.3471, probabilidades [0.001 0.5 0.999 0.5 ] -> atascado Comienzo 2: coste final 0.0013, probabilidades [0.001 0.998 0.998 0.001] -> resuelto
Comienzo 3: coste final 0.0013, probabilidades [0.001 0.998 0.998 0.001] -> resuelto Comienzo 4: coste final 0.0013, probabilidades [0.002 0.999 0.999 0.002] -> resuelto Comienzo 5: coste final 0.0013, probabilidades [0.001 0.998 0.998 0.001] -> resuelto
Cuatro de los seis comienzos resuelven XOR, con probabilidades muy cercanas a 0 y 1. Los otros dos se atascan con un coste de 0,347, respondiendo 0,5 para dos de los ejemplos: como K-Means en el capítulo 5, el descenso del gradiente puede terminar en un mínimo local. Es uno de los motivos por los que entrenar redes neuronales exige cuidado.
Miremos dentro de una de las redes que lo resolvieron. En el centro, la frontera de decisión en el espacio original ya no es una sola recta: la red clasifica como "apagada" una franja diagonal que contiene el (0, 0) y el (1, 1), y como "encendida" las dos regiones a ambos lados. A la derecha, los mismos cuatro ejemplos, pero descritos por las dos neuronas ocultas: la capa oculta los ha llevado a un espacio nuevo donde una sola recta sí los separa.
params, losses = runs[2]
W1, b1, W2, b2 = params
A1, P = forward_xor(X, params)
y = targets['XOR']
g1, g2 = np.meshgrid(np.linspace(-0.5, 1.5, 200), np.linspace(-0.5, 1.5, 200))
_, grid_p = forward_xor(np.c_[g1.ravel(), g2.ravel()], params)
fig, axes = plt.subplots(1, 3, figsize=(17, 4.5))
axes[0].plot(losses)
axes[0].set_xlabel('Época')
axes[0].set_ylabel('Coste (pérdida logarítmica)')
axes[0].set_title('Entrenamiento de la red XOR')
axes[1].contourf(g1, g2, grid_p.reshape(g1.shape) >= 0.5, alpha=0.2, cmap='coolwarm_r')
axes[1].set_title('Espacio original: una frontera no lineal')
axes[2].set_title('Capa oculta: ahora una recta las separa')
for ax, pts in [(axes[1], X), (axes[2], A1)]:
ax.scatter(*pts[y == 0].T, marker='^', s=150, color='tab:red', label='0 (apagada)')
ax.scatter(*pts[y == 1].T, marker='o', s=150, color='tab:blue', label='1 (encendida)')
ax.legend(fontsize=8)
axes[1].set_xlabel('$x_1$')
axes[1].set_ylabel('$x_2$')
axes[2].set_xlabel('neurona oculta 1')
axes[2].set_ylabel('neurona oculta 2')
xs = np.linspace(-1.1, 1.1, 2)
axes[2].plot(xs, -(W2[0, 0] * xs + b2[0]) / W2[0, 1], 'k--') # donde la neurona de salida da 0,5
axes[2].set_xlim(-1.1, 1.1)
axes[2].set_ylim(-1.1, 1.1)
plt.show()
print("Valores de la capa oculta para los cuatro ejemplos:")
for xi, ai in zip(X, A1):
print(f" {xi.astype(int)} -> {ai.round(3)}")
Valores de la capa oculta para los cuatro ejemplos: [0 0] -> [-0.947 -0.958] [0 1] -> [ 0.96 -1. ] [1 0] -> [-1. 0.97] [1 1] -> [-0.958 -0.969]
En el espacio oculto, los dos ejemplos "apagada", (0, 0) y (1, 1), quedan juntos cerca de (−0,95, −0,96), mientras que los dos "encendida" se van a esquinas opuestas: (0, 1) pasa a (0,96, −1) y (1, 0) a (−1, 0,97). Ahora una recta los separa. Cada neurona oculta ha aprendido por sí sola una característica, una "solo está encendido el interruptor 2" y la otra "solo está encendido el interruptor 1", y la neurona de salida solo tiene que combinarlas. Eso es lo que hacen las capas ocultas: construir características intermedias que hacen sencilla la decisión final.
Paso 5: Las funciones de activación¶
La no linealidad es imprescindible: sin ella, varias capas se reducirían a una sola transformación lineal. El libro presenta las funciones de activación más habituales. Sus derivadas importan tanto como las propias funciones, porque la retropropagación las multiplica capa tras capa:
z = np.linspace(-5, 5, 400)
activations = {
'escalón': (np.where(z >= 0, 1.0, 0.0), np.zeros_like(z)),
'sigmoide': (sigmoid(z), sigmoid(z) * (1 - sigmoid(z))),
'tanh': (np.tanh(z), 1 - np.tanh(z) ** 2),
'ReLU': (np.maximum(0, z), (z > 0).astype(float)),
}
fig, axes = plt.subplots(1, 2, figsize=(13, 4))
for name, (f, df) in activations.items():
axes[0].plot(z, f, label=name)
axes[1].plot(z, df, label=name)
axes[0].set_title('Funciones de activación')
axes[1].set_title('Sus derivadas')
for ax in axes:
ax.set_xlabel('z')
ax.grid(True)
ax.legend()
plt.show()
print(f"Pendiente máxima de la sigmoide: {np.max(activations['sigmoide'][1]):.3f}")
print(f"Diez capas sigmoides pueden encoger un gradiente hasta 0.25 ** 10 = {0.25 ** 10:.1e}")
Pendiente máxima de la sigmoide: 0.250 Diez capas sigmoides pueden encoger un gradiente hasta 0.25 ** 10 = 9.5e-07
El escalón tiene derivada cero en todas partes (y ninguna en el 0), así que el descenso del gradiente no puede aprender con él: por eso las neuronas modernas usan funciones suaves. La sigmoide tiene una pendiente de 0,25 como máximo, así que un gradiente que atraviesa muchas capas sigmoides puede volverse diminuto, el problema del desvanecimiento del gradiente que describe el libro. ReLU tiene pendiente 1 para toda entrada positiva, y esa es una de las razones por las que hizo entrenables las redes profundas.
Paso 6: El ejemplo completo de Marta¶
En sus notas, Marta desarrolla con todos los números la pequeña red de diagnóstico que el equipo vio en AIA: dos síntomas ($x_1 = 0{,}5$, $x_2 = 0{,}7$, el segundo es la tos), una capa oculta de 3 neuronas ReLU y una capa de salida de 3 neuronas con softmax, una para cada diagnóstico: resfriado, gripe y neumonía. El paciente tiene gripe. Softmax convierte las puntuaciones de salida (logits) en probabilidades, y el coste es la entropía cruzada, que para una etiqueta one-hot es menos el logaritmo de la probabilidad de la clase correcta:
$$\hat{y}_i = \frac{e^{z_i}}{\sum_k e^{z_k}} \qquad\qquad \mathcal{L} = -\sum_i y_i \log \hat{y}_i$$
x = np.array([0.5, 0.7])
W1 = np.array([[0.8, 0.5], [0.3, 0.9], [1.2, 0.4]]); b1 = np.array([0.1, -0.2, 0.0])
W2 = np.array([[1.1, 0.3, 0.5], [0.2, 1.0, 0.7], [0.6, 0.4, 1.3]]); b2 = np.array([0.0, 0.1, -0.1])
y_true = np.array([0, 1, 0]) # one-hot: gripe
classes = ['resfriado', 'gripe', 'neumonía']
def softmax(z):
e = np.exp(z - z.max())
return e / e.sum()
def forward(x, W1, b1, W2, b2):
z1 = W1 @ x + b1
a1 = np.maximum(0, z1) # ReLU
z2 = W2 @ a1 + b2
return z1, a1, z2, softmax(z2)
z1, a1, z2, y_hat = forward(x, W1, b1, W2, b2)
loss = -np.log(y_hat[1])
print("PASO HACIA ADELANTE")
print(f" capa oculta: z1 = {z1}, a1 = {a1}")
print(f" logits de salida: z2 = {z2}")
print(f" probabilidades: {dict((c, round(float(v), 4)) for c, v in zip(classes, y_hat))}")
print(f" entropía cruzada: {loss:.4f}")
PASO HACIA ADELANTE
capa oculta: z1 = [0.85 0.58 0.88], a1 = [0.85 0.58 0.88]
logits de salida: z2 = [1.549 1.466 1.786]
probabilidades: {'resfriado': 0.3137, 'gripe': 0.2887, 'neumonía': 0.3976}
entropía cruzada: 1.2423
Las probabilidades son 0,314 para el resfriado, 0,289 para la gripe y 0,398 para la neumonía: la red todavía no reconoce la gripe, y la pérdida es 1,242. En el libro el mismo ejemplo muestra 0,288 y una pérdida de 1,244, porque los valores intermedios se redondean a tres decimales; la probabilidad exacta de la gripe es 0,2887.
Ahora el paso hacia atrás. Con softmax y entropía cruzada, la señal de error de la capa de salida es simplemente $\delta^{(2)} = \hat{y} - y$. Cada matriz de pesos recibe el producto exterior del error de su capa de destino y las activaciones de su capa de origen, y el error viaja hacia la capa oculta a través de $(W^{(2)})^\top$ y la derivada de ReLU:
$$\frac{\partial \mathcal{L}}{\partial W^{(2)}} = \delta^{(2)} (a^{(1)})^\top \qquad \delta^{(1)} = \left((W^{(2)})^\top \delta^{(2)}\right) \odot \text{ReLU}'(z^{(1)}) \qquad \frac{\partial \mathcal{L}}{\partial W^{(1)}} = \delta^{(1)} x^\top$$
def backward(x, y_true, z1, a1, y_hat, W2):
d2 = y_hat - y_true # error de la salida
d1 = (W2.T @ d2) * (z1 > 0) # error oculto (derivada de ReLU: 1 si z > 0)
return {'W2': np.outer(d2, a1), 'b2': d2, 'W1': np.outer(d1, x), 'b1': d1}
grads = backward(x, y_true, z1, a1, y_hat, W2)
print("PASO HACIA ATRÁS")
for name, g in grads.items():
print(f" dL/d{name} =\n{g.round(3)}")
print(f"\n El peso de h1 a 'gripe', elemento (2, 1) de dL/dW2: {grads['W2'][1, 0]:.3f}")
print(f" El peso de la tos (x2) a h2, elemento (2, 2) de dL/dW1: {grads['W1'][1, 1]:.3f}")
PASO HACIA ATRÁS 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] dL/dW1 = [[ 0.221 0.309] [-0.229 -0.321] [ 0.088 0.123]] dL/db1 = [ 0.441 -0.458 0.176] El peso de h1 a 'gripe', elemento (2, 1) de dL/dW2: -0.605 El peso de la tos (x2) a h2, elemento (2, 2) de dL/dW1: -0.321
Estos son los gradientes de las notas de Marta. El error de la gripe es negativo (−0,711): la red infravalora la gripe, así que los pesos que la alimentan tienen que crecer. Por ejemplo, el peso de $h_1$ a la gripe tiene un gradiente de −0,605, y el de la tos a $h_2$ uno de −0,321, los dos elementos que Marta comprueba a mano. El libro muestra −0,712, y algunos terceros decimales difieren en una unidad, por el mismo motivo de redondeo. Las tres preactivaciones ocultas son positivas, así que ReLU deja pasar todo el error.
Por último, una actualización con tasa de aprendizaje $\eta = 0{,}1$, $\theta \leftarrow \theta - \eta \frac{\partial \mathcal{L}}{\partial \theta}$, y un nuevo paso hacia adelante para comprobar que la red se ha movido en la dirección correcta:
eta = 0.1
W1_new, b1_new = W1 - eta * grads['W1'], b1 - eta * grads['b1']
W2_new, b2_new = W2 - eta * grads['W2'], b2 - eta * grads['b2']
with np.printoptions(precision=4):
print(f"W2 tras la actualización:\n{W2_new}\nb2 tras la actualización: {b2_new}")
print(f"W1 tras la actualización:\n{W1_new}\nb1 tras la actualización: {b1_new}\n")
_, _, _, y_hat_new = forward(x, W1_new, b1_new, W2_new, b2_new)
print(f"Probabilidades antes: {dict((c, round(float(v), 3)) for c, v in zip(classes, y_hat))}")
print(f"Probabilidades después: {dict((c, round(float(v), 3)) for c, v in zip(classes, y_hat_new))}")
print(f"Pérdida: {loss:.3f} -> {-np.log(y_hat_new[1]):.3f}")
W2 tras la actualización:
[[1.0733 0.2818 0.4724]
[0.2605 1.0413 0.7626]
[0.5662 0.3769 1.265 ]]
b2 tras la actualización: [-0.0314 0.1711 -0.1398]
W1 tras la actualización:
[[0.7779 0.4691]
[0.3229 0.9321]
[1.1912 0.3877]]
b1 tras la actualización: [ 0.0559 -0.1542 -0.0176]
Probabilidades antes: {'resfriado': 0.314, 'gripe': 0.289, 'neumonía': 0.398}
Probabilidades después: {'resfriado': 0.274, 'gripe': 0.378, 'neumonía': 0.347}
Pérdida: 1.242 -> 0.972
Tras una sola actualización, la probabilidad de la gripe sube de 0,289 a 0,378 y la pérdida baja de 1,242 a 0,972, justo en la dirección que queríamos (el libro obtiene 0,379, trabajando con valores redondeados). Entrenar una red no es más que repetir este ciclo, paso hacia adelante, paso hacia atrás y actualización, miles de veces con muchos ejemplos.
Paso 7: Comprobar la retropropagación¶
¿Cómo sabemos que nuestras derivadas son correctas? Una prueba clásica es compararlas con derivadas numéricas: empujar cada peso una cantidad diminuta $\epsilon$ en los dos sentidos y medir cuánto cambia la pérdida,
$$\frac{\partial \mathcal{L}}{\partial \theta} \approx \frac{\mathcal{L}(\theta + \epsilon) - \mathcal{L}(\theta - \epsilon)}{2\epsilon}$$
Es demasiado lento para entrenar (dos pasos hacia adelante por cada peso), pero es una comprobación perfecta:
def loss_of(params):
return -np.log(forward(x, *params)[3][1])
params = [W1, b1, W2, b2]
epsilon = 1e-6
largest_difference = 0.0
for k, name in enumerate(['W1', 'b1', 'W2', 'b2']):
numeric = np.zeros_like(params[k])
for idx in np.ndindex(params[k].shape):
plus = [p.copy() for p in params]
minus = [p.copy() for p in params]
plus[k][idx] += epsilon
minus[k][idx] -= epsilon
numeric[idx] = (loss_of(plus) - loss_of(minus)) / (2 * epsilon)
largest_difference = max(largest_difference, np.max(np.abs(numeric - grads[name])))
print(f"Mayor diferencia entre la retropropagación y las derivadas numéricas: {largest_difference:.1e}")
Mayor diferencia entre la retropropagación y las derivadas numéricas: 1.5e-10
La diferencia es del orden de $10^{-10}$, solo error de redondeo: nuestra retropropagación calcula las derivadas correctas. Las bibliotecas de aprendizaje profundo hacen toda esta contabilidad automáticamente, como veremos en el siguiente cuaderno.
Conclusión¶
- Un perceptrón es una suma ponderada seguida de un escalón. Su regla de aprendizaje encuentra una recta de separación siempre que exista, como en AND y OR, pero ninguna recta puede resolver XOR.
- Una capa oculta con una activación no lineal transforma las entradas en un espacio donde las clases se vuelven separables. Es la idea clave de las redes multicapa.
- La retropropagación es la regla de la cadena aplicada desde la salida hacia la entrada: una señal de error $\delta$ por capa, y gradientes que son productos exteriores de errores y activaciones. Hemos reproducido el ejemplo completo de Marta y lo hemos comprobado con derivadas numéricas.
- El entrenamiento puede atascarse, y la elección de la activación importa: la sigmoide puede hacer que los gradientes se desvanezcan, algo que ReLU evita.
- Escribir todo esto a mano es el "nivel 0" de Ana: ideal para entender y demasiado laborioso para redes reales. El siguiente cuaderno sube a los niveles superiores con TensorFlow y Keras, y construye la red convolucional para las imágenes de dermatología.