Saltar al contenido
← Transformers desde cero

Lección 4 de 6 · La arquitectura · 25 min

El bloque Transformer y la clase GPT completa

Combinas atención, MLP, LayerNorm y conexiones residuales en un bloque pre-LN y ensamblas un GPT completo que ya produce logits y loss.


Ya tienes las dos piezas con información: embeddings (qué token, en qué posición) y atención causal (mezclar información entre posiciones). Falta la maquinaria que hace que esto se pueda apilar decenas de veces y siga entrenando: conexiones residuales, normalización y una red feed-forward por posición. Al final de esta lección tendrás la clase GPT completa.

Anatomía de un bloque

Un bloque Transformer estilo GPT-2 hace dos cosas, en orden:

  1. Comunicar: self-attention causal. Los tokens intercambian información.
  2. Computar: una MLP aplicada a cada posición de forma independiente. Cada token "piensa" sobre lo que leyó.

Cada sub-capa se envuelve con una LayerNorm antes y una suma residual después. Esta variante se llama Pre-LN:

x′=x+Attn(LN1(x))x′′=x′+MLP(LN2(x′))\begin{aligned} x' &= x + \text{Attn}(\text{LN}_1(x)) \\ x'' &= x' + \text{MLP}(\text{LN}_2(x')) \end{aligned}

normalizar, pasar por la atención y sumarle el resultado a lo que ya había; después, lo mismo con el MLP

El Transformer original usaba Post-LN: x′=LN(x+Attn(x))x' = \text{LN}(x + \text{Attn}(x)). Parece un cambio cosmético, pero Xiong et al. (2020) mostraron que con Post-LN los gradientes de los parámetros cercanos a la salida son grandes al inicializar, lo que vuelve imprescindible un warmup cuidadoso de la tasa de aprendizaje; con Pre-LN los gradientes están mejor comportados. GPT-2 y la gran mayoría de los LLM actuales usan Pre-LN (o variantes como RMSNorm en la misma posición).

Comprueba tu comprensión

¿Cuál es la diferencia estructural clave entre Pre-LN y Post-LN?

La vista del residual stream

Hay una forma muy útil de leer la arquitectura Pre-LN. Desenrolla las sumas: el vector de salida de la última capa es la entrada más todo lo que cada sub-capa le sumó:

xL=x0+∑ℓ=1L(Attnℓ(⋅)+MLPℓ(⋅))x_L = x_0 + \sum_{\ell=1}^{L} \Big( \text{Attn}_\ell(\cdot) + \text{MLP}_\ell(\cdot) \Big)

La MLP

Expande a 4d4d, aplica una no linealidad y vuelve a dd:

MLP(x)=W2 GELU(W1x+b1)+b2,W1∈R4d×d\text{MLP}(x) = W_2 \, \text{GELU}(W_1 x + b_1) + b_2, \qquad W_1 \in \mathbb{R}^{4d \times d}

agrandar el vector a 4 veces su tamaño, doblarlo con GELU y volver a achicarlo; W1W_1, W2W_2, b1b_1 y b2b_2 son números que el modelo aprende

GELU es una versión suave de ReLU que usa GPT-2. El factor 4 es una convención del paper original, no una ley. Aunque la atención se lleva la fama, en un bloque estándar dos tercios de los parámetros están en la MLP: 8d28d^2 frente a 4d24d^2 de la atención.

python
import math
import torch
import torch.nn as nn
import torch.nn.functional as F
# CausalSelfAttention y GPTConfig vienen de las lecciones anteriores


class MLP(nn.Module):
    def __init__(self, cfg):
        super().__init__()
        self.fc = nn.Linear(cfg.n_embd, 4 * cfg.n_embd)
        self.proj = nn.Linear(4 * cfg.n_embd, cfg.n_embd)
        self.drop = nn.Dropout(cfg.dropout)

    def forward(self, x):
        return self.drop(self.proj(F.gelu(self.fc(x))))


class Block(nn.Module):
    def __init__(self, cfg):
        super().__init__()
        self.ln1 = nn.LayerNorm(cfg.n_embd)
        self.attn = CausalSelfAttention(cfg)
        self.ln2 = nn.LayerNorm(cfg.n_embd)
        self.mlp = MLP(cfg)

    def forward(self, x):
        x = x + self.attn(self.ln1(x))   # comunicar
        x = x + self.mlp(self.ln2(x))    # computar
        return x

La clase GPT

Ahora ensamblamos todo: embeddings, LL bloques, LayerNorm final y la capa de salida atada a los embeddings.

python
class GPT(nn.Module):
    def __init__(self, cfg):
        super().__init__()
        self.cfg = cfg
        self.tok_emb = nn.Embedding(cfg.vocab_size, cfg.n_embd)
        self.pos_emb = nn.Embedding(cfg.block_size, cfg.n_embd)
        self.drop = nn.Dropout(cfg.dropout)
        self.blocks = nn.ModuleList([Block(cfg) for _ in range(cfg.n_layer)])
        self.ln_f = nn.LayerNorm(cfg.n_embd)
        self.lm_head = nn.Linear(cfg.n_embd, cfg.vocab_size, bias=False)
        self.lm_head.weight = self.tok_emb.weight          # weight tying

        self.apply(self._init_weights)
        # proyecciones que escriben en el residual stream: init más chica
        for name, p in self.named_parameters():
            if name.endswith("proj.weight"):
                nn.init.normal_(p, mean=0.0, std=0.02 / math.sqrt(2 * cfg.n_layer))

    def _init_weights(self, module):
        if isinstance(module, nn.Linear):
            nn.init.normal_(module.weight, mean=0.0, std=0.02)
            if module.bias is not None:
                nn.init.zeros_(module.bias)
        elif isinstance(module, nn.Embedding):
            nn.init.normal_(module.weight, mean=0.0, std=0.02)

    def forward(self, idx, targets=None):
        B, T = idx.shape
        assert T <= self.cfg.block_size, f"secuencia de {T} > block_size"
        pos = torch.arange(T, device=idx.device)
        x = self.drop(self.tok_emb(idx) + self.pos_emb(pos))
        for block in self.blocks:
            x = block(x)
        logits = self.lm_head(self.ln_f(x))                  # (B, T, V)

        loss = None
        if targets is not None:
            loss = F.cross_entropy(logits.view(-1, logits.size(-1)), targets.view(-1))
        return logits, loss

El escalado 0.02/2L0.02/\sqrt{2L} en las proyecciones de salida (attn.proj y mlp.proj) viene del paper de GPT-2: cada bloque suma dos contribuciones al residual stream, y con 2L2L sumas la varianza crecería con la profundidad si no se compensara.

“We scale the weights of residual layers at initialization by a factor of 1/√N where N is the number of residual layers.”
— Radford et al., 2019

Comprueba tu comprensión

¿Por qué el forward devuelve logits para TODAS las posiciones (B, T, V) y no solo para la última?

Contar parámetros

Por bloque, ignorando biases y LayerNorm: atención 4d24d^2 (Q, K, V y salida) + MLP 8d28d^2 = 12d212d^2. Para todo el modelo:

N≈12 L d2+Vd+TmaxdN \approx 12 \, L \, d^2 + V d + T_{max} d

parámetros totales: 12d212d^2 por cada una de las LL capas, más la tabla de tokens (V⋅dV \cdot d) y la de posiciones (Tmax⋅dT_{max} \cdot d)

Comprueba la fórmula con GPT-2 small (L=12L=12, d=768d=768, V=50257V=50257, Tmax=1024T_{max}=1024): 12⋅12⋅7682≈84,9M12 \cdot 12 \cdot 768^2 \approx 84{,}9\text{M}, más 38,6M38{,}6\text{M} de embeddings de tokens y 0,8M0{,}8\text{M} de posiciones: unos 124 M, el número oficial.

Con nuestra configuración de juguete:

python
cfg = GPTConfig(vocab_size=512, block_size=64, n_layer=4, n_head=4, n_embd=128)
model = GPT(cfg)
n_params = sum(p.numel() for p in model.parameters())
print(f"{n_params:,}")      # 867,072

idx = torch.randint(0, cfg.vocab_size, (2, 16))
logits, loss = model(idx, targets=idx)
print(logits.shape, loss.item())

model.parameters() no cuenta dos veces el tensor compartido entre tok_emb y lm_head, por eso el total ya refleja el weight tying.

Comprueba tu comprensión

Con una inicialización aleatoria y vocabulario de 512 tokens, ¿qué valor de loss esperas aproximadamente en el primer forward?

Lo que te llevas

  • Bloque = comunicar (atención) + computar (MLP), cada uno con Pre-LN y suma residual.
  • El residual stream es un canal compartido al que cada sub-capa le suma su aporte.
  • Un bloque tiene ~12d212d^2 parámetros, dos tercios en la MLP.
  • Ya tienes un GPT que produce logits y loss. Próxima lección: entrenarlo.

¿Qué tal esta lección?

Sé el primero en calificarla

Entra para calificar

¿Terminaste la lección?

Márcala para sumar +50 XP y mantener tu racha.