Saltar a contenido

Capítulo 6: Transformers — la arquitectura que lo cambió todo

Módulo: 03-DEEP-LEARNING · Capítulo: 6 de 8 AI Master Academy · Última revisión: julio de 2026 Requisitos previos: PyTorch, embeddings, softmax, producto punto, LayerNorm, conexiones residuales (ResNet) y el mecanismo de atención de seq2seq (capítulo 5).


En 2017, un equipo de Google publicó un paper con un título deliberadamente provocador: "Attention Is All You Need". La atención es todo lo que necesitas. Nueve años después, esa frase se ha convertido en una profecía cumplida: prácticamente todos los modelos de lenguaje que usas hoy —GPT, Claude, Gemini, Llama— son Transformers. También lo son los modelos de visión más potentes (ViT), los de audio (Whisper), los de proteínas (AlphaFold usa módulos de atención) y los de generación de imágenes (los DiT de los difusores modernos).

En este capítulo vas a desmontar el Transformer pieza a pieza, calcular la atención a mano con números reales, implementarla en NumPy y PyTorch, y terminar entrenando tu propio generador de texto en español desde cero. Al acabar, cuando leas "el modelo tiene 32 capas de atención con 64 cabezas", sabrás exactamente qué significa cada palabra.


Índice

  1. Contexto histórico: por qué eliminar la recurrencia
  2. Self-attention en profundidad
  3. 2.1. La intuición: cada palabra mira a las demás
  4. 2.2. Query, Key y Value: la biblioteca
  5. 2.3. Ejemplo numérico completo, a mano
  6. 2.4. El escalado por √d_k: por qué existe
  7. 2.5. Implementación en NumPy y PyTorch
  8. 2.6. La matriz de atención como herramienta de interpretación
  9. Multi-head attention: muchas miradas en paralelo
  10. El resto del bloque Transformer
  11. 4.1. Positional encoding: el orden importa
  12. 4.2. La red feed-forward por posición
  13. 4.3. Residuales y LayerNorm: pre-LN vs post-LN
  14. 4.4. Ensamblando el bloque completo
  15. Las tres familias: encoder-only, decoder-only, encoder-decoder
  16. Proyecto guiado: tu propio generador de texto en español
  17. Escalado: de tu modelo de juguete a los LLMs
  18. Sección empresarial: una arquitectura para gobernarlas a todas
  19. Buenas prácticas
  20. Malas prácticas
  21. Errores comunes
  22. Preguntas frecuentes
  23. Resumen
  24. Bibliografía

1. Contexto histórico: por qué eliminar la recurrencia

1.1. El mundo antes de 2017

En el capítulo 5 aprendiste cómo funcionaban los modelos de secuencias hasta 2017: redes recurrentes (RNN, LSTM, GRU) que procesaban el texto token a token, en orden, manteniendo un estado oculto que se actualizaba en cada paso. Para traducir, se usaba la arquitectura seq2seq: un encoder recurrente comprimía la frase de entrada y un decoder recurrente generaba la salida, ayudado por el mecanismo de atención de Bahdanau que permitía al decoder "mirar atrás" a los estados del encoder.

Ese diseño funcionaba, pero arrastraba dos problemas estructurales que ninguna mejora incremental conseguía resolver:

Problema 1: la secuencialidad impide paralelizar.

Una RNN calcula el estado oculto del paso t a partir del estado del paso t-1:

h_t = f(h_{t-1}, x_t)

Esa dependencia es una cadena: no puedes calcular h_50 sin haber calculado antes h_49, y este sin h_48... Las GPUs son máquinas de paralelismo masivo —miles de núcleos ejecutando la misma operación sobre datos distintos— y las RNNs las desaprovechan por diseño: por muy grande que sea tu GPU, el tiempo de procesar una secuencia crece linealmente con su longitud, paso a paso. Entrenar con corpus enormes era desesperantemente lento.

Problema 2: las dependencias a larga distancia se degradan.

Para que la información de la palabra 1 influya en la palabra 100, la señal debe sobrevivir a 99 actualizaciones del estado oculto. Cada actualización comprime, mezcla y potencialmente destruye información (el famoso problema del gradiente que se desvanece, que las LSTM mitigaban pero no eliminaban). En la práctica, las RNNs "olvidaban" el principio de párrafos largos. El número de pasos entre dos palabras cualesquiera —lo que el paper llama path length— era O(n): proporcional a la distancia entre ellas.

1.2. La propuesta radical de "Attention Is All You Need"

El paper de Vaswani et al. (2017) hizo una pregunta valiente: si el mecanismo de atención del seq2seq era lo que realmente permitía conectar palabras lejanas... ¿para qué necesitamos la recurrencia? ¿Y si construimos el modelo entero solo con atención?

La respuesta fue el Transformer, con dos consecuencias inmediatas:

Propiedad RNN / LSTM Transformer
Procesamiento de la secuencia Secuencial (token a token) Paralelo (todos los tokens a la vez)
Distancia máxima entre dos tokens O(n) pasos O(1) — un solo salto de atención
Aprovechamiento de la GPU Pobre Excelente (multiplicaciones de matrices gigantes)
Coste por capa O(n · d²) O(n² · d) (cuadrático en longitud, lo veremos en la sección 7)
Memoria de largo plazo Se degrada con la distancia Acceso directo a cualquier posición
  • Paralelización: en un Transformer, la representación de todos los tokens de la secuencia se calcula simultáneamente con unas pocas multiplicaciones de matrices. Es exactamente el tipo de operación para el que las GPUs (y luego las TPUs) fueron construidas. De golpe, se podía entrenar con órdenes de magnitud más datos en el mismo tiempo.
  • Dependencias a larga distancia: en self-attention, el token 1 y el token 100 se conectan directamente, en un solo paso, con la misma facilidad que dos tokens vecinos. El path length pasa de O(n) a O(1). La información ya no tiene que sobrevivir a una cadena de compresiones.
timeline
    title Del seq2seq al dominio del Transformer
    2014 : Seq2seq con LSTM (Sutskever et al.)
    2015 : Atención de Bahdanau sobre RNNs
    2017 : "Attention Is All You Need"— nace el Transformer
    2018 : BERT (encoder-only) y GPT-1 (decoder-only)
    2020 : GPT-3 y las scaling laws — escalar funciona
    2022 : ChatGPT populariza los LLMs conversacionales
    2024-2026 : Los decoder-only dominan; contextos de millones de tokens

Nota

el paper original diseñó el Transformer para traducción automática (inglés→alemán, inglés→francés), con una arquitectura encoder-decoder. La ironía histórica es que su mayor impacto llegaría con variantes que usan solo la mitad de la arquitectura: BERT (solo encoder) y GPT (solo decoder). Lo veremos en la sección 5.

Consejo profesional

cuando leas papers de arquitecturas, fíjate siempre en estas dos preguntas: ¿qué se puede paralelizar? y ¿cuántos pasos separan dos posiciones cualesquiera? Son los dos ejes que explican por qué el Transformer ganó, y siguen siendo los ejes de la investigación actual (Mamba, atención lineal, etc. atacan el coste cuadrático intentando no perder el path length corto).

1.3. El plano general, antes de entrar al detalle

Para que no pierdas el bosque entre los árboles, este es el mapa completo de un Transformer moderno (variante decoder-only, la de GPT). Cada caja es una sección de este capítulo; al terminarlo habrás implementado todas:

flowchart TB
    T["Texto: «La ciencia avanza»"] --> TOK["Tokenizador<br/>(texto -> índices)"]
    TOK --> EMB["Embeddings de tokens<br/>+ positional encoding &nbsp;(sec. 4.1)"]
    EMB --> B1["Bloque Transformer 1<br/>atención multi-cabeza (sec. 2-3) + FFN (sec. 4.2)"]
    B1 --> B2["Bloque Transformer 2<br/>(idéntico, pesos propios)"]
    B2 --> BN["... x N bloques &nbsp;(sec. 4.4)"]
    BN --> LNF["LayerNorm final &nbsp;(sec. 4.3)"]
    LNF --> HEAD["Cabeza de lenguaje<br/>(logits sobre el vocabulario)"]
    HEAD --> OUT["Distribución del siguiente token<br/>-> generación &nbsp;(sec. 5-6)"]

La receta cabe en una frase: convertir el texto en vectores, pasarlos por N bloques idénticos que alternan "hablar entre tokens" (atención) y "pensar cada token por su cuenta" (feed-forward), y proyectar el resultado a una predicción. Todo lo demás son detalles de esa frase — importantes, pero detalles. Empecemos por la pieza que da nombre al paper.


2. Self-attention en profundidad

2.1. La intuición: cada palabra mira a las demás

Empecemos por el problema que resuelve. Considera la palabra "banco" en estas tres frases:

  1. «Me senté en un banco del parque.»
  2. «El banco me denegó la hipoteca.»
  3. «Un banco de peces rodeó al buceador.»

La palabra es idéntica; su significado, completamente distinto. Un embedding estático (como los word2vec del capítulo 5) asigna a "banco" un único vector, una mezcla borrosa de mueble, entidad financiera y grupo de peces. Eso es claramente insuficiente.

Self-attention resuelve esto dejando que cada token ajuste su significado mirando a los demás tokens de la frase. En la frase 2, el vector de "banco" debería acercarse a la región del espacio semántico de las finanzas porque en la frase aparecen "denegó" e "hipoteca". El mecanismo es:

  1. Cada token empieza con su embedding genérico (contexto-independiente).
  2. Cada token calcula cuánta atención prestar a cada uno de los otros tokens (incluido él mismo): un peso entre 0 y 1, y todos los pesos suman 1.
  3. El nuevo vector del token es una media ponderada de (proyecciones de) los vectores de todos los tokens, usando esos pesos.

El resultado es un embedding contextual: "banco" en la frase 2 sale del bloque de atención siendo un vector distinto que "banco" en la frase 1, porque absorbió información distinta de sus vecinos.

Nota

se llama self-attention (auto-atención) porque la secuencia se atiende a sí misma: queries, keys y values salen todos de la misma frase. En el seq2seq del capítulo 5, la atención era cruzada: el decoder (queries) atendía al encoder (keys y values). El mecanismo matemático es el mismo; cambia de dónde salen los ingredientes.

2.2. Query, Key y Value: la búsqueda en la biblioteca

¿Cómo decide un token cuánta atención prestar a otro? Aquí entra el trío Query, Key, Value (consulta, clave, valor). La mejor analogía es una búsqueda en una biblioteca:

  • Query (Q): lo que tú buscas. Llegas al mostrador con una pregunta: «necesito algo sobre finanzas del siglo XIX».
  • Key (K): la etiqueta del lomo de cada libro. Cada libro anuncia de qué trata: «historia económica», «poesía barroca», «biología marina».
  • Value (V): el contenido del libro. Lo que realmente te llevas a casa si el libro encaja con tu búsqueda.

El proceso: comparas tu query con la key de cada libro (¿cuánto encaja mi pregunta con esta etiqueta?), obtienes una puntuación de afinidad por libro, y te llevas una mezcla de contenidos ponderada por esas afinidades. Los libros muy relevantes contribuyen mucho; los irrelevantes, casi nada.

Trasladado a los tokens: cuando "banco" procesa la frase «el banco me denegó la hipoteca»:

  • La query de "banco" expresa qué busca ese token para desambiguarse: «¿hay por aquí contexto financiero o mobiliario?».
  • La key de "hipoteca" anuncia qué ofrece ese token: «aquí hay semántica financiera».
  • Query de "banco" y key de "hipoteca" encajan → puntuación alta → el value de "hipoteca" (su contenido semántico empaquetado para compartir) fluye con mucho peso hacia el nuevo vector de "banco".

¿Qué son técnicamente Q, K y V? Nada exótico: tres proyecciones lineales aprendidas del mismo embedding. Si x es el vector del token (dimensión d_model), entonces:

q = x · W_Q        k = x · W_K        v = x · W_V

donde W_Q, W_K, W_V son tres matrices de pesos que la red aprende por backpropagation, exactamente igual que cualquier capa nn.Linear. El mismo vector x se proyecta a tres "papeles" distintos: qué pregunto (Q), qué anuncio (K) y qué entrego (V). No hay magia: son tres multiplicaciones de matrices, y todo lo interesante está en qué aprenden esas matrices durante el entrenamiento.

Advertencia

un error conceptual muy común es pensar que Q, K y V son "cosas" distintas que vienen de sitios distintos. En self-attention los tres salen del mismo vector de entrada, con tres proyecciones distintas. Un token usa su Q para preguntar, y ese mismo token es a la vez respondedor: su K y su V participan en las preguntas de todos los demás (y en la suya propia).

La fórmula completa de la atención escalada por producto punto, que ahora vamos a calcular a mano, es:

Attention(Q, K, V) = softmax( Q · Kᵀ / √d_k ) · V

2.3. Ejemplo numérico completo, a mano

Nada fija mejor un mecanismo que calcularlo con números. Vamos a procesar la frase de 3 tokens:

«el gato duerme»

con embeddings de dimensión 4 (d_model = 4) y proyecciones a dimensión 2 (d_k = d_v = 2), para que todas las cuentas quepan en una tabla. Usaremos matrices de pesos con ceros y unos para poder verificar cada producto mentalmente; en un modelo real serían números reales aprendidos.

Paso 0 — Los embeddings de entrada. La matriz X tiene un token por fila:

Token x₁ x₂ x₃ x₄
el 1 0 1 0
gato 0 1 0 1
duerme 1 1 0 0

Paso 1 — Las matrices de proyección aprendidas (shape 4×2 cada una):

W_Q c₁ c₂ W_K c₁ c₂ W_V c₁ c₂
f₁ 1 0 f₁ 0 1 f₁ 1 0
f₂ 0 1 f₂ 1 0 f₂ 0 1
f₃ 1 0 f₃ 0 1 f₃ 1 1
f₄ 0 1 f₄ 1 0 f₄ 0 0

Paso 2 — Calcular Q = X·W_Q, K = X·W_K, V = X·W_V.

Verifica una a mano: la query de "el" es [1,0,1,0] · W_Q. Columna 1: 1·1 + 0·0 + 1·1 + 0·0 = 2. Columna 2: 1·0 + 0·1 + 1·0 + 0·1 = 0. Resultado: [2, 0]. Repitiendo para todas las filas:

Token Q (q₁, q₂) K (k₁, k₂) V (v₁, v₂)
el (2, 0) (0, 2) (2, 1)
gato (0, 2) (2, 0) (0, 1)
duerme (1, 1) (1, 1) (1, 1)

Paso 3 — La matriz de puntuaciones S = Q·Kᵀ. Cada celda S[i][j] es el producto punto entre la query del token i y la key del token j: «¿cuánto encaja lo que busca i con lo que anuncia j?».

Ejemplo: S[el][gato] = (2,0)·(2,0) = 2·2 + 0·0 = 4.

Q\K el gato duerme
el 0 4 2
gato 4 0 2
duerme 2 2 2

Lee la primera fila: la query de "el" encaja mucho con la key de "gato" (4), algo con la de "duerme" (2) y nada con la suya propia (0). Con estos pesos de juguete, "el" y "gato" se buscan mutuamente — igual que en una frase real un artículo y su sustantivo suelen atenderse.

Paso 4 — Escalar por √d_k. Con d_k = 2, dividimos todo entre √2 ≈ 1.4142:

Q\K (S/√2) el gato duerme
el 0.000 2.828 1.414
gato 2.828 0.000 1.414
duerme 1.414 1.414 1.414

(El porqué de este escalado merece su propia subsección: es la 2.4, justo después.)

Paso 5 — Softmax por filas. Cada fila se convierte en una distribución de probabilidad (suma 1). Para la fila de "el": exp(0)=1.000, exp(2.828)=16.92, exp(1.414)=4.113; la suma es 22.03, así que los pesos son 1/22.03, 16.92/22.03, 4.113/22.03:

Pesos de atención A el gato duerme Σ fila
el 0.045 0.768 0.187 1.000
gato 0.768 0.045 0.187 1.000
duerme 0.333 0.333 0.333 1.000

Interpretación: el nuevo vector de "el" se construirá con un 76.8 % del value de "gato", un 18.7 % del de "duerme" y solo un 4.5 % del suyo propio. "duerme", cuya query encajaba igual con todas las keys, reparte su atención uniformemente.

Paso 6 — La salida: Z = A·V (suma ponderada de los values).

Para "el": 0.045·(2,1) + 0.768·(0,1) + 0.187·(1,1) = (0.091+0+0.187, 0.045+0.768+0.187) = (0.278, 1.000).

Token Salida z₁ Salida z₂
el 0.278 1.000
gato 1.723 1.000
duerme 1.000 1.000

Nota

fíjate en que la segunda coordenada de la salida vale 1.000 para los tres tokens. No es casualidad: la segunda componente de los tres values vale 1, y una media ponderada de unos (con pesos que suman 1) es 1. Es una comprobación útil cuando calcules atención a mano: la salida de cada token es siempre una combinación convexa de los values — vive "dentro" del polígono que forman los vectores V.

Eso es todo el mecanismo. Seis pasos, todos ellos multiplicaciones de matrices y un softmax. Cuando GPT procesa tu prompt, hace exactamente esto — con d_model = 12 288 en vez de 4, decenas de miles de tokens en vez de 3, y 96 cabezas en 96 capas en vez de una, pero exactamente esto.

Ejercicio 6.1: con las tablas anteriores, verifica el cálculo completo de la fila de "gato": su puntuación con cada key, el softmax y su vector de salida. ¿Por qué su salida (1.723) tiene la primera componente tan alta comparada con la de "el" (0.278)?

Ver solución Puntuaciones de "gato" (query `(0,2)`): con key de "el" `(0,2)` → `0·0+2·2 = 4`; con "gato" `(2,0)` → `0`; con "duerme" `(1,1)` → `2`. Escaladas: `2.828, 0, 1.414`. Softmax: `exp = 16.92, 1.000, 4.113`, suma `22.03` → pesos `(0.768, 0.045, 0.187)`. Salida: `0.768·(2,1) + 0.045·(0,1) + 0.187·(1,1) = (1.536+0+0.187, 0.768+0.045+0.187) = (1.723, 1.000)`. La primera componente es alta porque "gato" concentra el 76.8 % de su atención en "el", cuyo value es `(2,1)` — el de mayor primera componente. "el", en cambio, atendía sobre todo a "gato", cuyo value `(0,1)` tiene primera componente nula. Cada token literalmente "se lleva a casa" los values de aquellos a quienes atiende.

2.4. El escalado por √d_k: por qué evita saturar el softmax

¿Por qué la fórmula divide entre √d_k antes del softmax? La respuesta corta: porque el producto punto crece con la dimensión, y un softmax con entradas grandes se satura.

El problema, cuantificado. Si las componentes de q y k son variables aleatorias independientes con media 0 y varianza 1 (que es aproximadamente cómo se inicializan las redes), su producto punto q·k = Σ qᵢkᵢ es la suma de d_k términos independientes. Su media es 0, pero su varianza es d_k, es decir, su desviación típica es √d_k. Con d_k = 64, las puntuaciones típicas ya no rondan ±1: rondan ±8. Con d_k = 512, ±22.6.

¿Y qué le pasa al softmax con entradas grandes? Demostración numérica. Compara el softmax de tres puntuaciones "pequeñas" con el de las mismas puntuaciones multiplicadas por 10 (que es lo que pasaría, en orden de magnitud, al pasar de d_k=2 a d_k=200 sin escalar):

Entradas softmax Comentario
[0, 2, 1] [0.090, 0.665, 0.245] Distribución suave: todos participan
[0, 20, 10] [0.0000000021, 0.9999546, 0.0000454] Saturado: un token acapara todo

Con entradas grandes, el softmax se convierte de facto en un argmax: pone ≈1 en el máximo y ≈0 en el resto. Eso tiene dos consecuencias fatales:

  1. Gradientes que se desvanecen. El gradiente del softmax respecto a sus entradas depende de términos pᵢ(1-pᵢ). Si p ≈ 1 o p ≈ 0, ese producto es ≈ 0: la capa deja de aprender. Es la misma patología de saturación que viste con sigmoides en capítulos anteriores.
  2. Atención rígida. Cada token solo puede mirar a un token; se pierde la capacidad de mezclar información de varios, que es justamente la gracia del mecanismo.

La solución. Dividir las puntuaciones entre √d_k devuelve su desviación típica a ≈1 sea cual sea la dimensión: Var(q·k / √d_k) = d_k / (√d_k)² = 1. El softmax recibe siempre entradas en un rango cómodo, ni saturado ni plano, independientemente de que d_k sea 2 o 512. Es un truco de una línea que estabiliza todo el entrenamiento — y por eso el mecanismo se llama scaled dot-product attention.

Consejo profesional

esta lógica de "controlar la varianza en cada punto de la red" es un patrón universal del deep learning moderno: la inicialización de Xavier/He, el LayerNorm, el escalado por √d_k y el warmup del learning rate son todas variaciones del mismo principio. Cuando un entrenamiento diverge, la primera pregunta es casi siempre: ¿dónde está explotando o desapareciendo la escala de las activaciones?

2.5. Implementación: NumPy y PyTorch

Primero en NumPy puro, para que veas que no hay nada escondido. Reproduce exactamente el ejemplo de la sección 2.3.

import numpy as np                                  # Álgebra lineal básica: solo necesitamos matrices.

np.random.seed(42)                                  # Semilla fija: resultados reproducibles en cada ejecución.

def softmax_filas(m):
    """Softmax aplicado de forma independiente a cada fila de la matriz m."""
    m = m - m.max(axis=-1, keepdims=True)           # Restar el máximo de cada fila: estabilidad numérica
                                                    # (exp de números grandes desborda; restar una constante
                                                    # no cambia el resultado del softmax).
    e = np.exp(m)                                   # Exponencial elemento a elemento.
    return e / e.sum(axis=-1, keepdims=True)        # Normalizar: cada fila pasa a sumar exactamente 1.

def atencion(X, W_Q, W_K, W_V):
    """Scaled dot-product self-attention sobre la matriz de embeddings X (tokens en filas)."""
    Q = X @ W_Q                                     # Proyección a queries:  (n, d_model) @ (d_model, d_k) -> (n, d_k)
    K = X @ W_K                                     # Proyección a keys:     mismo shape que Q.
    V = X @ W_V                                     # Proyección a values:   (n, d_v).
    d_k = Q.shape[-1]                               # Dimensión de las queries/keys (aquí 2).
    S = Q @ K.T                                     # Puntuaciones: S[i,j] = q_i · k_j.  Shape (n, n).
    S = S / np.sqrt(d_k)                            # Escalado por raíz de d_k: evita saturar el softmax (sección 2.4).
    A = softmax_filas(S)                            # Pesos de atención: cada fila es una distribución que suma 1.
    Z = A @ V                                       # Salida: media ponderada de los values.  Shape (n, d_v).
    return Z, A                                     # Devolvemos también A para poder inspeccionarla (sección 2.6).

# --- Los mismos números del ejemplo a mano ---
X   = np.array([[1., 0., 1., 0.],                   # "el"
                [0., 1., 0., 1.],                   # "gato"
                [1., 1., 0., 0.]])                  # "duerme"
W_Q = np.array([[1., 0.], [0., 1.], [1., 0.], [0., 1.]])   # Proyección de queries (4x2).
W_K = np.array([[0., 1.], [1., 0.], [0., 1.], [1., 0.]])   # Proyección de keys (4x2).
W_V = np.array([[1., 0.], [0., 1.], [1., 1.], [0., 0.]])   # Proyección de values (4x2).

Z, A = atencion(X, W_Q, W_K, W_V)                   # Ejecutar la atención completa.
print("Pesos de atención A:\n", A.round(3))         # Debe coincidir con la tabla del paso 5.
print("Salida Z:\n", Z.round(3))                    # Debe coincidir con la tabla del paso 6.

Salida (coincide con nuestras tablas hechas a mano):

Pesos de atención A:
 [[0.045 0.768 0.187]
 [0.768 0.045 0.187]
 [0.333 0.333 0.333]]
Salida Z:
 [[0.278 1.   ]
 [1.723 1.   ]
 [1.    1.   ]]

Ahora la versión PyTorch, con la forma que usarás en modelos reales: entrada con dimensión de batch, proyecciones como nn.Linear y soporte de máscara (la necesitaremos en la sección 5 para la atención causal):

import torch                                        # Tensores con autograd y soporte GPU.
import torch.nn.functional as F                     # Funciones sin estado: softmax, etc.

torch.manual_seed(42)                               # Semilla fija para reproducibilidad.

def scaled_dot_product_attention(Q, K, V, mascara=None):
    """Q, K, V: tensores (batch, n_tokens, d_k). Devuelve (salida, pesos_de_atencion)."""
    d_k = Q.size(-1)                                # Dimensión de queries/keys (última dimensión del tensor).
    S = Q @ K.transpose(-2, -1)                     # Puntuaciones (batch, n, n): producto punto de cada q con cada k.
                                                    # transpose(-2,-1) intercambia tokens y canales de K -> Kᵀ.
    S = S / d_k ** 0.5                              # Escalado por √d_k, como en NumPy.
    if mascara is not None:                         # Si hay máscara (p. ej. causal, sección 5):
        S = S.masked_fill(mascara == 0, float("-inf"))  # Poner -inf donde la máscara es 0: tras el softmax
                                                        # esas posiciones tendrán peso exactamente 0.
    A = F.softmax(S, dim=-1)                        # Softmax sobre la última dimensión: normaliza cada fila.
    return A @ V, A                                 # Salida (batch, n, d_v) y pesos para inspección.

X_t = torch.tensor(X, dtype=torch.float32).unsqueeze(0)      # Añadimos dimensión de batch: (1, 3, 4).
Q = X_t @ torch.tensor(W_Q, dtype=torch.float32)             # En un modelo real esto sería nn.Linear;
K = X_t @ torch.tensor(W_K, dtype=torch.float32)             # aquí reutilizamos las matrices del ejemplo
V = X_t @ torch.tensor(W_V, dtype=torch.float32)             # para verificar que todo coincide.
Z, A = scaled_dot_product_attention(Q, K, V)                  # Misma salida que la versión NumPy.
print(A.squeeze(0).round(decimals=3))                         # [[0.045, 0.768, 0.187], ...] ✅

Nota

PyTorch trae esta operación ya optimizada: F.scaled_dot_product_attention(Q, K, V, is_causal=True), que además usa kernels tipo FlashAttention cuando el hardware lo permite. En producción usa siempre la versión nativa; aquí la escribimos a mano porque el objetivo es entenderla. En el proyecto de la sección 6 la implementaremos manualmente por transparencia.

2.6. La matriz de atención como herramienta de interpretación

La matriz A no es solo un paso intermedio del cálculo: es una ventana a lo que el modelo está "pensando". Cada fila te dice, para un token, de qué otros tokens está extrayendo información. Visualizarla como mapa de calor es una de las técnicas de interpretabilidad más antiguas y útiles del NLP moderno.

Así se ve (esquemáticamente) una cabeza de atención de un modelo real procesando «El banco me denegó la hipoteca ayer», con la intensidad del sombreado proporcional al peso:

                        K E Y S  (a quién se mira)
                 El    banco  me    denegó la    hipoteca ayer
              ┌───────────────────────────────────────────────┐
   El         │ ▓▓    ██     ░░    ░░     ░░    ░░       ░░   │
   banco      │ ▒▒    ▓▓     ░░    ▓▓     ░░    ██       ░░   │  <- "banco" mira con fuerza
   me         │ ░░    ▒▒     ▓▓    ██     ░░    ░░       ░░   │     a "hipoteca": ahí está
Q  denegó     │ ░░    ▓▓     ▒▒    ▓▓     ░░    ██       ░░   │     la clave para desambiguar
   la         │ ░░    ░░     ░░    ░░     ▓▓    ██       ░░   │
   hipoteca   │ ░░    ██     ░░    ▓▓     ▒▒    ▓▓       ░░   │
   ayer       │ ░░    ░░     ░░    ██     ░░    ▒▒       ▓▓   │
              └───────────────────────────────────────────────┘
               ░░ peso ≈ 0      ▒▒ bajo      ▓▓ medio      ██ alto

Patrones que se descubren mirando estas matrices en modelos entrenados:

  • Cabezas sintácticas: artículos que atienden a su sustantivo, verbos a su sujeto.
  • Cabezas posicionales: tokens que atienden sistemáticamente al token anterior o al primero de la frase.
  • Cabezas de correferencia: pronombres ("él", "la") que atienden a la entidad a la que se refieren.
  • Desambiguación léxica: exactamente el caso de "banco"→"hipoteca" de arriba.

Advertencia

los pesos de atención son una pista, no una prueba. La investigación en interpretabilidad ha demostrado que "atención alta" no siempre significa "causalmente importante" (el value de ese token podría ser casi nulo, o la información puede fluir por otras capas). Úsalos como herramienta exploratoria de depuración, no como explicación definitiva del comportamiento del modelo.

Ejercicio 6.2: en nuestro ejemplo a mano, la fila de "duerme" en la matriz de atención es exactamente uniforme (⅓, ⅓, ⅓). Mirando su query y las keys, explica por qué, y describe qué tendría que cambiar en W_Q o W_K para que "duerme" prestara más atención a "gato".

Ver solución La query de "duerme" es `(1,1)`, y su producto punto con las tres keys `(0,2)`, `(2,0)` y `(1,1)` da 2 en los tres casos: puntuaciones idénticas → softmax uniforme. La query de "duerme" apunta en una dirección "equidistante" de todas las keys. Para que atendiera más a "gato" (key `(2,0)`), su query debería alinearse más con esa key: por ejemplo, una query `(2,0)` daría puntuaciones `(0, 4, 2)` y el softmax concentraría el peso en "gato". Eso se consigue ajustando `W_Q` (cómo "duerme" formula su pregunta) o `W_K` (cómo "gato" se anuncia). Durante el entrenamiento, backpropagation ajusta exactamente esas matrices para que las preguntas y las etiquetas útiles se encuentren.

3. Multi-head attention: muchas miradas en paralelo

3.1. Motivación: una sola atención tiene que elegir

La atención de la sección 2 produce un único patrón de pesos por token. Pero fíjate en cuántas relaciones distintas necesita capturar un modelo de lenguaje en una misma frase:

Tipo de relación Ejemplo en «El gato que adoptamos ayer duerme en su cesta»
Sintáctica (sujeto-verbo) "duerme" ← "gato"
Correferencia "su" ← "gato"
Modificación temporal "adoptamos" ← "ayer"
Proximidad posicional cada token ← el token anterior
Desambiguación léxica "cesta" ← "gato", "duerme"

Si obligamos a una sola matriz de atención a capturar todas esas relaciones a la vez, el softmax tiene que repartir sus pesos entre objetivos que compiten: el resultado es una atención borrosa, un promedio mediocre de patrones incompatibles. Es como pedirle a un único lector que, en una sola pasada, analice la sintaxis, resuelva los pronombres y capte el tono.

La solución del paper es elegante: ejecutar varias atenciones en paralelo —las "cabezas"— cada una con sus propias matrices W_Q, W_K, W_V aprendidas. Cada cabeza opera en un subespacio distinto y es libre de especializarse: una aprende sintaxis, otra correferencia, otra simplemente "mira al token anterior". La red decide durante el entrenamiento qué le conviene a cada una; nadie se lo asigna a mano. Las cabezas sintácticas, posicionales y de correferencia que mencionamos en la sección 2.6 son exactamente esto: cabezas distintas de un mismo modelo.

flowchart TB
    X["Entrada x&nbsp;&nbsp;(B, n, d_model)"] --> P["Proyecciones W_Q, W_K, W_V<br/>y troceado en h cabezas"]
    P --> H1["Cabeza 1 (d_k)<br/>aprende p. ej. sintaxis"]
    P --> H2["Cabeza 2 (d_k)<br/>aprende p. ej. correferencia"]
    P --> H3["Cabeza ... h (d_k)<br/>aprende p. ej. posición"]
    H1 --> C["Concatenar&nbsp;&nbsp;(B, n, d_model)"]
    H2 --> C
    H3 --> C
    C --> WO["Proyección final W_O:<br/>mezcla lo visto por todas"]
    WO --> Y["Salida&nbsp;&nbsp;(B, n, d_model)"]

3.2. El mecanismo: dividir, atender en paralelo, concatenar, proyectar

La idea clave de la implementación: no añadimos h atenciones de tamaño completo (eso multiplicaría el coste por h), sino que repartimos la dimensión del modelo entre las cabezas. Con d_model = 512 y h = 8 cabezas, cada cabeza trabaja con vectores de dimensión d_k = d_model / h = 64. El coste total es prácticamente el mismo que el de una sola cabeza grande.

El proceso, paso a paso:

  1. Proyectar: la entrada X se proyecta a Q, K y V de dimensión d_model, como siempre.
  2. Dividir: cada uno se trocea en h bloques de dimensión d_k — uno por cabeza.
  3. Atender: cada cabeza ejecuta su scaled dot-product attention en paralelo e independientemente, produciendo su propia matriz de pesos A y su propia salida de dimensión d_k.
  4. Concatenar: las h salidas se pegan de nuevo en un vector de dimensión d_model.
  5. Proyectar: una última matriz W_O mezcla la información de todas las cabezas.

La tabla de shapes completa, con B = batch, n = tokens, d_model = 512, h = 8, d_k = 64:

Paso Operación Shape del resultado
Entrada X (B, n, 512)
Proyección Q = X·W_Q (ídem K, V) (B, n, 512)
Trocear en cabezas view + transpose (B, 8, n, 64)
Puntuaciones Q·Kᵀ / √64 (B, 8, n, n)
Pesos de atención softmax por filas (B, 8, n, n)
Salida por cabeza A·V (B, 8, n, 64)
Concatenar transpose + view (B, n, 512)
Proyección final ·W_O (B, n, 512)

Nota

observa que ahora hay h matrices de atención distintas por capa: el tensor de pesos es (B, 8, n, n). Cuando en la sección 2.6 hablábamos de visualizar "la" matriz de atención, en un modelo real eliges qué capa y qué cabeza visualizar — y cada una cuenta una historia diferente.

Consejo profesional

la proyección final W_O es fácil de pasar por alto, pero es esencial: sin ella, la salida sería una simple yuxtaposición de cabezas donde la coordenada 0-63 solo vería lo que dijo la cabeza 0. W_O permite que la información de todas las cabezas se combine en cada coordenada de salida.

3.3. Implementación en PyTorch, línea a línea

import torch                                            # Tensores, autograd, GPU.
import torch.nn as nn                                   # Capas con parámetros aprendibles.

torch.manual_seed(42)                                   # Semilla fija: pesos iniciales reproducibles.

class MultiHeadAttention(nn.Module):
    """Atención multi-cabeza como en 'Attention Is All You Need' (self-attention)."""

    def __init__(self, d_model: int, n_heads: int):
        super().__init__()                              # Inicializa nn.Module (registro de parámetros).
        assert d_model % n_heads == 0, "d_model debe ser divisible entre n_heads"
        self.d_model = d_model                          # Dimensión total del modelo (p. ej. 512).
        self.n_heads = n_heads                          # Número de cabezas (p. ej. 8).
        self.d_k = d_model // n_heads                   # Dimensión por cabeza: 512 // 8 = 64.
        self.W_q = nn.Linear(d_model, d_model)          # Proyección de queries de TODAS las cabezas a la vez:
                                                        # una sola matriz grande es más eficiente que 8 pequeñas.
        self.W_k = nn.Linear(d_model, d_model)          # Proyección de keys.
        self.W_v = nn.Linear(d_model, d_model)          # Proyección de values.
        self.W_o = nn.Linear(d_model, d_model)          # Proyección de salida: mezcla las cabezas concatenadas.

    def forward(self, x, mascara=None):
        B, n, _ = x.shape                               # B = tamaño de batch, n = número de tokens.
        Q = self.W_q(x)                                 # (B, n, d_model): queries de todas las cabezas juntas.
        K = self.W_k(x)                                 # (B, n, d_model).
        V = self.W_v(x)                                 # (B, n, d_model).
        # Trocear en cabezas: (B, n, d_model) -> (B, n, h, d_k) -> (B, h, n, d_k).
        # El transpose pone la dimensión de cabezas delante para que cada cabeza
        # sea una "matriz de tokens" independiente sobre la que operar en paralelo.
        Q = Q.view(B, n, self.n_heads, self.d_k).transpose(1, 2)
        K = K.view(B, n, self.n_heads, self.d_k).transpose(1, 2)
        V = V.view(B, n, self.n_heads, self.d_k).transpose(1, 2)
        S = Q @ K.transpose(-2, -1) / self.d_k ** 0.5   # Puntuaciones escaladas: (B, h, n, n).
                                                        # El @ opera por lotes: h atenciones a la vez.
        if mascara is not None:                         # Máscara opcional (causal en la sección 5):
            S = S.masked_fill(mascara == 0, float("-inf"))  # -inf -> peso 0 tras el softmax.
        A = torch.softmax(S, dim=-1)                    # Pesos de atención por cabeza: cada fila suma 1.
        Z = A @ V                                       # Salida por cabeza: (B, h, n, d_k).
        Z = Z.transpose(1, 2).contiguous().view(B, n, self.d_model)  # Concatenar: deshacer el troceo.
                                                        # contiguous(): reordena la memoria tras el transpose
                                                        # para que view() sea válido.
        return self.W_o(Z)                              # Proyección final: (B, n, d_model).

# --- Comprobación de shapes ---
mha = MultiHeadAttention(d_model=512, n_heads=8)        # 8 cabezas de dimensión 64.
x = torch.randn(2, 10, 512)                             # Batch de 2 frases de 10 tokens.
print(mha(x).shape)                                     # torch.Size([2, 10, 512]) — mismo shape que la entrada.

Que la salida tenga el mismo shape que la entrada no es un detalle: es lo que permite apilar bloques uno encima de otro, tantas veces como quieras. Un "Transformer de 96 capas" es este módulo (más las piezas de la sección 4) repetido 96 veces.

Ejercicio 6.3: calcula cuántos parámetros aprendibles tiene MultiHeadAttention(d_model=512, n_heads=8). Pista: son cuatro capas nn.Linear de 512→512, cada una con su bias. ¿Cambiaría el número si usáramos 16 cabezas en vez de 8?

Ver solución Cada `nn.Linear(512, 512)` tiene `512 × 512 = 262 144` pesos + `512` biases = `262 656` parámetros. Con cuatro (`W_q`, `W_k`, `W_v`, `W_o`): `4 × 262 656 = 1 050 624` parámetros, ≈ 1.05 M. Con 16 cabezas **el número no cambia en absoluto**: las proyecciones siguen siendo 512→512; solo cambia cómo se *trocea* el resultado (16 bloques de 32 en vez de 8 bloques de 64). El número de cabezas es un hiperparámetro de "forma", no de tamaño. Lo que sí cambia es la capacidad de especialización (más cabezas, más patrones distintos) a costa de cabezas más "estrechas" (menor `d_k`, comparaciones menos expresivas).

3.4. Qué mira cada cabeza: inspección rápida

Nuestra clase devuelve solo la salida final, pero para depurar e interpretar (sección 2.6) querrás ver los pesos de cada cabeza por separado. Este helper reconstruye el tensor de atención (B, h, n, n) a partir de un módulo ya creado:

@torch.no_grad()                                        # Solo inspección: sin gradientes.
def pesos_por_cabeza(mha: MultiHeadAttention, x: torch.Tensor) -> torch.Tensor:
    """Devuelve los pesos de atención de cada cabeza: (B, h, n, n)."""
    B, n, _ = x.shape
    # Repetimos los primeros pasos del forward, hasta el softmax:
    Q = mha.W_q(x).view(B, n, mha.n_heads, mha.d_k).transpose(1, 2)   # (B, h, n, d_k)
    K = mha.W_k(x).view(B, n, mha.n_heads, mha.d_k).transpose(1, 2)   # (B, h, n, d_k)
    S = Q @ K.transpose(-2, -1) / mha.d_k ** 0.5        # Puntuaciones por cabeza.
    return torch.softmax(S, dim=-1)                     # (B, h, n, n): h mapas de atención.

A = pesos_por_cabeza(mha, x)
print(A.shape)                                          # torch.Size([2, 8, 10, 10])
print(A[0].sum(dim=-1).allclose(torch.ones(8, 10)))     # True: cada fila de cada cabeza suma 1.
# Para visualizar: plt.imshow(A[0, cabeza]) con matplotlib -> 8 mapas de calor distintos.

En un módulo recién inicializado los ocho mapas son casi uniformes y muy parecidos (pesos aleatorios pequeños → puntuaciones cercanas a cero → softmax casi plano). En un modelo entrenado, en cambio, los mapas divergen y aparecen los patrones característicos:

Patrón visual del mapa de calor Qué suele significar
Diagonal marcada La cabeza copia/refuerza el propio token
Diagonal desplazada una posición Cabeza posicional: "mira al token anterior"
Una columna intensa Todos atienden a un token pivote (a menudo el primero)
Bloques dispersos e irregulares Relaciones semánticas o sintácticas específicas

Consejo profesional

cuando entrenes el proyecto de la sección 6, vuelve aquí y ejecuta este helper sobre la primera capa de tu modelo antes y después del entrenamiento. Ver nacer la estructura en los mapas de atención es la mejor demostración de que "el modelo aprende a mirar" no es una metáfora.


4. El resto del bloque Transformer

La atención multi-cabeza es el corazón, pero un bloque Transformer necesita tres piezas más para funcionar: información posicional, una red feed-forward y la fontanería de residuales + LayerNorm. Vamos con cada una: motivación, funcionamiento y código.

4.1. Positional encoding: el orden importa (y la atención lo ignora)

Motivación. Aquí hay un problema serio escondido a plena vista: la self-attention es invariante al orden de los tokens. Todos los cálculos de la sección 2 son productos punto entre pares de vectores; en ningún momento interviene la posición de cada token. Si barajas la frase, obtienes exactamente las mismas salidas... barajadas de la misma forma. Compruébalo:

torch.manual_seed(42)                               # Reproducibilidad.
mha = MultiHeadAttention(d_model=16, n_heads=4)     # Una atención pequeña para el experimento.
x = torch.randn(1, 5, 16)                           # Una "frase" de 5 tokens aleatorios.
perm = torch.tensor([4, 2, 0, 3, 1])                # Una permutación: barajamos los tokens.

y_normal = mha(x)                                   # Atención sobre la frase original.
y_barajada = mha(x[:, perm, :])                     # Atención sobre la frase barajada.

# ¿La salida de la frase barajada es la salida original... barajada igual?
print(torch.allclose(y_normal[:, perm, :], y_barajada, atol=1e-6))   # True

True: para la atención, «el gato persigue al perro» y «el perro persigue al gato» son la misma bolsa de palabras. Inaceptable para el lenguaje, donde el orden cambia el significado. La solución: inyectar la posición directamente en el embedding de cada token, sumándole un vector que depende solo de su posición. Así, "perro" en la posición 1 y "perro" en la posición 4 entran al bloque siendo vectores distintos, y la atención puede aprender patrones sensibles al orden.

La codificación sinusoidal del paper original. Cada posición pos recibe un vector de dimensión d_model construido con senos y cosenos de frecuencias decrecientes:

PE(pos, 2i)   = sin(pos / 10000^(2i/d_model))
PE(pos, 2i+1) = cos(pos / 10000^(2i/d_model))

Las primeras coordenadas oscilan rápido (distinguen posiciones vecinas); las últimas oscilan lentísimo (codifican la posición "a grandes rasgos"). Es la misma idea que un reloj: segundero, minutero y horario juntos identifican cualquier instante de forma única — cada aguja es un par (sin, cos) girando a su frecuencia.

import math                                          # Para log(10000).

def codificacion_sinusoidal(n_max: int, d_model: int) -> torch.Tensor:
    """Matriz (n_max, d_model) con el positional encoding del paper original."""
    pos = torch.arange(n_max, dtype=torch.float32).unsqueeze(1)     # Columna de posiciones: (n_max, 1).
    i = torch.arange(0, d_model, 2, dtype=torch.float32)            # Índices pares: 0, 2, 4, ...
    freq = torch.exp(-math.log(10000.0) * i / d_model)              # Frecuencias decrecientes 1 -> 1/10000.
    pe = torch.zeros(n_max, d_model)                                # Reservamos la matriz completa.
    pe[:, 0::2] = torch.sin(pos * freq)                             # Coordenadas pares: senos.
    pe[:, 1::2] = torch.cos(pos * freq)                             # Coordenadas impares: cosenos.
    return pe                                                       # Se SUMA al embedding: x + pe[:n].

pe = codificacion_sinusoidal(n_max=100, d_model=64)
print(pe.shape)                                      # torch.Size([100, 64])
print(pe[0, :4], pe[1, :4])                          # Posiciones 0 y 1: vectores claramente distintos.

Dos propiedades elegantes de esta codificación: no tiene parámetros que aprender, y la posición relativa es accesible mediante transformaciones lineales (la codificación de pos + k es una rotación fija de la de pos), lo que facilita aprender patrones como "atiende al token de hace 3 posiciones".

Nota

¿por qué se suma la posición al embedding en vez de concatenarla? Concatenar duplicaría la dimensión (más parámetros en todas las capas siguientes) para un beneficio marginal: con d_model grande, contenido y posición pueden convivir en subespacios distintos del mismo vector, y las proyecciones aprendidas W_Q/W_K extraen de cada uno lo que necesitan. La suma es la solución barata que funciona — un patrón muy del deep learning.

Las alternativas que dominan hoy:

Método Idea Quién lo usa
Sinusoidal Vectores fijos de senos/cosenos, se suman al embedding Transformer original (2017)
Aprendida Una tabla nn.Embedding(n_max, d_model) de posiciones: la red aprende el vector de cada posición como un parámetro más BERT, GPT-2, nuestro proyecto de la sección 6
RoPE (rotatoria) En vez de sumar nada al embedding, rota los vectores Q y K de cada token un ángulo proporcional a su posición, dentro de la propia atención Llama, Qwen, la mayoría de LLMs de 2023-2026

La intuición de RoPE merece un párrafo: imagina cada par de coordenadas de q y k como una manecilla en un plano. RoPE gira la manecilla del token en la posición m un ángulo m·θ. Cuando después calculas el producto punto q·k entre dos tokens, los giros absolutos se restan y solo sobrevive el ángulo relativo entre ellos: la puntuación de atención depende de la distancia entre los tokens, no de sus posiciones absolutas. Eso encaja con cómo funciona el lenguaje ("el adjetivo suele estar junto al sustantivo", sin importar si es al principio o al final del documento) y generaliza mejor a contextos más largos que los vistos en entrenamiento.

La variante aprendida es aún más simple de implementar — y es la que usaremos en el proyecto de la sección 6:

class EmbeddingsConPosicion(nn.Module):
    """Embedding de tokens + embedding de posiciones aprendido (estilo GPT-2)."""

    def __init__(self, vocab_size: int, d_model: int, n_max: int):
        super().__init__()
        self.emb_tokens = nn.Embedding(vocab_size, d_model)   # Qué token soy: tabla aprendida.
        self.emb_posicion = nn.Embedding(n_max, d_model)      # Dónde estoy: OTRA tabla aprendida,
                                                              # una fila por posición 0..n_max-1.
    def forward(self, idx):                                   # idx: (B, n) índices de tokens.
        n = idx.shape[1]
        pos = torch.arange(n, device=idx.device)              # [0, 1, ..., n-1].
        return self.emb_tokens(idx) + self.emb_posicion(pos)  # Suma: contenido + posición. (B, n, d_model)

La diferencia práctica entre las tres opciones se reduce a dos preguntas: ¿cuántos parámetros añade? y ¿generaliza a secuencias más largas que las de entrenamiento? La sinusoidal no añade ninguno y extrapola regular; la aprendida añade n_max × d_model y no puede extrapolar (no existe fila en la tabla para la posición n_max + 1); RoPE no añade ninguno y es la que mejor se comporta al estirar el contexto — por eso ganó.

Advertencia

el positional encoding se aplica una sola vez, en la entrada del modelo (o, en el caso de RoPE, dentro de cada atención, pero derivado siempre de la posición original). Un despiste típico al implementar es sumar la codificación en cada bloque: eso "machaca" progresivamente el contenido semántico con señal posicional repetida.

Ejercicio 6.4: sin ejecutar código, ¿qué imprimiría el experimento de la permutación (el torch.allclose) si antes de la atención sumáramos codificacion_sinusoidal(5, 16) a x? Razona la respuesta.

Ver solución Imprimiría `False`. Al sumar la codificación posicional, el token de la posición 0 de la frase barajada recibe el vector posicional de la posición 0, pero ese token ("quinto" en la frase original) recibía antes el de la posición 4. Los vectores de entrada ya no son los mismos permutados: son combinaciones distintas de (contenido + posición). La atención deja de ser invariante al orden — que es exactamente el objetivo.

4.2. La red feed-forward por posición

Motivación. La atención es excelente moviendo información entre tokens, pero es esencialmente una media ponderada: una operación lineal sobre los values (el único elemento no lineal, el softmax, actúa sobre los pesos, no sobre el contenido). Para procesar la información recogida —combinar rasgos, detectar patrones, aplicar no linealidades— cada bloque incluye una pequeña red totalmente conectada que se aplica a cada token por separado, con los mismos pesos.

La división del trabajo dentro del bloque es nítida:

Subcapa Qué hace Comunicación entre tokens
Multi-head attention Mueve información entre posiciones Sí — es la única que mira a otros tokens
Feed-forward Procesa cada posición en profundidad No — token a token, en paralelo

Funcionamiento. Es un MLP de dos capas con expansión intermedia: se proyecta de d_model a 4·d_model (la "expansión 4×" del paper, que sigue siendo el estándar), se aplica una no linealidad —hoy casi siempre GELU, una versión suavizada de ReLU que se comporta mejor con los gradientes cerca de cero— y se vuelve a d_model:

ffn = nn.Sequential(
    nn.Linear(d_model, 4 * d_model),    # Expansión: 512 -> 2048. Espacio ancho donde separar rasgos.
    nn.GELU(),                          # No linealidad suave (x · Φ(x)); estándar en Transformers modernos.
    nn.Linear(4 * d_model, d_model),    # Contracción: 2048 -> 512, de vuelta a la dimensión del bloque.
)

¿Por qué GELU y no la ReLU de los capítulos de visión? La diferencia es sutil pero consistente:

ReLU GELU
Fórmula max(0, x) x · Φ(x) (Φ = CDF de la normal)
Alrededor de 0 Esquina brusca; gradiente 0 exacto para x<0 Transición suave; gradiente pequeño pero no nulo
Riesgo de "neuronas muertas" Sí (x<0 permanente → gradiente 0 para siempre) Prácticamente no
Uso típico CNNs, MLPs clásicos Transformers (BERT, GPT-2 en adelante)

En la práctica, GELU (y variantes con puertas como SwiGLU, usada por Llama) entrena de forma un poco más estable y termina en pérdidas ligeramente mejores. No es la pieza que cambia el juego — pero a escala de millones de dólares por entrenamiento, "ligeramente mejor y gratis" se adopta siempre.

Nota

aunque parezca la parte "aburrida" del bloque, la FFN concentra dos tercios de los parámetros de un Transformer típico (compruébalo: 2 × d_model × 4·d_model frente a 4 × d_model² de la atención). La investigación en interpretabilidad sugiere que estas capas funcionan como una memoria asociativa clave-valor donde el modelo almacena gran parte de su conocimiento factual.

4.3. Residuales y LayerNorm: pre-LN vs post-LN

Motivación. Ya conoces ambas piezas: las conexiones residuales del capítulo de ResNet (salida = x + f(x), la autopista que deja pasar el gradiente intacto y permite apilar decenas de capas sin que la señal se degrade) y LayerNorm (normalizar cada vector de activaciones a media 0 y varianza 1, con escala y desplazamiento aprendibles). El Transformer las usa exactamente para lo mismo: sin residuales, un Transformer de 12+ capas es prácticamente inentrenable; sin normalización, las escalas de las activaciones derivan capa a capa hasta desestabilizar el entrenamiento (la misma lógica de varianza de la sección 2.4).

La cuestión del orden: ¿LayerNorm antes o después de la subcapa?

Variante Fórmula Características
Post-LN (paper original, 2017) x = LayerNorm(x + Sublayer(x)) El LayerNorm está dentro del camino residual: la autopista se interrumpe en cada bloque. Entrenamiento delicado: necesita warmup cuidadoso del learning rate y se vuelve inestable con muchas capas.
Pre-LN (estándar desde GPT-2) x = x + Sublayer(LayerNorm(x)) La autopista residual queda limpia de principio a fin: el gradiente fluye sin obstáculos por la suma. Entrenamiento mucho más estable; es lo que usan GPT-2/3, Llama y nuestro proyecto.

En código, la diferencia es solo dónde colocas la llamada — compara los dos forward:

# POST-LN (paper original, 2017): la normalización DESPUÉS de sumar la residual.
def forward_post_ln(self, x):
    x = self.ln1(x + self.attn(x))      # La suma pasa por LayerNorm: la autopista se "reprocesa"
    x = self.ln2(x + self.ffn(x))       # en cada bloque -> gradientes menos directos.
    return x

# PRE-LN (estándar moderno): la normalización DENTRO de la rama, antes de la subcapa.
def forward_pre_ln(self, x):
    x = x + self.attn(self.ln1(x))      # La identidad x viaja intacta de la primera capa a la última;
    x = x + self.ffn(self.ln2(x))       # solo se normaliza la COPIA que entra a cada subcapa.
    return x

Consejo profesional

si implementas un Transformer desde cero, usa pre-LN sin dudarlo (con un LayerNorm extra al final de la pila, porque en pre-LN la salida de la última suma no está normalizada). Prácticamente toda la literatura post-2020 asume pre-LN; los LLMs modernos además sustituyen LayerNorm por RMSNorm, una variante que se salta el centrado de la media y es un poco más rápida con resultados equivalentes.

4.4. Ensamblando el bloque completo

Con las cuatro piezas sobre la mesa, este es el plano de un bloque Transformer (variante pre-LN, la que implementaremos):

flowchart TB
    X["Entrada x (B, n, d_model)"] --> LN1[LayerNorm]
    LN1 --> MHA["Multi-head attention<br/>(mueve información entre tokens)"]
    MHA --> SUM1(("+"))
    X -->|conexión residual|SUM1
    SUM1 --> LN2[LayerNorm]
    LN2 --> FFN["Feed-forward 4x + GELU<br/>(procesa cada token)"]
    FFN --> SUM2(("+"))
    SUM1 -->|conexión residual|SUM2
    SUM2 --> OUT["Salida (B, n, d_model)"]

Y su implementación, línea a línea:

class BloqueTransformer(nn.Module):
    """Un bloque Transformer pre-LN: atención + FFN, cada una con residual y LayerNorm."""

    def __init__(self, d_model: int, n_heads: int, dropout: float = 0.1):
        super().__init__()                              # Inicializa nn.Module.
        self.ln1 = nn.LayerNorm(d_model)                # Normalización ANTES de la atención (pre-LN).
        self.attn = MultiHeadAttention(d_model, n_heads)  # La atención multi-cabeza de la sección 3.
        self.ln2 = nn.LayerNorm(d_model)                # Normalización ANTES de la FFN.
        self.ffn = nn.Sequential(                       # La red feed-forward por posición (sección 4.2):
            nn.Linear(d_model, 4 * d_model),            #   expansión 4x,
            nn.GELU(),                                  #   no linealidad suave,
            nn.Linear(4 * d_model, d_model),            #   contracción de vuelta a d_model.
        )
        self.dropout = nn.Dropout(dropout)              # Regularización: apaga activaciones al azar entrenando.

    def forward(self, x, mascara=None):
        # Subcapa 1: atención. Nota la estructura pre-LN: x + f(LayerNorm(x)).
        x = x + self.dropout(self.attn(self.ln1(x), mascara))   # La residual suma la ENTRADA original.
        # Subcapa 2: feed-forward, misma estructura.
        x = x + self.dropout(self.ffn(self.ln2(x)))              # Segunda autopista residual.
        return x                                        # Mismo shape que la entrada: los bloques se apilan.

# --- Un Transformer "de verdad" es simplemente esto apilado ---
bloques = nn.Sequential(*[BloqueTransformer(512, 8) for _ in range(6)])   # 6 capas, como el paper original.
x = torch.randn(2, 10, 512)
print(bloques(x).shape)                                 # torch.Size([2, 10, 512])

El reparto de responsabilidades (y de parámetros) dentro de cada bloque, en resumen:

Pieza Función % aproximado de los parámetros del bloque
Multi-head attention Comunicación entre tokens ~33 %
Feed-forward 4× Procesamiento por token (y "memoria" del modelo) ~66 %
2 × LayerNorm Estabilidad de escalas <1 %
Conexiones residuales Autopista del gradiente 0 (no tienen parámetros)

Eso es todo: embedding + positional encoding, N bloques idénticos apilados, y una capa final según la tarea. La diferencia entre este juguete y GPT-4 es de tamaño y datos, no de concepto.

Ejercicio 6.5: en el forward del bloque, un compañero propone "simplificar" la primera línea a x = self.dropout(self.attn(self.ln1(x), mascara)) — quitando el x +. El código ejecuta sin errores y los shapes cuadran. ¿Qué acabas de perder y qué esperas que ocurra al entrenar una pila de 12 bloques así?

Ver solución Has eliminado la **conexión residual**: la salida ya no es "entrada + corrección" sino solo la corrección. Consecuencias: (1) el gradiente ya no tiene la autopista directa hacia las capas tempranas — debe atravesar las 12 atenciones y 12 FFNs multiplicándose por sus jacobianos, y reaparece el desvanecimiento de gradiente que ResNet resolvió; (2) cada bloque debe *reconstruir* toda la información de la entrada en vez de limitarse a refinarla, un objetivo mucho más difícil; (3) la información posicional, que se sumó una sola vez en la entrada, se diluye tras el primer bloque. En la práctica, la pila profunda entrena mal o no entrena: la pérdida baja mucho más despacio o se estanca. Es de los bugs más traicioneros porque *no produce ningún error*, solo un modelo mediocre — razón de más para conocer el porqué de cada pieza.

5. Las tres familias: encoder-only, decoder-only, encoder-decoder

El paper de 2017 proponía una arquitectura encoder-decoder para traducción. Los años siguientes demostraron que las dos mitades también funcionan por separado, y cada configuración define una familia con usos distintos. La diferencia esencial entre ellas es una sola cosa: la máscara de atención — qué tokens puede mirar cada token.

5.1. Encoder-only: BERT y la comprensión bidireccional

En un encoder, cada token atiende a todos los tokens de la secuencia, anteriores y posteriores: atención bidireccional, exactamente la que hemos calculado en la sección 2. Es la configuración ideal cuando tienes el texto completo delante y quieres entenderlo, no continuar escribiéndolo.

BERT (2018) se preentrena con masked language modeling: se ocultan al azar ~15 % de los tokens de una frase («El [MASK] me denegó la hipoteca») y el modelo debe adivinarlos usando el contexto de ambos lados. Para adivinar [MASK], mirar "denegó" e "hipoteca" (que están después) es imprescindible — de ahí que la bidireccionalidad sea el punto fuerte de esta familia.

Usos típicos: clasificación de textos (sentimiento, spam, intención), reconocimiento de entidades (NER), búsqueda semántica y modelos de embeddings, sistemas de puntuación/reranking. Lo que un encoder no puede hacer bien es generar texto: no está entrenado para predecir "lo que viene después".

Usar un encoder para una tarea concreta es añadirle una cabeza pequeña encima y ajustar. El esqueleto completo de un clasificador sobre BERT cabe en unas líneas (lo harás de verdad en el capítulo 7, con transfer learning):

class ClasificadorSobreEncoder(nn.Module):
    """Esqueleto de BERT + cabeza de clasificación (el patrón del capítulo 7)."""

    def __init__(self, encoder, d_model: int, n_clases: int):
        super().__init__()
        self.encoder = encoder                       # El Transformer bidireccional PREENTRENADO.
        self.cabeza = nn.Linear(d_model, n_clases)   # Lo único nuevo: una proyección a las clases.

    def forward(self, idx):
        h = self.encoder(idx)                        # (B, n, d_model): embeddings contextuales.
        resumen = h[:, 0, :]                         # BERT antepone un token especial [CLS] cuya
                                                     # representación final resume la frase entera.
        return self.cabeza(resumen)                  # (B, n_clases): logits de clasificación.

Fíjate en la economía del patrón: todo el conocimiento lingüístico vive en el encoder preentrenado; tu tarea solo aporta una capa lineal y unos miles de ejemplos etiquetados. Este patrón —preentrenar caro una vez, adaptar barato muchas veces— es el tema central del capítulo 7.

Nota

el 15 % de enmascaramiento de BERT no es un número mágico sino un equilibrio: enmascarar poco da pocas señales de aprendizaje por frase; enmascarar mucho destruye tanto contexto que la tarea se vuelve adivinanza. Trabajos posteriores mostraron que ratios mayores funcionan si se compensan con más cómputo — otro recordatorio de que casi todo hiperparámetro es un trade-off, no una ley.

5.2. Decoder-only: GPT y la máscara causal

Un decoder genera texto de izquierda a derecha, y eso impone una restricción durante el entrenamiento: cuando el modelo aprende a predecir el token 4, no puede haber visto los tokens 4, 5, 6... — sería copiar la respuesta del examen. La solución es la máscara causal: una matriz triangular que impide a cada token atender a posiciones futuras.

Así se ve la máscara para «el gato duerme mucho» ( = puede atender, = bloqueado con -inf antes del softmax, como en nuestro código de la sección 2.5):

                    K E Y S  (a quién se permite mirar)
                  el      gato    duerme    mucho
              ┌─────────────────────────────────────┐
   el         │  ✅        ❌        ❌         ❌      │  <- solo se ve a sí mismo
Q  gato       │  ✅        ✅        ❌         ❌      │  <- ve "el" y a sí mismo
   duerme     │  ✅        ✅        ✅         ❌      │  <- ve todo lo anterior
   mucho      │  ✅        ✅        ✅         ✅      │  <- ve la secuencia completa
              └─────────────────────────────────────┘
                 Triangular inferior: el futuro no existe.

En PyTorch, esa máscara es una línea, y merece la pena ver su efecto exacto sobre los pesos:

n = 4
mascara = torch.tril(torch.ones(n, n))              # Triangular inferior: 1 = permitido, 0 = futuro.
print(mascara)
# tensor([[1., 0., 0., 0.],
#         [1., 1., 0., 0.],
#         [1., 1., 1., 0.],
#         [1., 1., 1., 1.]])

S = torch.randn(n, n)                               # Puntuaciones cualesquiera (ya escaladas).
S = S.masked_fill(mascara == 0, float("-inf"))      # El futuro pasa a valer -inf...
A = torch.softmax(S, dim=-1)                        # ...y exp(-inf) = 0: peso exactamente nulo.
print(A.round(decimals=2))
# tensor([[1.00, 0.00, 0.00, 0.00],    <- la fila 0 solo puede mirarse a sí misma
#         [0.31, 0.69, 0.00, 0.00],    <- la fila 1 reparte entre los tokens 0 y 1
#         [0.19, 0.55, 0.26, 0.00],
#         [0.08, 0.41, 0.30, 0.21]])   <- cada fila sigue sumando 1: distribución válida

Nota

por esto la máscara se aplica antes del softmax y con -inf, no después multiplicando por cero: exp(-inf) = 0 elimina al candidato y el softmax renormaliza el resto para que la fila siga sumando 1. Multiplicar por cero después dejaría filas que suman menos de 1 — una "distribución" corrupta (es uno de los errores de la tabla de la sección 11).

Esta restricción tiene un premio enorme en eficiencia de entrenamiento: cada posición de cada frase es un ejemplo de entrenamiento simultáneo. Con una frase de 100 tokens, el modelo aprende 100 predicciones a la vez, todas en una sola pasada paralela:

Ejemplo simultáneo El modelo ve... ...y aprende a predecir
1 el gato
2 el gato duerme
3 el gato duerme mucho
... ... ...
100 los 100 primeros tokens el token 101

Es la razón de que el objetivo "predice el siguiente token" escale tan bien: cada documento de internet se convierte, gratis, en tantos ejemplos de entrenamiento como tokens tiene.

La generación es autorregresiva: se predice un token, se añade a la secuencia, y se repite. El texto se escribe token a token, cada uno condicionado por todos los anteriores:

flowchart TD
    P["Prompt inicial: «La ciencia»"] --> M["Transformer decoder-only<br/>(una pasada forward)"]
    M --> D["Logits -> softmax:<br/>distribución sobre el vocabulario"]
    D --> S["Muestrear el siguiente token<br/>(con temperatura)"]
    S --> A["Añadirlo a la secuencia:<br/>«La ciencia a»"]
    A --> C{"¿Longitud máxima<br/>o token de fin?"}
    C -- "No"--> M
    C -- "Sí"--> F["Texto generado completo"]

5.3. Encoder-decoder: T5 y las tareas secuencia-a-secuencia

La arquitectura original completa: un encoder bidireccional procesa la entrada, y un decoder causal genera la salida atendiendo (con atención cruzada, como en el seq2seq del capítulo 5) a las representaciones del encoder. T5 (2019) es el ejemplo canónico: reformula toda tarea como texto→texto, anteponiendo a la entrada un prefijo que indica la tarea:

Entrada al encoder Salida del decoder
translate English to German: The house is small. Das Haus ist klein.
summarize: <artículo de 800 palabras> <resumen de 3 frases>
cola sentence: El casa es grande. unacceptable (¿es gramatical?)

Es la opción natural cuando entrada y salida son textos claramente distintos y de longitudes muy diferentes: traducción, resumen, corrección. Y fíjate en el germen de algo importante: ese prefijo que describe la tarea dentro del propio texto es el antepasado directo del prompting moderno.

5.4. Comparativa y el porqué del dominio decoder-only

Encoder-only Decoder-only Encoder-decoder
Ejemplo canónico BERT (2018) GPT (2018-hoy) T5 (2019)
Atención Bidireccional Causal (triangular) Bidireccional + causal + cruzada
Preentrenamiento Masked LM (rellenar huecos) Predecir el siguiente token Denoising texto→texto
¿Genera texto? No (no es su objetivo) Sí, autorregresivamente
Usos típicos Clasificación, NER, embeddings, reranking Chat, generación, código, razonamiento Traducción, resumen
Ejemplos 2026 Familias tipo BERT/RoBERTa, modelos de embeddings GPT, Claude, Gemini, Llama, Qwen, Mistral T5/FLAN-T5, Whisper (audio→texto)

¿Por qué los modelos grandes actuales son casi todos decoder-only? Cuatro razones que se refuerzan:

  1. El objetivo más simple y escalable: "predice el siguiente token" convierte cualquier texto de internet en datos de entrenamiento sin etiquetar nada, y cada token es una señal de aprendizaje.
  2. Generalidad emergente: un modelo que completa texto puede hacer cualquier tarea formulada como texto — la entrada del encoder-decoder se convierte simplemente en el principio del prompt. La distinción entre "entender" y "generar" se disuelve.
  3. Simplicidad de ingeniería: una sola pila de bloques idénticos, una sola máscara, un solo objetivo. A escala de miles de GPUs, la simplicidad vale oro.
  4. El KV cache (sección 7) hace la generación autorregresiva mucho más barata de lo que parece.

Nota

decoder-only no ha "matado" a las otras familias: los modelos de embeddings que alimentan los buscadores semánticos y los sistemas RAG que verás en el módulo 04-LLM siguen siendo esencialmente encoders. La familia correcta depende de la tarea; lo que ocurre es que la conversación de propósito general la ganó el decoder.

Ejercicio 6.6: tu empresa necesita (a) un chatbot de soporte, (b) clasificar 10 millones de tickets históricos en 12 categorías con coste mínimo por unidad, y (c) traducir documentación técnica. ¿Qué familia encaja mejor con cada caso y por qué?

Ver solución (a) **Decoder-only**: la conversación abierta es generación autorregresiva condicionada por el historial — el territorio de GPT/Claude/Llama. (b) **Encoder-only**: clasificar no requiere generar; un BERT pequeño ajustado (o un modelo de embeddings + clasificador) procesa cada ticket en una sola pasada bidireccional, con un coste por documento órdenes de magnitud menor que llamar a un LLM generativo. (c) **Encoder-decoder** es el encaje clásico (entrada y salida son textos distintos), aunque en la práctica de 2026 un decoder-only grande traduce igual de bien vía prompt; la decisión ahí es de coste/control, no de capacidad — el dilema exacto de la sección 8.

6. Proyecto guiado: tu propio generador de texto en español

Vamos a construir, desde cero y en unas 200 líneas de PyTorch, un GPT en miniatura: un decoder-only a nivel de carácter que aprenderá a generar texto imitando un pequeño corpus en español. Es la versión de bolsillo del nanoGPT de Karpathy: cada pieza es exactamente la que ya has estudiado en este capítulo, y verás al modelo pasar de escupir ruido a escribir frases reconocibles.

Decisiones de diseño (y por qué):

Decisión Elección Motivo
Tokenización Por caracteres Vocabulario diminuto (~45 símbolos), sin dependencias externas; el modelo aprende la ortografía desde cero, lo que hace el progreso muy visible
Arquitectura Decoder-only, pre-LN La familia de GPT (sección 5.2), la variante estable (sección 4.3)
Posiciones Embedding aprendido Una nn.Embedding más; el estilo GPT-2 (sección 4.1)
Tamaño d_model=64, 4 cabezas, 3 capas, contexto 64 Entrena en CPU en un par de minutos

6.1. El corpus y el tokenizador

Nuestro dataset completo: quince líneas sobre la historia de la ciencia. Sí, es ridículamente pequeño — la gracia es precisamente ver qué puede y qué no puede aprender un Transformer con tan poco.

import torch                                            # Tensores y autograd.
import torch.nn as nn                                   # Capas.
import torch.nn.functional as F                         # softmax, cross_entropy...

torch.manual_seed(42)                                   # TODO el proyecto es reproducible con esta semilla.

CORPUS = """La historia de la ciencia es la historia de las preguntas.
Durante siglos, la humanidad observó el cielo y anotó el movimiento de los astros.
Copérnico propuso que la Tierra gira alrededor del Sol.
Galileo apuntó su telescopio a la Luna y descubrió montañas.
Kepler encontró en los datos las leyes que rigen las órbitas.
Newton unió el cielo y la Tierra con una sola ley de gravitación.
Dos siglos después, Darwin explicó el origen de las especies.
Mendel cultivó guisantes y fundó la genética sin saberlo.
Maxwell escribió cuatro ecuaciones y unificó la luz y el magnetismo.
Marie Curie descubrió el radio y ganó dos premios Nobel.
Einstein imaginó que viajaba montado en un rayo de luz.
La mecánica cuántica reveló un mundo hecho de probabilidades.
Turing se preguntó si las máquinas pueden pensar.
Cada respuesta abrió diez preguntas nuevas.
La ciencia avanza porque nunca deja de preguntar."""

class TokenizadorCaracteres:
    """El tokenizador más simple posible: cada carácter distinto es un token."""

    def __init__(self, texto: str):
        self.caracteres = sorted(set(texto))            # Alfabeto del corpus, ordenado (reproducible).
        self.stoi = {c: i for i, c in enumerate(self.caracteres)}   # string -> int ("a" -> 5).
        self.itos = {i: c for c, i in self.stoi.items()}             # int -> string (5 -> "a").
        self.vocab_size = len(self.caracteres)          # Tamaño del vocabulario (~45 símbolos).

    def codificar(self, s: str) -> list[int]:
        return [self.stoi[c] for c in s]                # Texto -> lista de enteros.

    def decodificar(self, ids: list[int]) -> str:
        return "".join(self.itos[i] for i in ids)       # Lista de enteros -> texto.

tok = TokenizadorCaracteres(CORPUS)
datos = torch.tensor(tok.codificar(CORPUS), dtype=torch.long)   # El corpus entero como tensor de índices.
print(f"Vocabulario: {tok.vocab_size} símbolos | Corpus: {len(datos)} caracteres")
print(tok.codificar("ciencia"), "->", tok.decodificar(tok.codificar("ciencia")))   # Ida y vuelta ✅

Salida:

Vocabulario: 46 símbolos | Corpus: 903 caracteres
[16, 24, 18, 27, 16, 24, 14] -> ciencia

El alfabeto incluye el salto de línea, el espacio, los signos de puntuación, mayúsculas y las vocales acentuadas del español — 46 "tokens" en total. Ese es todo el universo simbólico de nuestro modelo.

Nota

los LLMs reales no tokenizan por caracteres sino por subpalabras (BPE: "preguntas" → quizá ["pre", "guntas"]), un equilibrio entre vocabularios manejables (~50 000-250 000 tokens) y secuencias no demasiado largas. Lo estudiarás en el módulo 04-LLM; conceptualmente, todo lo que sigue es idéntico.

6.2. El modelo: atención causal, bloques y la pila completa

class AtencionCausalMultiCabeza(nn.Module):
    """Multi-head attention con máscara causal fija: cada carácter solo ve el pasado."""

    def __init__(self, d_model: int, n_heads: int, contexto: int):
        super().__init__()
        self.n_heads = n_heads                          # Número de cabezas (4).
        self.d_k = d_model // n_heads                   # Dimensión por cabeza (64/4 = 16).
        self.W_qkv = nn.Linear(d_model, 3 * d_model)    # Truco habitual: Q, K y V en UNA sola proyección
                                                        # (mismo resultado, una multiplicación en vez de tres).
        self.W_o = nn.Linear(d_model, d_model)          # Proyección de salida.
        # La máscara triangular se precalcula UNA vez. register_buffer la guarda con el
        # modelo (se mueve a GPU con él) pero NO es un parámetro entrenable.
        self.register_buffer("mascara", torch.tril(torch.ones(contexto, contexto)))

    def forward(self, x):
        B, n, d = x.shape                               # batch, tokens, d_model.
        qkv = self.W_qkv(x)                             # (B, n, 3*d): las tres proyecciones juntas.
        Q, K, V = qkv.chunk(3, dim=-1)                  # Separar en Q, K, V de (B, n, d) cada uno.
        # Trocear en cabezas, igual que en la sección 3: (B, n, d) -> (B, h, n, d_k).
        Q = Q.view(B, n, self.n_heads, self.d_k).transpose(1, 2)
        K = K.view(B, n, self.n_heads, self.d_k).transpose(1, 2)
        V = V.view(B, n, self.n_heads, self.d_k).transpose(1, 2)
        S = Q @ K.transpose(-2, -1) / self.d_k ** 0.5   # Puntuaciones escaladas: (B, h, n, n).
        S = S.masked_fill(self.mascara[:n, :n] == 0, float("-inf"))   # Bloquear el futuro:
                                                        # recortamos la máscara a los n tokens reales.
        A = F.softmax(S, dim=-1)                        # Pesos causales: fila i solo reparte entre 0..i.
        Z = (A @ V).transpose(1, 2).contiguous().view(B, n, d)   # Atender + concatenar cabezas.
        return self.W_o(Z)                              # Mezclar cabezas: (B, n, d_model).

class BloqueDecoder(nn.Module):
    """Bloque Transformer pre-LN con atención causal (sección 4.4, versión decoder)."""

    def __init__(self, d_model: int, n_heads: int, contexto: int):
        super().__init__()
        self.ln1 = nn.LayerNorm(d_model)                # Pre-LN de la atención.
        self.attn = AtencionCausalMultiCabeza(d_model, n_heads, contexto)
        self.ln2 = nn.LayerNorm(d_model)                # Pre-LN de la FFN.
        self.ffn = nn.Sequential(                       # Feed-forward con expansión 4x y GELU (sección 4.2).
            nn.Linear(d_model, 4 * d_model),
            nn.GELU(),
            nn.Linear(4 * d_model, d_model),
        )

    def forward(self, x):
        x = x + self.attn(self.ln1(x))                  # Residual 1: la autopista de ResNet.
        x = x + self.ffn(self.ln2(x))                   # Residual 2.
        return x

class MiniGPT(nn.Module):
    """Decoder-only completo: embeddings + posiciones + bloques + cabeza de lenguaje."""

    def __init__(self, vocab_size: int, d_model=64, n_heads=4, n_capas=3, contexto=64):
        super().__init__()
        self.contexto = contexto                        # Máximo de caracteres que ve de golpe (64).
        self.emb_tokens = nn.Embedding(vocab_size, d_model)      # Tabla: carácter -> vector (¿qué soy?).
        self.emb_posicion = nn.Embedding(contexto, d_model)      # Tabla: posición -> vector (¿dónde estoy?).
                                                        # Posiciones APRENDIDAS, estilo GPT-2 (sección 4.1).
        self.bloques = nn.ModuleList(                   # La pila de bloques Transformer.
            [BloqueDecoder(d_model, n_heads, contexto) for _ in range(n_capas)]
        )
        self.ln_final = nn.LayerNorm(d_model)           # LayerNorm final (necesario en pre-LN, sección 4.3).
        self.cabeza = nn.Linear(d_model, vocab_size)    # Proyección a logits: un número por carácter posible.

    def forward(self, idx):
        B, n = idx.shape                                # idx: (B, n) índices de caracteres.
        pos = torch.arange(n, device=idx.device)        # [0, 1, ..., n-1]: las posiciones.
        x = self.emb_tokens(idx) + self.emb_posicion(pos)   # Contenido + posición: (B, n, d_model).
        for bloque in self.bloques:                     # Atravesar los 3 bloques en orden.
            x = bloque(x)
        x = self.ln_final(x)                            # Normalizar la salida de la pila.
        return self.cabeza(x)                           # Logits (B, n, vocab_size): en CADA posición,
                                                        # una predicción del carácter SIGUIENTE.

modelo = MiniGPT(tok.vocab_size)
n_params = sum(p.numel() for p in modelo.parameters())
print(f"Parámetros del modelo: {n_params:,}")           # ≈ 160 000. Los contaremos en la sección 7.

6.3. El entrenamiento: predecir el siguiente carácter

El objetivo de entrenamiento es el de todo decoder-only: en cada posición, predecir el carácter siguiente. Los datos de entrada y las etiquetas son el mismo texto desplazado un carácter:

Entrada  (x):  L  a     h  i  s  t  o  r  i ...
Etiqueta (y):  a     h  i  s  t  o  r  i  a ...
def lote(datos, contexto, batch_size):
    """Extrae batch_size trozos aleatorios del corpus, con sus etiquetas desplazadas."""
    inicios = torch.randint(len(datos) - contexto - 1, (batch_size,))   # Posiciones de inicio al azar.
    x = torch.stack([datos[i : i + contexto] for i in inicios])          # (B, contexto): las entradas.
    y = torch.stack([datos[i + 1 : i + contexto + 1] for i in inicios])  # (B, contexto): lo mismo,
    return x, y                                          # desplazado 1: el "siguiente carácter" de cada posición.

optimizador = torch.optim.AdamW(modelo.parameters(), lr=3e-3)   # AdamW: el estándar para Transformers.
PASOS, BATCH = 2000, 32                                  # Suficiente para memorizar nuestro micro-corpus.

for paso in range(PASOS + 1):
    x, y = lote(datos, modelo.contexto, BATCH)           # Un batch fresco de trozos del corpus.
    logits = modelo(x)                                   # (B, n, vocab_size): predicción en cada posición.
    # cross_entropy espera (N, clases) vs (N,): aplanamos batch y posiciones juntos.
    # Gracias a la máscara causal, las contexto predicciones de cada trozo son
    # ejemplos de entrenamiento SIMULTÁNEOS y legítimos (sección 5.2).
    perdida = F.cross_entropy(logits.view(-1, tok.vocab_size), y.view(-1))
    optimizador.zero_grad()                              # Limpiar gradientes del paso anterior.
    perdida.backward()                                   # Backpropagation por TODO el modelo.
    optimizador.step()                                   # Actualizar los ~160k parámetros.
    if paso % 400 == 0:
        print(f"paso {paso:4d} | pérdida {perdida.item():.3f}")

Salida típica del entrenamiento (un par de minutos en CPU):

paso    0 | pérdida 3.847     <- ≈ ln(46): el modelo elige caracteres al azar
paso  400 | pérdida 2.104     <- ya sabe qué caracteres son frecuentes y qué sigue a qué
paso  800 | pérdida 1.312     <- aprende sílabas y palabras cortas
paso 1200 | pérdida 0.721     <- palabras completas, estructura de frase
paso 1600 | pérdida 0.398     <- empieza a memorizar el corpus
paso 2000 | pérdida 0.253     <- reproduce pasajes casi literales

Nota

la pérdida inicial ≈ 3.85 no es casualidad: con ~46 caracteres equiprobables, la entropía cruzada es ln(46) ≈ 3.83. Comprobar que tu pérdida inicial coincide con ln(vocab_size) es un test de cordura clásico: si sale mucho más alta, algo está roto (inicialización, shapes, máscara).

Antes de lanzar cualquier entrenamiento de este estilo, esta lista de comprobaciones de 30 segundos ahorra horas:

  • [ ] La pérdida inicial ≈ ln(vocab_size) (si no, revisa shapes del cross_entropy y la cabeza).
  • [ ] Un solo batch pequeño puede sobreajustarse hasta pérdida ≈ 0 en pocos cientos de pasos (si no, hay un bug: el modelo debería poder memorizar 32 trozos).
  • [ ] x e y están de verdad desplazados un carácter (imprime tok.decodificar de ambos y míralo con tus ojos).
  • [ ] La máscara es triangular inferior y se recorta a [:n, :n].
  • [ ] La semilla está fijada y dos ejecuciones dan la misma curva de pérdida.

6.4. Generación con temperatura

Para generar, aplicamos el bucle autorregresivo del diagrama de la sección 5.2. La temperatura controla el equilibrio entre fidelidad y creatividad: divide los logits antes del softmax. T < 1 afila la distribución (elige casi siempre el carácter más probable); T > 1 la aplana (más diversidad, más errores).

@torch.no_grad()                                         # Generar no necesita gradientes: más rápido y menos memoria.
def generar(modelo, tok, inicio: str, longitud=200, temperatura=1.0):
    modelo.eval()                                        # Modo evaluación (desactivaría dropout si lo hubiera).
    idx = torch.tensor([tok.codificar(inicio)])          # El prompt codificado: (1, n_inicio).
    for _ in range(longitud):                            # Un carácter nuevo por iteración.
        ventana = idx[:, -modelo.contexto:]              # Recortar al contexto máximo: el modelo solo
                                                         # puede mirar sus últimos 64 caracteres.
        logits = modelo(ventana)[:, -1, :]               # Solo interesa la predicción de la ÚLTIMA posición.
        probs = F.softmax(logits / temperatura, dim=-1)  # Temperatura: T<1 conservador, T>1 arriesgado.
        siguiente = torch.multinomial(probs, num_samples=1)   # MUESTREAR (no argmax): da variedad.
        idx = torch.cat([idx, siguiente], dim=1)         # Añadir el carácter y volver a empezar.
    return tok.decodificar(idx[0].tolist())              # De índices a texto legible.

print(generar(modelo, tok, "La ciencia ", temperatura=0.8))

Nota

las APIs de LLMs exponen, además de la temperatura, top_k (muestrear solo entre los k tokens más probables) y top_p (solo entre los que acumulan una probabilidad p). Son filtros sobre la misma distribución que aquí muestreamos entera; añadir top-k a generar() son tres líneas y está propuesto como extensión en la sección 6.7.

6.5. La evolución: de ruido a lenguaje

Merece la pena guardar generaciones a lo largo del entrenamiento (basta llamar a generar() dentro del bucle cada 400 pasos) y ver la progresión. Con la semilla 42, obtendrás algo cualitativamente así:

Paso Muestra generada (prompt «La ciencia ») Qué ha aprendido
0 La ciencia ñQzQ,óbW.vNraKcMxE ci¿TDy Nada: caracteres uniformemente al azar
400 La ciencia de lo prento la de las el cie Frecuencias y bigramas: "vocal-consonante", "la", "de"
800 La ciencia es la histo de las pregunas y el cielo Palabras casi correctas, concordancias parciales
2000 La ciencia es la historia de las preguntas. Turing se preguntó si las máquinas pueden pensar. Frases completas del corpus, ortografía perfecta

Y el efecto de la temperatura sobre el modelo ya entrenado (paso 2000), mismo prompt:

Temperatura Muestra típica Carácter
0.2 La ciencia es la historia de las preguntas. La ciencia avanza porque nunca deja de preguntar. Recita el corpus, siempre igual: máxima fidelidad, cero sorpresa
0.8 La ciencia es la historia de los astros. Kepler encontró en los datos las órbitas. Recombina fragmentos del corpus de forma plausible
1.5 La ciencia avanzó guisantes y el cielo se pregunta sin saberlo diez rayos. Surrealista: gramática tocada, mezclas improbables
2.5 La ciencia amzr, ekió squenó blretas cu? La distribución es casi uniforme: vuelve el ruido

Ese progreso —ruido → estadística de caracteres → sílabas → palabras → frases— es exactamente el mismo que sigue un LLM real, solo que un LLM con miles de millones de parámetros y billones de tokens no se detiene en memorizar: generaliza, porque su corpus es demasiado grande para memorizarlo y la única forma de comprimirlo es aprender las reglas que lo generan.

Advertencia

nuestro modelo final está sobreajustado a conciencia: con 900 caracteres de corpus y 160 000 parámetros, memorizar es la solución óptima del problema de optimización, y por eso recita el corpus casi literal. En este proyecto es un éxito didáctico (demuestra que la arquitectura funciona); en cualquier proyecto real sería una señal de alarma. Súbele la temperatura a 1.3 y verás cómo, al alejarse de lo memorizado, comete errores ortográficos: está saliendo de su zona de confort estadística.

Ejercicio 6.7: modifica el proyecto para que genere con temperatura=0.1 y con temperatura=2.0, y explica lo que observas. ¿Qué temperatura elegirías para un asistente de código y cuál para un generador de nombres de marca?

Ver solución Con `T=0.1` la distribución se vuelve casi determinista: el modelo repite el pasaje del corpus más probable tras el prompt, siempre el mismo — texto correcto pero rígido y repetitivo. Con `T=2.0` la distribución se aplana tanto que caracteres improbables salen elegidos a menudo: palabras inventadas, ortografía rota, ruido creativo. Para un asistente de código conviene temperatura baja (0-0.3): la corrección importa más que la variedad y un carácter improbable puede romper la sintaxis. Para nombres de marca, temperatura alta (1.0-1.5): buscas exactamente lo improbable-pero-pronunciable. La temperatura no cambia lo que el modelo sabe; cambia cuánto se arriesga al muestrear de lo que sabe.

6.6. Ingeniería mínima: validación y checkpoints

Dos añadidos convierten el script didáctico en un flujo de trabajo defendible. Primero, medir la pérdida en texto que el modelo no ha visto, para cuantificar (en vez de intuir) el sobreajuste. Con nuestro micro-corpus reservamos las dos últimas líneas como validación:

frontera = int(len(datos) * 0.9)                     # 90 % para entrenar...
datos_train, datos_val = datos[:frontera], datos[frontera:]   # ...10 % (las últimas líneas) para validar.

@torch.no_grad()                                     # Evaluar no necesita gradientes.
def perdida_media(modelo, datos, n_lotes=20):
    """Pérdida promediada sobre varios lotes: estimación estable, no un solo lote ruidoso."""
    modelo.eval()                                    # Modo evaluación.
    total = 0.0
    for _ in range(n_lotes):
        x, y = lote(datos, modelo.contexto, BATCH)   # Lotes del split correspondiente.
        logits = modelo(x)
        total += F.cross_entropy(logits.view(-1, tok.vocab_size), y.view(-1)).item()
    modelo.train()                                   # Devolver el modelo a modo entrenamiento.
    return total / n_lotes

print(f"train: {perdida_media(modelo, datos_train):.3f} | val: {perdida_media(modelo, datos_val):.3f}")

Tras las 2000 iteraciones verás algo como train: 0.25 | val: 2.9: la brecha enorme entre ambas es el sobreajuste de la sección 6.5, ahora con un número delante. En un proyecto real, esa brecha dispararía las alarmas (más datos, más regularización o menos modelo); aquí es la consecuencia esperada de entrenar 160 000 parámetros sobre 900 caracteres.

Segundo, guardar y recuperar el modelo — nunca dejes un entrenamiento sin checkpoint:

torch.save({                                         # Guardamos un diccionario con todo lo necesario:
    "modelo": modelo.state_dict(),                   #   los pesos entrenados,
    "caracteres": tok.caracteres,                    #   el alfabeto del tokenizador (¡sin él, los
}, "minigpt_es.pt")                                  #   índices no significan nada!).

punto = torch.load("minigpt_es.pt", weights_only=False)   # Recuperar en otra sesión:
tok2 = TokenizadorCaracteres("".join(punto["caracteres"]))  # Reconstruir el tokenizador...
modelo2 = MiniGPT(tok2.vocab_size)                   # ...crear un modelo con la MISMA configuración...
modelo2.load_state_dict(punto["modelo"])             # ...y cargar los pesos entrenados.
print(generar(modelo2, tok2, "Newton "))             # Genera igual que el original. ✅

Advertencia

el error clásico al recargar es reconstruir el tokenizador desde otro texto: si el alfabeto cambia (un carácter más o menos), todos los índices se desplazan y el modelo genera basura sin lanzar ninguna excepción. Guarda siempre el vocabulario junto a los pesos, como arriba.

6.7. Ideas para extender el proyecto

El MiniGPT es una base perfecta para experimentar. Cada extensión de esta tabla se hace en menos de una tarde y enseña algo distinto:

Extensión Qué tocar Qué aprenderás
Corpus mayor (un libro de dominio público del Proyecto Gutenberg, ~1 MB) Solo CORPUS Ver al modelo generalizar en vez de memorizar: la brecha train/val se cierra
Escalar el modelo (d_model=128, 6 capas) Los argumentos de MiniGPT Tu primera scaling law casera: pérdida vs tamaño con datos fijos
Dropout en atención y FFN Añadir nn.Dropout(0.1) en los bloques Regularización: la brecha train/val se estrecha a igualdad de datos
Muestreo top-k En generar(): poner a -inf los logits fuera de los k mejores antes del softmax Por qué las APIs exponen top_k/top_p además de la temperatura
Visualizar la atención entrenada El helper pesos_por_cabeza de la sección 3.4 adaptado a AtencionCausalMultiCabeza Ver las cabezas especializarse (triángulo causal incluido)
Tokenizador de palabras Reescribir TokenizadorCaracteres con texto.split() El trade-off vocabulario grande vs secuencias cortas que motiva BPE
Curvas de pérdida train/val por paso Guardar perdida_media cada 100 pasos y graficar Detectar el punto exacto donde empieza el sobreajuste

Consejo profesional

de todas ellas, la primera es la más reveladora. Con un corpus mil veces mayor, el mismo modelo deja de recitar y empieza a inventar texto plausible que no está en los datos — el salto cualitativo de memorizar a modelar. Es la scaling law de la sección 7.2 experimentada en tu propia CPU.


7. Escalado: de tu modelo de juguete a los LLMs

Acabas de entrenar un Transformer completo. La pregunta natural: ¿qué separa a tu MiniGPT de un modelo de producción? La respuesta corta: nada conceptual y todo cuantitativo. Esta sección es el puente hacia el módulo 04-LLM.

7.1. Contando los parámetros de MiniGPT

Hagamos la cuenta que imprimió n_params, pieza a pieza (con vocab_size = 46, d_model = 64, contexto 64, 3 bloques):

Componente Cálculo Parámetros
Embedding de tokens 46 × 64 2 944
Embedding de posiciones 64 × 64 4 096
Atención por bloque (W_qkv + W_o, con bias) 64×192 + 192 + 64×64 + 64 16 640
FFN por bloque (64→256→64, con bias) 64×256 + 256 + 256×64 + 64 33 088
2 LayerNorm por bloque (γ y β) 2 × 2 × 64 256
Subtotal por bloque 49 984
× 3 bloques 3 × 49 984 149 952
LayerNorm final 2 × 64 128
Cabeza de lenguaje 64 × 46 + 46 2 990
Total ≈ 160 110

Dos observaciones que escalan a cualquier tamaño: la FFN pesa el doble que la atención (como anticipó la sección 4.2), y los embeddings, que aquí son ridículos, en un modelo real con vocabulario de 100 000+ subpalabras se vuelven cientos de millones de parámetros.

7.2. Scaling laws: la regularidad que lanzó la carrera

En 2020, OpenAI (Kaplan et al.) descubrió algo asombroso: la pérdida de un Transformer de lenguaje cae de forma suave y predecible —una ley de potencias, una recta en escala log-log— al aumentar tres cantidades: parámetros del modelo, tamaño del dataset y cómputo de entrenamiento. Sin saltos, sin techos a la vista, a lo largo de siete órdenes de magnitud.

La consecuencia estratégica fue enorme: por primera vez se podía predecir cuánto mejoraría un modelo 10 veces más grande antes de gastar los millones que costaba entrenarlo. El riesgo de escalar se volvió calculable, y la industria escaló. DeepMind refinó la receta en 2022 con el paper de "Chinchilla": para un presupuesto de cómputo dado, conviene equilibrar tamaño y datos.

Kaplan et al. (2020) Chinchilla (2022)
Pregunta ¿Mejora el modelo al escalar? Con cómputo fijo, ¿cómo repartirlo entre tamaño y datos?
Hallazgo La pérdida sigue leyes de potencias suaves en parámetros, datos y cómputo Los modelos de la época estaban infraentrenados: demasiado grandes para sus datos
Receta práctica "Más grande es mejor" (priorizaba parámetros) 20 tokens de entrenamiento por parámetro
Consecuencia La carrera por modelos gigantes (GPT-3: 175B) Modelos más pequeños con muchos más datos (Llama, y casi todo lo posterior)

Nuestro MiniGPT, con 160 000 parámetros y ~900 caracteres (~900 tokens), tiene una ratio de 0.006 tokens por parámetro — más de tres órdenes de magnitud por debajo del equilibrio de Chinchilla. Por eso memoriza en lugar de generalizar: es un camión de mudanzas transportando una carta.

7.3. Órdenes de magnitud: MiniGPT vs un LLM de producción

Dimensión MiniGPT (este capítulo) LLM de producción (clase GPT-3/4, 2020-2026) Factor
d_model 64 12 288 o más ~200×
Capas 3 96-120+ ~35×
Cabezas por capa 4 96-128 ~25×
Contexto 64 caracteres 128 000 - 1 000 000+ tokens ~10⁴×
Parámetros 1.6 × 10⁵ 10¹¹ - 10¹² ~10⁶-10⁷×
Datos de entrenamiento ~900 caracteres 10¹³ tokens (decenas de TB de texto) ~10¹⁰×
Hardware Tu CPU, ~2 minutos Decenas de miles de GPUs, meses ~10⁹-10¹⁰× en FLOPs
Coste 0 € 10⁷ - 10⁹ € por entrenamiento

La arquitectura de ambas columnas es la de este capítulo. Lo que compra esa diferencia de escala no es "más de lo mismo": a partir de ciertos tamaños aparecen capacidades que los modelos pequeños sencillamente no tienen:

Capacidad Aparece aproximadamente en... Qué la hace posible
Ortografía y gramática sólidas Millones de parámetros Estadística local del texto (tu MiniGPT ya roza esto)
Conocimiento factual amplio Miles de millones Capacidad de la FFN como memoria (sección 4.2) + datos masivos
In-context learning (aprender tareas desde el prompt) ~10¹⁰ parámetros La atención puede implementar "algoritmos" sobre los ejemplos del contexto
Seguir instrucciones, razonamiento en pasos 10¹⁰-10¹¹ + post-entrenamiento Escala + entrenamiento adicional que verás en el módulo 04-LLM

Nota

la palabra "emergen" es discutida en la literatura (parte del efecto depende de cómo se mida), pero el hecho práctico es incontestable: hay tareas donde un modelo 10 veces mayor no es un 10 % mejor, sino que pasa de no poder a poder. Esa discontinuidad práctica es la que justifica los presupuestos de la tabla anterior.

7.4. El coste cuadrático de la atención

La tabla de la sección 1 avisaba: la atención cuesta O(n² · d). Cada token atiende a todos los demás: con n tokens, la matriz de atención tiene entradas por cabeza y por capa. Duplicar el contexto cuadruplica ese coste. Con contextos de cientos de miles de tokens, la matriz de atención ni siquiera cabe en memoria de forma ingenua.

Para hacerse una idea del muro, el número de entradas de la matriz de atención por cabeza y capa:

Contexto n Entradas de la matriz () Comentario
64 (MiniGPT) 4 096 Trivial
2 048 (GPT-3, 2020) ~4.2 M Cómodo en GPU
128 000 (estándar 2024-2026) ~1.6 × 10¹⁰ Imposible de materializar de forma ingenua
1 000 000 (frontera) 10¹² Solo viable con algoritmos por bloques

Dos técnicas (de una familia enorme) que hacen viables los contextos largos modernos:

  • FlashAttention: no cambia las matemáticas, cambia el orden de las operaciones para no materializar nunca la matriz n×n completa en la memoria lenta de la GPU: procesa la atención por bloques que caben en la memoria rápida (SRAM). Resultado exacto, memoria O(n) y varias veces más rápido. Es lo que usa F.scaled_dot_product_attention bajo el capó.
  • GQA (grouped-query attention): varias cabezas de query comparten un mismo par K/V (p. ej., 32 cabezas de query con solo 8 de K/V). Apenas afecta a la calidad y divide por 4 la memoria del KV cache — que es justo el protagonista del siguiente apartado.

7.5. El KV cache: por qué generar no recalcula el pasado

Mira otra vez el bucle de generación de la sección 6.4: en cada iteración pasamos toda la secuencia por el modelo para quedarnos solo con la predicción del último token. Eso significa recalcular las K y las V de todos los tokens anteriores... que no han cambiado desde la iteración anterior (con máscara causal, los tokens pasados no pueden verse afectados por los nuevos).

La solución es de sentido común: guardar las K y V ya calculadas (el KV cache) y, en cada paso, calcular solo la query del token nuevo, atender contra las K/V almacenadas, y añadir al cache las K/V del token recién generado.

flowchart LR
    subgraph PREFILL["Fase 1: prefill (una pasada paralela)"]
        PR["Prompt completo:<br/>n tokens a la vez"] --> KV[("KV cache:<br/>K y V de cada token,<br/>capa y cabeza")]
    end
    subgraph DECODE["Fase 2: decode (token a token)"]
        Q1["Query del<br/>token nuevo"] --> AT["Atención contra<br/>el cache completo"]
        KV --> AT
        AT --> NT["Siguiente token"]
        NT -->|"añadir sus K,V al cache"|KV
        NT -.->|repetir|Q1
    end

En pseudocódigo, el bucle de generación con cache queda así (compáralo con nuestro generar() de la sección 6.4, que recalcula todo):

# Pseudocódigo del decode con KV cache (por capa):
K_cache, V_cache = prefill(prompt)              # Fase 1: K y V de todo el prompt, en una pasada paralela.
token = ultimo_token_del_prompt
while generando:
    q, k, v = proyectar(embedding(token))       # SOLO el token nuevo: (1, d_model) en vez de (n, d_model).
    K_cache = concatenar(K_cache, k)            # El cache crece un token...
    V_cache = concatenar(V_cache, v)
    z = softmax(q @ K_cache.T / sqrt(d_k)) @ V_cache   # ...y la atención es (1, n), no (n, n).
    token = muestrear(cabeza(ffn(z)))           # El siguiente token, como siempre.
Sin cache Con KV cache
Trabajo por token nuevo Recalcular los n tokens: O(n²) Solo el token nuevo contra el cache: O(n)
Generar n tokens O(n³) total O(n²) total
Coste extra Memoria: guardar K y V de cada token, capa y cabeza

Este intercambio (memoria a cambio de cómputo) explica varias cosas del mundo real de los LLMs que verás en el módulo 04-LLM:

  • Por qué los tokens de entrada son más baratos que los de salida en los precios de las APIs: el prompt se procesa en una sola pasada paralela (prefill, llenando el cache); cada token de salida requiere su propio paso secuencial de decode.
  • Por qué el contexto largo encarece la inferencia aunque generes poco: el KV cache de un contexto de 200 000 tokens ocupa gigabytes de memoria de GPU por conversación, y esa memoria hay que reservarla y pagarla.
  • Por qué existe el prompt caching que ofrecen las APIs modernas: si mil peticiones comparten el mismo prefijo de sistema, su KV cache se calcula una vez y se reutiliza.

Advertencia

el KV cache solo es válido mientras el prefijo no cambie: basta alterar un token al principio del prompt para invalidar el cache de todo lo que viene después (cada K y V depende, vía atención causal, de todos los tokens anteriores). Por eso las guías de prompt caching insisten en poner la parte estable (instrucciones, documentos) al principio y la variable (la pregunta del usuario) al final.

Consejo profesional

cuando en el módulo 04-LLM veas facturas de API, recuerda esta cadena causal: atención cuadrática → KV cache para generar barato → memoria de GPU como recurso escaso → precios por token de entrada/salida/cacheado distintos. La economía de los LLMs es, en el fondo, la geometría de la matriz de atención.

Ejercicio 6.8: estima la memoria del KV cache de una conversación de 100 000 tokens en un modelo con 96 capas, d_model = 12 288 y activaciones en 16 bits (2 bytes), sin GQA. Pista: por cada token hay que guardar un vector K y un vector V de dimensión d_model en cada capa. ¿Y con GQA que reduzca las cabezas K/V por un factor 8?

Ver solución Por token y capa: `2 vectores × 12 288 valores × 2 bytes = 49 152 bytes ≈ 48 KB`. Por token, en las 96 capas: `48 KB × 96 ≈ 4.6 MB`. Para 100 000 tokens: `4.6 MB × 100 000 ≈ 460 GB`... por conversación. Más memoria de la que tiene cualquier GPU individual (80-192 GB en 2026): sin trucos, ese contexto es directamente inviable. Con GQA reduciendo K/V por 8: ≈ 58 GB — todavía enorme, pero ya manejable repartido entre GPUs. Este cálculo de servilleta explica por qué GQA es estándar, por qué el contexto largo se cobra caro y por qué el prompt caching (reutilizar el cache de prefijos compartidos) ahorra dinero real. La aritmética de este ejercicio es, literalmente, la que hacen los equipos de inferencia a diario.

8. Sección empresarial: una arquitectura para gobernarlas a todas

8.1. La misma pila de bloques para todo

La consecuencia empresarial más profunda de este capítulo no es técnica sino económica: una misma arquitectura —la pila de bloques decoder-only que acabas de implementar— resuelve hoy conversación, generación de código, extracción de información y clasificación. Tareas que en 2019 exigían un modelo especializado cada una, con su equipo, sus datos etiquetados y su pipeline de reentrenamiento, hoy se resuelven cambiando el prompt de un único modelo:

Tarea Antes (2018-2021) Ahora (con un decoder-only grande)
Chatbot de soporte Sistema de diálogo con intents + respuestas enlatadas Prompt con instrucciones + historial de conversación
Generación de código Herramientas de autocompletado específicas El mismo modelo, prompt con el contexto del fichero
Extracción (facturas, contratos) Modelo NER entrenado por tipo de documento Prompt: «extrae los campos X, Y, Z en JSON»
Clasificación de tickets BERT fine-tuned por taxonomía Prompt con las categorías y algunos ejemplos

Las dos vías para adaptar el modelo a tu tarea:

  • In-context learning (prompting): describes la tarea y das ejemplos dentro del prompt. Sin entrenar nada: el modelo "aprende" la tarea al vuelo, en la propia pasada de atención. Coste de adaptación: minutos de trabajo. Coste unitario: cada llamada paga todos esos tokens de instrucciones.
  • Fine-tuning: ajustas los pesos con tus datos. Coste de adaptación: dataset + entrenamiento + evaluación + despliegue. A cambio: prompts cortos, latencia menor, y un comportamiento más consistente en tareas repetitivas de gran volumen.

Cara a cara, dimensión por dimensión:

Dimensión In-context (prompting) Fine-tuning
Tiempo hasta producción Horas-días Semanas
Datos necesarios 0-20 ejemplos en el prompt Cientos-miles de ejemplos etiquetados
Coste de cambiar la tarea Editar texto Reentrenar y redesplegar
Coste por llamada Mayor (instrucciones + ejemplos en cada petición, mitigable con prompt caching) Menor (prompt corto)
Consistencia del formato de salida Buena con validación; requiere reintentos Muy alta
Conocimiento del dominio muy específico Limitado a lo que cabe en contexto Se integra en los pesos
Riesgo principal Sensibilidad al fraseo del prompt Sobreajuste y desfase cuando el dominio cambia

La regla práctica de 2026: empieza siempre por prompting; recurre al fine-tuning (o a un encoder pequeño especializado) solo cuando el volumen, la latencia o el coste unitario lo justifiquen con números delante. Señales concretas de que ha llegado ese momento:

  • El prompt ya supera los miles de tokens de instrucciones y ejemplos, y aun así el formato de salida falla con frecuencia.
  • La tarea es idéntica millones de veces al mes y la factura de API crece linealmente con el negocio.
  • La latencia del modelo generalista incumple el SLA del producto.
  • Necesitas un tono, jerga o formato de dominio que los ejemplos in-context no consiguen fijar de forma estable.

8.2. Caso narrado: de cinco BERTs a un modelo por API

El caso siguiente es representativo de decenas de migraciones reales de 2023-2026; los números son órdenes de magnitud típicos.

Una empresa de software para gestión documental legal tenía en producción, en 2022, cinco modelos BERT especializados, uno por tarea: clasificación de tipo de contrato, extracción de partes y fechas (NER), detección de cláusulas de riesgo, clasificación de urgencia de emails entrantes y matching semántico contra plantillas. Cada modelo: ~110 M de parámetros, su dataset etiquetado (entre 3 000 y 40 000 ejemplos, mantenidos por el equipo legal), su pipeline de reentrenamiento trimestral, su servicio desplegado con GPU propia.

flowchart LR
    subgraph ANTES["2022: cinco modelos, cinco pipelines"]
        D1[Documento] --> B1["BERT clasificador<br/>de contratos"]
        D1 --> B2["BERT NER<br/>partes y fechas"]
        D1 --> B3["BERT cláusulas<br/>de riesgo"]
        E1[Email] --> B4["BERT urgencia"]
        D1 --> B5["BERT matching<br/>de plantillas"]
    end
    subgraph DESPUES["2025: un modelo, cinco prompts"]
        D2[Documento o email] --> P["Capa de prompts<br/>(uno por tarea)"]
        P --> LLM["LLM decoder-only<br/>vía API"]
        LLM --> J["Salidas JSON<br/>validadas"]
    end
    ANTES -.->|migración|DESPUES

En 2024 migraron cuatro de las cinco tareas a un único LLM vía API, cada tarea definida como un prompt con esquema de salida JSON. Los números gruesos del antes y el después:

Métrica 2022 (5 × BERT) 2025 (LLM + 1 BERT)
Modelos en producción 5 propios 1 vía API + 1 propio
Datasets etiquetados que mantener 5 (3k-40k ejemplos cada uno) 1 (el del BERT superviviente) + suites de evaluación
Tiempo hasta una tarea nueva 2-3 meses ~1 semana
Coste por documento procesado ~10⁻⁵ € ~10⁻³-10⁻² €
Latencia por documento ~20 ms 1-5 s
Personal dedicado a mantenimiento ML ~3 ingenieros ~1 ingeniero (evaluación y prompts)

El balance cualitativo tras un año:

Lo que ganaron:

  • Mantenimiento: de 5 pipelines de reentrenamiento y 5 despliegues a una capa fina de prompts versionados en git. El equipo de ML pasó de mantener infraestructura a diseñar evaluaciones.
  • Tiempo hasta una tarea nueva: añadir "detección de cláusulas de confidencialidad" pasó de un proyecto de 2-3 meses (etiquetar, entrenar, evaluar, desplegar) a una semana escribiendo y evaluando un prompt.
  • Calidad en casos raros: los BERT fallaban ante tipos de contrato poco representados en sus datasets; el LLM, entrenado con billones de tokens, generalizaba mejor ante lo nunca visto.
  • Capacidades imposibles antes: resúmenes ejecutivos y respuesta a preguntas sobre el documento — tareas generativas que ningún encoder podía ofrecer.

Lo que pagaron:

  • Coste unitario: clasificar un email con BERT propio costaba ~10⁻⁵ € en amortización de GPU; con el LLM por API, ~10⁻³-10⁻² € por documento (el prompt incluye el documento entero). Por eso la quinta tarea, la clasificación de urgencia —12 000 emails/día, taxonomía estable, un caso perfecto para un encoder pequeño— se quedó en BERT: multiplicar por 100 el coste de la tarea de mayor volumen no tenía sentido.
  • Latencia: de ~20 ms por documento a 1-5 segundos por respuesta generativa.
  • Dependencia y gobernanza: datos confidenciales saliendo hacia un proveedor externo (resuelto con acuerdos de no retención y cifrado), riesgo de cambios de precio o de modelo, y necesidad de una suite de evaluación propia para detectar regresiones cuando el proveedor actualiza el modelo.
  • No determinismo: las salidas requieren validación de esquema y reintentos; un clasificador softmax nunca devuelve JSON malformado.

Caso empresarial — la lección

la decisión no fue "LLM sí o no", sino por tarea: generalidad y velocidad de iteración donde el volumen lo permite (el LLM), y especialización barata donde el volumen manda (el BERT superviviente). Saber por qué — atención bidireccional barata vs generación autorregresiva cara, sección 5 y KV cache, sección 7 — es exactamente lo que distingue a un ingeniero de IA de un usuario de APIs.

8.3. Checklist de decisión por tarea

Cuando te toque decidir la arquitectura de una tarea de lenguaje en producción, recorre esta tabla de arriba abajo — la primera fila cuyo criterio se cumpla suele decidir:

Pregunta Si la respuesta es sí...
¿La tarea requiere generar texto (respuestas, resúmenes, código)? Decoder-only, sin discusión: es la única familia entrenada para ello
¿Es clasificación/extracción con >10⁵ unidades/día y taxonomía estable? Evalúa un encoder ajustado o un modelo de embeddings: coste unitario 100-1000× menor
¿La tarea cambia a menudo o hay muchas tareas pequeñas? LLM por API con prompts versionados: la velocidad de iteración domina el coste
¿Hay requisitos duros de latencia (<100 ms)? Modelo pequeño propio (encoder o decoder compacto), autohospedado
¿Los datos no pueden salir de tu infraestructura? Modelo open-weights autohospedado (la arquitectura es la misma; cambia quién opera la GPU)
¿Ninguna restricción fuerte? Empieza con LLM por API + prompting; mide, y optimiza solo lo que los números pidan

Y el formato típico de una tarea "clásica" resuelta por prompting, para que veas lo poco que hay que construir:

Sistema: Eres un clasificador de emails de un despacho legal.
Devuelve SOLO un JSON: {"urgencia": "alta"|"media"|"baja", "motivo": "<15 palabras>"}.

Ejemplo 1: "El juzgado adelanta la vista a mañana" -> {"urgencia": "alta", "motivo": "plazo judicial inminente"}
Ejemplo 2: "Adjunto la newsletter de marzo"        -> {"urgencia": "baja", "motivo": "comunicación informativa"}

Clasifica: "{email_entrante}"

Eso —un prompt con esquema y dos ejemplos, más validación del JSON de salida— sustituye a lo que en 2020 era un proyecto de meses con dataset etiquetado. El cómo diseñar, versionar y evaluar estos prompts con rigor es una disciplina en sí misma: te espera en el módulo 04-LLM.

Consejo profesional

trata los prompts de producción como código: en git, con revisión, con una suite de evaluación (decenas o cientos de casos con salida esperada) que se ejecuta ante cada cambio de prompt y ante cada actualización del modelo del proveedor. Los equipos que migran a LLMs y prescinden de esa suite descubren las regresiones cuando las descubre el cliente.


9. Buenas prácticas

  • Usa pre-LN con un LayerNorm final al implementar Transformers desde cero: entrenamiento estable sin warmup delicado (sección 4.3).
  • Verifica la pérdida inicial contra ln(vocab_size) antes de entrenar en serio: es el test de cordura más barato que existe (sección 6.3).
  • Comprueba shapes en cada paso con comentarios explícitos (B, h, n, d_k), como en nuestro código: la mayoría de bugs de atención son errores de view/transpose silenciosos.
  • En producción, usa F.scaled_dot_product_attention (o la implementación del framework): incorpora FlashAttention y está mejor probada que cualquier versión manual. Implementa a mano solo para aprender.
  • Registra la máscara causal con register_buffer, no como parámetro ni recalculada en cada forward: viaja con el modelo a la GPU y no aparece en el optimizador.
  • Fija las semillas (torch.manual_seed) en todo experimento: sin reproducibilidad no hay depuración posible.
  • Visualiza matrices de atención al depurar comportamientos extraños del modelo — pero interprétalas como pistas, no como pruebas (sección 2.6).
  • Al elegir familia de modelo, decide por tarea y por coste unitario, no por moda: un encoder pequeño sigue siendo imbatible en clasificación masiva de taxonomía estable (secciones 5.4 y 8.2).
  • Presupuesta el contexto: el coste cuadrático de la atención y la memoria del KV cache convierten cada token de contexto en dinero; recorta prompts antes de escalar hardware.
  • Guarda el vocabulario del tokenizador junto a los pesos en cada checkpoint: unos pesos sin su vocabulario son índices sin significado (sección 6.6).
  • Reserva siempre un split de validación, aunque el corpus sea diminuto: la brecha train/val es el único número que distingue "aprende" de "memoriza" (sección 6.6).
  • Entrena con model.train() y evalúa/genera con model.eval() + torch.no_grad(): el par de llamadas más barato para evitar resultados inconsistentes y memoria desperdiciada.
  • Antes de escalar un experimento, sobreajusta un solo batch: si el modelo no puede memorizar 32 ejemplos, tiene un bug; encontrarlo con 2 minutos de CPU es infinitamente más barato que con 2 días de GPU.
  • Estructura los prompts con la parte estable al principio y la variable al final: es la diferencia entre aprovechar el prompt caching o pagar el prefill completo en cada llamada (sección 7.5).

10. Malas prácticas

  • Olvidar el escalado por √d_k en una implementación manual: entrena, pero peor, y el bug es invisible porque no lanza ningún error (sección 2.4).
  • Sumar el positional encoding en cada bloque en lugar de solo en la entrada: degrada progresivamente el contenido semántico (sección 4.1).
  • Aplicar la máscara causal después del softmax (multiplicando por ceros): las filas dejan de sumar 1 y la distribución queda corrupta. La máscara va antes, como -inf sobre las puntuaciones.
  • Usar argmax en vez de muestrear durante la generación y quejarse de que el modelo "se repite en bucle": la degeneración por decodificación golosa es un clásico. Muestrea con temperatura (sección 6.4).
  • Evaluar un modelo generativo con el corpus de entrenamiento y celebrar la "calidad": como nuestro MiniGPT, puede estar recitando de memoria (sección 6.5).
  • Elegir un LLM generativo por API para clasificación masiva de alto volumen sin comparar el coste unitario contra un encoder ajustado: es el error simétrico a quedarse en BERT para todo (sección 8).
  • Ignorar el consumo del KV cache al dimensionar servicio de inferencia: los gigabytes por conversación de contexto largo tumban despliegues que "funcionaban en la demo" (sección 7.5).
  • Copiar hiperparámetros de modelos gigantes a modelos pequeños (learning rate, warmup, dropout): las recetas no son invariantes a la escala.
  • Tratar los pesos de atención como explicación causal definitiva del comportamiento del modelo ante un auditor o un cliente: son pistas exploratorias (sección 2.6), y presentarlos como prueba es sobrevender.
  • Meter todo el documento en el prompt "porque el contexto da": cada token de más se paga en latencia, dinero y, a menudo, en calidad (el modelo se distrae). Contexto grande es una capacidad, no una invitación.

11. Errores comunes

Error Síntoma Solución
Confundir las filas y columnas de la matriz de atención Interpretaciones sin sentido al visualizar Las filas son queries (quién mira), las columnas keys (a quién se mira); cada fila suma 1
view sin contiguous() tras transpose RuntimeError: view size is not compatible... Llamar .contiguous() antes de view, o usar .reshape()
Máscara con la orientación invertida (triu en vez de tril) El modelo "adivina" perfectamente en entrenamiento y genera basura La máscara causal es triangular inferior: torch.tril(torch.ones(n, n))
Olvidar recortar la ventana al generar (idx[:, -contexto:]) IndexError en emb_posicion al superar el contexto El embedding posicional solo tiene contexto entradas: recorta la secuencia antes del forward
Softmax en la dimensión equivocada (dim=1 con tensores 4D) Pérdida que baja poco y atención sin sentido El softmax normaliza sobre las keys: siempre dim=-1 sobre (B, h, n_q, n_k)
Pérdida inicial mucho mayor que ln(vocab_size) Arranque del entrenamiento anómalo Revisar inicialización, shapes del cross_entropy (aplanado (B·n, vocab)) y la máscara
d_model no divisible entre n_heads AssertionError o shapes rotos al trocear cabezas Elegir d_model múltiplo de n_heads; es una restricción estructural
Entrenar sin fijar semilla y "no poder reproducir" un buen resultado Resultados distintos en cada ejecución torch.manual_seed(42) (y numpy.random.seed) al inicio de todo script
Comparar logits de posiciones distintas de la última al generar Predicciones incoherentes con el prompt En generación autorregresiva solo se usa logits[:, -1, :]
Olvidar model.eval() al generar (con dropout activo) Generaciones ruidosas e irreproducibles pese a la semilla Llamar model.eval() antes de inferir; model.train() al volver a entrenar
Aplicar la temperatura después del softmax El parámetro "no hace nada" o distorsiona mal La temperatura divide los logits: softmax(logits / T)
Reconstruir el tokenizador desde un texto distinto al recargar un checkpoint El modelo cargado genera basura sin lanzar errores Guardar el vocabulario con los pesos y reconstruir desde él (sección 6.6)

12. Preguntas frecuentes

1. ¿Por qué se llama "Transformer"? El paper lo bautizó así porque transforma una secuencia de representaciones en otra, capa a capa, sin recurrencia ni convoluciones. No hay un motivo más profundo; el nombre pegadizo ayudó.

2. ¿Q, K y V tienen que tener la misma dimensión? Q y K sí (su producto punto lo exige: d_k común). V puede tener otra dimensión d_v — la salida de la atención hereda d_v. En la práctica casi todos los modelos usan d_k = d_v = d_model / h por simplicidad.

3. ¿La atención tiene parámetros aprendibles? La operación de atención en sí (puntuaciones, softmax, media ponderada) no tiene ninguno; todos los parámetros están en las proyecciones W_Q, W_K, W_V, W_O. Por eso decimos que la atención es un mecanismo de enrutamiento cuyos criterios de enrutado se aprenden.

4. ¿Por qué softmax y no otra normalización? El softmax da pesos positivos que suman 1 (una media ponderada bien definida), es diferenciable y su "competición suave" entre candidatos funciona muy bien. Existen alternativas (atención lineal, sparsemax) que cambian ese compromiso, normalmente sacrificando calidad por velocidad.

5. ¿Un Transformer puede procesar secuencias más largas que su contexto de entrenamiento? Con posiciones aprendidas, no: no existe embedding para la posición nueva. Con sinusoidales o RoPE, técnicamente sí, pero la calidad se degrada fuera del rango entrenado; extender contexto requiere técnicas específicas (interpolación de posiciones, fine-tuning largo) que verás en el módulo 04-LLM.

6. ¿Por qué mi MiniGPT genera texto distinto cada vez si la semilla está fijada? La semilla fija los pesos iniciales y el orden del entrenamiento, y también hace reproducible el muestreo si generas exactamente en el mismo punto del programa. Si añades llamadas intermedias que consuman números aleatorios, el estado del generador cambia. Para reproducibilidad exacta de una generación, re-siembra justo antes de llamar a generar().

7. ¿Las cabezas de atención se especializan siempre en cosas interpretables? No. Algunas cabezas de modelos reales tienen funciones claras (sintaxis, posición, correferencia) y otras son redundantes o ininterpretables — de hecho, muchas pueden podarse con poca pérdida de calidad. La especialización es una tendencia estadística, no una garantía.

8. ¿Qué es exactamente un "token" en un LLM real, si no son caracteres? Subpalabras aprendidas por algoritmos tipo BPE: fragmentos frecuentes de texto ("de", "ción", "http"). Un token medio equivale a ~4 caracteres en español. La tokenización es el primer tema del módulo 04-LLM.

9. ¿El Transformer solo sirve para texto? No: es un procesador genérico de conjuntos de vectores con posiciones. Con imágenes troceadas en parches (ViT), audio en espectrogramas (Whisper), aminoácidos (AlphaFold) o acciones de un agente, la misma maquinaria funciona. Por eso este capítulo es el más transferible del módulo.

10. ¿Por qué BERT no puede generar texto si también es un Transformer? Porque su preentrenamiento (rellenar huecos con contexto bidireccional) no le enseña la tarea de continuar texto, y su atención sin máscara causal le deja "ver el futuro": no hay una dirección de generación bien definida. La arquitectura podría; el objetivo de entrenamiento no.

11. ¿Sigue mereciendo la pena aprender a implementar la atención a mano en 2026? Sí, y este capítulo es la prueba: KV cache, prompt caching, precios de API, límites de contexto, GQA, FlashAttention — toda la economía y la ingeniería de los LLMs es incomprensible sin la mecánica de Q·Kᵀ. No implementarás atención en producción, pero razonarás sobre ella cada semana.

12. ¿Qué viene después del Transformer? La investigación ataca su coste cuadrático: modelos de espacio de estados (Mamba), atención lineal, arquitecturas híbridas. En 2026 hay híbridos en producción, pero el Transformer sigue siendo el estándar — y todas las alternativas se definen en relación con él, así que lo aprendido aquí es la base en cualquier escenario.

13. En el ejemplo a mano, "el" apenas se atiende a sí mismo (4.5 %). ¿Un token puede "ignorarse" del todo? Puede acercarse mucho a cero, pero no perderse: la conexión residual del bloque (sección 4.4) suma siempre la entrada original a la salida de la atención, así que el contenido propio del token sobrevive aunque la atención lo ignore. Atención y residual se reparten los papeles: una trae lo ajeno, la otra conserva lo propio.

14. ¿Por qué el proyecto usa AdamW y no el SGD que usamos en capítulos anteriores? Los Transformers entrenan notablemente mal con SGD puro: la escala de los gradientes varía mucho entre embeddings, atención y FFN, y Adam/AdamW adapta el learning rate por parámetro. AdamW (Adam con weight decay desacoplado) es el estándar de facto para Transformers desde 2019; con SGD necesitarías un ajuste fino de hiperparámetros para llegar a resultados parecidos.

15. ¿Puedo entrenar el MiniGPT en GPU? Sí, con tres líneas: device = "cuda" if torch.cuda.is_available() else "cpu", modelo.to(device) y mover cada lote con x, y = x.to(device), y.to(device). Con este tamaño la GPU apenas se nota (el modelo es demasiado pequeño para amortizarla), pero es el hábito correcto — y con las extensiones de la sección 6.7 (corpus y modelo mayores) se vuelve imprescindible.

16. ¿Qué diferencia hay entre "atención" (capítulo 5) y "self-attention" (este capítulo)? El mecanismo matemático es el mismo: queries contra keys, softmax, media ponderada de values. Cambia el origen de los ingredientes: en la atención cruzada del seq2seq, las queries venían del decoder y las keys/values del encoder (dos secuencias distintas); en self-attention, todo sale de la misma secuencia. El encoder-decoder (T5) usa ambas: self-attention en cada mitad y atención cruzada entre ellas.

17. ¿Cuánto de este capítulo necesito recordar para trabajar con LLMs vía API? Como mínimo: qué es un token, por qué existe un límite de contexto, qué hace la temperatura, la diferencia prefill/decode (precios de entrada vs salida) y qué es el KV cache y el prompt caching. Es decir: las secciones 5, 6.4 y 7. El resto es lo que te permitirá razonar cuando algo no funcione como promete la documentación.

18. ¿El ejemplo numérico de la sección 2.3 es realista con esas matrices de ceros y unos? Las matrices de juguete están elegidas para que puedas verificar cada producto mentalmente; en un modelo real, W_Q, W_K y W_V contienen números reales pequeños (positivos y negativos) aprendidos por backpropagation. Pero la mecánica — proyectar, puntuar, escalar, softmax, promediar — es idéntica bit a bit. Si repites la sección 2.3 con matrices aleatorias y una calculadora, obtendrás tablas menos bonitas y el mismo aprendizaje.


13. Resumen

Glosario exprés del capítulo, para repaso rápido:

Término En una frase
Self-attention Cada token reconstruye su vector como media ponderada de (proyecciones de) todos los tokens
Query / Key / Value Qué busco / qué anuncio / qué entrego: tres proyecciones lineales del mismo embedding
Cabeza de atención Una atención independiente en un subespacio de dimensión d_model/h
Positional encoding El vector que rompe la ceguera al orden: sinusoidal, aprendido o RoPE
Máscara causal Triangular inferior: prohíbe atender al futuro; define a los decoder
Pre-LN LayerNorm dentro de la rama, residual limpia: la variante estable moderna
Autorregresivo Generar token a token, cada uno condicionado por todos los anteriores
Temperatura Divisor de los logits antes del softmax: fidelidad (baja) vs diversidad (alta)
KV cache Guardar K y V de los tokens pasados para no recalcularlos al generar
Scaling laws La pérdida mejora de forma predecible con parámetros, datos y cómputo
  • El Transformer eliminó la recurrencia para ganar paralelización total y path length O(1) entre tokens: las dos propiedades que le permitieron escalar donde las RNNs no podían.
  • Self-attention construye embeddings contextuales: cada token proyecta su vector a Q (qué busco), K (qué anuncio) y V (qué entrego), puntúa contra todos con Q·Kᵀ/√d_k, y se lleva una media de values ponderada por el softmax. El escalado por √d_k evita saturar el softmax y es crítico para entrenar.
  • Multi-head attention ejecuta h atenciones en paralelo en subespacios de dimensión d_model/h: mismo coste, múltiples tipos de relación aprendidos a la vez, mezclados por la proyección final W_O.
  • El bloque completo añade positional encoding (la atención es ciega al orden; sinusoidal, aprendido o RoPE), la FFN por posición (expansión 4×, GELU — donde vive la mayoría de los parámetros) y residuales + LayerNorm en configuración pre-LN.
  • Las tres familias se distinguen por la máscara: encoder-only (BERT, bidireccional, comprensión), decoder-only (GPT, máscara causal, generación autorregresiva) y encoder-decoder (T5, seq2seq). Los LLMs actuales son decoder-only por simplicidad, escalabilidad y generalidad del objetivo "predice el siguiente token".
  • Tu MiniGPT de ~160 000 parámetros contiene, pieza a pieza, la misma arquitectura que un modelo de 10¹² parámetros: la diferencia es escala (scaling laws), no concepto.
  • La atención cuadrática y el KV cache explican la economía de la inferencia: prefill barato y paralelo, decode secuencial, memoria de GPU como recurso escaso, precios por tipo de token.
  • Empresarialmente, una sola arquitectura resuelve hoy tareas que exigían un modelo especializado cada una; la decisión prompting vs fine-tuning vs encoder pequeño se toma por tarea, con costes unitarios delante.

El mapa completo del capítulo, de un vistazo:

mindmap
  root((Transformer))
    Self-attention
      Q, K, V: tres proyecciones del mismo vector
      Puntuaciones Q·Kᵀ / √d_k
      Softmax por filas -> media ponderada de V
      Matriz de atención: interpretación
    Multi-head
      h cabezas en subespacios d_model/h
      Cada cabeza, una relación distinta
      Concatenar + proyección W_O
    El bloque
      Positional encoding: sinusoidal / aprendido / RoPE
      FFN 4x con GELU
      Residuales + LayerNorm pre-LN
    Tres familias
      Encoder-only: BERT, comprensión
      Decoder-only: GPT, máscara causal
      Encoder-decoder: T5, seq2seq
    Escalado
      Scaling laws y Chinchilla
      Coste cuadrático: FlashAttention, GQA
      KV cache y la economía de la inferencia

Y los cinco números de este capítulo que deberías poder reconstruir de memoria:

Número Qué es
√d_k El divisor que mantiene la varianza de las puntuaciones en ≈1 y el softmax sin saturar
d_model / h La dimensión de cada cabeza: multi-head no multiplica el coste, lo reparte
La expansión de la FFN — donde viven dos tercios de los parámetros
ln(vocab_size) La pérdida inicial esperada de un modelo de lenguaje: el test de cordura
O(n²) El coste de la atención con el contexto: la raíz de FlashAttention, GQA y el KV cache

Lo que te llevas al capítulo 7: ya sabes construir y entrenar la arquitectura; el siguiente capítulo responde a la pregunta que este deja abierta — ¿cómo se aprovecha un modelo que otro entrenó con millones de dólares? Transfer learning, preentrenamiento y fine-tuning: el puente definitivo hacia los LLMs del módulo 04.


14. Bibliografía

  • Vaswani, A. et al. (2017). Attention Is All You Need. arXiv:1706.03762. https://arxiv.org/abs/1706.03762 — El paper original. Tras este capítulo puedes leerlo entero; las secciones 3 y 4 te resultarán familiares.
  • Alammar, J. (2018). The Illustrated Transformer. https://jalammar.github.io/illustrated-transformer/ — La explicación visual de referencia; excelente segundo ángulo sobre lo aprendido aquí.
  • Karpathy, A. nanoGPT. https://github.com/karpathy/nanoGPT — Un GPT completo y entrenable en ~300 líneas; el hermano mayor de nuestro MiniGPT. Su vídeo Let's build GPT from scratch es el complemento perfecto de la sección 6.
  • Devlin, J. et al. (2018). BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding. arXiv:1810.04805. https://arxiv.org/abs/1810.04805 — La familia encoder-only.
  • Radford, A. et al. (2018, 2019). Improving Language Understanding by Generative Pre-Training (GPT-1) y Language Models are Unsupervised Multitask Learners (GPT-2). — El origen de la familia decoder-only.
  • Kaplan, J. et al. (2020). Scaling Laws for Neural Language Models. arXiv:2001.08361. https://arxiv.org/abs/2001.08361 — Las leyes de escalado de la sección 7.2.
  • Hoffmann, J. et al. (2022). Training Compute-Optimal Large Language Models (Chinchilla). arXiv:2203.15556. https://arxiv.org/abs/2203.15556 — El equilibrio parámetros/datos.
  • Su, J. et al. (2021). RoFormer: Enhanced Transformer with Rotary Position Embedding. arXiv:2104.09864. https://arxiv.org/abs/2104.09864 — RoPE, el positional encoding de los LLMs modernos (sección 4.1).
  • Dao, T. et al. (2022). FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness. arXiv:2205.14135. https://arxiv.org/abs/2205.14135 — La atención por bloques de la sección 7.4.

Anterior Módulo Siguiente
Capítulo 5: Secuencias: RNNs y embeddings 03-DEEP-LEARNING Capítulo 7: Transfer learning y el camino a los LLMs