Skip to content

English · Español

Lab 03 — Entrena un MLP clasificador de tiempos verbales

Objetivo: cerrar la Phase 09 entrenando un MLP end-to-end en una tarea de clasificación de 5 tiempos construida sobre la rejilla de gramática verbal de §A13. Entrada: un lookup de embedding de 64 dimensiones sobre los 20 verbos (mockeado — la Phase 13 construye el real). Salida: logits de 5 clases sobre (infinitive, present-3sg, past-simple, past-participle, future-will). Bucle de entrenamiento hecho a mano, sin clase Trainer (Phase 18). Objetivo: > 90 % de accuracy en train en < 100 epochs.

Tiempo estimado: 120–180 minutos.

Prereqs: Labs 00, 01, 02 cerrados. Teoría 02 + 03 leídas.


🇪🇸 Esta es la primera vez en el currículum que el modelo aprende algo del idioma. La forma del problema es lo más simple posible — identidad sobre 100 ejemplos, sin generalización — porque el objetivo pedagógico es que veas el bucle de entrenamiento funcionar end-to-end con piezas que tú mismo construiste: Tensor, Module, Linear, CrossEntropyLoss, Adam. La generalización viene en Phase 18; la gramática real, en Phase 12+.

Qué produces

  • experiments/09-tense-mlp-lab03/train.py — el script de entrenamiento.
  • experiments/09-tense-mlp-lab03/manifest.json — versiones + semilla + config (per CLAUDE.md §0.5).
  • experiments/09-tense-mlp-lab03/loss_curve.png — pérdida de entrenamiento por epoch.

(experiments/09-tense-mlp/ de PHASE_09_PLAN.md §3 es la versión más rica — verbo one-hot ⊕ persona one-hot, entrada de 23 dimensiones. Este lab es la variante más pequeña — solo embedding de entrada, sin persona — más simple para que te concentres en el bucle, no en el pipeline de datos.)

Especificación de datos

20 verbos × 5 formas verbales = 100 ejemplos. Las personas NO entran en la entrada de este lab — el Lab 03 clasifica la forma verbal de una conjugación individual, no el acuerdo con un sujeto.

Los 20 verbos (§A13):

  • Regulares (12): work, play, walk, talk, listen, watch, study, finish, start, look, want, like.
  • Irregulares (8): be, have, do, go, come, see, eat, write.

Las 5 clases de tiempo verbal (labels de salida 0–4):

label nombre ejemplo para "work"
0 infinitive work
1 present-3sg works
2 past-simple worked
3 past-participle worked
4 future-will will work

Nota: para los verbos regulares, el label 2 y el label 3 comparten forma superficial (worked). El modelo aun así debe separarlos por contexto de embedding. Como cada par (verbo, tiempo) obtiene su propio embedding mockeado (mira abajo), la entrada es única por ejemplo — así que la tarea es memorización, no desambiguación de forma superficial. Documenta esto en tu journal: la desambiguación real necesita contexto de oración (Phase 17).

El lookup de embedding (MOCKEADO)

La Phase 13 construye la capa de embedding real. Para el Lab 03, mockéalo con una tabla aleatoria determinista:

# In data/grammar/embedding_mock.py (or inline in train.py for the lab):

EMBEDDING_DIM = 64
NUM_FORMS = 100   # 20 verbs × 5 tenses

def build_mock_embedding_table(seed: int = 0) -> np.ndarray:
    rng = np.random.default_rng(seed)
    # Each of the 100 (verb, tense) pairs gets a fixed 64-dim vector.
    return rng.standard_normal((NUM_FORMS, EMBEDDING_DIM)).astype(np.float32)

Cada ejemplo de entrenamiento es una fila de esta tabla; el label es el índice de tiempo (0–4) de esa fila. Esto es clasificación de identidad — la entrada determina unívocamente la salida. Ese es el punto: confirmar que la maquinaria aprende la identidad, antes de que la Phase 18 introduzca las tareas más duras que generalizan.

Enumeración de (input, label)

def enumerate_examples(table: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
    # X: (100, 64) float32
    # y: (100,)   int64, values in {0, 1, 2, 3, 4}
    X = table
    y = np.tile(np.arange(5, dtype=np.int64), 20)   # 0,1,2,3,4,0,1,2,3,4,...
    return X, y

Convención de indexado: la fila 5*v + t es el verbo v, tiempo t. Documéntalo en manifest.json.

Modelo

Un MLP de 2 capas:

Linear(64, 32) → ReLU → Linear(32, 5)

Recuento de parámetros: 64·32 + 32 + 32·5 + 5 = 2048 + 32 + 160 + 5 = 2245. Ligeramente sobreparametrizado para 100 ejemplos — exactamente lo que queremos para que la tarea de identidad memorice limpiamente.

class TenseMLP(Module):
    def __init__(self) -> None:
        super().__init__()
        self.fc1 = Linear(64, 32)
        self.act = ReLU()
        self.fc2 = Linear(32, 5)

    def forward(self, x: Tensor) -> Tensor:
        # TODO: x → fc1 → act → fc2 → logits (shape (B, 5))
        raise NotImplementedError

TODOs

Bloque A — pérdida: CrossEntropyLoss

O bien la implementas como Module en src/minimodel/nn/losses.py (preferido — PHASE_09_PLAN.md §3 lo lista), o bien insertas la matemática en línea en train.py para este lab y refactorizas después.

Matemáticamente: CE(logits, y) = -log_softmax(logits)[y], promediada sobre el batch.

Usa el truco log-sum-exp para estabilidad numérica — la misma razón que para Softmax en el Lab 01:

log_softmax(z)_i = z_i - max(z) - log(sum(exp(z_j - max(z))))
class CrossEntropyLoss(Module):
    def forward(self, logits: Tensor, targets: Tensor) -> Tensor:
        # TODO:
        # 1. Shift logits by row-max for numeric stability.
        # 2. log_softmax = shifted - log(sum(exp(shifted), dim=-1, keepdim=True)).
        # 3. Gather the per-row log-softmax at the target index (advanced indexing).
        # 4. Return -mean of the gathered values.
        raise NotImplementedError

Si minitorch.Tensor carece de gather/advanced-indexing, baja a una multiplicación manual one-hot:

# one_hot: (B, 5) with 1 at the target column, 0 elsewhere.
# loss = -(one_hot * log_softmax).sum(dim=-1).mean()

Bloque B — bucle de entrenamiento

def train(seed: int = 0) -> dict[str, list[float]]:
    seed_everything(seed)

    table = build_mock_embedding_table(seed=seed)
    X_np, y_np = enumerate_examples(table)

    # Full-batch training — 100 examples fits in memory trivially.
    X = Tensor(X_np)
    y = Tensor(y_np)              # integer labels; CE handles the gather

    model = TenseMLP()
    loss_fn = CrossEntropyLoss()
    opt = Adam(model.parameters(), lr=1e-2)

    history: dict[str, list[float]] = {"loss": [], "acc": []}

    for epoch in range(100):
        opt.zero_grad()
        logits = model(X)                                # (100, 5)
        loss = loss_fn(logits, y)
        loss.backward()
        opt.step()

        # Accuracy:
        preds = np.argmax(logits.data, axis=-1)          # (100,)
        acc = float((preds == y_np).mean())

        history["loss"].append(float(loss.data))
        history["acc"].append(acc)

        if epoch % 10 == 0:
            print(f"epoch {epoch:3d}  loss={loss.data:.4f}  acc={acc:.3f}")

    return history
  • Envuelve train() en un bloque __main__ que también escriba manifest.json (seed, versiones de minimodel, minitorch, numpy, Python; dict de config).
  • Grafica history["loss"] y history["acc"] en loss_curve.png.

Bloque C — manifest

Per CLAUDE.md §0.5:

import json, platform, sys
import numpy

manifest = {
    "seed": seed,
    "versions": {
        "python": sys.version,
        "platform": platform.platform(),
        "numpy": numpy.__version__,
        # "minimodel": ..., "minitorch": ...
    },
    "config": {
        "embedding_dim": 64,
        "num_classes": 5,
        "num_examples": 100,
        "epochs": 100,
        "optimizer": "Adam",
        "lr": 1e-2,
        "hidden": 32,
        "loss": "CrossEntropyLoss",
    },
    "final_loss": history["loss"][-1],
    "final_acc": history["acc"][-1],
}
with open("manifest.json", "w") as f:
    json.dump(manifest, f, indent=2)

Bloque D — comprobación de aceptación

  • Ejecuta python train.py. La accuracy de la última epoch debe ser > 0.90.
  • La curva de pérdida debe ser (a grandes rasgos) monótonamente decreciente — pequeños rizos están bien con lr=1e-2, las oscilaciones grandes significan que el LR es demasiado alto.
  • Commitea loss_curve.png y manifest.json a experiments/09-tense-mlp-lab03/.

Bloque E — tests de sanity (en tests/test_train_tense_mlp.py)

  • test_table_shape: build_mock_embedding_table() devuelve (100, 64) float32.
  • test_label_distribution: cada una de las 5 clases tiene exactamente 20 ejemplos.
  • test_cross_entropy_against_manual: calcula CE a mano sobre un batch de logits (2, 3) con labels [0, 2]; asegura que la salida del módulo coincide hasta 1e-6.
  • test_one_epoch_reduces_loss: un único step de entrenamiento sobre un modelo recién inicializado reduce la pérdida (desigualdad estricta). Caza bugs dead-on-arrival.

A qué se parece 'terminado'

  • La última epoch reporta acc > 0.90 con la seed=0 por defecto.
  • loss_curve.png muestra la pérdida cayendo desde ~1.6 (≈ log(5), baseline de init aleatorio) hasta debajo de 0.3.
  • manifest.json queda commiteado, con seed, versions, config, final_loss, final_acc.
  • Los cuatro tests de sanity en verde.

Escollos habituales

  • Confusión tokens vs embeddings. El modelo de este lab consume embeddings (vectores de valores reales), no IDs de token. La tabla de embedding mockeada se sitúa delante del modelo; el modelo nunca ve el índice entero. La Phase 11 (BPE) y la Phase 13 (capa de embedding real) cierran el hueco. Si te encuentras queriendo alimentar el entero 5*v + t a fc1, para — tienes una confusión de orden de capas.
  • Fragilidad por sobreparametrización. Con 2245 parámetros y 100 ejemplos, estás en el régimen de memorización. Si la accuracy se estanca alrededor de 0.20 (aleatorio), el bug está en la pérdida o en el bucle, no en la capacidad. Si la accuracy llega a 1.00 en 5 epochs, no has cometido un error — es lo esperado. El ejercicio es el bucle, no la generalización.
  • Learning rate demasiado alto colapsa el softmax. lr=1e-1 con Adam produce actualizaciones gigantes que mandan los logits a ±100; Softmax se satura y los gradientes desaparecen. Si la accuracy oscila cerca de 0.20 durante muchas epochs, divide el LR a la mitad.
  • Olvidar opt.zero_grad(). Los gradientes se acumulan (invariante de Phase ⅞). Sin zero_grad, el gradiente de la segunda epoch es la suma de dos backward passes, y la actualización está mal por un factor de 2 (luego 3, luego 4...). Síntoma: la pérdida diverge inmediatamente. El Lab 02 ya machacó esto; verifícalo en el Bloque E.
  • Tarea de identidad != generalización. NO reportes la accuracy de este lab como resultado de calidad de modelo. Es un test de integración del framework. La Phase 18 introduce los splits train/val y la diferencia se vuelve significativa.
  • Ops in-place en la pérdida. logits -= logits.max(...) muta el grafo de autograd. Usa siempre ops out-of-place en el forward de CE.
  • Semilla aleatoria no honrada. seed_everything(seed) debe preceder tanto a la construcción de la tabla de embeddings como a la inicialización del modelo, o la ejecución no es reproducible. El manifest afirma reproducibilidad — verifícalo ejecutando dos veces con la misma semilla y haciendo diff de los pesos finales.

Restricciones

  • Nada de clase Trainer. Solo bucle a mano. La Phase 18 introduce la abstracción una vez que has sentido el boilerplate.
  • Nada de PyTorch. Todas las ops vía minitorch y minimodel.
  • Ámbito A13 estricto. 20 verbos, 5 formas verbales, sin plurales, sin español en este lab (el par español es parte del corpus desde la Phase 12 en adelante; aquí solo aprendemos identidad de tiempo verbal).
  • Solo embeddings mockeados. No construyas un lookup de embedding real — la Phase 13 es su fase. Anota la dependencia en manifest.json bajo la clave dependencies ({"phase-13-embeddings": "mocked-by-random-table"}).
  • Full-batch, no minibatched. 100 ejemplos → un batch. El minibatching es Phase 18.

Condiciones de parada

Terminado cuando:

  1. train.py alcanza acc > 0.90 de forma determinista con seed=0.
  2. manifest.json y loss_curve.png están commiteados.
  3. Los cuatro tests de sanity pasan.
  4. Puedes explicar en un párrafo por qué el número de accuracy de este lab no es un resultado de generalización.
  5. Puedes predecir el orden de magnitud de la pérdida final antes de ejecutar (≈ 0.1–0.3 para acc ≈ 0.95).

Cuándo consultar solutions/

Cuando la ejecución alcance acc > 0.90. solutions/03-train-tense-mlp-ref.md (en la apertura de la fase) compara el bucle con la versión canónica, con notas sobre qué refactorizará el Trainer de la Phase 18, y cómo la capa de embedding real de la Phase 13 reemplazará la tabla mockeada.


Siguiente fase: Phase 10 — inicialización, normalización, regularización. PHASE_10_PLAN.md.