Anterior: 04 La red. Siguiente: 06 Entrenamiento. Índice: Red neuronal from scratch.
Es la prueba que el notebook original no hace, y la razón principal por la que el proyecto está en módulos y no en un notebook.
La idea
La derivada tiene una definición que no necesita regla de la cadena ni saber nada de la red:
Tomas un solo peso, lo mueves un poquito hacia arriba, calculas la pérdida completa con forward prop, lo mueves hacia abajo, calculas otra vez, divides. Eso es la pendiente. Se repite para cada peso de cada matriz.
Es carísimo: dos forward props por parámetro. Por eso el test usa una red chica (5 ocultas) y 20 ejemplos aleatorios. No importa que sean inventados: las derivadas son correctas o no lo son, sin importar los datos. Y así el test no depende de haber descargado el CSV.
Se usa la diferencia centrada (+ε y −ε) en vez de solo +ε porque su error es proporcional a ε², mucho más preciso.
Cómo comparar
Se divide por las normas para que no dependa de la escala. En float64 con ε = 1e-6, si las fórmulas están bien el error queda cerca de 1e-9. Si hay un bug, sale del orden de 1. No hay zona gris.
El test
tests/test_model.py:
import numpy as np
from redneuro.losses import binary_cross_entropy
from redneuro.model import backward_prop, forward_prop, init_params
def loss_for(params, X, Y):
W1, b1, W2, b2 = params
_, _, _, A2 = forward_prop(W1, b1, W2, b2, X)
return binary_cross_entropy(A2, Y)
def numerical_gradient(params, idx, X, Y, eps=1e-6):
p = params[idx]
grad = np.zeros_like(p)
for i in np.ndindex(p.shape):
original = p[i]
p[i] = original + eps
loss_plus = loss_for(params, X, Y)
p[i] = original - eps
loss_minus = loss_for(params, X, Y)
p[i] = original
grad[i] = (loss_plus - loss_minus) / (2 * eps)
return grad
def test_gradient_check():
rng = np.random.default_rng(42)
n_x, n_h, m = 8, 5, 20
X = rng.standard_normal((n_x, m))
Y = (rng.random((1, m)) < 0.3).astype(float)
params = list(init_params(n_x=n_x, n_h=n_h, seed=1))
W1, b1, W2, b2 = params
Z1, A1, Z2, A2 = forward_prop(W1, b1, W2, b2, X)
analytic = backward_prop(Z1, A1, Z2, A2, W2, X, Y)
for idx, name in enumerate(["W1", "b1", "W2", "b2"]):
numeric = numerical_gradient(params, idx, X, Y)
rel_error = np.linalg.norm(numeric - analytic[idx]) / (
np.linalg.norm(numeric) + np.linalg.norm(analytic[idx])
)
print(f"{name}: error relativo {rel_error:.2e}")
assert rel_error < 1e-7, f"{name}: error relativo {rel_error:.2e}"np.ndindex(p.shape) recorre todos los índices de una matriz, sirve igual para (5, 8) que para (1, 1). p[i] = original restaura el peso al final de cada vuelta; como params[idx] es el mismo objeto que W1, modificarlo modifica lo que usa loss_for, y eso es intencional.
Resultado
pytest tests/ -v -s
W1: error relativo 6.18e-10
b1: error relativo 7.49e-10
W2: error relativo 3.50e-10
b2: error relativo 1.48e-10
PASSED
Cuatro errores del orden de 1e-10. El backward prop está demostrado correcto, no “parece que funciona”.
Para ver qué atrapa
Cambiar
dZ2 = A2 - YpordZ2 = Y - A2enmodel.py, correr pytest, ver el error saltar a1.00e+00, y deshacer. Gradiente exactamente al revés, shapes perfectos, y el test lo pilla.
Por qué esto importa más de lo que parece
En 06 Entrenamiento la pérdida baja bien a la primera. Sin el gradient check, que baje no dice mucho: una red con un gradiente medio mal (por ejemplo db1 con el eje equivocado) muchas veces también baja, solo que peor y sin que sepas por qué. Con el test pasando, cualquier problema que aparezca después ya no es de las derivadas, y eso descarta la mitad de los sospechosos.