Módulo 6 · Representaciones y atención

Lección 2 de 4

Atención y transformers

En esta páginaEl problema

El problema

Tienes una tabla de embeddings con tres palabras. Cada palabra tiene un vector de 4 números:

palabradim 0dim 1dim 2dim 3
perro1010
muerde0101
gato1001

Con estos vectores representas dos oraciones: «perro muerde gato» y «gato muerde perro». En Embeddings y autoencoders resumiste una secuencia con la media de sus embeddings. Aquí las dos medias salen iguales: [0.667, 0.333, 0.333, 0.667]. Además, el vector de perro es el mismo en las dos oraciones, aunque en una muerde y en la otra lo muerden.

Necesitas una operación que cambie el vector de cada palabra según las otras palabras de la oración y según el lugar que ocupa.

Atención de producto punto

Cada elemento de la secuencia es un token; aquí, cada palabra. En atención, cada token produce tres vectores: una consulta q, una clave k y un valor v. En un transformer salen de tres capas nn.Linear aplicadas al embedding del token. El puntaje entre el token i y el token j es el producto punto q_i · k_j dividido entre √d_k, donde d_k es la longitud de las claves. softmax convierte cada fila de puntajes en pesos de atención. La salida del token i es la suma de los valores, cada uno multiplicado por su peso.

Con matrices, las consultas de todos los tokens forman Q, las claves forman K y los valores forman V:

Atención(Q, K, V) = softmax(Q Kᵀ / √d_k) V

Pregunta. Con las matrices de la celda, ¿qué claves reciben más peso en la fila del token 0? ¿Y en la del token 2? La salida del token 0, ¿queda más cerca de [10, 0] o de [0, 10]?

Python 3 · ejemplo reproducible
import math

import torch
import torch.nn.functional as F
from torch import nn

Q = torch.tensor([[2.0, 0.0], [0.0, 2.0], [1.0, 1.0]])
K = torch.tensor([[1.0, 0.0], [0.0, 1.0], [1.0, 1.0]])
V = torch.tensor([[10.0, 0.0], [0.0, 10.0], [5.0, 5.0]])

d_k = K.shape[1]
puntajes = Q @ K.T / math.sqrt(d_k)
pesos = torch.softmax(puntajes, dim=-1)
salida = pesos @ V

print(puntajes.round(decimals=3))
print(pesos.round(decimals=3))
print(pesos.sum(dim=-1))
print(salida.round(decimals=2))
print(torch.allclose(salida, F.scaled_dot_product_attention(Q, K, V)))
Salida esperada

La salida esperada está oculta.

La división entre √d_k controla el tamaño de los puntajes. Si q y k tienen componentes aleatorias con media 0 y varianza 1, su producto punto tiene varianza d_k.

Pregunta. Con d_k = 64 y 8 claves aleatorias, ¿qué peso máximo da softmax sin la división y cuál con ella?

Ver resultado

Cada fila compara una consulta aleatoria de 64 números con 8 claves aleatorias, primero sin la división y después con ella. Las columnas dan la desviación estándar de los 8 puntajes, el peso que softmax da a cada clave (de la clave 0 a la 7) y el mayor de esos pesos. La segunda fila dice que, con la división, la clave 1 recibe 0.223 del peso.

puntajesdesviación estándarpesos de las 8 clavespeso máximo
q @ k.T6.480.000, 0.162, 0.025, 0.000, 0.000, 0.000, 0.812, 0.0000.812
q @ k.T / √640.810.041, 0.223, 0.177, 0.027, 0.096, 0.076, 0.273, 0.0870.273
Lectura. Sin la división, la desviación estándar de los puntajes es 6.48, del mismo orden que √64 = 8. softmax da 0.812 a una clave y 0 a cinco de las ocho, redondeado a tres decimales. Una clave con peso casi nulo también recibe un gradiente casi nulo. Con la división, la desviación estándar baja a 0.81, ninguna clave pasa de 0.273 y todas reciben algo de peso.

Máscara causal

Un modelo que genera texto palabra por palabra no puede usar las palabras que todavía no escribió. Una máscara causal impide que el token i atienda a los tokens j > i. Antes del softmax, los puntajes bloqueados se reemplazan por -inf. La exponencial de -inf es 0.

torch.triu(..., diagonal=1) marca con True las posiciones que quedan por encima de la diagonal.

Pregunta. Escribe la primera fila de pesos_causales. ¿Cuántos pesos distintos de 0 tiene la fila del token 1?

Python 3 · ejemplo reproducible
mascara = torch.triu(torch.ones(3, 3, dtype=torch.bool), diagonal=1)
pesos_causales = torch.softmax(puntajes.masked_fill(mascara, float("-inf")), dim=-1)

print(mascara)
print(pesos_causales.round(decimals=3))
print(
    torch.allclose(
        pesos_causales @ V, F.scaled_dot_product_attention(Q, K, V, is_causal=True)
    )
)
Salida esperada

La salida esperada está oculta.

Pregunta. Cambias el valor del último token por [-100, -100]. ¿Qué salidas cambian con la máscara causal?

Ver resultado

Cada fila es un token. Las columnas dan su salida de atención causal con V y con V_cambiado, que es V con la fila del token 2 reemplazada por [-100, -100]. La fila del token 1 dice que su salida es [1.96, 8.04] con los dos juegos de valores.

tokensalida con Vsalida con V_cambiado
0[10.00, 0.00][10.00, 0.00]
1[1.96, 8.04][1.96, 8.04]
2[5.00, 5.00][-47.87, -47.87]
Lectura. Las salidas de los tokens 0 y 1 no cambian. Ninguno de los dos puede atender al token 2. Solo cambia la salida del token 2, que sí atiende a su propio valor.
Explorador

Atención con máscara causal

Elige la palabra que consulta con un clic o con las flechas y aplica la máscara causal; observa qué pesos pasan a 0 y la columna Σ fila.

Qué probar

Mueve la temperatura y compara el mayor peso de la fila.

elgatonegroduermesobrelaalfombra
Parámetros

Con τ menor que 1 la atención se concentra; con τ mayor que 1 se reparte. Pesos = softmax(QKᵀ / (τ·√4)), con Q y K de 4 números.

Consulta «la», sin máscara: ve los 7 tokens; el mayor peso va a «alfombra» (0.74). Todas las filas suman 1.00.

Atención multi-cabeza

Una sola atención produce un juego de pesos por token. La atención multi-cabeza repite el cálculo h veces en paralelo. Cada cabeza tiene sus propias proyecciones para Q, K y V, de tamaño d / h. Las h salidas se concatenan y una capa lineal final las mezcla en un vector de tamaño d. Cada cabeza puede asignar pesos distintos a los mismos tokens.

nn.MultiheadAttention(embed_dim, num_heads) implementa ese cálculo. Recibe query, key y value. En autoatención, los tres son la misma secuencia x. Con batch_first=True, x tiene forma (batch, tokens, embed_dim). La llamada devuelve la salida y los pesos.

Aquí x es la oración «perro muerde gato» con la tabla de El problema.

Pregunta. Con embed_dim=4 y num_heads=2, escribe la forma de x, de la salida y de los pesos con average_attn_weights=False. ¿Cuántos parámetros tiene el módulo?

Python 3 · ejemplo reproducible
tabla = {
    "perro": [1.0, 0.0, 1.0, 0.0],
    "muerde": [0.0, 1.0, 0.0, 1.0],
    "gato": [1.0, 0.0, 0.0, 1.0],
}


def oracion(texto):
    return torch.tensor([[tabla[palabra] for palabra in texto.split()]])


x = oracion("perro muerde gato")

torch.manual_seed(0)
atencion = nn.MultiheadAttention(embed_dim=4, num_heads=2, batch_first=True)

with torch.no_grad():
    salida_mha, pesos_mha = atencion(x, x, x, average_attn_weights=False)

print(x.shape, salida_mha.shape, pesos_mha.shape)
print(pesos_mha[0].round(decimals=2))
print(sum(p.numel() for p in atencion.parameters()))
Salida esperada

La salida esperada está oculta.

nn.MultiheadAttention acepta una máscara en attn_mask. En este módulo, True significa «bloquear».

Pregunta. Con attn_mask=mascara, ¿qué entradas de la matriz de pesos promediada quedan en 0?

Python 3 · ejemplo reproducible
with torch.no_grad():
    _, pesos_promedio = atencion(x, x, x, attn_mask=mascara)

print(pesos_promedio.shape)
print(pesos_promedio[0].round(decimals=2))
Salida esperada

La salida esperada está oculta.

Bloque transformer

Un bloque transformer del encoder combina cuatro piezas:

  1. autoatención multi-cabeza
  2. una conexión residual, que suma la entrada de una subcapa a su salida
  3. normalización de capa (nn.LayerNorm), que resta al vector de cada token la media de sus propios números y lo divide entre su desviación estándar; después lo multiplica por una escala y le suma un desplazamiento, ambos aprendidos
  4. un MLP que se aplica a cada token por separado, aquí 4 → 16 → 4 con ReLU

El transformer original aplica x = LayerNorm(x + Atención(x)) y después x = LayerNorm(x + MLP(x)). nn.TransformerEncoderLayer sigue ese orden por defecto. bloque_a_mano repite el cálculo con las subcapas del mismo objeto.

Pregunta. ¿Qué forma tiene la salida del bloque para x? Con los parámetros iniciales de LayerNorm (escala 1, desplazamiento 0), ¿qué media y qué desviación estándar tiene el vector de cada token a la salida?

Python 3 · ejemplo reproducible
torch.manual_seed(0)
capa = nn.TransformerEncoderLayer(
    d_model=4, nhead=2, dim_feedforward=16, dropout=0.0, batch_first=True
)
capa.eval()


def bloque_a_mano(x):
    atendido, _ = capa.self_attn(x, x, x)
    x = capa.norm1(x + atendido)
    mlp = capa.linear2(torch.relu(capa.linear1(x)))
    return capa.norm2(x + mlp)


with torch.no_grad():
    y_capa = capa(x)
    y_mano = bloque_a_mano(x)

print(y_capa.shape)
print(torch.allclose(y_capa, y_mano, atol=1e-5))
print(y_capa.mean(dim=-1).abs().round(decimals=3))
print(y_capa.std(dim=-1, unbiased=False).round(decimals=3))
Salida esperada

La salida esperada está oculta.

Ahora las dos oraciones de El problema pasan por el mismo bloque. perro está en la posición 0 de la primera y en la posición 2 de la segunda.

Pregunta. ¿El vector de perro a la salida del bloque es el mismo en las dos oraciones? ¿Las medias de las dos salidas son iguales?

Python 3 · ejemplo reproducible
x_a = oracion("perro muerde gato")
x_b = oracion("gato muerde perro")

with torch.no_grad():
    y_a = capa(x_a)
    y_b = capa(x_b)

print(x_a[0, 0], y_a[0, 0].round(decimals=3))
print(x_b[0, 2], y_b[0, 2].round(decimals=3))
print(torch.allclose(y_a[0, 0], y_b[0, 2], atol=1e-5))
print(torch.allclose(y_a.mean(dim=1), y_b.mean(dim=1), atol=1e-5))
Salida esperada

La salida esperada está oculta.

Codificación posicional

Una codificación posicional es un vector por posición que se suma al embedding de cada token antes del primer bloque. El transformer original usa senos y cosenos con frecuencias distintas. Para la posición pos y el par de dimensiones (2i, 2i + 1):

  • PE[pos, 2i] = sin(pos / 10000^(2i / d))
  • PE[pos, 2i + 1] = cos(pos / 10000^(2i / d))

Pregunta. Escribe la fila de la posición 0. Después de sumar pe a las dos oraciones, ¿el vector de perro a la salida del bloque sigue siendo el mismo? ¿Y las medias?

Python 3 · ejemplo reproducible
def posicional_senoidal(longitud, d):
    posiciones = torch.arange(longitud, dtype=torch.float32).unsqueeze(1)
    i = torch.arange(0, d, 2, dtype=torch.float32)
    angulos = posiciones / 10000 ** (i / d)
    pe = torch.zeros(longitud, d)
    pe[:, 0::2] = torch.sin(angulos)
    pe[:, 1::2] = torch.cos(angulos)
    return pe


pe = posicional_senoidal(3, 4)
print(pe.round(decimals=3))

with torch.no_grad():
    z_a = capa(x_a + pe)
    z_b = capa(x_b + pe)

print(z_a[0, 0].round(decimals=3))
print(z_b[0, 2].round(decimals=3))
print(torch.allclose(z_a[0, 0], z_b[0, 2], atol=1e-5))
print(torch.allclose(z_a.mean(dim=1), z_b.mean(dim=1), atol=1e-5))
Salida esperada

La salida esperada está oculta.

Otra opción es aprender la codificación posicional: una tabla nn.Embedding(longitud_máxima, d) que se indexa con torch.arange(longitud) y se entrena con el resto del modelo. Dos modelos conocidos usan esta opción:

  • BERT apila bloques del encoder como el de esta lección. BERT-base tiene 12 bloques, vectores de 768 números y 12 cabezas. No usa máscara causal: cada token atiende a los tokens de su izquierda y de su derecha.
  • ViT (Vision Transformer) aplica bloques del encoder a imágenes. A diferencia del bloque de esta lección, normaliza antes de cada subcapa: x = x + Atención(LayerNorm(x)). Divide una imagen de 224×224 píxeles en parches de 16×16, así que obtiene 14 × 14 = 196 parches. Cada parche se convierte en un vector con una capa lineal y funciona como un token.

Práctica

Traza guiada

Con estas matrices, escribe antes de procesar: los puntajes Q Kᵀ / √2, la clave que más pesa para cada consulta, la suma de cada fila de pesos y si la salida del token 0 queda más cerca de [4, 0] o de [0, 4]. Repite la traza con is_causal=True.

Python 3 · ejemplo reproducible
Q_traza = torch.tensor([[0.0, 2.0], [2.0, 0.0]])
K_traza = torch.tensor([[0.0, 1.0], [1.0, 0.0]])
V_traza = torch.tensor([[4.0, 0.0], [0.0, 4.0]])

Comprueba con torch.softmax y con F.scaled_dot_product_attention. Con la máscara causal, la salida del token 0 debe ser exactamente [4, 0]. Si no coincide, revisa en qué dimensión aplicaste softmax.

Número de cabezas

Crea nn.MultiheadAttention(embed_dim=4, num_heads=h, batch_first=True) con h igual a 1, 2 y 4. Antes de procesar, predice el número de parámetros y la forma de los pesos con average_attn_weights=False para la oración x. Comprueba ambas cosas con assert. Después intenta num_heads=3 y relaciona el error con el tamaño d / h de cada cabeza.

Codificador diminuto

Escribe un módulo CodificadorDiminuto que:

  1. reciba índices de palabras con forma (batch, tokens) y los convierta con nn.Embedding(3, 4)
  2. sume posicional_senoidal(tokens, 4)
  3. aplique un nn.TransformerEncoderLayer con d_model=4, nhead=2, dim_feedforward=16, dropout=0.0 y batch_first=True
  4. promedie los vectores de los tokens y devuelva 2 logits con nn.Linear(4, 2)

Con torch.manual_seed(0), pasa las oraciones [0, 1, 2] y [2, 1, 0] en un solo batch. Comprueba con assert que la salida tiene forma (2, 2) y que los logits de las dos oraciones son distintos. Quita el paso 2 y comprueba con torch.allclose que ahora son iguales.

Reto opcional

Genera 2000 secuencias de 5 tokens con valores de 0 a 9 y la etiqueta 1 si el primer token es mayor que el último. Adapta CodificadorDiminuto a un embedding de 10 palabras y d_model=16, y entrénalo con y sin codificación posicional. Compara la exactitud de validación de las dos versiones y explica la diferencia con lo que viste en «Codificación posicional».

Errores que revisar

  • Si las filas de pesos no suman 1, revisa la dimensión de softmax: debe ser la de las claves, dim=-1, no la de las consultas.
  • Si los pesos de nn.MultiheadAttention tienen forma (tokens, batch, batch) en vez de (batch, tokens, tokens), falta batch_first=True. Sin él, el módulo lee la entrada como (tokens, batch, embed_dim): la salida conserva la forma, pero la atención mezcla las oraciones del batch en vez de los tokens.
  • Si un token atiende a tokens futuros, o solo a ellos, revisa el significado de True en la máscara booleana. En nn.MultiheadAttention, True bloquea la posición. En F.scaled_dot_product_attention, True la permite.
  • Si nn.MultiheadAttention lanza AssertionError: embed_dim must be divisible by num_heads, elige un número de cabezas que divida a embed_dim.
  • Si al reordenar los tokens de entrada las salidas solo se reordenan, falta sumar la codificación posicional antes del primer bloque.

Cierre

Explica por qué el vector de perro sale igual en «perro muerde gato» y «gato muerde perro» después del bloque sin codificación posicional, y qué cambia cuando se suma pe.

Lista de comprobación

  • ¿Qué representa cada fila de la matriz softmax(Q Kᵀ / √d_k) y por qué suma 1?
  • ¿Qué pasa con los pesos de atención si los puntajes no se dividen entre √d_k y d_k es grande?
  • ¿Qué salidas cambian si se modifica el último token con máscara causal?
  • ¿Qué forma tienen la entrada y la salida de un bloque transformer y por qué permite apilar bloques?
  • ¿Por qué la atención sin codificación posicional no distingue el orden de los tokens?

Documentación oficial

Cargando progreso…