Análisis en profundidad de Transformer
En 2017, Google publicó el artículo "Attention Is All You Need", proponiendo la arquitectura Transformer. Nadie esperaba que este artículo cambiara por completo el campo de la IA.
Los GPT, Claude, Gemini, Llama de hoy... casi todos los principales modelos de lenguaje grandes son, en esencia, variantes de Transformer.
Si entiendes Transformer, entiendes el 90% de los LLM modernos.
En este artículo profundizaremos en cada componente de Transformer: autoatención, atención de múltiples cabezas, codificación posicional, red de avance, normalización de capa... No solo hablaremos de "qué es", sino también de "por qué".
Este es uno de los módulos de mayor profundidad técnica. Usaremos código para demostrar el cálculo central, asegurando que no solo entiendas las fórmulas, sino que también puedas implementarlas.
Antecedentes de Transformer: limitaciones de las RNN
Antes de la aparición de Transformer, las tareas de secuencias (como la traducción, la generación de texto) usaban principalmente RNN (redes neuronales recurrentes) y sus variantes LSTM y GRU.
¿Cómo funciona la RNN?
La idea central de RNN es "procesar uno a uno": la secuencia de entrada entra en la red palabra por palabra, y la salida de cada paso contiene información de todas las palabras anteriores.
Ejemplo
# Demostración simplificada de propagación hacia adelante de RNN
# ============================================
import math
class SimpleRNN:
"""Implementación simplificada de RNN para demostrar el principio"""
def __init__(self, input_size: int, hidden_size: int):
"""Inicializar parámetros de RNN"""
import random
random.seed(42) # Establecer la semilla aleatoria para garantizar la reproducibilidad
# Pesos de entrada a la capa oculta
self.Wx = [[random.uniform(-0.1, 0.1) for _ in range(hidden_size)]
for _ in range(input_size)]
# Pesos de capa oculta a capa oculta
self.Wh = [[random.uniform(-0.1, 0.1) for _ in range(hidden_size)]
for _ in range(hidden_size)]
# Sesgo
self.b = [0.0 for _ in range(hidden_size)]
def step(self, x: list, h_prev: list) -> list:
"""RNN de un solo paso: entrada x y estado oculto del momento anterior h_prev, salida del nuevo estado oculto"""
hidden_size = len(h_prev)
h_new = [0.0 for _ in range(hidden_size)]
# h_new = tanh(Wx·x + Wh·h_prev + b)
for i in range(hidden_size):
# Calcular Wx·x
wx_sum = sum(x[j] * self.Wx[j][i] for j in range(len(x)))
# Calcular Wh·h_prev
wh_sum = sum(h_prev[j] * self.Wh[j][i] for j in range(hidden_size))
# Añadir sesgo, pasar por tanh
h_new[i] = math.tanh(wx_sum + wh_sum + self.b[i])
return h_new
def forward(self, sequence: list) -> list:
"""Procesar la secuencia completa"""
hidden_size = len(self.b)
h = [0.0 for _ in range(hidden_size)] # Estado oculto inicial
hidden_states = []
for x in sequence:
h = self.step(x, h)
hidden_states.append(h)
return hidden_states
# Prueba: demuestra con una secuencia simple de vectores
input_size = 4
hidden_size = 3
rnn = SimpleRNN(input_size, hidden_size)
# Supongamos que la secuencia de entrada son 4 palabras, cada palabra representada por un vector de 4 dimensiones
sequence = [
[1.0, 0.0, 0.0, 0.0], # palabra 1
[0.0, 1.0, 0.0, 0.0], # palabra 2
[0.0, 0.0, 1.0, 0.0], # palabra 3
[0.0, 0.0, 0.0, 1.0], # palabra 4
]
hidden_states = rnn.forward(sequence)
print("Demostración simple de RNN de Ejemplo")
print("=" * 40)
for i, h in enumerate(hidden_states):
print(fEstado oculto en el paso {i+1}: {[f'{v:.4f}' for v in h]})
# salida:
# Demostración simple de RNN de Ejemplo
# ========================================
# Estado oculto del paso 1: ['0.0204', '-0.0434', '0.0556']
# Paso 2 estado oculto: ['-0.0061', '-0.0529', '0.0775']
# Estado oculto del paso 3: ['0.0641', '-0.0409', '0.0655']
# Estado oculto del paso 4: ['0.0143', '-0.0805', '0.0277']
RNN parece razonable, pero tiene tres problemas fatales:
Problema 1: desvanecimiento del gradiente, dificultad para recordar dependencias de largo alcance
RNN tiene una estructura "en cadena": el gradiente de cada paso debe propagarse hacia atrás hasta el primer paso. Después de múltiples multiplicaciones, el gradiente decae exponencialmente, volviéndose casi 0.
Por ejemplo, en la frase "Fui a París en 2010, ..., esa es mi ciudad favorita": "ciudad" debe referirse a "París", pero hay demasiadas palabras en medio, y RNN difícilmente puede aprender esa dependencia de largo alcance.
LSTM y GRU aliviaron este problema, pero no lo resolvieron por completo.
Problema 2: no se puede calcular en paralelo, entrenamiento lento
RNN debe esperar a que se complete el paso t-1 para poder calcular el paso t. Esto significa:
- Incluso con 100 GPUs, solo se puede calcular palabra por palabra
- Cuanto más larga sea la secuencia, mayor será el tiempo de entrenamiento
- Es difícil escalar a datos de muy gran escala
Problema 3: la información anterior tiende a ser "sobrescrita"
El estado oculto de RNN se actualiza paso a paso, y la información posterior cubre continuamente la anterior. La información importante al comienzo de la oración puede haber quedado "diluida" al final.
La aparición de Transformer resolvió estos tres problemas de una vez.
| Característica | RNN/LSTM | Transformer |
|---|---|---|
| Forma de cálculo | Serial, paso a paso | En paralelo, de una sola vez |
| Dependencias de largo alcance | Débil, desvanecimiento del gradiente | Fuerte, conexión directa a cualquier distancia |
| Información posicional | Orden natural | Requiere codificación posicional |
| Velocidad de entrenamiento | Lento | Rápida (paralelizable) |
Mecanismo de autoatención (Self-Attention)
La autoatención es el núcleo de Transformer. Su idea es simple:Cada palabra debe "comunicarse" con todas las palabras de la oración para ver quién es importante para ella, y luego hacer una suma ponderada según el grado de importancia.
Veamos primero el diagrama general de la arquitectura:
Comprensión intuitiva de Q, K, V
La autoatención usa tres vectores para describir cada palabra:
- Q (Query, consulta)—"¿A quién estoy buscando?"—a quién quiere prestar atención esta palabra
- K (Key, clave)—"¿Quién soy?"—la identidad de esta palabra
- V (Value, valor)—"¿Qué tengo?"—el contenido real de esta palabra
El proceso de cálculo es:
- Cada palabra usa Q para "emparejarse" con la K de todas las palabras, obteniendo puntuaciones de atención
- Usar Softmax para normalizar las puntuaciones, sumando 1
- Usar las puntuaciones normalizadas para hacer la suma ponderada de las V de todas las palabras
Se ve más claro en la figura:

Fórmula de cálculo de las puntuaciones de atención
La fórmula completa es:
Attention(Q, K, V) = softmax( Q·Kᵀ / √dₖ ) · V
Implementemos esto paso a paso en Python puro:
Ejemplo
# Implementación de Self-Attention en Python puro
# ============================================
import math
def softmax(x: list) -> list:
"""Calcula Softmax: convierte un conjunto de números en una distribución de probabilidad cuya suma es 1"""
# Restar el valor máximo para evitar el desbordamiento numérico
max_val = max(x)
exp_x = [math.exp(v - max_val) for v in x]
sum_exp = sum(exp_x)
return [v / sum_exp for v in exp_x]
def matrix_multiply(A: list, B: list) -> list:
"""Multiplicación de matrices: A es m×n, B es n×p, salida m×p"""
m = len(A)
n = len(B)
p = len(B[0])
result = [[0.0 for _ in range(p)] for _ in range(m)]
for i in range(m):
for j in range(p):
for k in range(n):
result[i][j] += A[i][k] * B[k][j]
return result
def transpose(matrix: list) -> list:
"""Transposición de matriz"""
return list(map(list, zip(*matrix)))
class SelfAttention:
"""Capa de autoatención implementada en Python puro"""
def __init__(self, d_model: int, d_k: int):
"""
Inicializar capa de autoatención
d_model: dimensión de entrada/salida
d_k: dimensión de Q/K (normalmente d_k = d_model / num_heads)
"""
import random
random.seed(42) # ejemplo: fijar semilla aleatoria
self.d_model = d_model
self.d_k = d_k
# Inicializar matrices de proyección de Q, K, V
# En la práctica, estos se aprenden durante el entrenamiento
self.Wq = [[random.normalvariate(0, 0.1) for _ in range(d_k)]
for _ in range(d_model)]
self.Wk = [[random.normalvariate(0, 0.1) for _ in range(d_k)]
for _ in range(d_model)]
self.Wv = [[random.normalvariate(0, 0.1) for _ in range(d_k)]
for _ in range(d_model)]
def forward(self, X: list) -> list:
"""
Propagación hacia adelante de la autoatención
X: secuencia de entrada, forma [seq_len, d_model]
Retorna: secuencia de salida, forma [seq_len, d_k]
"""
seq_len = len(X)
# Paso 1: Calcular Q, K, V
# Q = X·Wq, K = X·Wk, V = X·Wv
Q = matrix_multiply(X, self.Wq)
K = matrix_multiply(X, self.Wk)
V = matrix_multiply(X, self.Wv)
print(" Forma de Q:", len(Q), "×", len(Q[0]))
print(" Forma de K:", len(K), "×", len(K[0]))
print(" Forma de V:", len(V), "×", len(V[0]))
# Paso 2: Calcular puntuaciones de atención Q·Kᵀ
K_transposed = transpose(K)
scores = matrix_multiply(Q, K_transposed)
print("\nPuntuaciones de atención brutas:")
for i, row in enumerate(scores):
print(f" Fila {i}: {[f'{v:.4f}' for v in row]}")
# Paso 3: Escalar: dividir por √d_k
# Esto es para evitar que Softmax entre en la región de gradiente saturado
scale_factor = math.sqrt(self.d_k)
scores_scaled = [[v / scale_factor for v in row] for row in scores]
print("\nPuntuaciones de atención escaladas:")
for i, row in enumerate(scores_scaled):
print(f" Fila {i}: {[f'{v:.4f}' for v in row]}")
# Paso 4: Normalización Softmax
attention_weights = []
for row in scores_scaled:
attention_weights.append(softmax(row))
print("\nPesos de atención (después de Softmax):")
for i, row in enumerate(attention_weights):
print(f" Fila {i}: {[f'{v:.4f}' for v in row]}")
# Paso 5: Suma ponderada de V
output = matrix_multiply(attention_weights, V)
return output, attention_weights
# ============================================
# Demostración: ejecutar autoatención con un ejemplo simple
# ============================================
# Supongamos que tenemos una oración de 4 palabras, cada una representada por un vector de 8 dimensiones
# Para la demostración, usamos one-hot + pequeño ruido aleatorio
d_model = 8
d_k = 6
# Secuencia de entrada: 4 palabras, cada una de 8 dimensiones
X = [
[1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], # Palabra 0
[0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], # Palabra 1
[0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0], # Palabra 2
[0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0], # Palabra 3
]
print("=" * 50)
print("Ejemplo Demostración de autoatención")
print("=" * 50)
print(f"Longitud de la secuencia de entrada: {len(X)}")
print(f"Dimensión de entrada: {len(X)
print()
attn = SelfAttention(d_model=d_model, d_k=d_k)
output, attention_weights = attn.forward(X)
print("\n" + "=" * 50)
print("Salida de autoatención:")
print("=" * 50)
for i, row in enumerate(output):
print(f"Salida de la palabra {i}: {[f'{v:.4f}' for v in row]}")
Al ejecutar este código, verás el proceso de cálculo completo: generación de Q/K/V, cálculo de puntuaciones de atención, escalado, Softmax y salida ponderada final.
¿Por qué dividir entre √dₖ?
Este es un detalle importante. Supongamos que cada elemento de Q y K es una variable aleatoria con media 0 y varianza 1, entonces la varianza de su producto punto es dₖ.
Si dₖ es grande (por ejemplo, 512), el valor del producto punto será muy grande, lo que hace que Softmax entre en la "región de saturación" — el gradiente es casi 0 y el entrenamiento no avanza.
Después de dividir por √dₖ, la varianza vuelve a ser 1, y el gradiente de Softmax se vuelve saludable.
El producto punto escalado es una de las técnicas clave para la estabilidad del entrenamiento de Transformer.
Atención de múltiples cabezas (Multi-Head Attention)
La autoatención de una sola cabeza es poderosa, pero tiene un problema: cada palabra solo puede "prestar atención" una vez. ¿Qué pasa si queremos que la palabra preste atención a múltiples aspectos diferentes al mismo tiempo?
Por ejemplo, en la oración "En el zoológico, el tigre persigue al conejo":
- "Tigre" podría necesitar una cabeza para prestar atención a "zoológico" (lugar)
- Otra cabeza para prestar atención a "conejo" (objeto de la acción)
- Una tercera cabeza para prestar atención a "persigue" (acción)
La atención de múltiples cabezas consiste en dividir Q/K/V en múltiples "cabezas", cada cabeza aprende un patrón de atención diferente, y finalmente se concatenan.
Pasos de cálculo de la atención de múltiples cabezas
- Dividir Q/K/V en h partes según la última dimensión (h es el número de cabezas)
- Cada cabeza realiza autoatención de forma independiente
- Concatenar las salidas de todas las cabezas
- Pasar por una capa de proyección lineal para obtener la salida final
La fórmula es:
MultiHead(Q, K, V) = Concat(head₁, head₂, ..., headₕ) · Wₒ 其中 headᵢ = Attention(Q·Wqᵢ, K·Wkᵢ, V·Wvᵢ)
Ejemplo
# Implementación de atención de múltiples cabezas (Multi-Head Attention) en Python puro
# ============================================
import math
import random
random.seed(42) # ejemplo: fijar semilla aleatoria
def softmax(x: list) -> list:
"""Calcular Softmax"""
max_val = max(x)
exp_x = [math.exp(v - max_val) for v in x]
sum_exp = sum(exp_x)
return [v / sum_exp for v in exp_x]
def matrix_multiply(A: list, B: list) -> list:
"""Multiplicación de matrices"""
m = len(A)
n = len(B)
p = len(B[0])
result = [[0.0 for _ in range(p)] for _ in range(m)]
for i in range(m):
for j in range(p):
for k in range(n):
result[i][j] += A[i][k] * B[k][j]
return result
def transpose(matrix: list) -> list:
"""Transposición de matrices"""
return list(map(list, zip(*matrix)))
class MultiHeadAttention:
"""Capa de atención de múltiples cabezas implementada en Python puro"""
def __init__(self, d_model: int, num_heads: int):
"""
Inicializar atención de múltiples cabezas
d_model: dimensión del modelo (debe ser divisible por num_heads)
num_heads: número de cabezas de atención
"""
assert d_model % num_heads == 0, "d_model debe ser divisible por num_heads"
self.d_model = d_model
self.num_heads = num_heads
self.d_k = d_model // num_heads # Dimensión de cada cabeza
# Inicializar matrices de proyección
self.Wq = [[random.normalvariate(0, 0.1) for _ in range(d_model)]
for _ in range(d_model)]
self.Wk = [[random.normalvariate(0, 0.1) for _ in range(d_model)]
for _ in range(d_model)]
self.Wv = [[random.normalvariate(0, 0.1) for _ in range(d_model)]
for _ in range(d_model)]
self.Wo = [[random.normalvariate(0, 0.1) for _ in range(d_model)]
for _ in range(d_model)]
def split_heads(self, X: list) -> list:
"""
Dividir la última dimensión en num_heads cabezas
Forma de entrada: [seq_len, d_model]
Forma de salida: [num_heads, seq_len, d_k]
"""
seq_len = len(X)
# Reformar: [seq_len, num_heads, d_k]
reshaped = []
for row in X:
head_rows = []
for h in range(self.num_heads):
start = h * self.d_k
end = start + self.d_k
head_rows.append(row[start:end])
reshaped.append(head_rows)
# Transponer: [num_heads, seq_len, d_k]
result = []
for h in range(self.num_heads):
head_data = [reshaped[i][h] for i in range(seq_len)]
result.append(head_data)
return result
def combine_heads(self, heads: list) -> list:
"""
Concatenar las múltiples cabezas de nuevo
Forma de entrada: [num_heads, seq_len, d_k]
Forma de salida: [seq_len, d_model]
"""
seq_len = len(heads[0])
result = []
for i in range(seq_len):
combined = []
for h in range(self.num_heads):
combined.extend(heads[h][i])
result.append(combined)
return result
def attention_single_head(self, Q: list, K: list, V: list) -> list:
"""Autoatención de una sola cabeza"""
# Q·K^T
scores = matrix_multiply(Q, transpose(K))
# Escalar
scale = math.sqrt(self.d_k)
scores_scaled = [[v / scale for v in row] for row in scores]
# Softmax
weights = [softmax(row) for row in scores_scaled]
# Suma ponderada de V
output = matrix_multiply(weights, V)
return output, weights
def forward(self, X: list) -> list:
"""Propagación hacia adelante de la atención de múltiples cabezas"""
seq_len = len(X)
# Paso 1: Calcular Q, K, V
Q = matrix_multiply(X, self.Wq)
K = matrix_multiply(X, self.Wk)
V = matrix_multiply(X, self.Wv)
# Paso 2: Dividir en múltiples cabezas
Q_heads = self.split_heads(Q)
K_heads = self.split_heads(K)
V_heads = self.split_heads(V)
# Paso 3: Cada cabeza realiza autoatención de forma independiente
output_heads = []
all_weights = []
for h in range(self.num_heads):
out, weights = self.attention_single_head(
Q_heads[h], K_heads[h], V_heads[h]
)
output_heads.append(out)
all_weights.append(weights)
# Paso 4: Concatenar todas las cabezas
combined = self.combine_heads(output_heads)
# Paso 5: Proyección lineal final
output = matrix_multiply(combined, self.Wo)
return output, all_weights
# ============================================
# Demostrar atención de múltiples cabezas
# ============================================
d_model = 12
num_heads = 3 # 12 / 3 = 4 dimensiones por cabeza
# Secuencia de entrada: 4 palabras, cada una de 12 dimensiones
X = [
[1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
[0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
]
print("=" * 50)
print("Ejemplo Demostración de atención de múltiples cabezas")
print("=" * 50)
print(f"d_model = {d_model}, num_heads = {num_heads}")
print(f"Dimensión de cada cabeza: d_k = {d_model // num_heads}")
print()
mha = MultiHeadAttention(d_model=d_model, num_heads=num_heads)
output, all_weights = mha.forward(X)
print("\nPesos de atención de cada cabeza:")
for h in range(num_heads):
print(f"\nCabeza {h}:")
for i, row in enumerate(all_weights[h]):
print(f" Posición {i}: {[f'{v:.4f}' for v in row]}")
print("\n" + "=" * 50)
print("Forma de salida final de la atención de múltiples cabezas:", len(output), "×", len(output[0]))
print("=" * 50)
for i, row in enumerate(output):
print(f"Posición {i}: {[f'{v:.4f}' for v in row[:6]]}...")
El poder de la atención de múltiples cabezas radica en:Diferentes cabezas aprenden automáticamente diferentes tipos de patrones de atención.
Por ejemplo, en tareas de traducción:
- Algunas cabezas se enfocan en relaciones gramaticales (sujeto-verbo-objeto)
- Algunas cabezas se enfocan en relaciones de referencia (a quién se refiere el pronombre)
- Algunas cabezas se enfocan en palabras vecinas locales
Estos patrones no son diseñados manualmente, sino que el modelo los aprende automáticamente durante el entrenamiento.
Codificación posicional (Positional Encoding)
La autoatención tiene un problema: es un modelo de "bolsa de palabras" — no le importa el orden de las palabras. "Te quiero" y "Me quieres" pueden parecer iguales para la autoatención.
Pero el orden es crucial para el lenguaje. Necesitamos "inyectar" información posicional al modelo.
Codificación posicional sinusoidal
El Transformer original utiliza codificación posicional sinusoidal:
PE(pos, 2i) = sin(pos / 10000^(2i/d_model)) PE(pos, 2i+1) = cos(pos / 10000^(2i/d_model))
Donde:
- pos es la posición (0, 1, 2, ...)
- i es el índice de dimensión
- d_model es la dimensión del modelo
Este diseño es muy ingenioso:
- Cada posición tiene una codificación única
- Las posiciones relativas pueden representarse mediante combinaciones lineales (sin(a+b) = sin a cos b + cos a sin b)
- Se puede extrapolar a secuencias más largas que las del entrenamiento
Ejemplo
# Implementar codificación posicional sinusoidal
# ============================================
import math
def sinusoidal_positional_encoding(seq_len: int, d_model: int) -> list:
"""
Calcular codificación posicional sinusoidal
Retorna: matriz de codificación posicional de [seq_len, d_model]
"""
pe = [[0.0 for _ in range(d_model)] for _ in range(seq_len)]
for pos in range(seq_len):
for i in range(0, d_model, 2):
# Usar sin para dimensiones pares
div_term = math.pow(10000, 2 * i / d_model)
pe[pos][i] = math.sin(pos / div_term)
# Usar cos para dimensiones impares (si todavía hay una siguiente dimensión)
if i + 1 < d_model:
pe[pos][i + 1] = math.cos(pos / div_term)
return pe
# Demostración: generar codificación posicional
seq_len = 10
d_model = 8
pe = sinusoidal_positional_encoding(seq_len, d_model)
print("=" * 50)
print("Ejemplo Demostración de codificación posicional sinusoidal")
print("=" * 50)
print(f"Longitud de secuencia: {seq_len}, Dimensión: {d_model}")
print()
for pos in range(seq_len):
print(f"Posición {pos:2d}: {[f'{v:.4f}' for v in pe[pos]]}")
# Veamos la relación de posiciones relativas
print("\n" + "=" * 50)
print("Verificar la relación lineal de posiciones relativas")
print("=" * 50)
print("Diferencia de codificación entre la posición 0 y la posición 1:")
diff = [pe[1][i] - pe[0][i] for i in range(d_model)]
print([f'{v:.4f}' for v in diff])
RoPE: codificación posicional rotatoria
La codificación posicional sinusoidal es clásica, pero luego aparecieron mejores soluciones. Los grandes modelos actuales (como LLaMA) usanRoPE(Rotary Positional Embedding)。
La idea de RoPE es:"Rotar" la información de posición dentro de Query y Key.
El método concreto es aplicar una rotación a cada par de dimensiones de Q y K:
[ q₁, q₂, q₃, q₄, ... ] ↓ 旋转 [ q₁·cos mθ - q₂·sin mθ, q₁·sin mθ + q₂·cos mθ, q₃·cos mθ' - q₄·sin mθ', q₃·sin mθ' + q₄·cos mθ', ... ]
Ventajas de RoPE:
- Contiene naturalmente información de posición relativa
- Buena extrapolación (funciona incluso cuando la longitud supera la del entrenamiento)
- El rendimiento suele ser mejor que el de la codificación posicional sinusoidal
ALiBi: atención con decaimiento por distancia
Otro esquema simple y efectivo esALiBi(Attention with Linear Biases)。
No añade codificación posicional, sino que añade directamente un sesgo a las puntuaciones de atención: cuanto más lejos, menor es la puntuación.
La ventaja de ALiBi es que es simple y especialmente amigable con la extrapolación de longitud.
| Métodos de codificación posicional | Principio | Ventajas | Modelos representativos |
|---|---|---|---|
| Codificación posicional sinusoidal | Función sin/cos | Extrapolable, cálculo simple | Transformer original, BERT |
| Codificación posicional aprendible | Entrenar directamente el embedding | Fuerte capacidad expresiva | GPT-1/2 |
| RoPE | Rotación de Q/K | Posición relativa, buen rendimiento | LLaMA, PaLM |
| ALiBi | Añadir sesgo a las puntuaciones de atención | Simple, bueno para secuencias largas | MPT, BLOOM |
Capa de red de avance (FFN)
En cada capa del Transformer, después de la atención hay también una red feed-forward (Feed-Forward Network).
Estructura de la FFN
La FFN del Transformer original es:
FFN(x) = max(0, x·W₁ + b₁) · W₂ + b₂
Es decir: capa lineal → activación ReLU → capa lineal.
Normalmente la dimensión intermedia es mayor que la dimensión de entrada (por ejemplo, d_model=512, dimensión intermedia=2048).
ReLU vs SwiGLU
Originalmente se usaba ReLU, pero ahora la corriente principal esSwiGLU:
SwiGLU(x) = (x·W₁ ⊗ sigmoid(x·V₁)) · W₂
Donde ⊗ es la multiplicación elemento a elemento.
SwiGLU se desempeña mejor que ReLU en muchas tareas; LLaMA, PaLM, etc. lo usan.
¿Por qué es importante la FFN?
- La atención es "agregar información"; la FFN es "procesar información".
- La FFN tiene una gran cantidad de parámetros; es el lugar principal donde el modelo "memoriza" conocimiento.
- Sin la FFN, la capacidad expresiva del Transformer se reduciría significativamente.
Ejemplo
# Implementación de la FFN: versión ReLU y versión SwiGLU
# ============================================
import math
import random
random.seed(42) # ejemplo
def relu(x: float) -> float:
"""Función de activación ReLU: max(0, x)"""
return max(0.0, x)
def sigmoid(x: float) -> float:
"""Función de activación Sigmoid"""
if x < -50: # Prevenir desbordamiento numérico
return 0.0
return 1.0 / (1.0 + math.exp(-x))
def linear(x: list, W: list, b: list) -> list:
"""Capa lineal: y = x·W + b"""
output_size = len(b)
output = [0.0 for _ in range(output_size)]
for i in range(output_size):
sum_wx = sum(x[j] * W[j][i] for j in range(len(x)))
output[i] = sum_wx + b[i]
return output
class FFNReLU:
"""FFN versión ReLU"""
def __init__(self, d_model: int, d_ff: int):
self.d_model = d_model
self.d_ff = d_ff
# Parámetros inicializados aleatoriamente (en la práctica se obtienen mediante entrenamiento)
self.W1 = [[random.normalvariate(0, 0.1) for _ in range(d_ff)]
for _ in range(d_model)]
self.b1 = [0.0 for _ in range(d_ff)]
self.W2 = [[random.normalvariate(0, 0.1) for _ in range(d_model)]
for _ in range(d_ff)]
self.b2 = [0.0 for _ in range(d_model)]
def forward(self, x: list) -> list:
"""Propagación hacia adelante"""
# Primera capa: lineal + ReLU
hidden = linear(x, self.W1, self.b1)
hidden = [relu(v) for v in hidden]
# Segunda capa: lineal
output = linear(hidden, self.W2, self.b2)
return output
class FFNSwiGLU:
"""FFN versión SwiGLU (una opción más moderna)"""
def __init__(self, d_model: int, d_ff: int):
self.d_model = d_model
self.d_ff = d_ff
# SwiGLU necesita dos capas lineales para la compuerta, más una capa de salida
self.W1 = [[random.normalvariate(0, 0.1) for _ in range(d_ff)]
for _ in range(d_model)]
self.V1 = [[random.normalvariate(0, 0.1) for _ in range(d_ff)]
for _ in range(d_model)]
self.W2 = [[random.normalvariate(0, 0.1) for _ in range(d_model)]
for _ in range(d_ff)]
def forward(self, x: list) -> list:
"""Propagación hacia adelante: SwiGLU"""
# Calcular las dos transformaciones lineales
x1 = linear(x, self.W1, [0.0 for _ in range(self.d_ff)])
x2 = linear(x, self.V1, [0.0 for _ in range(self.d_ff)])
# SwiGLU:x1 * sigmoid(x2)
hidden = [a * sigmoid(b) for a, b in zip(x1, x2)]
# Capa de salida
output = linear(hidden, self.W2, [0.0 for _ in range(self.d_model)])
return output
# Demostración de la FFN
d_model = 8
d_ff = 16
# Entrada de prueba
x = [0.1, -0.2, 0.3, -0.4, 0.5, -0.6, 0.7, -0.8]
print("=" * 50)
print("Demostración de FFN de Ejemplo")
print("=" * 50)
print(f"Entrada: {[f'{v:.4f}' for v in x]}")
ffn_relu = FFNReLU(d_model, d_ff)
output_relu = ffn_relu.forward(x)
print(f"\n"Salida de la FFN ReLU: {[f'{v:.4f}' for v in output_relu]}")
ffn_swiglu = FFNSwiGLU(d_model, d_ff)
output_swiglu = ffn_swiglu.forward(x)
print(f"Salida de la FFN SwiGLU: {[f'{v:.4f}' for v in output_swiglu]}")
Normalización de capa (Layer Norm)
En el Transformer hay otro componente importante: la normalización de capa (Layer Normalization).
Qué hace la normalización de capa
La normalización de capa normaliza las características de cada muestra a media 0 y varianza 1:
LN(x) = γ ⊗ ((x - μ) / √σ²) + β
Donde:
- μ es la media de x, σ² es la varianza de x
- γ (escala) y β (desplazamiento) son parámetros aprendibles
- ⊗ es la multiplicación elemento a elemento
Ejemplo
# Implementación de la normalización de capa (Layer Normalization)
# ============================================
import math
def layer_norm(x: list, gamma: list, beta: list, eps: float = 1e-6) -> list:
"""
Normalización de capa
x: vector de entrada
gamma: parámetro de escala
beta: parámetro de desplazamiento
eps: constante pequeña para evitar división por cero
"""
# Calcular la media
mean = sum(x) / len(x)
# Calcular la varianza
var = sum((v - mean) ** 2 for v in x) / len(x)
# Normalización + escala y desplazamiento
normalized = []
for xi, gi, bi in zip(x, gamma, beta):
normalized.append(gi * (xi - mean) / math.sqrt(var + eps) + bi)
return normalized
# Demostración de la normalización de capa
d_model = 6
# Vector de entrada
x = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]
# gamma inicializado a 1, beta inicializado a 0
gamma = [1.0 for _ in range(d_model)]
beta = [0.0 for _ in range(d_model)]
print("=" * 50)
print("Demostración de normalización de capa de Ejemplo")
print("=" * 50)
print(f"Entrada: {[f'{v:.4f}' for v in x]}")
output = layer_norm(x, gamma, beta)
print(f"Salida: {[f'{v:.4f}' for v in output]}")
# Verificar la media y la varianza
mean_out = sum(output) / len(output)
var_out = sum((v - mean_out) ** 2 for v in output) / len(output)
print(f"\n"Media de salida: {mean_out:.4f}")
print(f"Varianza de salida: {var_out:.4f}")
Pre-Norm vs Post-Norm
Hay dos formas de colocar la normalización de capa:
- Post-Norm: subcapa → residual → normalización (Transformer original)
- Pre-Norm: normalización → subcapa → residual (actualmente el estándar)
Pre-Norm es más estable durante el entrenamiento; casi todos los modelos grandes actuales lo usan.
RMS Norm
RMS NormEs una versión simplificada de la normalización de capa: solo calcula la raíz cuadrada media, sin restar la media:
RMS(x) = sqrt(mean(x²)) RMSNorm(x) = γ ⊗ (x / RMS(x))
RMS Norm es más rápida de calcular y tiene un rendimiento similar; modelos como LLaMA la usan.
Arquitectura Encoder-Decoder vs. solo Decoder
El Transformer original es de arquitectura Encoder-Decoder, pero la mayoría de los grandes modelos de lenguaje actuales son Decoder-Only.
Encoder-Decoder: para tareas como la traducción
Arquitectura Encoder-Decoder:
- Encoder: lee la secuencia de entrada (por ejemplo, la frase en el idioma fuente) y genera una representación contextual
- Decoder: genera la secuencia objetivo (por ejemplo, el resultado de la traducción) basándose en la salida del Encoder
El Decoder tiene dos tipos de atención:
- Atención propia enmascarada: solo puede ver las palabras anteriores (no puede ver el futuro)
- Atención cruzada: para atender a la salida del Encoder
Tareas adecuadas: traducción automática, reconocimiento de voz, generación de resúmenes (necesitan "reescribir" la entrada como salida).
Solo Decoder: la serie GPT, para generación
La arquitectura Decoder-Only solo tiene Decoder, sin Encoder. Sus características:
- Solo atención propia enmascarada (atención causal)
- Generación autorregresiva: genera una palabra a la vez
- Adecuada para el paradigma de preentrenamiento + ajuste fino
Los GPT, LLaMA, Claude, Gemini... actuales son casi todos Decoder-Only.
Solo Encoder: la serie BERT, para comprensión
La arquitectura Encoder-Only solo tiene Encoder. Sus características:
- Atención bidireccional: cada palabra puede ver todas las palabras
- Adecuada para tareas de comprensión: clasificación, reconocimiento de entidades nombradas, preguntas y respuestas extractivas
- Se preentrena con "modelo de lenguaje enmascarado" (MLM)
| Arquitectura | Componentes | Tipo de atención | Modelos representativos | Tareas aplicables |
|---|---|---|---|---|
| Encoder-Decoder | Encoder + Decoder | Bidireccional + enmascarada + cruzada | T5, BART | Traducción, resúmenes |
| Decoder-Only | Decoder | Enmascarada (causal) | GPT, LLaMA, Claude | Generación de texto |
| Encoder-Only | Encoder | Bidireccional | BERT, RoBERTa | Clasificación, NER |
¿Por qué Decoder-Only se ha convertido en la corriente principal? Porque es simple y, con preentrenamiento a gran escala, su capacidad de generación es extremadamente fuerte. El enfoque "talla única" funciona mejor que el "diseño específico".
Flash Attention: hace que el cálculo de atención sea rápido y con menos memoria
La atención propia estándar tiene complejidad temporal y de memoria O(n²); cuando la secuencia es larga (por ejemplo, n=8192), es lenta y la memoria de la GPU no es suficiente.
Flash AttentionEs una optimización clave; su aparición en 2022 hizo factible el entrenamiento de secuencias largas.
El cuello de botella de memoria de la atención estándar
La autoatención estándar necesita guardar los resultados intermedios (la matriz de pesos de atención) para la retropropagación. Para n=8192, esto es 8192×8192 ≈ 67 millones de elementos, solo esto ocupa decenas de GB de memoria de GPU.
Principio de optimización de Flash Attention
La idea central de Flash Attention:
- Cálculo por bloques: dividir Q/K/V en bloques pequeños y calcular bloque por bloque
- Softmax en línea: no guardar la matriz de atención completa, usar trucos matemáticos para recalcular durante la retropropagación
- Usar SRAM: colocar los bloques en la caché de alta velocidad de la GPU (SRAM), reduciendo el acceso a HBM
El resultado es:
- La complejidad de memoria se reduce de O(n²) a O(n)
- Velocidad 2-4 veces más rápida
- Precisión exactamente igual (matemáticamente equivalente)
Ahora el entrenamiento de modelos grandes casi siempre usa Flash Attention o sus variantes (Flash Attention v2, Memory-Efficient Attention, etc.).
KV Cache: principio de aceleración de inferencia
Después de optimizar el entrenamiento, la inferencia (generación) también necesita optimización.KV CacheEs la clave para acelerar la inferencia.
Problema: cálculo repetido en la generación autorregresiva
Durante la generación autorregresiva, cada vez que se genera un token nuevo, hay que recalcular las K y V de todos los tokens anteriores; esto es un desperdicio.
Por ejemplo, al generar el token número 100, las K/V de los primeros 99 ya se calcularon antes, no es necesario recalcularlas.
Cómo se hace KV Cache
La idea de KV Cache es simple:
- Almacenar en caché las K y V de todas las posiciones anteriores
- Al generar un token nuevo, solo calcular las K/V del token nuevo y añadirlas a la caché
- Usar las K/V completas para la atención
Así, el cálculo por paso se reduce de O(n²) a O(n), y la velocidad de generación mejora enormemente.
Ejemplo
# Demostrar el principio de KV Cache
# ============================================
import math
import random
random.seed(42)
def softmax(x):
max_val = max(x)
exp_x = [math.exp(v - max_val) for v in x]
sum_exp = sum(exp_x)
return [v / sum_exp for v in exp_x]
def matmul(A, B):
m = len(A)
n = len(B)
p = len(B[0])
result = [[0.0 for _ in range(p)] for _ in range(m)]
for i in range(m):
for j in range(p):
for k in range(n):
result[i][j] += A[i][k] * B[k][j]
return result
def transpose(matrix):
return list(map(list, zip(*matrix)))
class SimpleAttentionWithKVCache:
"""Autoatención simple con KV Cache"""
def __init__(self, d_model: int):
self.d_model = d_model
self.Wq = [[random.normalvariate(0, 0.1) for _ in range(d_model)]
for _ in range(d_model)]
self.Wk = [[random.normalvariate(0, 0.1) for _ in range(d_model)]
for _ in range(d_model)]
self.Wv = [[random.normalvariate(0, 0.1) for _ in range(d_model)]
for _ in range(d_model)]
def forward(self, x_new: list, kv_cache: tuple = None) -> tuple:
"""
Generación de un solo paso, con KV Cache
x_new: entrada nueva actual (una sola posición)
kv_cache: K y V anteriores, (K_cache, V_cache)
Retorna: (salida, nueva kv_cache)
"""
# Calcular Q, K, V de la posición actual
q = matmul([x_new], self.Wq)
k = matmul([x_new], self.Wk)
v = matmul([x_new], self.Wv)
# Actualizar KV Cache
if kv_cache is None:
K_full = k
V_full = v
else:
K_cache, V_cache = kv_cache
K_full = K_cache + k
V_full = V_cache + v
# Atención: solo usar la Q de la posición actual
scores = matmul(q, transpose(K_full))
scale = math.sqrt(self.d_model)
scores_scaled = [[v / scale for v in row] for row in scores]
weights = [softmax(row) for row in scores_scaled]
output = matmul(weights, V_full)
return output[0], (K_full, V_full)
# Demostrar el proceso de generación con KV Cache
d_model = 4
# Simular generación de 4 pasos
inputs = [
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
]
attn = SimpleAttentionWithKVCache(d_model)
print("=" * 50)
print("Ejemplo KV Cache demo")
print("=" * 50)
kv_cache = None
for step, x in enumerate(inputs):
output, kv_cache = attn.forward(x, kv_cache)
K_cache, V_cache = kv_cache
print(f"\n"Paso {step}:")
print(f" Tamaño de KV Cache: {len(K_cache)} posiciones")
print(f" Salida: {[f'{v:.4f}' for v in output]}")
print("\n" + "=" * 50)
print("Nota: en cada paso solo se calculan las K/V de la posición actual,")
print("y luego se concatenan en la caché, ¡no hay que recalcular las anteriores!")
print("=" * 50)
KV Cache tiene un pequeño costo: necesita memoria de GPU adicional para almacenar K y V. Para un modelo de 7B con contexto de 4k, KV Cache ocupa aproximadamente 1-2GB de memoria de GPU; vale la pena.
También hay optimizaciones más avanzadas:
- Multi-Query Attention: múltiples cabezas comparten K/V
- Grouped Query Attention: compartición agrupada de K/V
Estas pueden reducir aún más el uso de memoria y mejorar la velocidad de inferencia.
Resumen: flujo completo de Transformer
Unamos todos los componentes y resumamos el flujo completo de Transformer.
Una capa de Transformer de solo Decoder
x → LayerNorm → Masked Multi-Head Attention → 残差连接 →
→ LayerNorm → FFN → 残差连接 → 输出
Modelo completo de solo Decoder
- IDs de token de entrada → embeddings de palabras
- Añadir codificación posicional
- Pasar por N capas de Transformer
- LayerNorm final
- Capa lineal que mapea al tamaño del vocabulario
- Softmax para obtener la probabilidad del siguiente token
Hiperparámetros clave
| Hiperparámetro | Significado | GPT-2 (124M) | LLaMA-7B | GPT-3 (175B) |
|---|---|---|---|---|
| d_model | Dimensión del modelo | 768 | 4096 | 12288 |
| num_heads | Número de cabezas de atención | 12 | 32 | 96 |
| num_layers | Número de capas | 12 | 32 | 96 |
| d_ff | Dimensión intermedia de FFN | 3072 | 11008 | 49152 |
| context_len | Longitud de contexto | 1024 | 2048/4096 | 2048 |
Guía de implementación del código
Si quieres implementar un Transformer simplificado desde cero, los pasos son:
- Implementar la normalización de capas
- Implementar la autoatención (con máscara)
- Implementar la atención multicabezal
- Implementar FFN
- Implementar la capa Transformer
- Implementar la codificación posicional
- Ensamblar el modelo completo
- Implementar el bucle de entrenamiento
En la práctica, usamos frameworks como PyTorch en lugar de Python puro. La implementación en Python puro aquí es para demostrar los principios y que entiendas qué hace cada paso.
Otras extensionesLa mejor manera de entender Transformer es implementarlo una vez. Aunque sea una versión simplificada, te dará una nueva perspectiva sobre muchos detalles.