Curso / PyTorch / Autograd y optimización
PyTorch · 3 de 5

PyTorch · Fundamentos

Autograd y el loop de optimización, con nombre y apellido

Ya construiste backprop a mano en fast.ai Parte 2 y verificaste que coincide con autograd. Acá está la explicación oficial de cómo torch.autograd lo hace por dentro, y el loop de entrenamiento canónico que vas a reescribir, con variaciones, en cada proyecto.

Antes de leerPyTorch 2, nn.Module · fast.ai L3 §2, SGD

🎯 Al terminar vas a poder

  • Explicar qué construye autograd durante el forward, qué consume backward() y por qué un segundo backward() sobre el mismo grafo falla.
  • Elegir entre no_grad, inference_mode, detach y requires_grad_(False) según lo que quieras desactivar.
  • Escribir un training loop completo con acumulación de gradientes, clipping, precisión mixta (bf16 o fp16 + GradScaler) y un scheduler, con cada llamada en el orden correcto.
  • Diagnosticar los síntomas clásicos — memoria que crece por epoch, loss que no baja, NaN a las pocas iteraciones — a partir de qué línea falta o sobra en el loop.

1. El DAG dinámico: por qué PyTorch permite control de flujo

torch.autograd mantiene, mientras se ejecuta el forward pass, un grafo acíclico dirigido (DAG) de objetos Function — las hojas son los tensores de entrada, la raíz es el tensor de salida (típicamente el loss). Cada operación queda registrada como un nodo con su función de derivada correspondiente, accesible vía .grad_fn:

z = torch.matmul(x, w) + b
loss = F.binary_cross_entropy_with_logits(z, y)

print(z.grad_fn)     # <AddBackward0 object>
print(loss.grad_fn)  # <BinaryCrossEntropyWithLogitsBackward0 object>

El detalle que la documentación oficial marca como clave: este grafo se reconstruye desde cero en cada forward pass. No hay un grafo estático definido de antemano (como en TensorFlow 1.x o en Theano) — cada llamada a .backward() consume el grafo actual, y el próximo forward pass arma uno nuevo. Esto es exactamente lo que permite usar if, loops y control de flujo arbitrario de Python dentro de un modelo: la forma, el tamaño y las operaciones pueden cambiar en cada iteración sin ningún problema, porque no hay un grafo fijo que reconciliar.

"Consumir el grafo" tiene un significado preciso: para calcular derivadas, cada nodo guardó los tensores intermedios que necesita (la entrada de un matmul, la salida de una sigmoid) — eso es la memoria de activaciones, la razón por la que entrenar cuesta mucho más memoria que inferir. backward() libera esos tensores a medida que los usa. Por eso un segundo backward() sobre el mismo grafo da RuntimeError: Trying to backward through the graph a second time (or directly access saved tensors after they have already been freed). Si de verdad necesitás dos pasadas (dos losses que comparten un forward, o derivadas de orden superior), la primera se llama con retain_graph=True:

📐 Fundamento matemático: Vector-Jacobian Product (VJP) en Reverse-Mode

Para una red neuronal que compone transformaciones vectoriales sucesivas x∈RN→h1→…→L∈R: El modo directo (*Forward-mode*) calcula productos Jacobiano-vector Jv, requiriendo N evaluaciones para N entradas escalares (O(N) pasadas).

El modo inverso (*Reverse-mode* de Autograd) calcula productos vector-Jacobiano:

v⊤J=∂L∂y⊤∂y∂x

Dado que la función objetivo final es un escalar L∈R (dimensión de salida 1), una sola pasada hacia atrás (O(1) backward pass) calcula simultáneamente los gradientes respecto a los millones de parámetros:

∇θL=∂L∂θ1,∂L∂θ2,…,∂L∂θP
El grafo que autograd construye en un forward y lo que backward consume Hojas x (sin gradiente), w y b (con gradiente). Los nodos MmBackward, AddBackward y BCEWithLogitsBackward guardan los tensores que su derivada necesita: x y w, nada, z e y. loss.backward() recorre el grafo desde la raíz hacia las hojas, deja .grad solo en w y b, y libera los tensores guardados; un segundo backward sobre el mismo grafo falla sin retain_graph. FORWARD · cada operación deja un nodo con su derivada y guarda lo que va a necesitar x sin gradiente w hoja, con grad b hoja, con grad MmBackward guarda x y w AddBackward no guarda nada BCEWithLogitsBackward guarda z e y loss raíz z = x @ w z + b loss.grad_fn BACKWARD · loss.backward() recorre el grafo al revés y lo consume w.grad y b.grad: solo las hojas reciben .grad · x.grad queda en None (retain_grad() para un intermedio) «consumir» = liberar los tensores guardados (x, w, z, y): esa memoria de activaciones es la razón por la que entrenar cuesta mucho más que inferir, y por la que un segundo backward sobre el mismo grafo falla sin retain_graph=True el grafo se arma de nuevo en cada forward: por eso un if de Python o un loop de largo variable adentro del modelo son legales (define-by-run)
Lo que hay detrás de loss.grad_fn. Cada nodo es una función de derivada más los tensores intermedios que necesita, y esos tensores son la memoria de activaciones. backward() los usa y los suelta, así que el grafo vive exactamente un forward; el próximo forward arma uno nuevo, y eso es lo que permite control de flujo arbitrario adentro del modelo.
out = model(x)
loss_a, loss_b = crit_a(out, y), crit_b(out, y)
loss_a.backward(retain_graph=True)   # conserva las activaciones
loss_b.backward()                    # segunda pasada sobre el mismo grafo
# ...aunque casi siempre es más simple y barato: (loss_a + loss_b).backward()

torch.autograd.grad es la interfaz funcional de lo mismo: en vez de escribir en .grad de cada parámetro, devuelve los gradientes respecto de los tensores que le pidas. Es lo que se usa para penalizar gradientes (gradient penalty de WGAN-GP), para derivadas segundas (create_graph=True deja el resultado dentro del grafo) y en métodos de interpretabilidad que derivan la salida respecto de la entrada:

x = torch.randn(4, requires_grad=True)
y = (x ** 3).sum()
(dy_dx,) = torch.autograd.grad(y, x, create_graph=True)     # 3x²; x.grad sigue en None
(d2y_dx2,) = torch.autograd.grad(dy_dx.sum(), x)              # 6x

2. requires_grad, no_grad, inference_mode y detach

Solo los tensores marcados con requires_grad=True acumulan historial de gradiente — y solo se puede leer .grad en los leaf nodes del grafo (los tensores de entrada, no los resultados intermedios; para un intermedio hay que pedirlo antes con .retain_grad()). Para inferencia, donde no hace falta ningún gradiente, dos formas equivalentes de desactivarlo:

with torch.no_grad():
    z = torch.matmul(x, w) + b   # z.requires_grad == False acá dentro

z_det = z.detach()                # mismo efecto, sobre un tensor puntual

Dos razones prácticas para desactivarlo: marcar partes del modelo como parámetros congelados (exactamente lo que pasa con el modelo base cuando entrenás solo un adapter LoRA), y acelerar el forward pass cuando no hace falta backward — construir y mantener el grafo tiene un costo real de memoria y cómputo que no vale la pena pagar en pura inferencia.

Son cuatro herramientas parecidas con alcances distintos, y conviene tener claro cuál va en cada caso:

HerramientaAlcanceCuándo
p.requires_grad_(False)Un parámetro, para siempre (hasta que lo revirtás).Congelar el modelo base en LoRA / fine-tuning parcial. Los tensores que dependen solo de parámetros congelados no arman grafo.
t.detach()Un tensor: devuelve una vista sin historial (comparte memoria).Cortar el grafo en un punto: targets calculados por el propio modelo, EMA, loggear activaciones, .detach().cpu().numpy().
with torch.no_grad()Todo lo que corre dentro del bloque.Evaluación durante el entrenamiento, actualizaciones manuales de pesos (es lo que usan los optimizadores por dentro).
with torch.inference_mode()Como no_grad, más estricto: los tensores creados adentro son "inference tensors" y no se pueden usar después en autograd.Inferencia pura (serving, generación): es algo más rápido porque además omite el conteo de versiones. Si vas a reusar la salida en un grafo (por ejemplo, para destilación), usá no_grad.
with torch.inference_mode():
    logits = model(x)
logits.requires_grad_()            # RuntimeError: Inference tensors cannot be saved for backward...
loss = (logits * w).sum(); loss.backward()   # ...y tampoco se pueden meter en un grafo nuevo

Regla corta: inference_mode para servir, no_grad para evaluar mientras entrenás. Los dos son ortogonales a model.eval() — ver §5.

3. Los gradientes se acumulan — por diseño, no por bug

Cada llamada a .backward() suma el gradiente calculado al valor que ya había en .grad, en vez de reemplazarlo. Es intencional (útil para acumular gradientes de varios mini-batches antes de un solo step, una técnica real para simular batches más grandes de los que entran en memoria), pero significa que sin limpiar explícitamente entre steps, los gradientes de iteraciones distintas se mezclan:

optimizer.zero_grad()    # 1. limpiar gradientes de la iteración anterior
loss.backward()          # 2. calcular gradientes nuevos, sumándolos a .grad
optimizer.step()         # 3. ajustar parámetros usando esos gradientes

Estos tres pasos, en este orden exacto (o con zero_grad() al final, algunas variantes lo ubican ahí), son el corazón de cualquier training loop de PyTorch que vayas a ver — desde un nn.Linear de dos capas hasta el fine-tuning de un LLM de 70B parámetros con LoRA.

Acumulación de gradientes, en código

La "técnica real" merece verse escrita, porque es la forma en que entrenás un batch efectivo de 32 en una GPU donde solo entran 8. La clave es dividir la loss por la cantidad de micro-batches: la loss promedio de un batch de 32 es el promedio de las losses de cuatro batches de 8, y como el gradiente es lineal, sumar cuatro gradientes de loss/4 da exactamente el gradiente del batch grande (con la salvedad de BatchNorm, que ve estadísticas de 8 y no de 32):

accum = 4                                        # batch efectivo = batch_size * accum
optimizer.zero_grad()
for i, (X, y) in enumerate(dataloader):
    loss = loss_fn(model(X), y) / accum          # dividir: así la suma de 4 backward == 1 batch grande
    loss.backward()                              # suma sobre .grad, no reemplaza
    if (i + 1) % accum == 0:
        torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
        optimizer.step()
        optimizer.zero_grad()

clip_grad_norm_ va siempre en el mismo lugar: después del último backward() y antes de step(). Reescala todos los gradientes juntos para que su norma global no supere max_norm (1,0 es el valor habitual en transformers), y devuelve la norma que había antes de recortar — un número que vale la pena loggear, porque un pico repentino ahí es el primer aviso de que el entrenamiento se va a desestabilizar. Es la misma gradient_accumulation_steps + max_grad_norm del Trainer de HF y de los TrainingArguments que usaste en el Capítulo 3.

El orden del loop de optimización, dibujado con acumulación de gradientes Cuatro micro-batches: cada uno hace forward, divide la loss por 4 y hace backward, que suma sobre el buffer .grad de cada parámetro; el buffer se llena al 25, 50, 75 y 100 por ciento. Recién entonces clip_grad_norm_, optimizer.step, scheduler.step y zero_grad, que vacía el buffer. Abajo, por qué la loss se divide, por qué el clip va antes del step, por qué el scheduler va después y por qué loggear con .item(). accum = 4 · batch efectivo = 4 × batch_size · el buffer .grad de cada parámetro se llena de a cuartos micro-batch 1 forward → loss / 4 backward: += .grad .grad al 25 % micro-batch 2 forward → loss / 4 backward: += .grad .grad al 50 % micro-batch 3 forward → loss / 4 backward: += .grad .grad al 75 % micro-batch 4 forward → loss / 4 backward: += .grad .grad al 100 % cada 4 clip_grad_norm_ step → sched.step zero_grad() el buffer vuelve a 0 y si falta: acumula POR QUÉ CADA PIEZA VA DONDE VA loss / accum la loss de un batch de 4B es el promedio; 4 backward sin dividir dan un gradiente 4× más grande clip después del último backward, antes de step reescala todos los gradientes juntos para que la norma global no supere max_norm; devuelve la norma previa: loggeala scheduler.step después de optimizer.step al revés PyTorch avisa: el primer step usaría el lr equivocado · un scheduler.step por optimizer.step, no por micro-batch loss.item() en todo lo que acumules un running_loss += loss sin .item() retiene el grafo de cada batch: la memoria crece hasta el OOM
Los gradientes se acumulan por diseño, y el loop es el mismo con o sin acumulación: backward suma, step consume, zero_grad limpia. Lo único que cambia es cada cuántos backward se hace el resto, y la división de la loss para que cuatro sumas parciales valgan lo mismo que un batch grande.

4. El loop de optimización completo

Con loss, optimizer y los tres pasos de arriba, el loop completo de la documentación oficial:

def train_loop(dataloader, model, loss_fn, optimizer):
    model.train()
    for batch, (X, y) in enumerate(dataloader):
        pred = model(X)
        loss = loss_fn(pred, y)
        loss.backward()
        optimizer.step()
        optimizer.zero_grad()

def test_loop(dataloader, model, loss_fn):
    model.eval()
    test_loss, correct = 0, 0
    with torch.no_grad():
        for X, y in dataloader:
            pred = model(X)
            test_loss += loss_fn(pred, y).item()
            correct += (pred.argmax(1) == y).type(torch.float).sum().item()

nn.CrossEntropyLoss combina internamente LogSoftmax + NLLLoss (Negative Log Likelihood) — por eso el modelo devuelve logits crudos, sin softmax aplicado a mano, y es la loss la que se encarga de normalizarlos. Entre los optimizadores disponibles (torch.optim), SGD es el más simple; Adam y RMSProp suelen converger más rápido en la práctica para la mayoría de arquitecturas modernas.

Fijate en el .item() de test_loop: no es cosmético. loss_fn(pred, y) es un tensor con grad_fn (aun bajo no_grad es un tensor, y fuera de no_grad arrastra el grafo entero); .item() lo convierte a un float de Python y suelta todo. Un running_loss += loss sin .item() en el loop de entrenamiento guarda una referencia al grafo de cada iteración: la memoria crece batch a batch hasta el OOM, y el mensaje de error no menciona el logging por ningún lado.

🟢 Actualización 2026

El tutorial usa SGD por simplicidad. El default de facto para transformers es torch.optim.AdamW — Adam con weight decay desacoplado (Loshchilov & Hutter, 2017), que es lo que Trainer usa si no decís lo contrario. Un valor de partida razonable para fine-tuning: AdamW(params, lr=2e-5 a 2e-4, betas=(0.9, 0.95 o 0.999), weight_decay=0.1 o 0.01). Tené presente el costo: Adam guarda dos tensores fp32 (momento y varianza) por parámetro entrenable — con LoRA eso es casi nada; con full fine-tuning son 8 bytes por parámetro solo de estado. Las variantes fused=True (una sola llamada a kernel por step) y los optimizadores de 8 bits de bitsandbytes atacan justamente ese costo.

5. model.train() vs model.eval(): un interruptor, no cosmética

La documentación oficial insiste en un detalle que es fácil pasar por alto: llamar model.train() antes de entrenar y model.eval() antes de evaluar no es una convención cosmética. Capas como BatchNorm y Dropout se comportan de forma distinta en cada modo — BatchNorm usa estadísticas del batch actual en training pero un promedio acumulado en eval; Dropout apaga neuronas al azar en training pero las deja todas activas en eval. Olvidarse de este switch produce resultados de inferencia inconsistentes y difíciles de diagnosticar, porque el modelo "funciona", solo que con números ligeramente distintos cada vez de una forma que no tiene una causa obvia hasta que se revisa este detalle puntual.

model.eval() y torch.no_grad() se confunden porque siempre aparecen juntos, pero son dos cosas independientes que hay que hacer las dos:

Qué cambiaSi te lo olvidás
model.eval()El atributo training de cada submódulo: Dropout se apaga, BatchNorm usa sus estadísticas acumuladas. No toca autograd.Resultados no determinísticos (Dropout activo) y métricas de evaluación distintas a las de producción.
torch.no_grad()Autograd: no se construye el grafo ni se guardan activaciones. No toca el comportamiento de las capas.La evaluación usa tanta memoria como el entrenamiento (se puede quedar sin memoria con un batch que en training entraba) y es más lenta.

Y el olvido simétrico: después de evaluar, model.train() de vuelta. El Trainer de HF y el Learner de fastai lo hacen por vos; en un loop escrito a mano, la evaluación de mitad de epoch que deja el modelo en eval es un bug clásico — el entrenamiento sigue, sin Dropout y con BatchNorm congelada, y nadie lo nota hasta que las curvas se ven raras.

model.eval() y torch.no_grad() son dos interruptores distintos: la matriz de las cuatro combinaciones Filas: model.train() o model.eval(). Columnas: sin o con torch.no_grad(). train sin no_grad es entrenar. train con no_grad es un bug: Dropout sigue activo y BatchNorm usa el batch actual. eval sin no_grad es otro bug: las capas están bien pero se guardan activaciones y la memoria se va. eval con no_grad es evaluar o servir. DOS INTERRUPTORES INDEPENDIENTES sin torch.no_grad() con torch.no_grad() model.train() (default) model.eval() entrenar Dropout activo, BN con estadísticas del batch se construye el grafo, se guardan activaciones bug clásico al evaluar Dropout sigue activo, BN usa el batch actual métricas distintas en cada corrida, y peores bug clásico al evaluar capas bien: Dropout apagado, BN con promedios pero guarda activaciones para nada: OOM evaluar · servir Dropout apagado, BN con promedios corridos sin grafo: menos memoria y más rápido eval() cambia el atributo training de cada submódulo; no_grad() cambia autograd · en evaluación van los dos, y model.train() al volver una evaluación de mitad de epoch que deja el modelo en eval es un bug que nadie nota: sigue entrenando sin Dropout y con BatchNorm congelada · inference_mode para servir
Siempre aparecen juntos y por eso se confunden, pero hacen cosas distintas: eval() le habla a las capas con comportamiento dual, no_grad() le habla a autograd. Cada uno solo deja un bug característico; los dos juntos son evaluar, y train() al volver cierra el ciclo.

6. Lo que el tutorial no cubre: precisión mixta y schedulers

Precisión mixta: autocast + (a veces) GradScaler

Entrenar todo en fp32 desperdicia la mitad del ancho de banda y los tensor cores. La receta estándar es mixed precision: los parámetros y el optimizador quedan en fp32 (los master weights), pero el forward y el backward corren en 16 bits donde es seguro. torch.autocast decide por operación — matmuls y convoluciones en bf16/fp16, reducciones, softmax y normas en fp32 — sin que toques el modelo:

scaler = torch.amp.GradScaler("cuda")                       # solo hace falta con float16

for X, y in dataloader:
    with torch.autocast(device_type="cuda", dtype=torch.float16):
        loss = loss_fn(model(X), y)                       # forward en 16 bits donde es seguro
    scaler.scale(loss).backward()                         # backward sobre la loss escalada
    scaler.unscale_(optimizer)                            # des-escalar ANTES de clipear
    torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
    scaler.step(optimizer)                                # salta el step si hubo inf/nan
    scaler.update()                                       # ajusta el factor de escala
    optimizer.zero_grad()

GradScaler existe por el rango chico de fp16 (ver la tabla de dtypes de la página 1): multiplica la loss por un factor grande para que los gradientes pequeños no se vayan a cero, los divide de vuelta antes del step, y si detecta inf/nan salta ese step y baja el factor. Con bfloat16 no hace falta: tiene el mismo rango que fp32, así que el loop queda en dos líneas — y es la razón por la que bf16 es el default en cualquier GPU Ampere o más nueva:

with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
    loss = loss_fn(model(X), y)
loss.backward()                                           # sin scaler
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
optimizer.step(); optimizer.zero_grad()

Esto es lo que activan bf16=True / fp16=True en TrainingArguments. Distinto de cargar el modelo directamente en bf16 (dtype=torch.bfloat16): ahí no hay master weights en fp32, las actualizaciones chicas del optimizador se pierden por redondeo, y es aceptable para inferencia o para LoRA (donde los pesos que se actualizan son pocos y se pueden mantener en fp32), pero no para full fine-tuning serio.

Schedulers: el learning rate no es una constante

Un scheduler cambia el learning rate del optimizador a lo largo del entrenamiento. Dos que cubren casi todo: OneCycleLR sube el lr linealmente desde un valor bajo hasta max_lr y después baja (por defecto con coseno) hasta casi cero — es el fit_one_cycle de fastai, y se llama por batch. CosineAnnealingLR baja el lr siguiendo medio coseno desde el valor inicial hasta eta_min a lo largo de T_max steps — combinado con un warmup lineal es la curva de casi todos los LLMs. La regla de ubicación: scheduler.step() va después de optimizer.step(), una vez por cada step real del optimizador (con acumulación de gradientes, adentro del mismo if):

steps_total = epochs * len(dataloader) // accum
scheduler = torch.optim.lr_scheduler.OneCycleLR(optimizer, max_lr=3e-4, total_steps=steps_total)
# o: CosineAnnealingLR(optimizer, T_max=steps_total, eta_min=1e-5)

for i, (X, y) in enumerate(dataloader):
    (loss_fn(model(X), y) / accum).backward()
    if (i + 1) % accum == 0:
        optimizer.step()
        scheduler.step()                  # después de optimizer.step(), nunca antes
        optimizer.zero_grad()
        lr_actual = scheduler.get_last_lr()[0]   # para loggear

Si invertís el orden, PyTorch avisa (Detected call of lr_scheduler.step() before optimizer.step()): el primer step usaría el lr equivocado. Y si construís OneCycleLR con un total_steps menor que los steps reales, el scheduler lanza una excepción al pasarse — mejor que fallar en silencio.

7. Errores comunes

Casi todos son de orden: una línea que falta o está en el lugar equivocado del loop.

SíntomaCausa probableQué hacer
RuntimeError: Trying to backward through the graph a second time...Dos backward() sobre el mismo grafo: dos losses del mismo forward, o un tensor de una iteración anterior (un estado oculto, una loss acumulada sin .item()) que sigue conectado al grafo viejo.Sumá las losses y hacé un solo backward(); retain_graph=True solo si de verdad necesitás las dos pasadas; detach() lo que cruza iteraciones (§1).
La memoria de GPU crece un poco en cada iteración hasta el OOM, aunque el batch es fijoLogging sin .item(): total += loss guarda el grafo de cada batch. Mismo efecto al guardar activaciones o pred en una lista.loss.item() (o .detach()) en todo lo que acumules o guardes (§4).
La loss baja al principio y después oscila o diverge sin motivo; los gradientes son enormesFalta zero_grad(): los gradientes de todas las iteraciones se suman.Un zero_grad() por step del optimizador — al principio o al final, pero siempre (§3).
La loss baja pero un step "atrasada"; la primera iteración no cambia nadaoptimizer.step() antes de loss.backward(): el step usa los gradientes de la iteración anterior (o ninguno en la primera).Orden fijo: forward → backward → (clip) → step → zero_grad (§3).
Resultados de evaluación distintos en cada corrida con los mismos pesos; accuracy de test peor que la de training por muchoFalta model.eval(): Dropout sigue activo y BatchNorm usa estadísticas del batch. no_grad no lo arregla — son cosas distintas.model.eval() + no_grad(), y model.train() al volver (§5).
La evaluación se queda sin memoria con un batch que en training entraFalta no_grad(): se guardan las activaciones para un backward que nunca va a ocurrir.with torch.no_grad(): (o inference_mode) alrededor del loop de evaluación (§2, §5).
Loss nan a las pocas iteraciones (a veces precedida por inf)Learning rate demasiado alto (los pesos saltan a valores donde softmax/log desbordan), o fp16 sin GradScaler, o un log/división de cero en la loss.Bajá el lr 3-10×, agregá warmup y clip_grad_norm_; con fp16 usá el scaler o pasá a bf16; torch.autograd.set_detect_anomaly(True) para localizar la operación que produce el primer NaN (§3, §6).
RuntimeError: element 0 of tensors does not require grad and does not have a grad_fnEl backward se llamó sobre una loss que no está conectada a ningún parámetro: forward bajo no_grad/inference_mode, todos los parámetros congelados, o un .detach()/.item() antes de la loss.Verificá loss.grad_fn y que al menos un parámetro tenga requires_grad=True (§2).
UserWarning: Detected call of lr_scheduler.step() before optimizer.step()Orden invertido, o scheduler.step() en cada micro-batch cuando hay acumulación de gradientes.Un scheduler.step() por optimizer.step(), inmediatamente después (§6).

8. Resumen

  1. Autograd reconstruye el grafo de cómputo en cada forward pass — dinámico, no estático, lo que permite control de flujo arbitrario de Python dentro del modelo. backward() consume el grafo (libera las activaciones); una segunda pasada requiere retain_graph=True, y torch.autograd.grad es la versión funcional que no escribe en .grad.
  2. no_grad()/detach() desactivan el tracking de gradientes — para congelar parámetros o acelerar inferencia pura; inference_mode() es la versión más estricta y rápida para servir.
  3. Los gradientes se acumulan por diseño; zero_grad() es obligatorio en cada iteración salvo que quieras acumular a propósito — y entonces dividís la loss por el número de micro-batches y hacés el step cada N.
  4. El loop de optimización son pasos fijos en orden fijo: backward(), clip_grad_norm_, step(), scheduler.step(), zero_grad() — el mismo patrón en cualquier escala de modelo. .item() en todo lo que loggees.
  5. model.train()/model.eval() cambian el comportamiento real de BatchNorm y Dropout — no es un detalle estético, es funcional, y es independiente de no_grad: en evaluación van los dos.
  6. Precisión mixta: autocast corre el forward en 16 bits con master weights en fp32; fp16 necesita GradScaler, bf16 no. AdamW es el optimizador por defecto de los transformers; OneCycle y coseno con warmup, las curvas de lr habituales.

9. Ponete a prueba

Respondé antes de abrir cada respuesta. Si fallás una, volvé a la sección indicada.

1. Querés un batch efectivo de 64 pero solo entran 16 en la GPU. ¿Qué cambia en el loop y por qué hay que dividir la loss?

Acumulás 4 micro-batches: backward() en cada uno (los gradientes se suman en .grad) y step() + zero_grad() solo cada 4. Dividís la loss por 4 porque la loss de un batch de 64 es el promedio sobre 64 ejemplos, y sumar cuatro gradientes de promedios de 16 sin dividir daría un gradiente 4 veces más grande que el del batch real. Clipping y scheduler van dentro del mismo if. (§3, §6)

2. ¿Por qué running_loss += loss hace crecer la memoria y running_loss += loss.item() no?

loss es un tensor con grad_fn: mantenerlo vivo mantiene vivo el grafo entero de esa iteración, incluidas las activaciones guardadas para el backward. Acumularlo encadena los grafos de todos los batches. .item() devuelve un float de Python sin referencia al grafo, que se libera normalmente. (§4)

3. Evaluás con torch.no_grad() pero sin model.eval(). ¿Qué pasa exactamente?

No se construye el grafo (memoria y velocidad bien), pero Dropout sigue apagando neuronas al azar y BatchNorm usa las estadísticas del batch actual en vez de las acumuladas. Resultado: métricas distintas en cada corrida y peores que las reales. Son dos interruptores independientes: eval() cambia el comportamiento de capas, no_grad() cambia autograd. (§5)

4. ¿Por qué bf16 no necesita GradScaler y fp16 sí? ¿Qué pasa si usás fp16 sin él?

fp16 tiene 5 bits de exponente: los gradientes menores a ~6×10⁻⁸ se vuelven cero (underflow) y las activaciones mayores a 65 504 desbordan a inf. El scaler multiplica la loss para mover los gradientes a rango representable y salta los steps con inf/nan. bf16 tiene 8 bits de exponente, el mismo rango que fp32, así que no hay underflow que corregir. Sin scaler en fp16, típicamente ves gradientes en cero (no aprende) o NaN a las pocas iteraciones. (§6)

5. Tenés dos losses calculadas del mismo forward y hacés loss_a.backward(); loss_b.backward(). ¿Qué error da y cuáles son las dos soluciones?

"Trying to backward through the graph a second time": el primer backward liberó las activaciones que el segundo necesita. Soluciones: loss_a.backward(retain_graph=True) antes del segundo, o mejor (loss_a + loss_b).backward(), que hace una sola pasada y es más barato en memoria y tiempo. (§1)

6. Con acumulación de gradientes cada 4 micro-batches, ¿cuántas veces por epoch llamás scheduler.step() si el dataloader tiene 1000 batches?

250 — una por cada optimizer.step(), no una por micro-batch. Si lo llamás 1000 veces, un OneCycleLR configurado con total_steps correcto lanza una excepción al pasarse, y un coseno llega a su mínimo cuatro veces antes de lo previsto. (§6)

10. Para practicar

✏️ Ejercicio 1 — verificar que acumular gradientes equivale a un batch grande

Calculá el gradiente de un batch de 8 de una vez, y después como 2 micro-batches de 4 con la loss dividida por 2. Compará. Corre en CPU.

import torch, torch.nn as nn
torch.manual_seed(0)
model = nn.Sequential(nn.Linear(10, 32), nn.ReLU(), nn.Linear(32, 3))
loss_fn = nn.CrossEntropyLoss()
X, y = torch.randn(8, 10), torch.randint(0, 3, (8,))

# (a) un solo batch de 8
model.zero_grad()
loss_fn(model(X), y).backward()
g_full = [p.grad.clone() for p in model.parameters()]

# (b) dos micro-batches de 4, loss dividida por 2
model.zero_grad()
for Xi, yi in ((X[:4], y[:4]), (X[4:], y[4:])):
    (loss_fn(model(Xi), yi) / 2).backward()
g_accum = [p.grad.clone() for p in model.parameters()]

print(max((a - b).abs().max().item() for a, b in zip(g_full, g_accum)))

# (c) sin dividir: ¿cuánto más grande queda?
model.zero_grad()
for Xi, yi in ((X[:4], y[:4]), (X[4:], y[4:])):
    loss_fn(model(Xi), yi).backward()
print(model[0].weight.grad.norm() / g_full[0].norm())
Pista / qué esperar

La diferencia máxima en (b) tiene que ser del orden de 1e-7 (ruido de punto flotante). En (c) el cociente de normas da 2,0: sin dividir, el gradiente acumulado es N veces el del batch grande. Con Adam la escala del gradiente importa menos (normaliza por la varianza), pero con SGD y con el clipping por norma importa mucho.

✏️ Ejercicio 2 — provocar los tres errores de orden y leerlos

Con el mismo modelo, escribí tres celdas: (a) dos backward() seguidos sobre la misma loss, (b) un forward bajo inference_mode seguido de backward(), (c) un loop de 5 iteraciones donde acumulás total += loss sin .item() y después imprimís total.grad_fn. Anotá los mensajes.

loss = loss_fn(model(X), y)
loss.backward()
loss.backward()                              # (a)

with torch.inference_mode():
    out = model(X)
loss_fn(out, y).backward()                   # (b)

total = 0
for _ in range(5):
    total += loss_fn(model(X), y)
print(total.grad_fn, total.item())           # (c): hay grad_fn → el grafo de las 5 iteraciones sigue vivo
Pista / qué esperar

(a) "Trying to backward through the graph a second time". (b) "element 0 of tensors does not require grad and does not have a grad_fn" — bajo inference_mode la salida no tiene historial. (c) No hay error, y ese es el punto: total.grad_fn muestra un AddBackward0, prueba de que los cinco grafos siguen en memoria. Con .item(), total es un float y no tiene grad_fn.

✏️ Ejercicio 3 — ver qué hace autocast, sin GPU

autocast también funciona en CPU con bf16. Verificá que dentro del bloque los matmuls salen en bf16 mientras los parámetros siguen en fp32, y que el gradiente vuelve en fp32.

import torch, torch.nn as nn
lin = nn.Linear(64, 64)
x = torch.randn(8, 64)
with torch.autocast(device_type="cpu", dtype=torch.bfloat16):
    h = lin(x)
    s = h.float().softmax(-1)
    loss = h.sum()
print("activación:", h.dtype, "| parámetro:", lin.weight.dtype)
loss.backward()
print("gradiente:", lin.weight.grad.dtype)
Pista / qué esperar

torch.bfloat16, torch.float32, torch.float32. Esa es la definición de mixed precision: el cómputo pesado baja a 16 bits, los master weights y sus gradientes quedan en fp32 para que el optimizador no pierda las actualizaciones chicas. Si en vez de autocast hacés lin.bfloat16(), los tres dtypes dan bf16 — y ahí sí perdés precisión en el step.

11. Fuentes y para seguir