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 claseTrainer(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:
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:
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 escribamanifest.json(seed, versiones deminimodel,minitorch,numpy, Python; dict de config). - Grafica
history["loss"]yhistory["acc"]enloss_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.pngymanifest.jsonaexperiments/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 hasta1e-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.90con laseed=0por defecto. loss_curve.pngmuestra la pérdida cayendo desde ~1.6 (≈log(5), baseline de init aleatorio) hasta debajo de0.3.manifest.jsonqueda commiteado, conseed,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 + tafc1, 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 a1.00en 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-1conAdamproduce actualizaciones gigantes que mandan los logits a ±100;Softmaxse satura y los gradientes desaparecen. Si la accuracy oscila cerca de0.20durante muchas epochs, divide el LR a la mitad. - Olvidar
opt.zero_grad(). Los gradientes se acumulan (invariante de Phase ⅞). Sinzero_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
minitorchyminimodel. - Á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.jsonbajo la clavedependencies({"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:
train.pyalcanzaacc > 0.90de forma determinista conseed=0.manifest.jsonyloss_curve.pngestán commiteados.- Los cuatro tests de sanity pasan.
- Puedes explicar en un párrafo por qué el número de accuracy de este lab no es un resultado de generalización.
- Puedes predecir el orden de magnitud de la pérdida final antes de ejecutar (≈
0.1–0.3paraacc ≈ 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.