Una red neuronal desde cero
Dos capas, sin librerías: propagación hacia delante, pérdida, retropropagación y descenso de gradiente. Cada derivada se comprueba contra diferencias finitas.
Vas a escribir una red de dos capas (2 entradas, H neuronas ocultas con tanh, 1 salida con sigmoide) que aprende XOR y, en la vista previa, dos espirales. Los parámetros son { W1, b1, W2, b2 } (initParams te da unos aleatorios con semilla). Cinco funciones:
forward(p, x): h[i] = tanh(b1[i] + Σ W1[i][j] · x[j]), y = sigmoid(b2 + Σ W2[i] · h[i]). Devuelve { h, y }.
loss(p, X, Y): entropía cruzada binaria media, -(Y · log y + (1 - Y) · log(1 - y)), con y acotada a [1e-7, 1 - 1e-7].
gradients(p, X, Y): la misma forma que p. Por cada ejemplo, dz = (y - Y[k]) / n en la salida; después dW2[i] += dz · h[i], db2 += dz, dh = dz · W2[i] · (1 - h[i]²), db1[i] += dh, dW1[i][j] += dh · x[j].
update(p, g, lr): un objeto NUEVO con cada peso menos lr por su gradiente. No modifiques p.
train(p, X, Y, epochs, lr): repite gradientes y actualización epochs veces y guarda la pérdida de cada época. Devuelve { params, losses }.
El prelude trae numericGradients, que estima cada derivada moviendo el peso un poco. Un reto compara las tuyas con esas: si coinciden, tu retropropagación es correcta.
Retos 0/5
- forward: tanh en la capa oculta y sigmoide en la salida
- loss: entropía cruzada media, ln 2 cuando la red no sabe nada
- gradients coincide con las diferencias finitas en todos los pesos
- update devuelve parámetros nuevos y deja los antiguos intactos
- train aprende XOR: la pérdida baja de 0,1 y los cuatro casos salen bien
function forward(p, x) {
// h[i] = tanh(b1[i] + suma de W1[i][j] * x[j]); y = sigmoid(b2 + suma de W2[i] * h[i])
return { h: [], y: 0.5 };
}
function loss(p, X, Y) {
// entropía cruzada binaria media: -(Y log y + (1 - Y) log(1 - y)), con y acotada a [1e-7, 1 - 1e-7]
return 0;
}
function gradients(p, X, Y) {
// la misma forma que p; dz = (y - Y[k]) / n en la salida, y luego hacia atrás por W2, tanh (1 - h^2) y W1
return { W1: p.W1.map((row) => row.map(() => 0)), b1: p.b1.map(() => 0), W2: p.W2.map(() => 0), b2: 0 };
}
function update(p, g, lr) {
// un objeto de parámetros NUEVO: cada peso menos lr por su gradiente; p se queda intacto
return p;
}
function train(p, X, Y, epochs, lr) {
// epochs veces: gradientes, actualización y guardar la pérdida; devuelve { params, losses }
return { params: p, losses: [] };
}
const run = train(initParams(2, 4, 1), XOR.X, XOR.Y, 2000, 0.5);
console.log('loss', run.losses[run.losses.length - 1], XOR.X.map((x) => forward(run.params, x).y.toFixed(2)));La idea
Una red neuronal es una función con muchos diales. La propagación hacia delante calcula la salida con los diales tal como están; la pérdida mide cuánto se equivoca; la retropropagación calcula, para cada dial, hacia dónde girarlo para equivocarse un poco menos; y el descenso de gradiente lo gira. Eso es todo el entrenamiento. Los frameworks lo esconden detrás de .backward(), pero son estas cuatro líneas.
XOR es el ejemplo clásico porque una sola neurona no puede resolverlo: no hay recta que separe (0,1) y (1,0) de (0,0) y (1,1). Con cuatro neuronas ocultas y tanh la red dobla el plano y lo consigue en unos cientos de épocas.
La parte que más errores esconde es la retropropagación. Por eso el prelude trae numericGradients: mueve cada peso una milésima y mira cuánto cambia la pérdida. Es lento, pero no puede equivocarse. Si tus derivadas coinciden con esas hasta la sexta cifra, puedes fiarte. Esa comprobación es la misma que hacen los frameworks en sus tests.
Las espirales de la vista previa enseñan el coste: la misma red las separa, pero con ocho neuronas ocultas necesita unas 2000 épocas en vez de 300. Con muchos más puntos o más dimensiones, este bucle en JavaScript se queda corto, y ahí empiezan PyTorch y la GPU. La idea es la misma.
Hazlo en tu máquina
Con Python y NumPy, la misma red sobre las espirales de este ejercicio (CC BY 4.0), con 32 neuronas ocultas:
curl -O https://vorluxai.com/data/labs/espirales.csv
python3 -c "
import numpy as np
d = np.loadtxt('espirales.csv', delimiter=',', skiprows=1)
X, Y = d[:, :2], d[:, 2:]
rng = np.random.default_rng(1)
W1, b1, W2, b2 = rng.normal(0, 0.8, (2, 32)), np.zeros(32), rng.normal(0, 0.8, (32, 1)), 0.0
for epoch in range(5000):
H = np.tanh(X @ W1 + b1); P = 1 / (1 + np.exp(-(H @ W2 + b2)))
dz = (P - Y) / len(X); dh = dz @ W2.T * (1 - H ** 2)
W2 -= 0.5 * H.T @ dz; b2 -= 0.5 * dz.sum(); W1 -= 0.5 * X.T @ dh; b1 -= 0.5 * dh.sum(0)
print('correct', ((P > 0.5) == Y).mean())
" Para profundizar: la referencia de Google Machine Learning Crash Course (en inglés) →
¿Esto en producción, con tus datos? Hablemos 15 minutos →