Skip to content

English · Español

Lab 02 — Entrenar un MLP escalar en una tarea diminuta de identidad de tiempo verbal usando solo minigrad.scalar

Objetivo: construir un MLP de 2 capas desde neuronas Value, entrenarlo en una tarea microscópica de tiempo verbal, y ver descender una curva de pérdida. La ejecución de entrenamiento ML extremo a extremo más pequeña y pedagógicamente más pura que existe, anclada en el dominio §A13 de gramática verbal.

Tiempo estimado: 90–120 minutos.

Prerrequisitos: lab 00, lab 01 (todas las operaciones implementadas y testeadas).


La tarea (el anclaje §A13)

Elige un verbo — digamos work. Sus 5 tiempos son:

index tense English form Spanish
0 infinitive (to) work trabajar
1 present (3rd sg) works trabaja
2 past simple worked trabajó
3 past participle worked trabajado
4 future (will) will work trabajará

La tarea es el mapeo de identidad de tiempo verbal de 5 vías: dado un input one-hot 5-dim que codifica "qué tiempo es este", producir una salida 5-dim cuyo argmax sea igual al argmax del input. Es artificialmente sencilla — un autoencoder perfecto para un one-hot — pero ese es precisamente el punto: cada componente no trivial del modelo (autograd, parámetros, pérdida, bucle de entrenamiento) debe ser correcto para que la red aprenda esto. Cualquier fallo es un bug de Fase 7, no un bug de problema difícil.

Lo que produces

Un directorio experiments/07-train-tense-logits/ conteniendo:

  • model.py — clases Neuron, Layer, MLP construidas desde Value. ~60 líneas.
  • train.py — el bucle de entrenamiento. ~40 líneas.
  • loss.png — curva de pérdida sobre el entrenamiento.
  • predictions.json — las salidas del MLP entrenado en los 5 inputs.
  • manifest.json — esquema estándar.
  • README.md — qué entrenaste, qué pérdida alcanzaste, cuánto tardó, qué mejorarías.

Más un directorio separado experiments/07-visualize-graph/:

  • viz.py — construye una pequeña expresión, renderiza el DAG vía graphviz, guarda como SVG.
  • graph.svg — el grafo renderizado, nodos etiquetados con data del forward y grad del backward.
  • manifest.json.

TODOs (experimento 1: identidad de tiempo verbal)

Bloque A — Neuron, Layer, MLP

En model.py:

  • Neuron: toma n_in entradas. Posee w: list[Value] de longitud n_in (inicializado aleatoriamente a valores pequeños, p. ej., desde random.uniform(-1, 1)) y b: Value (inicializado a 0). __call__(self, xs: list[Value]) devuelve (sum(wᵢ · xᵢ) + b).tanh().
  • Layer: toma n_in, n_out. Posee neurons: list[Neuron]. __call__(self, xs) devuelve la lista de la salida de cada neurona.
  • MLP: toma n_in, layer_sizes: list[int]. Posee layers: list[Layer]. __call__(self, xs) los encadena. Una última capa multi-output (el caso aquí — 5 salidas) devuelve la lista, no un único Value.
  • Método parameters(self) en cada uno (devuelve lista de Value para todos los pesos y sesgos). La Fase 9 introduce Parameter; para la Fase 7 simplemente recolecta Values manualmente.

Bloque B — Dataset de tiempos verbales

Los 5 pares input/target son los 5 vectores one-hot de tiempo verbal para work:

input                target
(1, 0, 0, 0, 0)  →  (1, -1, -1, -1, -1)    # infinitive  / to work
(0, 1, 0, 0, 0)  →  (-1, 1, -1, -1, -1)    # present 3sg / works
(0, 0, 1, 0, 0)  →  (-1, -1, 1, -1, -1)    # past simple / worked
(0, 0, 0, 1, 0)  →  (-1, -1, -1, 1, -1)    # participle  / worked
(0, 0, 0, 0, 1)  →  (-1, -1, -1, -1, 1)    # future      / will work
  • Codifica como xs: list[list[Value]] (5 inputs, cada uno un 5-vector de Values) e ys: list[list[Value]] (5 targets).
  • Usa activaciones tanh en el modelo. Con tanh, la "etiqueta 0" se codifica mejor como -1 (ya que las salidas de tanh están en (-1, 1)). Usa codificación {-1, 1} para los targets.

🇪🇸 La tarea es deliberadamente trivial: dado un one-hot de "qué tiempo verbal", devuelve ese mismo one-hot. La gracia no está en aprender gramática — eso lo hace la fase 9 con el grid completo — sino en confirmar que tu autograd, tu loss y tu loop de entrenamiento funcionan extremo a extremo.

Bloque C — bucle de entrenamiento

En train.py:

  • Instancia model = MLP(5, [4, 5]) — 5 entradas (one-hot de tiempo), una capa oculta de 4 neuronas, 5 salidas.
  • Hiperparámetros: lr = 0.05, n_epochs = 300.
  • Para cada epoch:
  • Calcula predicciones: preds = [model(x) for x in xs]. Cada pred es una lista de 5 Values.
  • Calcula la pérdida: loss = sum((p - y)**2 for pred, target in zip(preds, ys) for p, y in zip(pred, target)). (Suma de errores cuadráticos sobre los 25 logits = 5 inputs × 5 outputs.) La pérdida es un Value.
  • Pon a cero los gradientes en todos los parámetros: for p in model.parameters(): p.grad = 0.0.
  • loss.backward().
  • Actualiza parámetros: for p in model.parameters(): p.data -= lr * p.grad.
  • Loguea epoch, loss.data a un logger estructurado (usando el get_logger de la Fase 6).
  • Grafica pérdida vs epoch con matplotlib. Guarda como loss.png.
  • Tras entrenar, ejecuta model(x) para cada one-hot de tiempo. Guarda las salidas como predictions.json.

Bloque D — asegurar éxito

En tu train.py (o un verify.py aparte):

  • Comprueba con assert que la pérdida final < 0.5 (5 outputs × 5 ejemplos = 25 logits; objetivo holgado por-logit ~0.02).
  • Comprueba con assert que argmax(model(x)) iguala el argmax del input para los 5 inputs.

Si alguna comprobación falla, tu entrenamiento no convergió. Diagnostica: - ¿Pérdida no decrece? Probable bug en backward. Re-ejecuta los tests unitarios. - ¿Pérdida decrece y luego explota? lr demasiado alto. Prueba 0.01. - ¿Pérdida decrece lentamente? lr demasiado bajo o capa oculta demasiado pequeña.

TODOs (experimento 2: visualizar)

Bloque E — visualización del grafo

En experiments/07-visualize-graph/viz.py:

  • Elige una expresión pequeña: p. ej., el ejemplo del diamante de theory/03: L = (a*b + c)*(a - c) con a=2, b=3, c=4.
  • Constrúyela con minigrad.scalar. Llama L.backward().
  • Usa graphviz (binding de Python) para construir un Digraph. Para cada nodo Value, añade un nodo etiquetado con {op | data | grad}. Añade aristas desde cada _prev al nodo.
  • Renderiza como SVG: dot.render('graph', format='svg', cleanup=True).
  • Guarda graph.svg.
  • Imprime la ruta. Abre en navegador. Confirma:
  • Los nodos muestran data y grad.
  • Forma de diamante visible: a tiene dos aristas salientes.

Esta es la visualización que la especificación pide en §4 FASE 7.

Bloque F — manifest para ambos

Esquema estándar según el lab 00 de la Fase 6. Incluye en config:

  • Para identidad de tiempo verbal: hiperparámetros (lr, epochs, layer_sizes, seed, rango de init, verbo elegido).
  • Para viz: qué expresión se renderizó, versión de graphviz.

Restricciones

  • Solo minigrad.scalar. Sin NumPy en el modelo o el bucle de entrenamiento. Solo listas de Value.
  • Usa utilidades de la Fase 6. seed_everything(42), get_logger(__name__) — sin print.
  • graphviz debe estar instalado. En Fedora: dnf install graphviz para el paquete del sistema + pip install graphviz para el binding de Python.
  • Reproducible. La misma semilla debe producir la misma pérdida final (con ~1% por ruido de coma flotante).

Resultados esperados

  • La pérdida final debe alcanzar ~0.05–0.5 dentro de 300 epochs (25 logits, objetivo por-logit ~0.01-0.02).
  • Las 5 predicciones deben tener argmax igual al argmax del input.
  • graph.svg debe mostrar claramente el patrón de diamante (a teniendo dos flechas salientes).

Condiciones de parada

Hecho cuando:

  1. loss.png muestra pérdida monótona decreciente hasta por debajo de 0.5.
  2. Las 5 predicciones de identidad de tiempo correctas (argmax coincide).
  3. graph.svg renderizado, se abre en navegador, visualmente correcto.
  4. Ambos ficheros manifest.json existen con el esquema esperado.

Escollos

  • Olvidar poner a cero los gradientes. La pérdida explota tras el primer epoch. Añade p.grad = 0.0 antes de cada backward().
  • Pesos inicializados a cero. Todas las neuronas computan la misma salida; sin ruptura de simetría; el modelo no aprende. Inicializa con random.uniform(-1, 1) o similar.
  • Usar etiquetas 0/1 con salidas tanh. tanh saca valores en (-1, 1); con targets (0, 1), el modelo quiere empujar salidas a 0 (mitad del rango), los gradientes son diminutos, el entrenamiento es lento. Usa etiquetas (-1, 1).
  • Sin sembrar. La varianza ejecución-a-ejecución es enorme para modelos diminutos. Llama seed_everything(42) al inicio de train.py.
  • Saturación de tanh. tanh(muy_grande) está bien (satura a ±1), pero el gradiente (1 - tanh²) en saturación es ≈0 — gradientes desvanecientes. Con una capa oculta de 4 neuronas y lr=0.05 esto raramente muerde; si lo hace, baja el rango de init a random.uniform(-0.5, 0.5).
  • Graphviz no instalado. Dos capas: paquete del sistema (comando dot) y binding de Python (pip install graphviz). Ambos deben estar presentes. Testea con dot -V desde el shell.
  • Tratar las 5 salidas como un escalar. Cada model(x) devuelve una lista de 5 Values, no un Value. Suma sobre los ejes de ejemplo y salida al computar la pérdida; un bucle olvidado aquí es el bug más común de este lab.

Cuándo consultar solutions/

Después de que tu experimento de tiempos verbales converja y exista graph.svg. Luego solutions/02-train-tense-logits-ref.md (en la apertura de fase) proporciona la curva de pérdida de referencia y la comparación de visualización.


Fin de los labs de la Fase 7. Siguiente: escribir PHASE_07_REPORT.md y learners/borja/phase-07/reflections.md.