Skip to content

English · Español

Lab 00 — Esqueleto de Parameter y Module

Objetivo: implementa Parameter (una subclase de 2 líneas de Tensor) y Module (la clase base de ~25 líneas con registro vía __setattr__, parameters() recursivo, zero_grad, state_dict, load_state_dict y __repr__). Deja mypy --strict limpio. Escribe los tests estructurales que prueban que la mecánica de registro funciona.

Tiempo estimado: 90–120 minutos.

Prereqs: Fase 8 cerrada (minitorch.Tensor disponible). Teoría 00 + 01 leída.


Qué produces

Un nuevo módulo src/minimodel/nn/ con:

  • __init__.py re-exportando Parameter, Module.
  • module.py conteniendo ambas clases.

Y tests/test_module_skeleton.py con los tests de la mecánica de registro.

🇪🇸 Toda la fase 9 descansa sobre este lab. Si Module no descubre los parámetros correctamente, ni Linear ni Sequential ni los optimizadores funcionan. Saca 90 minutos para que pase limpio antes de pasar al Lab 01.

TODOs

Bloque A — Parameter

En src/minimodel/nn/module.py:

  • Define Parameter(Tensor):
  • __init__(self, data, requires_grad: bool = True) — llama a super().__init__(data, requires_grad=requires_grad).
  • Nada más.

Bloque B — clase base Module

  • Define Module:
  • __init__(self) -> None:
    • Usa object.__setattr__(self, "_parameters", {}) y object.__setattr__(self, "_modules", {}).
    • También object.__setattr__(self, "training", True).
  • __setattr__(self, name, value) -> None:
    • Haz pop de name en ambos _parameters y _modules (maneja el caso de re-asignación).
    • Si isinstance(value, Parameter): registra en _parameters[name].
    • Sino si isinstance(value, Module): registra en _modules[name].
    • En todos los casos, también object.__setattr__(self, name, value).
  • parameters(self) -> Iterator[Parameter]:
    • Yield from _parameters.values().
    • Para cada submódulo en _modules.values(), recursivamente yield from submodule.parameters().
  • zero_grad(self) -> None:
    • Para cada p en self.parameters(), pon p.grad = None.
  • state_dict(self, prefix: str = "") -> dict[str, np.ndarray]:
    • Dict plano; las claves son rutas separadas por puntos.
    • Recorre _modules recursivamente.
  • load_state_dict(self, state, prefix: str = "") -> None:
    • Copia in-place state[key] al Parameter.data que coincida.
    • Recorre _modules recursivamente.
  • train(self, mode: bool = True) -> None y eval(self) -> None: pon self.training; stub para la Fase 9 (no-op semánticamente, pero el atributo debe existir).
  • forward(self, *args, **kwargs) -> Any: lanza NotImplementedError.
  • __call__(self, *args, **kwargs): despacha a self.forward(*args, **kwargs).
  • __repr__(self) -> str: emite un print estilo árbol de submódulos y shapes de parámetros.

Tests

En tests/test_module_skeleton.py:

Bloque C — registro básico

  • test_parameter_is_tensor:

    p = Parameter(np.array([1.0, 2.0, 3.0]))
    assert isinstance(p, Tensor)
    assert p.requires_grad is True
    

  • test_module_init_no_parameters:

    m = Module()
    assert list(m.parameters()) == []
    

  • test_single_parameter_registered:

    class M(Module):
        def __init__(self):
            super().__init__()
            self.w = Parameter(np.zeros(3))
    m = M()
    params = list(m.parameters())
    assert len(params) == 1
    assert params[0] is m.w
    

  • test_submodule_registered:

    class Inner(Module):
        def __init__(self):
            super().__init__()
            self.w = Parameter(np.zeros(3))
    
    class Outer(Module):
        def __init__(self):
            super().__init__()
            self.inner = Inner()
            self.b = Parameter(np.zeros(1))
    
    m = Outer()
    params = list(m.parameters())
    assert len(params) == 2
    assert params[0] is m.b           # direct Parameter first
    assert params[1] is m.inner.w     # then submodule's parameter
    
    (Nota: el orden de yield es _parameters y luego _modules. Fija esta convención.)

Bloque D — re-asignación / sobreescritura

  • test_parameter_reassign_replaces_registration:

    class M(Module):
        def __init__(self):
            super().__init__()
            self.w = Parameter(np.zeros(3))
    m = M()
    new_p = Parameter(np.ones(5))
    m.w = new_p
    params = list(m.parameters())
    assert len(params) == 1
    assert params[0] is new_p
    

  • test_parameter_overwritten_with_module: Pon self.w = Parameter(...), luego self.w = Linear(2, 3) (puedes usar una clase stub temporal para Linear aquí — el Lab 01 construye el real). Verifica que _parameters ya no tiene "w" y _modules sí.

  • test_parameter_overwritten_with_none: Pon self.w = Parameter(...), luego self.w = None. Verifica que _parameters ya no tiene "w", y self.w is None.

Bloque E — zero_grad, state_dict, load_state_dict

  • test_zero_grad_clears_grads: Construye un módulo con 2 parámetros. Asigna manualmente p.grad = np.ones_like(p.data). Llama a m.zero_grad(). Comprueba que todos los grad de los parámetros son None.

  • test_state_dict_keys:

    m = Outer()
    state = m.state_dict()
    assert set(state.keys()) == {"b", "inner.w"}
    

  • test_load_state_dict_roundtrip:

  • m1 = Outer(). Captura state = m1.state_dict().
  • m2 = Outer() (init aleatorio distinto).
  • m2.load_state_dict(state).
  • Comprueba m1.b.data is not m2.b.data (objetos distintos).
  • Comprueba np.array_equal(m1.b.data, m2.b.data) (mismos valores).

Bloque F — casos límite

  • test_shared_parameter_yields_twice: Dos atributos apuntando al mismo Parameter:

    class M(Module):
        def __init__(self):
            super().__init__()
            shared = Parameter(np.zeros(3))
            self.in_emb = shared
            self.out_emb = shared
    
    m = M()
    params = list(m.parameters())
    assert len(params) == 2
    assert params[0] is params[1]
    
    Documenta esto como comportamiento esperado (embeddings atados). La reflexión del Lab 02 pregunta si deduplicar.

  • test_repr_does_not_crash: repr(M()) devuelve una string y no recurre infinitamente.

  • test_call_dispatches_to_forward: Subclasa Module, sobreescribe forward(self, x) para devolver x * 2. Verifica m(5) == 10.

  • test_forward_not_implemented_raises: Module()(any_arg) debe lanzar NotImplementedError.

Bloque G — mypy --strict limpio

  • Sin tipos Any excepto donde aparecen en la API upstream de Tensor.
  • parameters está tipado Iterator[Parameter].
  • state_dict está tipado dict[str, np.ndarray].
  • forward está tipado Any (placeholder; subclases lo estrechan).

Restricciones

  • Sin Linear, sin Sequential, sin activaciones. Esos son el Lab 01.
  • Module es la única base abstracta. No introduzcas Layer, Container, LossModule etc. KISS — la Fase 10+ puede refactorizar si es necesario.
  • Sin PyTorch en src/minimodel/. PyTorch vive solo en tests/ (algunos tests de integración del Lab 02 lo usan).
  • __init_subclass__ está prohibido. La astucia del framework está limitada a __setattr__. Sin metaclases, sin decoradores de clase.

Escollos

  • Recursión de __setattr__. Si Module.__setattr__ escribe vía self.X = value (asignación normal), se dispara a sí mismo. Usa siempre object.__setattr__(self, name, value) para el almacenamiento real.
  • super().__init__() olvidado. Pruébalo construyendo una subclase que olvide llamar a super, luego asignando un Parameter — debe lanzar AttributeError porque _parameters no existe. Documenta el error esperado.
  • parameters() devuelve un generador, no una lista. Los consumidores deben convertir (list(model.parameters())) si necesitan iterar dos veces. La clase base del optimizador hace la conversión internamente.
  • Colisiones de clave en state_dict. Si dos submódulos tienen el mismo nombre (no debería ocurrir pero podría vía subclasing extraño), el segundo sobreescribe al primero. Añade un test (test_state_dict_no_collision) que asegure claves únicas.

Condiciones de parada

Hecho cuando:

  1. Parameter es una subclase de 2 líneas.
  2. Module es ≤ 60 líneas incluyendo type hints y el repr.
  3. Los 12+ tests anteriores están en verde.
  4. mypy --strict src/minimodel/nn/module.py limpio.
  5. ruff check src/minimodel/nn/module.py limpio.
  6. Puedes explicar por qué usamos object.__setattr__ en __init__ (no en el cuerpo de __setattr__).

Cuándo consultar solutions/

Tras pasar todos los tests. solutions/00-parameter-and-module-skeleton-ref.md (en la apertura de fase) compara tu Module contra la implementación canónica y señala dónde la astucia puede ser incluso más corta (o más larga, con mejores mensajes de error).


Siguiente lab: lab/01-linear-and-activations.md.