Curso de ML EN

Capítulo 35 de 37 · avanzado

Perceptrones multicapa y backpropagation

Qué cubre este capítulo

El capítulo pasado terminó contra una pared. Un solo perceptrón traza una línea recta, XOR necesita dos, y no hay entrenamiento que mueva una línea a un lugar al que no puede llegar. Minsky y Papert lo demostraron, el campo les creyó, y las redes neuronales se apagaron por una década. Este capítulo es el escape. Apila una segunda capa de unidades entre la entrada y la salida, dóblalas con una activación no lineal, y la frontera deja de ser una línea: puede curvarse, plegarse, envolver una clase. El problema exacto que detuvo al perceptrón se desmorona en unas cuatro líneas de NumPy.

Construimos desde cero una red con una capa oculta: una capa lineal, una no linealidad tanh, una segunda capa lineal y una sigmoide que entrega una probabilidad. Luego la entrenamos con backpropagation, que suena a nombre propio y en realidad es solo la regla de la cadena corriendo hacia atrás a través de las dos capas: obtén el error en la salida, empújalo de regreso a través de los pesos de salida, multiplica por la pendiente de la activación para obtener el error en la capa oculta, y lee el gradiente de cada peso en el camino. El descenso por gradiente hace el resto. Verificamos todo contra el MLPClassifier de scikit-learn con los mismos datos.

La pieza central es una animación de la frontera aprendiendo a doblarse. La ves empezar como un corte casi recto mal colocado y curvarse hasta tomar forma época tras época mientras backprop moldea las unidades ocultas, con un panel de pérdida drenándose debajo. Y el titular es un solo hecho que vale el capítulo entero: una capa oculta con activación no lineal resuelve XOR exactamente, al 100%, cosa que un solo perceptrón demostrablemente no puede hacer. Esas son las dos ideas — profundidad y no linealidad — sobre las que se construye todo lo que viene después.

Un poco de historia

La solución era imaginable en 1969 y nadie podía entrenarla. Apilar perceptrones en capas era una idea obvia; el problema era que la regla de aprendizaje del perceptrón es local a una unidad, y una vez que entierras una unidad en medio de una red no hay etiqueta contra la cual compararla, así que la regla no tiene hacia dónde corregir. Necesitas una forma de asignarle culpa a una unidad oculta por un error cometido en la salida, dos capas más allá. Ese es el problema de asignación de crédito, y es lo que mantuvo la pila sin poder entrenarse.

La respuesta es backpropagation, y su historia es la de una buena idea inventada varias veces antes de pegar. Paul Werbos la escribió en su tesis doctoral de Harvard en 1974, como una forma de calcular derivadas a través de cualquier cadena de funciones, y casi nadie en machine learning lo notó. Otros — la diferenciación automática en modo reverso de Seppo Linnainmaa en 1970, trabajos de David Parker y Yann LeCun a principios de los ochenta — rondaron la misma idea. Lo que la hizo aterrizar fue un paper de 1986 en Nature de David Rumelhart, Geoffrey Hinton y Ronald Williams, "Learning representations by back-propagating errors". No fueron los primeros en derivarla, y lo dijeron, pero fueron quienes la mostraron funcionando: una red aprendiendo representaciones internas útiles en su capa oculta, resolviendo problemas que una sola capa no podía, XOR entre ellos. Ese paper reinició la investigación en redes neuronales. Hinton recibió un Turing Award por la línea de trabajo que abrió.

La teoría se puso al día rápido. En 1989 George Cybenko demostró el teorema de aproximación universal: una red con una sola capa oculta de unidades sigmoidales puede aproximar cualquier función continua en una región acotada con la precisión que quieras, dadas suficientes unidades ocultas. Kurt Hornik lo generalizó en 1991: la magia no es la sigmoide, es la no linealidad más una capa oculta. Este es el teorema que la gente agita cuando dice que las redes neuronales pueden aprender cualquier cosa, y la letra chica importa: dice que existe una red que ajusta la función, no que el descenso por gradiente la vaya a encontrar, y "suficientes unidades ocultas" puede ser un número absurdo. Pero zanjó la pregunta representacional que Minsky y Papert habían planteado. Una capa oculta es, en principio, suficiente para representar cualquier cosa. El resto del deep learning se trata de hacer eso práctico.

La intuición

Aquí va la idea completa, y vale la pena captarla antes de cualquier matemática. Un solo perceptrón calcula una suma ponderada y le aplica un umbral: una línea, un corte. Una capa oculta calcula varias sumas ponderadas a la vez, una por unidad oculta, cada una su propia línea a través del espacio de entrada. Luego la capa de salida toma esas respuestas ocultas como sus nuevas features y traza una línea a través de ellas. Una línea en el espacio de "de qué lado de cada línea oculta estás" es una forma doblada, por tramos, de regreso en el espacio original. Dos líneas se vuelven una esquina; un puñado se vuelve una curva; suficientes envuelven un blob. La red no está trazando una frontera: está aprendiendo un nuevo sistema de coordenadas en la capa oculta donde el problema es linealmente separable, y luego lo separa ahí.

La no linealidad es lo que hace que esto funcione, y no es opcional. Si la activación oculta fuera lineal — pasar la suma ponderada sin tocarla — entonces dos capas lineales apiladas colapsan en una sola capa lineal, algebraicamente, y estás de vuelta en un perceptrón con pasos de más. Un producto de matrices es una matriz. La tanh de en medio es lo que rompe ese colapso; dobla la respuesta de cada unidad oculta para que la composición no pueda aplanarse de regreso a una línea. Profundidad sin no linealidad no te compra nada. Profundidad con no linealidad te lo compra todo.

Estos son los datos con los que la red va a aprender: dos medias lunas entrelazadas, 200 puntos, el tipo de cosa que ninguna línea recta se acerca a separar. Una clase se enrosca dentro de la otra. Guarda esta imagen: toda la animación de más adelante es este scatter con la frontera de la red doblándose sobre él.

La matemática

Cuatro ideas: el paso hacia adelante, la pérdida, el paso hacia atrás, la actualización. El paso hacia atrás es el único con algo de filo, y aun así es solo la regla de la cadena aplicada dos veces. Sea XX las entradas, una fila por ejemplo, con DD features de ancho. La red tiene una capa oculta de HH unidades y una sola unidad de salida.

El paso hacia adelante empuja los datos a través de dos capas lineales con una no linealidad entre ellas. Primera capa: un mapa lineal, luego tanh.

Z1=XW1+b1A1=tanh(Z1)Z_1 = X W_1 + b_1 \qquad A_1 = \tanh(Z_1)

Segunda capa: otro mapa lineal, luego una sigmoide para aplastar el puntaje en una probabilidad de clase 1.

Z2=A1W2+b2y^=σ(Z2)=11+eZ2Z_2 = A_1 W_2 + b_2 \qquad \hat{y} = \sigma(Z_2) = \frac{1}{1 + e^{-Z_2}}

Aquí W1W_1 es D×HD \times H, W2W_2 es H×1H \times 1, y A1A_1 es la representación oculta — las nuevas coordenadas que la red aprende. Todo lo que el modelo sabe vive en esos cuatro arreglos de parámetros.

La pérdida es la entropía cruzada binaria, el mismo log loss de la regresión logística, porque la unidad de salida es una unidad logística. Para etiquetas yi{0,1}y_i \in \{0, 1\} y predicciones y^i\hat{y}_i, promediada sobre NN ejemplos:

L=1Ni=1N[yilogy^i+(1yi)log(1y^i)]L = -\frac{1}{N} \sum_{i=1}^{N} \Big[ y_i \log \hat{y}_i + (1 - y_i) \log(1 - \hat{y}_i) \Big]

Ahora backpropagation, que es la regla de la cadena, empezando por la salida. La razón por la que arranca limpio es un pequeño milagro de diseño: la sigmoide y la entropía cruzada se hicieron la una para la otra, y cuando derivas la pérdida a través de la sigmoide los factores engorrosos se cancelan, dejando el error pelón en la salida.

δ2=y^y\delta_2 = \hat{y} - y

Ese error, en producto punto con las activaciones ocultas, es el gradiente de los pesos de salida. El gradiente del sesgo es simplemente el error sumado.

LW2=1NA1δ2Lb2=1Niδ2,i\frac{\partial L}{\partial W_2} = \frac{1}{N} A_1^{\top} \delta_2 \qquad \frac{\partial L}{\partial b_2} = \frac{1}{N} \sum_i \delta_{2,i}

Luego el paso que le da nombre a todo el método. Empuja el error de salida hacia atrás a través de los pesos de salida para ver cuánto contribuyó cada unidad oculta, y multiplica por la derivada de tanh 1A121 - A_1^2 para convertir "error en la activación oculta" en "error en la pre-activación oculta". Ese producto es el error de la capa oculta.

δ1=(δ2W2)(1A12)\delta_1 = (\delta_2 W_2^{\top}) \odot (1 - A_1^2)

Y el error oculto, en producto punto con las entradas, da los gradientes de la primera capa — la misma forma de expresión que la capa de salida, un peldaño más abajo en la cadena.

LW1=1NXδ1Lb1=1Niδ1,i\frac{\partial L}{\partial W_1} = \frac{1}{N} X^{\top} \delta_1 \qquad \frac{\partial L}{\partial b_1} = \frac{1}{N} \sum_i \delta_{1,i}

Ese es el algoritmo entero. Dos deltas, cuatro gradientes, y la única idea genuinamente nueva es la línea de en medio: el error fluye hacia atrás a través de los mismos pesos que llevaron la señal hacia adelante, doblado por la pendiente de la activación en cada capa. La actualización es descenso por gradiente pelado — empuja cada parámetro cuesta abajo con una tasa de aprendizaje η\eta.

WWηLWbbηLbW \leftarrow W - \eta\, \frac{\partial L}{\partial W} \qquad b \leftarrow b - \eta\, \frac{\partial L}{\partial b}

Haz el paso hacia adelante, mide la pérdida, retropropaga los gradientes, da el paso, repite. Ese loop, corrido unas cuantas miles de veces, es el entrenamiento.

En qué es bueno, en qué no

Lo que el MLP hace bien es justo lo que el perceptrón no podía: representa fronteras no lineales, y el teorema de aproximación universal dice que una capa oculta puede representar esencialmente cualquiera de ellas. Dale suficientes unidades ocultas y una activación diferenciable y ajustará curvas, esquinas, regiones desconectadas — lo que los datos necesiten — y aprende esas formas por sí mismo, desde el gradiente, sin feature engineering de tu parte. Ese es un quiebre genuino con todo lo anterior del libro, donde o tenías un modelo lineal o fabricabas a mano las features no lineales tú mismo. La red construye sus propias features en la capa oculta. Esa es toda la idea, y escala: cambia las dos capas densas por convoluciones y tienes la red del siguiente capítulo.

Lo que no es: convexo, barato ni interpretable. La superficie de pérdida tiene muchos mínimos locales y largas regiones planas, así que la respuesta que obtienes depende de la inicialización aleatoria de pesos y de la tasa de aprendizaje, y dos corridas honestas pueden aterrizar en lugares distintos. No hay un teorema de convergencia limpio como el del perceptrón — afinas, observas la pérdida, cruzas los dedos. Quiere más datos que un modelo lineal para llenar todos esos parámetros sin overfitting, el entrenamiento es más lento, y cuando termina tienes un montón de pesos que predicen bien y no explican nada. En un problema genuinamente lineal es demasiado y una regresión logística lo igualará con una décima parte del esfuerzo. Recurres al MLP cuando la frontera es curva y no conoces su forma — que, para ser justos, es la mayoría de los problemas interesantes.

Los datos

Dos datasets, uno para demostrar el punto y otro para medirlo.

El primero es XOR, el problema de cuatro puntos que rompió al perceptrón: entradas en las esquinas de un cuadrado, (±1,±1)(\pm 1, \pm 1), etiquetadas 1 cuando las coordenadas tienen signos opuestos y 0 cuando coinciden. Las esquinas diagonalmente opuestas comparten clase, así que ninguna línea recta las separa. Cuatro puntos, ninguna línea — todo el argumento de Minsky y Papert. Lo conservamos porque es la demostración más limpia posible de que la capa extra cambia lo que es representable, no solo qué tan bien ajusta.

El segundo es el set de lunas de la sección de intuición: 200 puntos del make_moons de scikit-learn con un ruido de 0.2, dos medialunas entrelazadas, divididos en 140 para entrenamiento y 60 apartados para el test. XOR es una prueba de capacidad — ¿puede el modelo siquiera expresar la respuesta? — y las lunas son la medición honesta de accuracy sobre datos con una frontera curva real y algo de ruido para que se la gane.

Constrúyelo, una función a la vez

Seis piezas, de abajo hacia arriba, en el orden que pide la historia de adelante-y-luego-atrás. Empieza con la inicialización, porque por primera vez en el libro los pesos iniciales importan. El perceptrón arrancaba en cero y no pasaba nada. Aquí, cero es fatal.

def init_params(n_in, n_hidden, n_out=1, seed=0):
    """Random small weights, zero biases, for a one-hidden-layer net.

    The weights CANNOT start at zero the way the perceptron's did. If every
    hidden unit starts identical it stays identical — they all get the same
    gradient forever, and the layer collapses to a single unit. Random init
    breaks that symmetry. The 1/sqrt(fan-in) scale (Xavier-style) keeps the
    tanh activations away from their flat saturated ends at the start, where
    gradients would be too small to move.
    """
    rng = np.random.default_rng(seed)
    return {
        "W1": rng.normal(0.0, np.sqrt(1.0 / n_in), size=(n_in, n_hidden)),
        "b1": np.zeros(n_hidden),
        "W2": rng.normal(0.0, np.sqrt(1.0 / n_hidden), size=(n_hidden, n_out)),
        "b2": np.zeros(n_out),
    }

El comentario dice por qué, y vale la pena leerlo dos veces. Si cada unidad oculta arranca idéntica, cada unidad oculta recibe el gradiente idéntico, así que se actualizan en sincronía y se quedan idénticas para siempre — la capa colapsa a una sola unidad y perdiste todo el punto de tener una capa. La inicialización aleatoria rompe esa simetría para que las unidades puedan especializarse. La escala 1/fan-in1/\sqrt{\text{fan-in}} es el viejo truco de Xavier: mantiene las entradas de la tanh en el centro de su rango al inicio, lejos de los extremos planos y saturados donde la pendiente 1A121 - A_1^2 es casi cero y los gradientes mueren.

Ahora el paso hacia adelante — lineal, tanh, lineal, sigmoide — exactamente las cuatro ecuaciones de la sección de matemáticas. La sigmoide está escrita en su forma numéricamente estable de dos ramas para que un puntaje muy negativo no desborde la exponencial.

def sigmoid(z):
    """Squash to (0, 1), in the numerically stable branch form."""
    z = np.asarray(z, dtype=float)
    out = np.empty_like(z)
    pos = z >= 0
    out[pos] = 1.0 / (1.0 + np.exp(-z[pos]))
    ez = np.exp(z[~pos])
    out[~pos] = ez / (1.0 + ez)
    return out


def forward(X, p):
    """Forward pass: linear -> tanh -> linear -> sigmoid.

        z1 = X W1 + b1        a1 = tanh(z1)
        z2 = a1 W2 + b2       yhat = sigmoid(z2)

    X is (N, D). The hidden layer a1 is (N, H); the output yhat is (N, 1),
    the probability of class 1. Returns yhat and a cache of the intermediate
    activations that backprop needs to reconstruct the gradients.
    """
    z1 = X @ p["W1"] + p["b1"]
    a1 = np.tanh(z1)
    z2 = a1 @ p["W2"] + p["b2"]
    yhat = sigmoid(z2)
    cache = {"X": X, "a1": a1, "yhat": yhat}
    return yhat, cache

Fíjate que devuelve un cache. El paso hacia atrás necesita las activaciones intermedias para reconstruir los gradientes, así que las guardamos de pasada en lugar de recalcularlas. Ese es el intercambio de espacio por tiempo en el corazón de backprop: conserva el trabajo del paso hacia adelante, y el paso hacia atrás sale barato.

La pérdida es la entropía cruzada, una línea, con un clip para mantener el logaritmo lejos del cero.

def cross_entropy(yhat, y, eps=1e-12):
    """Mean binary cross-entropy. y is (N, 1) in {0, 1}.

    The same log loss as logistic regression, because the output layer IS a
    logistic unit. A confident, correct probability costs almost nothing; a
    confident, wrong one costs a lot. Clipping keeps log() off zero.
    """
    yhat = np.clip(yhat, eps, 1.0 - eps)
    return float(-np.mean(y * np.log(yhat) + (1 - y) * np.log(1 - yhat)))

Luego la pieza que es la razón entera de que este capítulo exista. Backprop: el delta de salida primero, luego el empujón de regreso a la capa oculta, luego los gradientes. Es una transcripción directa de las cuatro ecuaciones hacia atrás — léela contra ellas.

def backward(cache, y, p):
    """Backprop: the chain rule, layer by layer, output first.

    The output delta is the bare error, because sigmoid + cross-entropy were
    built for each other and their derivatives cancel:

        d2 = yhat - y                          (N, 1)

    Output-layer gradients — the hidden activations dotted with that error:

        dW2 = a1^T d2 / N        db2 = mean(d2)

    Now the key step. Push the output error back through the output weights,
    then multiply by the tanh derivative (1 - a1^2) to get the error AT the
    hidden layer. That product is the whole of backpropagation:

        d1 = (d2 W2^T) * (1 - a1^2)             (N, H)

    Hidden-layer gradients — the inputs dotted with the hidden error:

        dW1 = X^T d1 / N         db1 = mean(d1)
    """
    X, a1, yhat = cache["X"], cache["a1"], cache["yhat"]
    N = X.shape[0]
    d2 = (yhat - y) / N                 # fold the 1/N in once, at the source
    dW2 = a1.T @ d2
    db2 = d2.sum(axis=0)
    d1 = (d2 @ p["W2"].T) * (1.0 - a1**2)
    dW1 = X.T @ d1
    db1 = d1.sum(axis=0)
    return {"W1": dW1, "b1": db1, "W2": dW2, "b2": db2}

La línea que hace el trabajo es d1 = (d2 @ p["W2"].T) * (1.0 - a1**2). El primer factor manda el error de salida de regreso a través de los pesos de salida; el segundo lo dobla con la pendiente de la tanh. Todo lo demás es un producto punto que convierte un error en un gradiente. Meto el 1/N1/N dentro de d2 una sola vez, en el origen, para que fluya a cada gradiente aguas abajo sin repetirse.

La actualización es el paso de descenso por gradiente más simple que hay — camina cada parámetro cuesta abajo.

def sgd_update(p, grads, lr):
    """One gradient-descent step: walk every parameter downhill."""
    for k in p:
        p[k] = p[k] - lr * grads[k]
    return p

Y el loop que lo corre: inicializa, y en cada época haz un paso hacia adelante, mide la pérdida, retropropaga, da el paso. Full-batch, porque estos datasets son diminutos y cada época se puede dar el lujo de ver todos los datos a la vez. El gancho record_every toma snapshots de los pesos según un calendario para que podamos reproducir la frontera doblándose hasta tomar forma.

def fit(X, y, n_hidden=8, lr=0.5, n_epochs=4000, seed=0, record_every=0):
    """Full-batch gradient descent with backprop.

    Init the params, then each epoch: forward pass, measure the loss,
    backprop the gradients, take one step against them. Full-batch here
    because the datasets are tiny — every epoch sees all the data at once.
    With record_every > 0 we snapshot (epoch, params, loss) on a schedule
    so the chapter can replay the boundary bending into shape.
    """
    y = np.asarray(y, dtype=float).reshape(-1, 1)
    p = init_params(X.shape[1], n_hidden, 1, seed=seed)
    history = []
    for epoch in range(n_epochs):
        yhat, cache = forward(X, p)
        loss = cross_entropy(yhat, y)
        if record_every and epoch % record_every == 0:
            history.append((epoch, {k: v.copy() for k, v in p.items()}, loss))
        grads = backward(cache, y, p)
        p = sgd_update(p, grads, lr)
    if record_every:
        final_loss = cross_entropy(forward(X, p)[0], y)
        history.append((n_epochs, {k: v.copy() for k, v in p.items()}, final_loss))
        return p, history
    return p

Míralo trabajar

Esta es la red aprendiendo las lunas, y es el capítulo en una sola imagen. El panel superior son los datos de entrenamiento; la cuadrícula sombreada detrás es la predicción actual de la red en todo el plano — cian donde predice clase 0, morado donde predice clase 1, y la costura entre ambos es la frontera de decisión. El panel inferior es la pérdida de entropía cruzada cayendo mientras corre el entrenamiento. Cada cuadro es un snapshot real de los pesos reales en esa época — sin suavizado, sin montaje. Reinícialo y reprodúcelo las veces que quieras; la semilla está fija, así que es la misma corrida cada vez.

Observa la frontera al inicio. La época 0 son pesos aleatorios, y la frontera es un solo corte casi recto en el lugar equivocado — la visión del mundo de un perceptrón: una línea, mal apuntada. Luego backprop se pone a trabajar. La línea desarrolla un quiebre, luego una curva, luego se enrosca para seguir el hueco entre las medialunas. Ese enroscamiento son las unidades ocultas especializándose: cada una está reclamando una región de la entrada, y la capa de salida está cosiendo sus respuestas en una forma que ninguna línea sola podría hacer. Para el último cuadro la frontera ha envuelto las lunas entrelazadas y la accuracy de entrenamiento anda por 0.96, la pérdida cerca de 0.07. Un solo perceptrón, con todo el tiempo del mundo, no podría trazar esa curva. Este la trazó en unos cuantos miles de pasos baratos.

El panel de pérdida cuenta la misma historia como un número bajando. Cae rápido al principio — los gradientes tempranos son grandes porque los errores son grandes — y luego se aplana en la larga cola donde la red está refinando una frontera que ya está más o menos bien. Esa forma, empinada y luego plana, es como se ve la curva de pérdida de casi cualquier red neuronal, y saber leerla es la mitad de saber si el entrenamiento va bien.

Ahora la recompensa, aquello para lo que toda esta arquitectura fue construida. Esta es la superficie de decisión que la misma red aprende sobre XOR — los cuatro puntos, y la región que la red asigna a cada clase sombreada detrás.

Mira lo que hizo la red. Talló el plano en una banda diagonal de una clase cruzando entre dos esquinas de la otra — no una línea sino un par de ellas, dobladas en una X, exactamente la forma que XOR necesita y exactamente la forma que un solo perceptrón no puede hacer. Cada uno de los cuatro puntos queda en la región del color correcto. Esta es la imagen que el campo esperó quince años. La capa extra no solo ajustó mejor; cambió el conjunto de formas que el modelo podía trazar en absoluto, y ese cambio es toda la diferencia entre una línea y un cerebro en entrenamiento.

Una vista más del entrenamiento, la pérdida sola, para ambos datasets en un mismo eje. Este es el número que rastreaba el panel inferior de la animación, graficado de punta a punta.

Ambas curvas tienen el mismo carácter — una caída temprana empinada mientras los errores son grandes, luego una larga cola plana de refinamiento. XOR cae más lejos y más rápido porque cuatro puntos limpios son un ajuste más fácil que 140 ruidosos; las lunas se nivelan más arriba porque el ruido pone un piso que ninguna frontera puede vencer. Ninguna de las dos curvas es monótonamente suave si le haces zoom, y está bien — el descenso por gradiente full-batch sobre una pérdida no convexa se tambalea. Lo que importa es la tendencia, y la tendencia es hacia abajo.

La implementación completa

El archivo completo, de arriba a abajo — inicialización, forward, pérdida, backward, actualización, el loop de entrenamiento y los helpers de predicción que usan las trazas. Este es el código exacto que corrió la animación:

"""A multilayer perceptron, built from scratch.

One hidden layer is all it takes. Push the inputs through a linear layer,
bend them with a nonlinear activation (tanh), push that through a second
linear layer, and squash the result to a probability with the sigmoid.
That stack can draw a curved decision boundary — which a single perceptron,
the previous chapter, provably cannot.

Training is backpropagation, which is nothing more than the chain rule run
backwards through the two layers: get the error at the output, push it back
through the output weights, multiply by the activation's derivative to get
the error at the hidden layer, and read off the gradient for every weight
on the way. Then take a gradient-descent step and do it again.

Pure NumPy. Every function below appears in the chapter one step at a time;
the `# region:` markers are what the book's include directives pull in.
Labels live in {0, 1}; the output is P(class = 1).
"""

import numpy as np


# region: init_params
def init_params(n_in, n_hidden, n_out=1, seed=0):
    """Random small weights, zero biases, for a one-hidden-layer net.

    The weights CANNOT start at zero the way the perceptron's did. If every
    hidden unit starts identical it stays identical — they all get the same
    gradient forever, and the layer collapses to a single unit. Random init
    breaks that symmetry. The 1/sqrt(fan-in) scale (Xavier-style) keeps the
    tanh activations away from their flat saturated ends at the start, where
    gradients would be too small to move.
    """
    rng = np.random.default_rng(seed)
    return {
        "W1": rng.normal(0.0, np.sqrt(1.0 / n_in), size=(n_in, n_hidden)),
        "b1": np.zeros(n_hidden),
        "W2": rng.normal(0.0, np.sqrt(1.0 / n_hidden), size=(n_hidden, n_out)),
        "b2": np.zeros(n_out),
    }
# endregion


# region: forward
def sigmoid(z):
    """Squash to (0, 1), in the numerically stable branch form."""
    z = np.asarray(z, dtype=float)
    out = np.empty_like(z)
    pos = z >= 0
    out[pos] = 1.0 / (1.0 + np.exp(-z[pos]))
    ez = np.exp(z[~pos])
    out[~pos] = ez / (1.0 + ez)
    return out


def forward(X, p):
    """Forward pass: linear -> tanh -> linear -> sigmoid.

        z1 = X W1 + b1        a1 = tanh(z1)
        z2 = a1 W2 + b2       yhat = sigmoid(z2)

    X is (N, D). The hidden layer a1 is (N, H); the output yhat is (N, 1),
    the probability of class 1. Returns yhat and a cache of the intermediate
    activations that backprop needs to reconstruct the gradients.
    """
    z1 = X @ p["W1"] + p["b1"]
    a1 = np.tanh(z1)
    z2 = a1 @ p["W2"] + p["b2"]
    yhat = sigmoid(z2)
    cache = {"X": X, "a1": a1, "yhat": yhat}
    return yhat, cache
# endregion


# region: loss
def cross_entropy(yhat, y, eps=1e-12):
    """Mean binary cross-entropy. y is (N, 1) in {0, 1}.

    The same log loss as logistic regression, because the output layer IS a
    logistic unit. A confident, correct probability costs almost nothing; a
    confident, wrong one costs a lot. Clipping keeps log() off zero.
    """
    yhat = np.clip(yhat, eps, 1.0 - eps)
    return float(-np.mean(y * np.log(yhat) + (1 - y) * np.log(1 - yhat)))
# endregion


# region: backward
def backward(cache, y, p):
    """Backprop: the chain rule, layer by layer, output first.

    The output delta is the bare error, because sigmoid + cross-entropy were
    built for each other and their derivatives cancel:

        d2 = yhat - y                          (N, 1)

    Output-layer gradients — the hidden activations dotted with that error:

        dW2 = a1^T d2 / N        db2 = mean(d2)

    Now the key step. Push the output error back through the output weights,
    then multiply by the tanh derivative (1 - a1^2) to get the error AT the
    hidden layer. That product is the whole of backpropagation:

        d1 = (d2 W2^T) * (1 - a1^2)             (N, H)

    Hidden-layer gradients — the inputs dotted with the hidden error:

        dW1 = X^T d1 / N         db1 = mean(d1)
    """
    X, a1, yhat = cache["X"], cache["a1"], cache["yhat"]
    N = X.shape[0]
    d2 = (yhat - y) / N                 # fold the 1/N in once, at the source
    dW2 = a1.T @ d2
    db2 = d2.sum(axis=0)
    d1 = (d2 @ p["W2"].T) * (1.0 - a1**2)
    dW1 = X.T @ d1
    db1 = d1.sum(axis=0)
    return {"W1": dW1, "b1": db1, "W2": dW2, "b2": db2}
# endregion


# region: update
def sgd_update(p, grads, lr):
    """One gradient-descent step: walk every parameter downhill."""
    for k in p:
        p[k] = p[k] - lr * grads[k]
    return p
# endregion


# region: fit
def fit(X, y, n_hidden=8, lr=0.5, n_epochs=4000, seed=0, record_every=0):
    """Full-batch gradient descent with backprop.

    Init the params, then each epoch: forward pass, measure the loss,
    backprop the gradients, take one step against them. Full-batch here
    because the datasets are tiny — every epoch sees all the data at once.
    With record_every > 0 we snapshot (epoch, params, loss) on a schedule
    so the chapter can replay the boundary bending into shape.
    """
    y = np.asarray(y, dtype=float).reshape(-1, 1)
    p = init_params(X.shape[1], n_hidden, 1, seed=seed)
    history = []
    for epoch in range(n_epochs):
        yhat, cache = forward(X, p)
        loss = cross_entropy(yhat, y)
        if record_every and epoch % record_every == 0:
            history.append((epoch, {k: v.copy() for k, v in p.items()}, loss))
        grads = backward(cache, y, p)
        p = sgd_update(p, grads, lr)
    if record_every:
        final_loss = cross_entropy(forward(X, p)[0], y)
        history.append((n_epochs, {k: v.copy() for k, v in p.items()}, final_loss))
        return p, history
    return p
# endregion


def predict_proba(X, p):
    """P(class = 1) for every row."""
    return forward(X, p)[0].ravel()


def predict(X, p, threshold=0.5):
    """0/1 labels at a decision threshold."""
    return (predict_proba(X, p) >= threshold).astype(int)


def accuracy(X, y, p, threshold=0.5):
    """Fraction of rows the net labels correctly."""
    return float((predict(X, p, threshold) == np.asarray(y)).mean())

La versión de librería

No armarías esto a mano en producción; scikit-learn lo trae como MLPClassifier. Le damos la misma arquitectura — una capa oculta de ocho unidades tanh, salida logística, pérdida de entropía cruzada — para que la comparación sea honesta:

def sklearn_mlp(Xtr, ytr, Xte, yte, n_hidden=8, seed=0, max_iter=4000):
    """Fit sklearn's MLPClassifier and report train + test accuracy.

    Same architecture as the scratch net: one hidden layer of `n_hidden`
    tanh units, a logistic output, cross-entropy loss. Returns the fitted
    model, its train accuracy, test accuracy, and the epochs it ran.
    """
    clf = MLPClassifier(
        hidden_layer_sizes=(n_hidden,),
        activation="tanh",
        solver="adam",
        max_iter=max_iter,
        random_state=seed,
    )
    clf.fit(Xtr, ytr)
    return (
        clf,
        float(clf.score(Xtr, ytr)),
        float(clf.score(Xte, yte)),
        int(clf.n_iter_),
    )

Las diferencias están todas bajo el cofre, y son las diferencias que querrías. Donde nuestra versión corre descenso por gradiente full-batch pelado a tasa de aprendizaje fija, sklearn usa por defecto el optimizador adam — un tamaño de paso adaptativo por parámetro que suele entrenar más rápido y necesita menos afinación — y agrega una pequeña penalización L2 (el parámetro alpha) que regulariza los pesos, cosa que la nuestra omite. También se detiene solo cuando la pérdida deja de mejorar, en lugar de correr un número fijo de épocas. Conservamos esos defaults a propósito. El punto del duelo es comparar contra la librería usada como realmente la usarías, no contra un clon lisiado de nuestro propio código.

Desde cero contra librería

Dos preguntas, dos datasets. En XOR la pregunta es binaria: ¿puede el modelo expresar la respuesta? En las lunas la pregunta es cuantitativa: ¿con qué accuracy etiqueta puntos que nunca vio?

En XOR ambos modelos sacan 1.0 — un ajuste perfecto, los cuatro puntos del lado correcto de una frontera curva. Ese es el titular convertido en número: el perceptrón solo del capítulo pasado sacó aquí 0.5, un volado, y una capa oculta lo lleva a 1.0. La capa extra no mejoró XOR, lo desbloqueó. Si quieres una sola comparación que justifique el resto de un curso de deep learning, es el salto de 0.5 a 1.0 en un problema que un niño resuelve dibujando dos líneas.

En las lunas, medido sobre los 60 puntos de test apartados, nuestra red desde cero saca 0.983 y la de sklearn 0.867. La nuestra gana aquí por poco, lo cual merece una nota honesta al pie más que una vuelta olímpica. El test set es pequeño — 0.983 contra 0.867 son unos cincuenta y nueve aciertos contra cincuenta y dos de sesenta — y los dos modelos apostaron distinto. Nuestro descenso por gradiente full-batch corrió las cuatro mil épocas completas a una tasa de aprendizaje bastante agresiva y ajustó el set de entrenamiento con fuerza (accuracy de entrenamiento 0.964); el adam de sklearn se detuvo solo tras 513 iteraciones y carga esa penalización L2, y ambas cosas lo jalan hacia una frontera más suave y conservadora. En este problema 2D particular, pequeño y de poco ruido, ajustar más duro resultó rentable. Eso no es una afirmación general de que lo hecho a mano le gana a la librería — bájale la regularización a sklearn y déjalo correr y cierra la brecha — es un recordatorio de que en redes neuronales los hiperparámetros son el modelo, y dos elecciones razonables dan dos fronteras distintas. Ambas trazan una curva a través de las lunas. Que tracen curvas ligeramente distintas es toda la historia de la no convexidad en miniatura.

Conclusiones

El perceptrón multicapa es donde el machine learning se vuelve deep learning, y la razón son dos ideas que caben en una mano. Profundidad: pon una capa de unidades entre la entrada y la salida para que la red pueda construir sus propias features en lugar de usar las que le diste. No linealidad: dobla esas unidades con algo como tanh para que las capas no colapsen algebraicamente de vuelta en una sola. Con ambas, la frontera puede curvarse, y el teorema de aproximación universal dice que puede curvarse en esencialmente cualquier forma. Sin cualquiera de las dos, tienes un perceptrón. Ese es todo el salto conceptual, y todo lo más sofisticado — más capas, ReLU, dropout, convoluciones, atención — es ingeniería encima de estos dos movimientos.

Tres cosas para llevarte. Primera, backpropagation no es magia y no es un algoritmo separado del cálculo — es la regla de la cadena, aplicada capa por capa, reutilizando las activaciones cacheadas del paso hacia adelante para que el gradiente completo cueste más o menos lo mismo que un paso hacia adelante extra. Una vez que escribiste los dos deltas a mano, el .backward() de un framework nunca volverá a parecerte un misterio; está haciendo exactamente esto, solo que con más capas. Segunda, las garantías que tenías ya no están. El perceptrón convergía demostrablemente en datos separables; la superficie de pérdida del MLP es un paisaje no convexo, y la inicialización, la tasa de aprendizaje y el momento en que paras mueven la respuesta. Cambias una garantía por una capacidad, y administras la capacidad observando la curva de pérdida — por eso la dibujamos. Tercera, este es el cimiento sobre el que se para el siguiente capítulo. Una red convolucional es este mismo loop de adelante-pérdida-atrás-actualización con la primera capa densa reemplazada por convoluciones de pesos compartidos; la historia del entrenamiento no cambia, solo cambia la capa. Domina el MLP en frío — el paso hacia adelante, la entropía cruzada, los dos deltas, el paso — y habrás leído el esqueleto de toda red neuronal que existe. XOR es donde el perceptrón terminó y esto comenzó. Aquí es donde comienzan las redes profundas.