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ísticaRNN/LSTMTransformer
Forma de cálculoSerial, paso a pasoEn paralelo, de una sola vez
Dependencias de largo alcanceDébil, desvanecimiento del gradienteFuerte, conexión directa a cualquier distancia
Información posicionalOrden naturalRequiere codificación posicional
Velocidad de entrenamientoLentoRá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:

  1. Cada palabra usa Q para "emparejarse" con la K de todas las palabras, obteniendo puntuaciones de atención
  2. Usar Softmax para normalizar las puntuaciones, sumando 1
  3. Usar las puntuaciones normalizadas para hacer la suma ponderada de las V de todas las palabras

Se ve más claro en la figura:

自注意力 QKV 计算流程图

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

  1. Dividir Q/K/V en h partes según la última dimensión (h es el número de cabezas)
  2. Cada cabeza realiza autoatención de forma independiente
  3. Concatenar las salidas de todas las cabezas
  4. 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 posicionalPrincipioVentajasModelos representativos
Codificación posicional sinusoidalFunción sin/cosExtrapolable, cálculo simpleTransformer original, BERT
Codificación posicional aprendibleEntrenar directamente el embeddingFuerte capacidad expresivaGPT-1/2
RoPERotación de Q/KPosición relativa, buen rendimientoLLaMA, PaLM
ALiBiAñadir sesgo a las puntuaciones de atenciónSimple, bueno para secuencias largasMPT, 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:

  1. Atención propia enmascarada: solo puede ver las palabras anteriores (no puede ver el futuro)
  2. 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)
ArquitecturaComponentesTipo de atenciónModelos representativosTareas aplicables
Encoder-DecoderEncoder + DecoderBidireccional + enmascarada + cruzadaT5, BARTTraducción, resúmenes
Decoder-OnlyDecoderEnmascarada (causal)GPT, LLaMA, ClaudeGeneración de texto
Encoder-OnlyEncoderBidireccionalBERT, RoBERTaClasificació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:

  1. Cálculo por bloques: dividir Q/K/V en bloques pequeños y calcular bloque por bloque
  2. Softmax en línea: no guardar la matriz de atención completa, usar trucos matemáticos para recalcular durante la retropropagación
  3. 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

  1. IDs de token de entrada → embeddings de palabras
  2. Añadir codificación posicional
  3. Pasar por N capas de Transformer
  4. LayerNorm final
  5. Capa lineal que mapea al tamaño del vocabulario
  6. Softmax para obtener la probabilidad del siguiente token

Hiperparámetros clave

HiperparámetroSignificadoGPT-2 (124M)LLaMA-7BGPT-3 (175B)
d_modelDimensión del modelo768409612288
num_headsNúmero de cabezas de atención123296
num_layersNúmero de capas123296
d_ffDimensión intermedia de FFN30721100849152
context_lenLongitud de contexto10242048/40962048

Guía de implementación del código

Si quieres implementar un Transformer simplificado desde cero, los pasos son:

  1. Implementar la normalización de capas
  2. Implementar la autoatención (con máscara)
  3. Implementar la atención multicabezal
  4. Implementar FFN
  5. Implementar la capa Transformer
  6. Implementar la codificación posicional
  7. Ensamblar el modelo completo
  8. 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.

La mejor manera de entender Transformer es implementarlo una vez. Aunque sea una versión simplificada, te dará una nueva perspectiva sobre muchos detalles.

Otras extensiones