Lección 3 de 6 · La arquitectura · 25 min
Self-attention causal, multi-cabeza
Implementas la atención causal con máscara, la divides en cabezas y verificas con un test que el modelo no puede espiar el futuro.
Hasta ahora cada token es un vector que no sabe nada de sus vecinos. La self-attention (auto-atención) es el mecanismo que permite que cada posición lea información de las demás. En un modelo de lenguaje autoregresivo, con una restricción clave: la posición solo puede mirar las posiciones . Eso es la atención causal.
“We propose a new simple network architecture, the Transformer, based solely on attention mechanisms, dispensing with recurrence and convolutions entirely.”
Queries, keys y values
Cada token proyecta su vector en tres roles mediante matrices aprendidas:
- Query (consulta) : "¿qué estoy buscando?"
- Key (clave) : "¿qué ofrezco para ser encontrado?"
- Value (valor) : "si me eligen, esto es lo que aporto".
El puntaje de afinidad entre y es el producto punto . Normalizamos con softmax y promediamos los values. En forma matricial, para toda la secuencia:
donde es la máscara causal: si y si . Como , el softmax asigna peso exactamente cero al futuro.
Escribe una oración y observa la matriz de pesos con máscara causal. Cada fila es un token "mirando" hacia atrás; el triángulo superior queda vacío:
¿Por qué dividir por ?
Si las componentes de y son aproximadamente independientes con media 0 y varianza 1, su producto punto tiene varianza . Con , los puntajes tienen desvío estándar ~8; el softmax de valores tan dispersos se satura en un one-hot y sus gradientes casi desaparecen. Dividir por devuelve la varianza a ~1.
Comprueba tu comprensión
Si eliminas la división por √d_k en un modelo con cabezas de dimensión grande, ¿qué esperas observar al inicio del entrenamiento?
Múltiples cabezas
Una sola atención produce un único promedio ponderado por posición. Pero un token puede necesitar varias cosas a la vez: el sujeto de la oración, el token anterior, la apertura de un paréntesis. La multi-head attention (atención multi-cabeza) parte la dimensión en cabezas de tamaño , corre atención en paralelo en cada una y concatena los resultados:
varias atenciones en paralelo, cada una atenta a algo distinto; pegamos sus resultados y los mezcla en un solo vector
El costo en parámetros es el mismo que una cabeza de dimensión : solo reorganizamos los tensores.
Implementación
Calculamos Q, K y V con una sola capa lineal de salida (más eficiente que tres capas), reordenamos para tener las cabezas como dimensión de batch, y aplicamos la máscara con masked_fill.
import math
import torch
import torch.nn as nn
import torch.nn.functional as F
class CausalSelfAttention(nn.Module):
def __init__(self, cfg):
super().__init__()
assert cfg.n_embd % cfg.n_head == 0
self.n_head = cfg.n_head
self.qkv = nn.Linear(cfg.n_embd, 3 * cfg.n_embd)
self.proj = nn.Linear(cfg.n_embd, cfg.n_embd)
self.attn_drop = nn.Dropout(cfg.dropout)
self.resid_drop = nn.Dropout(cfg.dropout)
mask = torch.tril(torch.ones(cfg.block_size, cfg.block_size, dtype=torch.bool))
self.register_buffer("mask", mask.view(1, 1, cfg.block_size, cfg.block_size),
persistent=False)
def forward(self, x):
B, T, C = x.shape
hs = C // self.n_head
q, k, v = self.qkv(x).split(C, dim=2) # 3 x (B, T, C)
q = q.view(B, T, self.n_head, hs).transpose(1, 2) # (B, nh, T, hs)
k = k.view(B, T, self.n_head, hs).transpose(1, 2)
v = v.view(B, T, self.n_head, hs).transpose(1, 2)
att = (q @ k.transpose(-2, -1)) / math.sqrt(hs) # (B, nh, T, T)
att = att.masked_fill(~self.mask[:, :, :T, :T], float("-inf"))
att = F.softmax(att, dim=-1)
att = self.attn_drop(att)
y = att @ v # (B, nh, T, hs)
y = y.transpose(1, 2).contiguous().view(B, T, C) # concatena cabezas
return self.resid_drop(self.proj(y))
Detalles que importan:
register_buffer(..., persistent=False): la máscara viaja con el módulo a la GPU (.to(device)) pero no es un parámetro entrenable ni se guarda en elstate_dict.self.mask[:, :, :T, :T]: recortamos la máscara al largo real de la secuencia, que puede ser menor queblock_size..contiguous()antes delview:transposedevuelve una vista no contigua en memoria, yviewlo exige.
Comprueba tu comprensión
En la máscara causal, la fila i corresponde a la query de la posición i. ¿Qué posiciones de key tienen peso distinto de cero en esa fila?
Un test que vale oro: ¿se filtra el futuro?
Los bugs de máscara son silenciosos: el modelo entrena "demasiado bien" porque puede leer la respuesta. Un test simple: si cambias el último token, las salidas de las posiciones anteriores no deben moverse.
from dataclasses import dataclass
@dataclass
class Cfg:
n_embd: int = 32
n_head: int = 4
block_size: int = 16
dropout: float = 0.0
torch.manual_seed(0)
attn = CausalSelfAttention(Cfg()).eval()
x = torch.randn(1, 10, 32)
x2 = x.clone()
x2[:, -1] = torch.randn(32) # perturbamos solo la última posición
y, y2 = attn(x), attn(x2)
assert torch.allclose(y[:, :-1], y2[:, :-1], atol=1e-6), "¡fuga de información!"
assert not torch.allclose(y[:, -1], y2[:, -1])
print("La máscara causal funciona")
Agrega este test a tu suite: atrapa errores de indexado, transposiciones invertidas y máscaras mal recortadas.
En producción: la versión fusionada
PyTorch 2 incluye F.scaled_dot_product_attention, que implementa lo mismo en un kernel fusionado (FlashAttention cuando el hardware lo permite) sin materializar la matriz completa en memoria:
y = F.scaled_dot_product_attention(q, k, v, is_causal=True,
dropout_p=self.attn_drop.p if self.training else 0.0)
Escribir la versión explícita primero vale la pena: es la que puedes inspeccionar, visualizar y depurar. Luego reemplázala y usa el test de arriba para verificar que da los mismos resultados.
Un último número para tener en mente: la matriz de atención tiene entradas por cabeza, así que el costo de esta capa crece cuadráticamente con el largo del contexto. Es la razón de fondo de buena parte de la investigación en contextos largos.
Comprueba tu comprensión
Duplicas el largo de contexto de T a 2T. ¿Por cuánto se multiplica aproximadamente el tamaño de la matriz de atención (por cabeza, por capa)?
Lo que te llevas
- Atención = promedio de values ponderado por afinidad query–key, normalizado con softmax.
- La máscara causal pone en el futuro; el escalado evita saturar el softmax.
- Multi-cabeza = varios canales de lectura en paralelo, al mismo costo de parámetros.
- Siempre testea que no haya fuga de información del futuro.
¿Qué tal esta lección?
Sé el primero en calificarla
¿Terminaste la lección?
Márcala para sumar +50 XP y mantener tu racha.
Tutor IA
Contexto: Self-attention causal, multi-cabeza
Conectando con el tutor…