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.Parameterresuelve 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:
paramsse mantiene a mano. Añade una tercera capa, olvídate de extenderparams, 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.- 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. - 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:
- Una clase
Parameter— unTensorconrequires_grad=Truey un marcador que dice "soy un peso propiedad". El marcador es solo una subclase (class Parameter(Tensor): pass); la magia es queModulesabe cómo encontrar Parameters por reflexión. - Una clase base
Modulecon dos responsabilidades: - Cuando haces
self.W1 = Parameter(...)dentro deModule.__init__, la clase base intercepta la asignación (vía__setattr__) y registraW1en un dict interno_parameters. module.parameters()recorre el dict_parametersy visita recursivamente los_modulessubmódulos para producir cadaParameteren 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 luegoself.register_parameter("W1", p). La mayoría del código quiere el atajoself.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,_parametersno 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:
requires_grad=Truepor defecto (porque los parámetros siempre son aprendibles).- Es una instancia de la subclase
Parameter, así queisinstance(x, Parameter)lo distingue de unTensornormal — 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 cadaParameteren el árbol del modelo. zero_grad()recursivo. Recorre el árbol, ponep.grad = Noneen 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 amodel.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:
- 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. - Puentes futuros. La Fase 24 importa PyTorch y usa
nn.Modulereal. El modelo mental que construyes en la Fase 9 debe ser el mismo que usa la Fase 24. Si elModuledeminimodelfuncionara 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 sí 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.functionalvsnn.Module. PyTorch tiene ambos. Nosotros tenemos unminitorch.functionaldelgado (Fase 8) yminimodel.nn.*(Fase 9). La dualidad es real:nn.CrossEntropyLoss()(logits, targets)llama aminitorch.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