Skip to content

English · Español

00 — Motivación: por qué necesitamos Module y Parameter

🇪🇸 La Fase 8 nos dejó con Tensors y un autograd que funciona. Pero si intentas entrenar una red — incluso una sencilla — descubres rápido que necesitas (1) una manera estándar de marcar "este tensor es un peso aprendible" y (2) una manera de encontrar todos los pesos de un modelo recursivamente para pasárselos al optimizador. Parameter resuelve la primera; Module (con su registro automático vía __setattr__) resuelve la segunda. Es ergonomía pura — pero buena ergonomía es la diferencia entre escribir una red en 10 líneas o en 100.


El problema que resuelven Module y Parameter

Supón que tienes el Tensor de la Fase 8 y quieres entrenar un MLP de 2 capas. La aproximación ingenua:

W1 = Tensor(rng.standard_normal((23, 16)), requires_grad=True)
b1 = Tensor(np.zeros(16), requires_grad=True)
W2 = Tensor(rng.standard_normal((16, 5)), requires_grad=True)
b2 = Tensor(np.zeros(5), requires_grad=True)

def forward(x):
    h = (x @ W1 + b1).relu()
    return h @ W2 + b2

# Bucle de entrenamiento
params = [W1, b1, W2, b2]   # ← mantienes esta lista a mano
for _ in range(100):
    loss = cross_entropy(forward(x), y)
    loss.backward()
    for p in params:
        p.data -= 0.01 * p.grad
        p.grad = None

Esto funciona. Incluso escala — para dos capas. Pero fíjate:

  1. params se mantiene a mano. Añade una tercera capa, olvídate de extender params, y esa capa nunca se actualiza. El bug es silencioso — la pérdida decrece (porque las otras capas siguen entrenando), solo menos de lo que debería.
  2. Sin encapsulación. "¿Qué es este modelo?" no tiene respuesta — es la unión de W1, b1, W2, b2, forward. No hay un objeto que puedas pasar, guardar, cargar o imprimir.
  3. Sin reutilización. Si quieres un MLP de 3 capas, copias y pegas el código de la capa. No hay abstracción Linear.

La solución son dos ideas:

  1. Una clase Parameter — un Tensor con requires_grad=True y un marcador que dice "soy un peso propiedad". El marcador es solo una subclase (class Parameter(Tensor): pass); la magia es que Module sabe cómo encontrar Parameters por reflexión.
  2. Una clase base Module con dos responsabilidades:
  3. Cuando haces self.W1 = Parameter(...) dentro de Module.__init__, la clase base intercepta la asignación (vía __setattr__) y registra W1 en un dict interno _parameters.
  4. module.parameters() recorre el dict _parameters y visita recursivamente los _modules submódulos para producir cada Parameter en el árbol.

Con estas dos piezas, el MLP se convierte en:

class TenseMLP(Module):
    def __init__(self):
        super().__init__()
        self.fc1 = Linear(23, 16)
        self.fc2 = Linear(16, 5)
    def forward(self, x):
        return self.fc2(self.fc1(x).relu())

model = TenseMLP()
optim = SGD(model.parameters(), lr=0.01)
for _ in range(100):
    optim.zero_grad()
    loss = cross_entropy(model(x), y)
    loss.backward()
    optim.step()

Cinco líneas para el bucle de entrenamiento, una definición para el modelo. Sin lista de parámetros que mantener. Añade una tercera capa — self.fc3 = Linear(5, 5) — y se incorpora automáticamente. Eso es lo que te da Module.

¿Por qué __setattr__ y no magia en __init__?

Dos alternativas al registro con __setattr__:

  • Alternativa A — register_parameter("W1", p) explícito. Esto funciona (PyTorch lo soporta como API subyacente). Pero es verboso — cada parámetro necesita dos líneas: p = Parameter(...) y luego self.register_parameter("W1", p). La mayoría del código quiere el atajo self.W1 = Parameter(...).
  • Alternativa B — introspección con __init_subclass__. Pythonicamente ingenioso pero difícil de depurar. Escaneas atributos de clase en tiempo de definición. No maneja parámetros creados en tiempo de instancia (p.ej., tablas de embedding cuyo tamaño depende de los args de __init__).

PyTorch eligió __setattr__ y el diseño ha envejecido bien. Lo copiamos.

La mecánica en 20 líneas (el Lab 00 construye esto):

class Module:
    def __init__(self):
        # Usa object.__setattr__ para sortear nuestro propio __setattr__ — el huevo y la gallina.
        object.__setattr__(self, "_parameters", {})
        object.__setattr__(self, "_modules", {})

    def __setattr__(self, name, value):
        if isinstance(value, Parameter):
            self._parameters[name] = value
        elif isinstance(value, Module):
            self._modules[name] = value
        object.__setattr__(self, name, value)

    def parameters(self):
        yield from self._parameters.values()
        for m in self._modules.values():
            yield from m.parameters()

Ese es todo el framework. Todo lo demás (Linear, Sequential, Adam) descansa encima de esto.

🇪🇸 La idea más sutil: tener que llamar a super().__init__() antes de asignar el primer Parameter. Si lo olvidas, _parameters no existe todavía, y la primera asignación falla. PyTorch tiene este mismo "gotcha" — es el primer error que todo principiante encuentra.

Parameter vs Tensor: la única diferencia

Parameter no es una clase más rica que Tensor. Es un Tensor con dos cosas:

  1. requires_grad=True por defecto (porque los parámetros siempre son aprendibles).
  2. Es una instancia de la subclase Parameter, así que isinstance(x, Parameter) lo distingue de un Tensor normal — que es lo que __setattr__ necesita.

Eso es todo. Sin nuevas ops, sin nuevos métodos, sin nuevo estado. El sentido completo de Parameter es ser un marcador.

class Parameter(Tensor):
    def __init__(self, data, requires_grad=True):
        super().__init__(data, requires_grad=requires_grad)

(Clase de dos líneas. El trabajo está enteramente en Module.)

Lo que Module te da gratis

Una vez que el registro funciona, varias comodidades aparecen:

  • Enumeración recursiva de parámetros. model.parameters() produce cada Parameter en el árbol del modelo.
  • zero_grad() recursivo. Recorre el árbol, pone p.grad = None en cada Parameter.
  • state_dict() recursivo. Representación serializable: {"fc1.weight": ndarray, "fc1.bias": ndarray, ...}.
  • load_state_dict(state) recursivo. Inverso del anterior.
  • print(model) que recorre el árbol y emite algo legible.
  • Forward como __call__. model(x) llama a model.forward(x). Convención de PyTorch.

El nn.Module de PyTorch añade más (hooks, modos train/eval, buffers, transferencia de dispositivo) — la Fase 9 implementa el mínimo. La Fase 10 añade train/eval; la Fase 17 añade buffers (para embeddings posicionales); la Fase 18 añade hooks (para logueo de gradientes).

¿Por qué usar la convención de PyTorch?

Dos razones:

  1. Transferencia ergonómica. El sentido completo del port drill de la Fase 9 (experiments/09-pytorch-port-drill/) es confirmar que la API está lo bastante cerca de PyTorch como para portar un script pequeño en 30 minutos. Cuanto más cercana la API, más suave la transferencia.
  2. Puentes futuros. La Fase 24 importa PyTorch y usa nn.Module real. El modelo mental que construyes en la Fase 9 debe ser el mismo que usa la Fase 24. Si el Module de minimodel funcionara totalmente diferente, el onboarding de la Fase 24 costaría esfuerzo cognitivo extra.

No estamos copiando PyTorch servilmente — omitimos mucho (device, dtype, hooks, buffers). Pero lo que implementamos coincide con la API de PyTorch hasta los nombres de método y el orden de los parámetros.

Ancla temática (§A13)

El MLP que entrenaremos al final de esta fase tiene espacio de entrada (one-hot del verbo ⊕ one-hot de la persona) — 23 dimensiones — y espacio de salida (logits sobre 5 tiempos). La clase del modelo es TenseMLP. Sus submódulos son fc1: Linear(23, 16) y fc2: Linear(16, 5). Su método parameters() debe producir fc1.weight, fc1.bias, fc2.weight, fc2.bias en ese orden. El grid gramatical (20 verbos × 3 personas × 5 tiempos = 300 triplas) es la fuente de datos de entrenamiento.

Al final de la Fase 9, Borja posee suficiente framework para escribir cualquier MLP pequeño — incluido el que conduce el tutor de conjugación §A13 en la Fase 32.

Lo que esta página NO cubre

  • Modos Module.train() / eval(). Stub como no-op en la Fase 9; el BatchNorm de la Fase 10 los activa.
  • Buffers (estado persistente no aprendible). La Fase 11 (embeddings, codificaciones posicionales) los necesita.
  • Dualidad nn.functional vs nn.Module. PyTorch tiene ambos. Nosotros tenemos un minitorch.functional delgado (Fase 8) y minimodel.nn.* (Fase 9). La dualidad es real: nn.CrossEntropyLoss()(logits, targets) llama a minitorch.cross_entropy(logits, targets) por debajo. Documenta en la apertura de fase.

Recapitulación de un párrafo

Module y Parameter son una capa ergonómica sobre el Tensor de la Fase 8. Parameter es una subclase de Tensor con requires_grad=True y un marcador para isinstance. Module usa __setattr__ para interceptar la asignación de atributos, registrar Parameters y submódulos en dicts, y exponer un método parameters() recursivo. ~20 líneas de astucia; el resto es composición directa. La API de PyTorch se copia de cerca porque ha envejecido bien y porque el onboarding de la Fase 24 al PyTorch real será más suave por ello.


Siguiente: 01-parameter-and-module.md