English · Español
04 — Gradcheck y tests de propiedades: cómo saber que tu autograd tensorial está bien¶
Una librería de autograd que pasa los unit tests obvios y falla silenciosamente en un caso raro de broadcasting es peor que no tener autograd. Aquí montamos dos defensas: (1)
gradcheck— comparar el gradiente analítico contra una derivada numérica — y (2)hypothesis— generar formas (shapes) aleatorias para fuzzar todos los caminos. Si las dos están verdes, el autograd es de fiar.
Por qué gradcheck¶
Has escrito un closure _backward para softmax. Compila. Corre. La salida grad tiene la forma (shape) correcta. ¿Es correcta?
Tres maneras de estar mal sin darte cuenta: 1. Error de signo en uno de los términos. 2. Olvidaste multiplicar por una entrada del Jacobiano. 3. El broadcasting reverso sumó a lo largo del eje equivocado.
La comprobación más barata que detecta las tres: comparar contra una derivada numérica.
La matemática: para una función f de valor escalar de un tensor x, el gradiente en el elemento x[i] es aproximadamente
donde e_i es la perturbación unitaria en el índice i. Esta es la diferencia finita centrada. Es precisa a O(ε²) para f suave, razón por la que la usamos en lugar de la diferencia hacia delante (f(x+ε) - f(x))/ε (solo O(ε)).
En código, gradcheck es:
def gradcheck(f, x, eps=1e-6, atol=1e-4):
# f: callable Tensor -> scalar Tensor
# x: Tensor with requires_grad=True
# 1. Analytical gradient via autograd.
out = f(x)
out.backward()
grad_analytical = x.grad.copy()
# 2. Numerical gradient by perturbing each element.
grad_numerical = np.zeros_like(x.data)
for i in np.ndindex(x.data.shape):
x.data[i] += eps
f_plus = f(x).data
x.data[i] -= 2 * eps
f_minus = f(x).data
x.data[i] += eps # restore
grad_numerical[i] = (f_plus - f_minus) / (2 * eps)
return np.allclose(grad_analytical, grad_numerical, atol=atol)
Ese es el arnés entero. El coste es 2 · N pasadas de forward para un tensor de N elementos. Aceptable para tensores de hasta ~100 elementos (así que no hagas gradcheck a un matmul (64, 64); usa un ejemplo (4, 5) para el gradcheck y fía la cobertura de forma (shape) a hypothesis).
Eligiendo eps¶
Dos errores que compiten gobiernan el gradcheck:
- Error de truncamiento. La diferencia finita es solo precisa a
O(ε²). Mayorε→ mayor error de truncamiento. - Error de redondeo. Calcular
f(x + ε) - f(x - ε)resta dos números casi iguales. La cancelación catastrófica (fase 2) significa que el error relativo crece como1/ε.
El error total escala como ε² + δ/ε donde δ es el epsilon de máquina. Minimizado cuando ε ≈ δ^(1/3).
Para FP64 (δ ≈ 1e-16), el ε óptimo es ~1e-5. Para FP32 (δ ≈ 1e-7), es ~1e-3 — pero en FP32 el redondeo ya inunda cualquier gradcheck con sentido. Haz gradcheck siempre en FP64. Esto es innegociable.
La gráfica del error de gradcheck frente a ε tiene forma de U: alto a la izquierda (redondeo), alto a la derecha (truncamiento), con un mínimo cerca de 1e-5-1e-6. El lab 02 hace que Borja produzca esta gráfica para una op.
La regla de oro: si
gradcheckfalla coneps=1e-6y FP64, el gradiente está mal, no el chequeo. Es muy raro que la derivada numérica esté mal y la analítica bien.
Receta de gradcheck por op¶
Para cada op:
1. Construye un input aleatorio pequeño (forma (shape) ≤ (3, 4) típicamente).
2. Convierte a FP64 (x = Tensor(np.array(..., dtype=np.float64), requires_grad=True)).
3. Define f(x) = op(x).sum() — gradcheck quiere salida escalar.
4. Llama a gradcheck(f, x).
5. Asegúrate de que el retorno es True con atol=1e-4.
Para ops binarias (add, mul, matmul), haz gradcheck a ambos inputs independientemente: mantén B fijo y comprueba ∂/∂A, luego cambia.
Hypothesis: fuzzing de formas (shape) aleatorias¶
Gradcheck sobre formas (shapes) escogidas a mano detecta la mayoría de bugs. El bug que se le escapa es el bug de broadcasting en una forma (shape) que no testeaste.
hypothesis es una librería de Python para tests de propiedades. Escribes una estrategia (un generador aleatorio) para los inputs y una propiedad (un assert que debería sostenerse para todos los inputs). Hypothesis prueba muchas tiradas aleatorias y reduce (shrink) los ejemplos que fallan a un reproductor mínimo.
Una estrategia de forma (shape) para nuestro autograd:
from hypothesis import strategies as st
shape_strat = st.lists(st.integers(min_value=1, max_value=4), min_size=1, max_size=3).map(tuple)
# Generates shapes like (3,), (2, 4), (1, 3, 2), etc.
def array_strat(shape):
return st.lists(
st.floats(min_value=-2.0, max_value=2.0, allow_nan=False, allow_infinity=False),
min_size=int(np.prod(shape)), max_size=int(np.prod(shape)),
).map(lambda xs: np.array(xs, dtype=np.float64).reshape(shape))
Un test de propiedades para add:
@given(shape=shape_strat)
def test_add_gradient(shape, data=data()):
a = data.draw(array_strat(shape))
b = data.draw(array_strat(shape))
A = Tensor(a, requires_grad=True)
B = Tensor(b, requires_grad=True)
f = lambda: (A + B).sum()
assert gradcheck_pair(f, A, B)
Hypothesis lo ejecuta con ~100 tiradas aleatorias de (shape, a, b) por defecto. Si alguna falla, lo reduce al caso mínimo que falla.
Estrategias específicas de broadcasting¶
La estrategia de hypothesis de mayor valor es pares de formas (shapes) broadcasteables:
@st.composite
def broadcastable_pair(draw):
rank = draw(st.integers(min_value=1, max_value=3))
base = tuple(draw(st.integers(min_value=1, max_value=4)) for _ in range(rank))
# Now create a partner shape that broadcasts to `base`:
# - Each dim is either 1 or matches base.
# - Optionally drop leading dimensions.
partner_full = tuple(draw(st.sampled_from([d, 1])) for d in base)
drop = draw(st.integers(min_value=0, max_value=rank - 1))
partner = partner_full[drop:]
return base, partner
Cada tirada produce un (base_shape, partner_shape) donde partner broadcastea a base. Aplica ops elementwise a tensores de estas formas (shapes); asegúrate de que gradcheck pasa. Si un único eje de broadcasting se suma mal en backward, esto lo detecta en ~10 tiradas.
Lo que hypothesis no puede detectar¶
Tres clases de bug:
- Inestabilidad numérica que no dispara el test de forma (shape) pequeña. P. ej., overflow de softmax para inputs de magnitud ~1000. Hypothesis usa por defecto floats acotados y no generará inputs extremos a menos que se lo pidas.
- Memory leaks / problemas de refcount. Los tests de propiedades no se dan cuenta de que retuviste un
Tensorintermedio y el grafo creció sin parar. - Bugs de concurrencia. No relevante en la fase 8 (single-threaded).
Para (1), añade ejemplos explícitos para los stress tests: @example(x=np.array([1e3, 1e3, 1e3])). Para (2), la fase 18 cubre la memoria del loop de entrenamiento.
La política de los dos oráculos¶
Cada op recibe dos tests:
- Oráculo PyTorch. Construye el mismo grafo en PyTorch a FP64, calcula backward, compara gradientes. Tolerancia
1e-7. - Oráculo gradcheck. Diferenciación numérica como arriba. Tolerancia
1e-4(la diferencia respecto a PyTorch refleja el redondeo FP en la diferencia centrada; no es un bug).
Si ambos pasan, la op es correcta.
Si PyTorch coincide pero gradcheck falla: tienes un bug en el gradcheck, no en la op. Vuelve a derivar tu fórmula numérica.
Si gradcheck pasa pero PyTorch falla: sutil. A veces difiere una convención de signo o transposición. Comprueba la semántica de la op contra la documentación de PyTorch.
Si fallan ambos: la op está mal. Vuelve a derivar desde theory/02 o 03.
Presupuesto de tests¶
Para las 20 ops de la fase 8, la cuenta de tests es:
- Cross-check PyTorch por op: 20 tests, ~5 líneas cada uno. ~100 líneas.
- Gradcheck por op: 20 tests, ~10 líneas cada uno. ~200 líneas.
- Tests de pares broadcasting con hypothesis: 5 tests basados en estrategias cubriendo ops elementwise × formas (shapes) broadcasteables. ~50 líneas.
- Casos límite (manuales): softmax con inputs extremos, CE con objetivos one-hot en clases en el borde, reshape que cambia el rango. ~50 líneas.
Total: ~400 LOC de tests. Aproximadamente el mismo tamaño que tensor.py. Esto es normal y correcto. El ratio test:código de la fase 8 es ~1:1 porque el coste de un bug silencioso del autograd es enorme.
Cuándo dejar de testear¶
Cuando (a) el cross-check de PyTorch está verde para las 20 ops, (b) gradcheck está verde para las 20 ops en FP64, © los tests de hypothesis de broadcasting han corrido ≥ 100 tiradas cada uno sin fallar, y (d) el MLP de experiments/08-tense-classifier/ entrena a > 90% de precisión. En ese punto el autograd es lo bastante fiable como para construir la fase 9 encima.
Lo que esta página NO cubre¶
- Los internals del propio gradcheck de PyTorch (
torch.autograd.gradcheck). Funcionalmente idénticos; el de PyTorch añade una ruta de producto Jacobiano-vector para salidas no escalares. - Cross-check con autodiff en modo forward. Útil para salidas de alta dimensión; no necesario a nuestra escala.
- Gradchecks de orden superior.
gradgradcheck— verificar segundas derivadas. Fuera de alcance (no implementamos backward de segundo orden enminitorch).
Siguiente: lab/00-tensor-skeleton.md.