Skip to content

English · Español

Lab 01 — Jacobiana de una MLP minúscula, analítica vs numérica

🇪🇸 Una MLP de dos capas. Calculas ∂y/∂W1 de dos maneras: con regla de la cadena (matmul de Jacobianas) y con diferencias finitas. Las dos deben coincidir dentro de 1e-4. Si no, lo arreglas — porque cuando Fase 7 implemente autograd, esta misma derivación tiene que producir el mismo número.

Objetivo

Para una MLP de 2 capas y = W₂ · relu(W₁ x + b₁) + b₂, calcula la Jacobiana ∂y/∂W₁ analíticamente (usando la regla de la cadena multivariable de teoría 02) y numéricamente (diferencias finitas). Verifica que coinciden dentro de 1e-4. Esta es la prueba de concepto para el autograd escalar de la Fase 7.

Planteamiento

  • Formas: x ∈ R³, W₁ ∈ R^{4×3}, b₁ ∈ R⁴, W₂ ∈ R^{2×4}, b₂ ∈ R². Así y ∈ R².
  • Parámetros totales en W₁: 4 × 3 = 12. Calcularemos la Jacobiana 2 × 12 ∂y/∂vec(W₁).
  • Usa init aleatoria determinista: np.random.default_rng(seed=42).

Tareas

Parte A — La MLP y el pase hacia adelante

import numpy as np

rng = np.random.default_rng(42)
x = rng.standard_normal(3)
W1 = rng.standard_normal((4, 3))
b1 = rng.standard_normal(4)
W2 = rng.standard_normal((2, 4))
b2 = rng.standard_normal(2)

def mlp(x, W1, b1, W2, b2):
    h_pre = W1 @ x + b1     # (4,)
    h = np.maximum(0, h_pre) # ReLU, (4,)
    y = W2 @ h + b2          # (2,)
    return y, h_pre, h        # devuelve intermedios para la Jacobiana analítica

Parte B — Jacobiana analítica

Aplica la regla de la cadena paso a paso.

  1. ∂y/∂h = W₂ (forma (2, 4)).
  2. ∂h/∂h_pre = diag(h_pre > 0) — ReLU es 1 donde h_pre > 0 y 0 en otro caso. (Forma (4, 4).)
  3. ∂h_pre/∂W₁: aquí hay que ser cuidadoso con las formas. h_pre = W₁ x. El elemento i de h_pre es Σ_j W₁_{ij} x_j. Así que ∂h_pre_i / ∂W₁_{ab} = δ_{ia} x_b. Aplana W₁ a vec(W₁) ∈ R^{12} con orden row-major: índice (a, b)3a + b. Entonces ∂h_pre/∂vec(W₁) es una matriz (4, 12) donde la fila i tiene x_b en la columna 3i + b y cero en otro caso.
def dh_pre_dW1(x):
    J = np.zeros((4, 12))
    for i in range(4):
        for b in range(3):
            J[i, 3*i + b] = x[b]
    return J
  1. Encadénalo todo:
def analytical_jacobian(x, W1, b1, W2, b2):
    y, h_pre, h = mlp(x, W1, b1, W2, b2)
    dy_dh = W2                          # (2, 4)
    dh_dh_pre = np.diag((h_pre > 0).astype(float))  # (4, 4)
    dh_pre_dW1_mat = dh_pre_dW1(x)      # (4, 12)
    return dy_dh @ dh_dh_pre @ dh_pre_dW1_mat  # (2, 12)

Parte C — Jacobiana numérica (diferencias finitas)

def numerical_jacobian(x, W1, b1, W2, b2, h=1e-5):
    y_base, _, _ = mlp(x, W1, b1, W2, b2)
    J = np.zeros((2, 12))
    for k in range(12):
        a, b = divmod(k, 3)
        W1_plus = W1.copy(); W1_plus[a, b] += h
        W1_minus = W1.copy(); W1_minus[a, b] -= h
        y_plus, _, _ = mlp(x, W1_plus, b1, W2, b2)
        y_minus, _, _ = mlp(x, W1_minus, b1, W2, b2)
        J[:, k] = (y_plus - y_minus) / (2 * h)
    return J

Parte D — Compara

J_an = analytical_jacobian(x, W1, b1, W2, b2)
J_num = numerical_jacobian(x, W1, b1, W2, b2)
max_err = np.max(np.abs(J_an - J_num))
print(f"Max element-wise error: {max_err:.2e}")
assert max_err < 1e-4, f"Jacobians disagree: {max_err}"

Si max_err < 1e-7, genial — ambos métodos coinciden a precisión fp64 (módulo truncamiento por diferencias finitas).

Parte E — Caso límite: ReLU en cero

La derivada de ReLU es 1 para entrada positiva, 0 para negativa. En cero exacto, no está definida — la convención es 0 (ya que np.maximum(0, x) da 0). Prueba la comparación con una entrada deliberadamente en el cruce por cero de ReLU:

# Forzar h_pre[0] a ser exactamente 0 vía un W1, b1 cuidadosamente construidos
b1_zeroed = b1.copy()
b1_zeroed[0] = -W1[0] @ x  # hace h_pre[0] = 0 exactamente
# Vuelve a correr la comparación

Documenta el resultado: en h_pre = 0, el método analítico dice ∂h/∂h_pre = 0; las diferencias finitas dan 0 por la derecha y 1 por la izquierda, promediando a ~0.5. FD centrada dará ~0.5 mientras que la analítica da 0. El desacuerdo en un conjunto de medida cero es esperado y OK.

Entregable

learners/borja/phase-04/lab-01-jacobian.md: - Listado de código. - Salida de max_err. - Reflexión de 3 frases sobre el caso límite de ReLU en cero.

Aceptación

  • Las Jacobianas analítica y numérica coinciden dentro de 1e-4 para la entrada con semilla aleatoria.
  • Coinciden dentro de 1e-7 para entradas típicas (donde ReLU no está en cero).
  • El caso de ReLU en cero está documentado pero no "arreglado" — es una no-diferenciabilidad genuina.

Escollos

  • Aplanado row-major vs column-major. .flatten() de NumPy por defecto es row-major (orden C). Sé explícito. Si tu dh_pre/dW1 usa una convención y tu vec(W1) la otra, obtendrás basura.
  • Olvidar copiar W1 antes de perturbar. W1 += h_vec modifica el original; las perturbaciones posteriores se acumulan. Siempre W1_plus = W1.copy(); W1_plus[a, b] += h.
  • Usar diferencias hacia adelante. Error O(h); para h = 1e-5, da ~1e-5 de error — falla el umbral 1e-7 para entradas "típicas" incluso cuando las matemáticas son correctas.
  • Confusión con la derivada de ReLU. En h_pre < 0, la derivada es 0 y la entrada no influencia la salida. Algunas personas escriben ∂relu/∂x = (x > 0).astype(float) (correcto); algunas escriben (x >= 0) (off-by-one en cero). Mantén el > estricto.
  • h minúsculo causando cancelación. h = 1e-12 da basura por cancelación de punto flotante en f(x+h) - f(x-h). Usa h = 1e-5.

Ampliación

  • Extiende para calcular ∂y/∂b₁ y ∂y/∂W₂ analítica y numéricamente. También deberían coincidir dentro de 1e-4.
  • Implementa el gradiente completo ∂L/∂W₁ donde L = ‖y − target‖²/2 para algún target aleatorio. Esto es lo que el autograd de la Fase 7 producirá automáticamente.

Siguiente: 02-optimizers-on-rosenbrock.md