Machine Learning desde cero

Red Neuronal desde Cero en JavaScript: Backpropagation sin Librerías

Construye tu propia red neuronal en JavaScript desde cero, sin librerías: propagación hacia delante, backpropagation y descenso de gradiente implementados paso a paso. Aprende qué ocurre por debajo de TensorFlow.

por Santi López ·

Menos frameworks, más entendimiento

En resumen: Construyo una red neuronal en JavaScript desde cero, sin ninguna librería de Machine Learning, para entender de verdad qué ocurre por debajo de TensorFlow. Implemento una neurona, la propagación hacia delante, la backpropagation y el descenso de gradiente en unas pocas clases y funciones. Entrenada sobre un dataset de más de 1.000 ejemplos, supera el 90% de precisión en validación.

  • Una neurona es solo pesos, un sesgo y una función de activación
  • La propagación hacia delante combina entradas, pesos y activación capa por capa
  • La backpropagation usa la regla de la cadena para calcular el error de cada peso
  • El descenso de gradiente ajusta los pesos en la dirección que reduce el error
  • JavaScript puro: la misma red corre en Node.js o en el navegador

Cuando terminé mis especializaciones de Machine Learning, sabía llamar a sklearn y TensorFlow para entrenar modelos. Pero me incomodaba algo: si me preguntaban qué ocurría exactamente entre la llamada y el resultado, no podía explicarlo con precisión. Estaba usando cajas negras.

La solución fue la que recomiendo a cualquiera que quiera entender machine learning en javascript de verdad: construirlo desde cero. Sin librerías, sin atajos. Este ejercicio me enseñó más sobre redes neuronales que cualquier tutorial teórico, porque cada línea de código es una pieza de la matemática que hay que entender para escribirla bien.

Esta guía es el resultado. Después de leerla, cuando uses un framework de verdad, sabrás exactamente qué está pasando por debajo.

Qué es una neurona en código

Una neurona artificial recibe varias entradas, cada una multiplicada por un peso, suma un sesgo y pasa el resultado por una función de activación. Eso es todo:

salida = activación( Σ (entrada[i] × peso[i]) + sesgo )

El componente no lineal que usaremos es la sigmoide, que comprime cualquier número a un valor entre 0 y 1. Es la que nos permite decidir una clase binaria (0 o 1):

function sigmoid(x) {
  return 1 / (1 + Math.exp(-x));
}

// Su derivada, imprescindible para la backpropagation
function dSigmoid(x) {
  return x * (1 - x);
}

Por qué necesitamos la no linealidad: si todas las capas fueran combinaciones lineales, toda la red se reduciría a una sola recta y no podría aprender patrones complejos. La activación entre capas es lo que da potencia expresiva a la red.

La clase NeuralNetwork

La red es una lista de capas. Entre cada par de capas hay una matriz de pesos y un vector de sesgos. La inicializo con pesos aleatorios en el rango [-1, 1]:

class NeuralNetwork {
  constructor(layers) {
    this.layers = layers;        // p. ej. [2, 9, 1]
    this.weights = [];
    this.biases = [];
    for (let i = 0; i < layers.length - 1; i++) {
      const rows = layers[i + 1]; // neuronas de la capa destino
      const cols = layers[i];     // neuronas de la capa de origen
      const w = [];
      for (let r = 0; r < rows; r++) {
        const row = [];
        for (let c = 0; c < cols; c++) row.push(Math.random() * 2 - 1);
        w.push(row);
      }
      this.weights.push(w);
      this.biases.push(new Array(rows).fill(0));
    }
  }
}

Propagación hacia delante (forward pass)

Dada una entrada, la paso por cada capa: sumo (peso × entrada) más el sesgo y aplico la sigmoide. Guardo las activaciones de cada capa porque las necesitaremos en la backpropagation:

forward(input) {
  const activations = [input];
  let current = input;
  for (let l = 0; l < this.weights.length; l++) {
    const next = new Array(this.weights[l].length).fill(0);
    for (let r = 0; r < this.weights[l].length; r++) {
      let sum = this.biases[l][r];
      for (let c = 0; c < this.weights[l][r].length; c++) {
        sum += this.weights[l][r][c] * current[c];
      }
      next[r] = sigmoid(sum);
    }
    activations.push(next);
    current = next;
  }
  return activations; // activations[última] es la salida
}

predict(input) {
  const activations = this.forward(input);
  return activations[activations.length - 1];
}

La salida de la última capa es la predicción: un valor entre 0 y 1 que interpretamos como la probabilidad de la clase positiva.

Backpropagation: repartir el error

Aquí está el corazón del algoritmo. Tras calcular la pérdida (usaré error cuadrático medio, o MSE), la backpropagation reparte ese error hacia atrás aplicando la regla de la cadena del cálculo:

  1. En la capa de salida, el error de cada neurona es (predicción − objetivo) × derivada. Es directo porque conocemos el objetivo.
  2. En cada capa oculta, el error de cada neurona es la suma ponderada de los errores de la capa siguiente, multiplicada por la derivada de su activación.
// Dentro del método train, tras la forward pass:
let deltas = [];
const lastLayer = activations.length - 1;
const output = activations[lastLayer];

// 1. Error de la capa de salida
let delta = output.map((o, j) => (o - y[i][j]) * dSigmoid(o));
deltas.unshift(delta);

// 2. Propagar el error hacia atrás
for (let l = lastLayer - 1; l >= 1; l--) {
  const prevDelta = deltas[0];
  const w = this.weights[l];
  const act = activations[l];
  const newDelta = new Array(act.length).fill(0);
  for (let c = 0; c < act.length; c++) {
    let sum = 0;
    for (let r = 0; r < prevDelta.length; r++) {
      sum += prevDelta[r] * w[r][c];
    }
    newDelta[c] = sum * dSigmoid(act[c]);
  }
  deltas.unshift(newDelta);
}

Descenso de gradiente: ajustar los pesos

Con los deltas calculados, actualizo cada peso y sesgo moviéndolo en la dirección opuesta al gradiente, escalado por la tasa de aprendizaje (lr):

for (let l = 0; l < this.weights.length; l++) {
  const prevAct = activations[l];
  for (let r = 0; r < this.weights[l].length; r++) {
    for (let c = 0; c < this.weights[l][r].length; c++) {
      this.weights[l][r][c] -= lr * deltas[l][r] * prevAct[c];
    }
    this.biases[l][r] -= lr * deltas[l][r];
  }
}

Repitiendo este proceso durante muchas épocas, la red desplaza sus pesos poco a poco hacia los valores que minimizan el error:

PasoQué haceCoste computacional
Forward passCalcula la predicciónBajo
Cálculo de pérdidaMide el error (MSE)Muy bajo
BackpropagationReparte el error por capasMedio
Actualización de pesosDescenso de gradienteBajo

Entrenamiento y resultados

El bucle de entrenamiento recorre las épocas y, sobre cada muestra, aplica forward, backpropagation y actualización. Muestro la pérdida cada 500 épocas para ver la convergencia:

train(X, y, epochs, lr) {
  for (let e = 0; e < epochs; e++) {
    let totalLoss = 0;
    for (let i = 0; i < X.length; i++) {
      const activations = this.forward(X[i]);
      const output = activations[activations.length - 1];

      let loss = 0;
      for (let j = 0; j < output.length; j++) {
        loss += Math.pow(output[j] - y[i][j], 2);
      }
      totalLoss += loss / output.length;

      /* ... backpropagation y actualización de pesos ... */
    }
    if (e % 500 === 0) {
      console.log(`Epoch ${e}: loss = ${(totalLoss / X.length).toFixed(4)}`);
    }
  }
}

Con una arquitectura [2, 9, 1] (2 entradas, 9 neuronas ocultas, 1 salida), una tasa de aprendizaje de 0.1 y 2.000 épocas, la pérdida descendió de forma consistente y la red superó el 90% de precisión en el conjunto de validación.

Conclusión: sabes más de lo que crees

Construir esta red desde cero cambió mi forma de trabajar:

1. Un framework ya no es una caja negra. Cuando usas TensorFlow o PyTorch sabes que detrás hay pesos, sesgos, backpropagation y descenso de gradiente. Puedes interpretar el comportamiento del modelo y depurarlo.

2. La matemática importa, y ahora la ves. La regla de la cadena, las derivadas de activación y el gradiente dejan de ser fórmulas abstractas: son las líneas que actualizan cada peso.

3. JavaScript basta. Puedes hacer machine learning en javascript de verdad, incluso en el navegador, sin infraestructura de servidor. Este proyecto está disponible en /proyectos/red-neuronal-js/ y su librería en GitHub.

Si quieres profundizar en los fundamentos, mi libro Principios de Machine Learning cubre el álgebra lineal y el cálculo que sostienen cada uno de estos pasos. Y para ver por qué a veces una red neuronal no es la mejor opción, te recomiendo el análisis de comparación de modelos sobre el dataset Titanic.

Preguntas frecuentes

¿Por qué implementar una red neuronal desde cero si existen TensorFlow o PyTorch?

Porque usar una librería sin entender qué ocurre por debajo convierte el Machine Learning en una caja negra. Implementar la propagación hacia delante, la backpropagation y el descenso de gradiente desde cero te obliga a entender la matemática real de cada componente. Cuando después usas TensorFlow, sabes qué significa cada parámetro y por qué un ajuste mejora o empeora tu modelo. Es la base que sostiene todo lo demás.

¿Qué es la backpropagation y por qué es clave?

La backpropagation (retropropagación) es el algoritmo que calcula cuánto debe ajustarse cada peso y sesgo de la red para reducir el error. Aplica la regla de la cadena del cálculo para propagar el error desde la capa de salida hacia atrás, capa por capa, y obtener el gradiente de la función de pérdida respecto a cada parámetro. Con ese gradiente, el descenso de gradiente actualiza los pesos en la dirección que reduce el error.

¿Qué es el descenso de gradiente y cómo se aplica en una red neuronal?

El descenso de gradiente es un algoritmo de optimización. Tras calcular el gradiente del error respecto a cada peso, desplaza cada peso en la dirección opuesta a ese gradiente: si el error crece al aumentar el peso, lo reduce. La velocidad del ajuste la controla la tasa de aprendizaje (learning rate). Repitiendo este paso durante muchas épocas, la red reduce progresivamente el error hasta converger en una buena solución.

¿Cuántas capas y neuronas necesito para un problema simple?

Para una tarea de clasificación sencilla basta una capa de entrada, una capa oculta y una capa de salida. En mi palabra de ejemplo usé una capa oculta de 9 neuronas sobre un dataset de más de 1.000 ejemplos y superé el 90% de precisión en validación. La regla general: más capas y neuronas dan más capacidad, pero también más riesgo de sobreajuste; con poco datos, más vale una red pequeña y bien entrenada que una enorme.

¿Esta red neuronal implementada en JavaScript funciona en un navegador?

Sí. Al ser JavaScript puro sin dependencias, la librería se puede ejecutar tanto en Node.js como en un navegador. El entrenamiento se puede hacer offline con Node y el modelo resultante (pesos y sesgos) serializarse en JSON para ejecutar solo la inferencia en el cliente. Es una forma sencilla de llevar Machine Learning a aplicaciones web sin infraestructura de servidor.