PyTorch · Fundamentos
nn.Module: la unidad de composición de toda arquitectura
Una arquitectura entera —desde un MLP de dos capas hasta un transformer de 70B parámetros— se arma anidando la misma clase base una y otra vez. Esta página fija exactamente cómo funciona esa composición.
Antes de leerPyTorch 1 §1, tensores
🎯 Al terminar vas a poder
- Explicar qué hace
nn.Modulepor vos en__init__y en__call__, y por quémodel(x)no es lo mismo quemodel.forward(x). - Distinguir parámetros, buffers y submódulos — qué se entrena, qué viaja en el
state_dict, qué se mueve con.to(device). - Escribir un módulo con capas en
nn.ModuleList/nn.ModuleDict, un buffer registrado y una inicialización conapply(), y verificar con una línea cuántos parámetros entrena. - Inspeccionar activaciones de cualquier modelo ajeno con
register_forward_hooksin tocar su código. - Reconocer los bugs de composición clásicos: capas en listas de Python, Softmax antes de
CrossEntropyLoss, capas creadas dentro deforward.
1. nn.Module: todo en PyTorch es un módulo
Cada módulo en PyTorch hereda de nn.Module. Una red neuronal completa es en sí
misma un módulo que contiene otros módulos (capas) — la misma estructura anidada,
recursivamente, sin límite de profundidad:
class NeuralNetwork(nn.Module):
def __init__(self):
super().__init__()
self.flatten = nn.Flatten()
self.linear_relu_stack = nn.Sequential(
nn.Linear(28*28, 512),
nn.ReLU(),
nn.Linear(512, 512),
nn.ReLU(),
nn.Linear(512, 10),
)
def forward(self, x):
x = self.flatten(x)
return self.linear_relu_stack(x)
Dos reglas fijas: el __init__ declara las capas (con super().__init__()
siempre primero, para que nn.Module pueda registrar internamente todo lo que se asigne
después), y forward define cómo fluyen los datos a través de ellas. Esta separación
—declarar piezas, después definir el flujo— es lo que permite que el mismo módulo se pueda inspeccionar,
mover a GPU, guardar y componer dentro de módulos más grandes sin cambiar nada de su código interno.
El mecanismo detrás de "registrar" es concreto: nn.Module sobreescribe
__setattr__. Cada vez que hacés self.algo = valor, mira qué es
valor: si es un nn.Module lo guarda en self._modules, si es un
nn.Parameter en self._parameters, y si no, es un atributo de Python común. De
ahí salen parameters(), state_dict(), .to(), train()
— todos recorren esos diccionarios recursivamente. Y de ahí sale el error más clásico al olvidar
super().__init__(): AttributeError: cannot assign module before Module.__init__()
call, porque los diccionarios donde iría la capa todavía no existen.
📐 Anatomía interna del pipeline __call__, hooks y autograd.Function
En PyTorch nunca se llama directamente a model.forward(x), sino a model(x). Esto se debe a que nn.Module.__call__ ejecuta un pipeline riguroso de cuatro etapas:
- Pre-forward hooks (
_forward_pre_hooks): transforman o inspeccionan los tensores de entrada antes del cómputo (e.g. observadores de cuantización estática o adaptadores de entrada). - Invocación del método
forward(): ejecución del código de usuario en Python. - Post-forward hooks (
_forward_hooks): capturan las activaciones para inspección de gradientes, profiling o guardado de intermediarios para visualización. - Registro en el grafo autograd: vinculación de las funciones de backward mediante nodos C++ de autodiferenciación.
Llamar a model.forward(x) directamente desactiva silenciosamente todos los hooks registrados, corrompiendo mecanismos como activation checkpointing o profilings de PyTorch.
Diferencia entre nn.Parameter y register_buffer: Ambos se serializan en el state_dict y se transfieren entre CPU y GPU con .to(device). La distinción matemática es que nn.Parameter inicializa con requires_grad=True y se incluye en model.parameters() para optimización por descenso de gradiente. Los buffers (self.register_buffer('inv_freq', ...)) representan constantes numéricas no derivables (e.g. frecuencias angulares fijas en RoPE o medias móviles en BatchNorm).
Derivación de autograd.Function y Vector-Jacobian Product (VJP): Para definir capas no estándar con memoria eficiente, se hereda de torch.autograd.Function implementando el producto vector-Jacobiano analítico:
En el pase hacia adelante, ctx.save_for_backward(x, w) retiene únicamente los tensores mínimos indispensables; en el pase hacia atrás, backward(ctx, grad_output) recibe el gradiente entrante y computa analíticamente los gradientes respecto de entradas y pesos sin registrar sub-grafos intermedios.
Imprimir un módulo muestra el árbol completo, con los nombres que vos elegiste y los índices de los
contenedores — los mismos nombres que después aparecen en named_parameters() y en el
state_dict:
print(NeuralNetwork())
NeuralNetwork(
(flatten): Flatten(start_dim=1, end_dim=-1)
(linear_relu_stack): Sequential(
(0): Linear(in_features=784, out_features=512, bias=True)
(1): ReLU()
(2): Linear(in_features=512, out_features=512, bias=True)
(3): ReLU()
(4): Linear(in_features=512, out_features=10, bias=True)
)
)
Cuando en el Capítulo 1 de HF hiciste
print(model) sobre un transformer y viste 32 bloques idénticos con
self_attn, mlp, input_layernorm, era exactamente esto: un
nn.Module con un nn.ModuleList de bloques, cada bloque un módulo con submódulos.
[512, 784] transpuestos respecto del orden (in, out) son la convención de nn.Linear, no un error.2. Por qué nunca llamás a .forward() directamente
La documentación es enfática: nunca llamar model.forward() directamente —
siempre model(x). La razón es que nn.Module sobreescribe
__call__, y ese __call__ ejecuta forward además de "operaciones de
fondo" necesarias: hooks registrados (para inspeccionar activaciones, como hiciste con
ActivationStats en fast.ai Parte 2), el modo train/eval, y el registro para autograd.
Llamar a forward a mano salta todo eso silenciosamente.
Vale precisar qué salta y qué no. Autograd registra las operaciones en el momento en que se ejecutan sobre
tensores con requires_grad, así que forward() a secas sí produce
gradientes; y training es un atributo que forward lee, así que Dropout también
se comporta según el modo. Lo que sí se pierde son los hooks — los forward pre-hooks,
forward hooks y backward hooks que vos, una librería (PEFT para inyectar LoRA, Accelerate
para offloading, herramientas de profiling y de interpretabilidad) o torch.compile hayan
registrado sobre ese módulo. Como no sabés qué hooks tiene un modelo ajeno, la regla es absoluta: siempre
model(x). La sección 6 muestra cómo se registran.
3. Las piezas: Flatten, Linear, ReLU, Sequential — y dónde no va Softmax
Nada de esto es nuevo si venís de fast.ai Parte 2 — son los mismos bloques, con nombre oficial:
nn.Flatten: convierte cada imagen 2D en un vector contiguo, preservando la dimensión de batch (dim=0).nn.Linear(in_features, out_features): la transformación linealx@W.T + b, con los pesos ya como parámetros entrenables. Elweightse guarda como(out, in)— por eso en el ejemplo de §4 la primera capa muestra[512, 784]y no[784, 512].nn.ReLU: la no-linealidad entre capas lineales — sin ella, apilarLinearcolapsa matemáticamente en una sola transformación lineal, como ya viste en fast.ai Lección 3. Es stateless (no tiene parámetros), así que da lo mismo usar una instancia oF.relu; los transformers modernos usan variantes suaves (GELU, SiLU/SwiGLU) por la misma razón.nn.Sequential: un contenedor que pasa los datos por sus módulos internos en orden — útil para prototipar rápido, aunque elforwardexplícito de una clase da más control (ramas, skip connections, múltiples inputs). Acepta unOrderedDictsi querés nombres en vez de índices.nn.Softmax(dim): convierte logits (valores sin acotar) en probabilidades que suman 1 sobre la dimensión indicada — el mismo softmax de atención que ya usaste en el Capítulo 1 de HF. El tutorial oficial lo aplica afuera del modelo, sobre los logits, y solo para leer la predicción.
⚠️ Cuidado: Softmax no va al final del modelo si la loss es CrossEntropyLoss
nn.CrossEntropyLoss ya aplica log_softmax internamente y espera
logits crudos. Si terminás el modelo con nn.Softmax, la loss vuelve a
aplicar softmax sobre probabilidades: el resultado sigue siendo un número válido y el entrenamiento
"funciona", pero las probabilidades quedan aplastadas hacia el centro, los gradientes se achican y el
modelo aprende mucho más lento y con un techo de confianza artificial. Es un bug silencioso — no hay
error, solo una curva de loss que no baja como debería. La regla: el modelo devuelve
logits; softmax (o argmax, que da la misma clase sin normalizar) se aplica
solo al leer la salida. Lo mismo vale para nn.Sigmoid +
BCEWithLogitsLoss. Este texto originalmente listaba Softmax como una pieza más del
modelo; el énfasis correcto es el contrario.
logits = model(X) # (B, 10), sin normalizar
loss = nn.CrossEntropyLoss()(logits, y) # log_softmax + NLL adentro
probs = logits.softmax(dim=1) # solo para mostrar / calibrar
pred = logits.argmax(dim=1) # la clase, sin necesidad de softmax
4. parameters(): cómo el optimizador encuentra qué ajustar
Subclasear nn.Module rastrea automáticamente cada campo asignado dentro de él — no hace
falta registrar nada a mano. Esto es lo que hace posible model.parameters() y
model.named_parameters():
for name, param in model.named_parameters():
print(f"Layer: {name} | Size: {param.size()}")
# Layer: linear_relu_stack.0.weight | Size: torch.Size([512, 784])
# Layer: linear_relu_stack.0.bias | Size: torch.Size([512])
Esta es la lista exacta que se le pasa a cualquier optimizador (torch.optim.SGD(model.parameters(),
lr=...)) — sin este rastreo automático, cada capa nueva agregada a un modelo requeriría actualizar
manualmente la lista de parámetros a optimizar en algún otro lugar del código. Es un detalle de diseño
pequeño que evita una categoría entera de bugs silenciosos ("me olvidé de agregar esta capa a la lista de
parámetros entrenables").
Un nn.Parameter es un tensor con requires_grad=True por defecto y la marca de
"esto se registra". Contar cuántos hay, y cuántos se entrenan, es la línea que más veces vas a escribir:
total = sum(p.numel() for p in model.parameters())
entrenables = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"{total/1e6:.2f}M parámetros, {entrenables/1e6:.2f}M entrenables")
0.67M parámetros, 0.67M entrenables
Verificalo a mano con el modelo de §1: 784×512 + 512 + 512×512 + 512 + 512×10 + 10 = 669 706. Con un
adapter LoRA sobre un modelo base congelado, la segunda cifra baja a una fracción de un por ciento de la
primera — el print_trainable_parameters() de PEFT es exactamente esta línea. Congelar es
simplemente p.requires_grad_(False); y el optimizador conviene construirlo con
[p for p in model.parameters() if p.requires_grad] para no arrastrar estado de Adam
(dos tensores fp32 por parámetro) sobre pesos que nunca van a cambiar.
5. Lo que nn.Module registra y lo que no: buffers, ModuleList, ModuleDict
El registro automático de §1 tiene una regla simple y dos consecuencias que hay que conocer: se registra
lo que es un nn.Module o un nn.Parameter asignado directamente como
atributo. Una lista de Python con capas adentro no es ninguna de las dos cosas.
Listas y diccionarios de módulos
class MalHecho(nn.Module):
def __init__(self):
super().__init__()
self.layers = [nn.Linear(8, 8) for _ in range(3)] # lista de Python: invisible
class BienHecho(nn.Module):
def __init__(self):
super().__init__()
self.layers = nn.ModuleList([nn.Linear(8, 8) for _ in range(3)])
def forward(self, x):
for layer in self.layers: # ModuleList se itera e indexa como una lista
x = torch.relu(layer(x))
return x
len(list(MalHecho().parameters())), len(list(BienHecho().parameters()))
(0, 6)
El modelo "mal hecho" corre sin ningún error: el forward funciona, la loss baja o no, pero
parameters() está vacío, el optimizador no actualiza nada, .to("cuda") deja
esas capas en CPU y el state_dict las omite. nn.ModuleList se comporta como
una lista pero registra; nn.ModuleDict hace lo mismo con claves (útil para ramas por nombre:
self.heads = nn.ModuleDict({"cls": ..., "reg": ...})); y nn.ParameterList /
nn.ParameterDict son los equivalentes para tensores sueltos. A diferencia de
nn.Sequential, ModuleList no define forward: vos decidís el orden
y podés meter lógica entre capas — así están escritos los bloques de todos los transformers de HF.
Buffers: estado que no se entrena pero viaja con el modelo
Hay tensores que forman parte del modelo pero no son parámetros: la media y varianza acumuladas de
BatchNorm, las tablas de senos y cosenos de RoPE, una máscara causal precalculada, los
factores de escala de un modelo cuantizado. Si los guardás como atributo común
(self.mask = torch.tril(...)) se quedan en CPU cuando movés el modelo a GPU y desaparecen
del checkpoint. register_buffer resuelve las dos cosas:
class CausalAttentionLite(nn.Module):
def __init__(self, dim, max_len=1024):
super().__init__()
self.qkv = nn.Linear(dim, 3 * dim)
mask = torch.tril(torch.ones(max_len, max_len, dtype=torch.bool))
self.register_buffer("mask", mask, persistent=False) # viaja con .to(), no va al state_dict
self.register_buffer("calls", torch.zeros(())) # persistent=True (default): sí se guarda
def forward(self, x):
T = x.shape[1]
q, k, v = self.qkv(x).chunk(3, dim=-1)
att = (q @ k.transpose(-2, -1)) / q.shape[-1] ** 0.5
att = att.masked_fill(~self.mask[:T, :T], float("-inf")).softmax(-1)
self.calls += 1
return att @ v
m = CausalAttentionLite(16)
[n for n, _ in m.named_buffers()], list(m.state_dict().keys())
(['mask', 'calls'], ['qkv.weight', 'qkv.bias', 'calls'])
Los dos buffers se mueven con m.to("cuda") y con m.bfloat16() (ojo: un buffer
booleano no cambia con el cast de dtype, uno float sí); solo calls aparece en el
state_dict porque mask se marcó como no persistente — sentido común para
cualquier cosa que se pueda recomputar desde la config y ocupe espacio en el checkpoint. Cuando en un
state_dict de HF ves claves como rotary_emb.inv_freq o
bn.running_mean, son buffers.
nn.Module o nn.Parameter asignado como atributo directo, más lo que declarás con register_buffer. Todo lo demás es un atributo de Python común, y el modelo «funciona» sin entrenarlo, sin guardarlo y sin moverlo, que es la peor clase de bug.6. Herramientas del día a día: to(), apply(), hooks
model.to(device) — in-place, a diferencia de los tensores
Para un tensor, .to() devuelve una copia. Para un módulo, .to() modifica
el módulo en el lugar (recorre parámetros y buffers y los reemplaza) y devuelve self
solo para poder encadenar. Así que model.to("cuda") sin asignar funciona, y
x.to("cuda") sin asignar no. El mismo método acepta dtype (model.to(torch.bfloat16),
o los atajos model.half() / model.bfloat16()), y es lo que hace
from_pretrained(..., dtype=torch.bfloat16, device_map="cuda") por vos.
device = "cuda" if torch.cuda.is_available() else "cpu"
model.to(device) # in-place; el return es el mismo objeto
X, y = X.to(device), y.to(device) # tensores: hay que reasignar
next(model.parameters()).device # truco para saber dónde vive un modelo
model.apply(fn) — inicialización recursiva
apply llama fn(module) sobre cada submódulo, hijos primero, y termina en el
propio modelo. Es la forma canónica de inicializar pesos según el tipo de capa, y de ahí vienen los
_init_weights que ves en todos los modelos de Transformers:
def init_weights(m):
if isinstance(m, nn.Linear):
nn.init.trunc_normal_(m.weight, std=0.02) # estilo GPT-2
if m.bias is not None:
nn.init.zeros_(m.bias)
elif isinstance(m, nn.Embedding):
nn.init.normal_(m.weight, std=0.02)
model.apply(init_weights)
Los nn.init.*_ son in-place sobre parámetros — funcionan porque corren bajo
no_grad por dentro (recordá la página anterior).
El default de nn.Linear es una uniforme tipo Kaiming; para redes profundas con ReLU la
inicialización de He/Kaiming (kaiming_normal_) es la que viste en fast.ai Parte 2 cuando
mediste que las activaciones no se fueran a cero capa a capa.
register_forward_hook — ver adentro sin tocar el código
Un forward hook es una función fn(module, input, output) que __call__ ejecuta
después del forward de ese módulo. Sirve para imprimir shapes, guardar activaciones,
medir estadísticas o incluso reemplazar la salida (si devuelve algo). El ejemplo clásico, que aplica a
cualquier modelo aunque no sepas cómo está escrito:
handles = []
def print_shape(name):
def hook(module, inputs, output):
print(f"{name:28s} {tuple(inputs[0].shape)} -> {tuple(output.shape)}")
return hook
for name, module in model.named_modules():
if isinstance(module, (nn.Linear, nn.Flatten)):
handles.append(module.register_forward_hook(print_shape(name)))
with torch.no_grad():
model(torch.rand(2, 1, 28, 28))
for h in handles: h.remove() # siempre: los hooks quedan pegados al módulo
flatten (2, 1, 28, 28) -> (2, 784)
linear_relu_stack.0 (2, 784) -> (2, 512)
linear_relu_stack.2 (2, 512) -> (2, 512)
linear_relu_stack.4 (2, 512) -> (2, 10)
named_modules() recorre el árbol completo (incluida la raíz), children() solo
el primer nivel. Los mismos hooks, con register_forward_pre_hook (antes del forward) y
register_full_backward_hook (durante backward), son la base de ActivationStats
de fast.ai, de las herramientas de interpretabilidad que leen residual streams, y de los "hooks de
parcheo" con los que se inyecta un adapter sin reescribir el modelo. Y son la razón por la que
model.forward(x) directo es un error: se saltea todo esto.
7. Errores comunes
La mayoría de estos bugs no lanzan excepción: el modelo entrena "algo" y el problema aparece como una loss que no baja o un checkpoint incompleto.
| Síntoma | Causa probable | Qué hacer |
|---|---|---|
AttributeError: cannot assign module before Module.__init__() call | Falta super().__init__(), o está después de la primera asignación de capa. | Primera línea de __init__: super().__init__() (§1). |
La loss no baja nada; parameters() tiene menos entradas de las esperadas; optimizer got an empty parameter list | Capas guardadas en una lista o dict de Python en vez de nn.ModuleList/nn.ModuleDict. | Reemplazá el contenedor; verificá con la línea de conteo de parámetros de §4 (§5). |
| Entrena, pero muy lento; las probabilidades nunca pasan de ~0,6; loss estancada en un valor "razonable" | nn.Softmax al final del modelo con CrossEntropyLoss (doble softmax). Análogo: Sigmoid + BCEWithLogitsLoss. | El modelo devuelve logits; softmax solo al leer la salida (§3). |
Cada llamada al modelo da salidas distintas con los mismos pesos cargados; la loss no baja; el state_dict no tiene esa capa | forward crea capas nuevas (nn.Linear(...) adentro del forward): pesos aleatorios en cada llamada y fuera de parameters(). | Toda capa con parámetros se declara en __init__; en forward solo se usan (§1, §5). |
RuntimeError: Expected all tensors to be on the same device aunque hiciste model.to(device) | Un tensor guardado como atributo común (máscara, tabla de posiciones) en vez de buffer; o una capa en una lista de Python que .to() no recorrió. | register_buffer para estado no entrenable; ModuleList para capas (§5, §6). |
| Estadísticas de BatchNorm inconsistentes; ramas que se "contaminan" entre sí | Un mismo nn.BatchNorm reutilizado en dos ramas o dos posiciones: comparte running_mean/running_var y los pesos afines entre ambas. (Compartir nn.ReLU es inocuo porque no tiene estado; compartir nn.Dropout no rompe la matemática, pero te impide fijar p por rama y confunde hooks y nombres.) | Una instancia de norma por posición; instanciá en un loop con ModuleList (§5). |
Cargar un checkpoint da Missing key(s) para mask o similares | Buffer registrado con persistent=True en una versión y False en otra, o al revés. | Decidí por buffer si se guarda o se recomputa, y mantenelo; strict=False solo si entendés qué falta (ver la página 4) (§5). |
| Los hooks de una herramienta de debugging "no disparan" | Alguien llama module.forward(x) en vez de module(x). | Buscá .forward( en el código y reemplazalo por la llamada normal (§2). |
8. Resumen
- Una red neuronal es un
nn.Moduleque contiene otrosnn.Module— composición recursiva, sin límite de profundidad. El registro automático funciona porque__setattr__intercepta cada asignación de módulo o parámetro. - Siempre
model(x), nuncamodel.forward(x)—__call__ejecuta los hooks (tuyos, de PEFT, de profilers) queforwardsolo no dispara. - Flatten, Linear, ReLU y Sequential son los mismos bloques que ya usaste en fast.ai, con su nombre oficial en
torch.nn. Softmax no va al final del modelo si la loss esCrossEntropyLoss: el modelo devuelve logits. - Subclasear
nn.Modulerastrea automáticamente todos los parámetros —model.parameters()es lo que cualquier optimizador necesita para saber qué ajustar;sum(p.numel() for p in model.parameters() if p.requires_grad)es la verificación de una línea. - Solo se registra lo asignado directamente: listas de Python no.
nn.ModuleList/nn.ModuleDictpara capas,register_bufferpara estado no entrenable que debe moverse con.to()y (opcionalmente) guardarse. model.to()es in-place (a diferencia de los tensores),model.apply(fn)es la forma canónica de inicializar, yregister_forward_hookpermite ver shapes y activaciones de cualquier modelo sin tocar su código.
9. Ponete a prueba
Respondé antes de abrir cada respuesta. Si fallás una, volvé a la sección indicada.
1. Un módulo tiene self.blocks = [Block() for _ in range(4)]. El forward corre bien y la loss cae de 2,3 a 2,2 y se estanca. ¿Qué pasa y cómo lo confirmás en una línea?
La lista de Python no se registra: los cuatro bloques no están en parameters(), el optimizador solo actualiza las capas que sí están (por eso baja un poco) y .to() ni el state_dict los ven. Confirmás con sum(p.numel() for p in model.parameters()): da mucho menos de lo que calculás a mano. Solución: nn.ModuleList. (§5)
2. ¿Por qué x = x.to("cuda") necesita la asignación y model.to("cuda") no?
Para tensores, .to() devuelve un tensor nuevo y deja el original intacto. Para módulos, recorre parámetros y buffers y los reemplaza in-place, devolviendo self por conveniencia. Confundirlo en la dirección "tensor" es la fuente típica de "Expected all tensors to be on the same device". (§6)
3. Tu modelo termina en nn.Softmax(dim=1) y entrenás con nn.CrossEntropyLoss. No hay ningún error. ¿Cuál es el problema?
CrossEntropyLoss aplica log_softmax adentro: sobre probabilidades ya normalizadas, el segundo softmax aplasta la distribución (un vector como [0,9, 0,05, 0,05] se vuelve mucho más plano), los gradientes se achican y el modelo aprende lento con un techo de confianza artificial. El modelo debe devolver logits; softmax solo para leer la salida. (§3)
4. ¿Cuál es la diferencia entre un parámetro, un buffer persistente y un buffer no persistente respecto de parameters(), .to() y state_dict()?
Parámetro: está en parameters(), se mueve con .to(), va al state_dict, recibe gradiente. Buffer persistente: no está en parameters() (no se entrena), se mueve con .to(), va al state_dict — ejemplo: running_mean de BatchNorm. Buffer no persistente: igual pero no se guarda — ejemplo: una máscara causal recomputable. Un atributo común: nada de lo anterior. (§5)
5. Querés imprimir el shape de salida de cada nn.Linear de un modelo de Transformers que no escribiste. ¿Qué hacés sin modificar su código?
Recorrés model.named_modules(), filtrás por isinstance(m, nn.Linear) y registrás en cada uno un register_forward_hook que imprima output.shape; corrés un forward con model(x) (no forward, o los hooks no disparan) y después llamás handle.remove() sobre cada handle. (§2, §6)
6. ¿Qué imprime sum(p.numel() for p in nn.Linear(784, 512).parameters()) y por qué?
401 920: el weight es (512, 784) = 401 408 valores, más el bias de 512. El peso se guarda como (out, in) y el forward hace x @ W.T + b. (§3, §4)
10. Para practicar
✏️ Ejercicio 1 — el doble softmax, medido
Entrená dos MLPs idénticos sobre datos sintéticos separables, uno que devuelve logits y otro que termina en nn.Softmax, ambos con CrossEntropyLoss. Compará la loss final y la confianza máxima. Corre en CPU en segundos.
import torch, torch.nn as nn
torch.manual_seed(0)
X = torch.randn(2000, 20); y = (X[:, :5].sum(1) > 0).long() # 2 clases, fácil
def mlp(con_softmax):
capas = [nn.Linear(20, 64), nn.ReLU(), nn.Linear(64, 2)]
if con_softmax: capas.append(nn.Softmax(dim=1))
return nn.Sequential(*capas)
for con_softmax in (False, True):
torch.manual_seed(1)
m = mlp(con_softmax); opt = torch.optim.Adam(m.parameters(), lr=1e-2); loss_fn = nn.CrossEntropyLoss()
for step in range(300):
loss = loss_fn(m(X), y); opt.zero_grad(); loss.backward(); opt.step()
with torch.no_grad():
out = m(X); probs = out if con_softmax else out.softmax(1)
acc = (out.argmax(1) == y).float().mean()
print(f"softmax al final={con_softmax}: loss={loss.item():.3f} acc={acc:.3f} confianza media={probs.max(1).values.mean():.3f}")
Pista / qué esperar
Las dos versiones alcanzan accuracy alta (el problema es fácil), pero la versión con Softmax queda con una loss claramente mayor y una confianza media más baja: CrossEntropyLoss sobre probabilidades tiene un mínimo teórico por encima de cero (con dos clases, el mejor caso es log_softmax de [1, 0] ≈ 0,31, no 0). En un problema difícil, esa distorsión del gradiente se traduce en aprendizaje más lento.
✏️ Ejercicio 2 — buffers, ModuleList y hooks en un mini-transformer
Armá un modelo con nn.ModuleList de dos bloques CausalAttentionLite (§5) y una máscara como buffer no persistente. Verificá: (a) que el state_dict no contiene mask, (b) que después de .to(torch.bfloat16) el buffer calls cambió de dtype y mask no, (c) con un hook, que la salida de cada bloque tiene shape (B, T, dim).
import torch, torch.nn as nn
# pegá acá la clase CausalAttentionLite de la sección 5
class MiniLM(nn.Module):
def __init__(self, dim=32, n=2):
super().__init__()
self.blocks = nn.ModuleList([CausalAttentionLite(dim) for _ in range(n)])
def forward(self, x):
for b in self.blocks: x = x + b(x)
return x
m = MiniLM()
print([k for k in m.state_dict()])
print(sum(p.numel() for p in m.parameters()))
m.to(torch.bfloat16)
print(m.blocks[0].calls.dtype, m.blocks[0].mask.dtype)
hs = [b.register_forward_hook(lambda mod, i, o: print("bloque ->", tuple(o.shape))) for b in m.blocks]
m(torch.randn(4, 10, 32, dtype=torch.bfloat16))
for h in hs: h.remove()
Pista / qué esperar
Claves del state_dict: blocks.0.qkv.weight, blocks.0.qkv.bias, blocks.0.calls y lo mismo con blocks.1 — sin mask. Parámetros: 2 × (32×96 + 96) = 6 336. Dtypes: torch.bfloat16 y torch.bool. El hook imprime dos veces (4, 10, 32). Si cambiás ModuleList por una lista, el conteo da 0 y el .to(bfloat16) deja los bloques en fp32 — probalo.
11. Fuentes y para seguir
- Build the Neural Network — Learn the Basics — el tutorial original; el recorrido capa por capa con shapes impresos que esta página resume.
- Modules (PyTorch notes) — la nota oficial que explica parámetros, buffers, hooks,
applyy el ciclo de vida de un módulo; es la mejor lectura después de esta página. - torch.nn.Module — referencia — la lista completa de métodos (
named_modules,register_buffer,register_forward_hook,to,apply) con sus firmas exactas. - torch.nn.CrossEntropyLoss — la documentación dice explícitamente que la entrada son logits sin normalizar; leela una vez para no volver a poner Softmax al final.
- Delving Deep into Rectifiers (He et al., 2015, arXiv:1502.01852) — el paper de la inicialización de Kaiming/He que
nn.init.kaiming_normal_implementa; la sección 2.2 explica por qué la varianza depende del fan-in y de ReLU.