¿Cómo ensamblamos los bloques?
Ahora tenemos todas las piezas que necesitamos: self-attention para que los tokens se comuniquen entre sí, atención multi-head para capturar diferentes tipos de relaciones en paralelo, positional encoding para inyectar orden, conexiones residuales para mantener el flujo de gradientes, layer normalization para estabilizar el entrenamiento, y una red feed-forward para añadir no linealidad por token. La pregunta es cómo estas piezas encajan en una sola arquitectura coherente, y la respuesta resulta ser sorprendentemente uniforme.
Una capa del encoder encadena los bloques en un orden fijo: self-attention multi-head, luego add-and-norm (una conexión residual seguida de layer norm), luego la red feed-forward, luego otro add-and-norm. Apilamos $N$ de estas capas idénticas una sobre otra (el transformer original en Vaswani et al. (2017) usó $N = 6$; BERT-base usa $N = 12$), y la salida de la capa final es una secuencia de vectores contextuales, uno por cada token de entrada, donde cada vector ha sido refinado a través de $N$ rondas de self-attention y transformación no lineal.
Un detalle distingue al encoder del decoder que construiremos en el próximo artículo: el encoder usa self-attention bidireccional , lo que significa que cada token puede atender a todos los demás tokens en la secuencia. No hay máscara causal. Cuando calculamos la atención para la posición $i$, los pesos de atención abarcan todas las posiciones $1, 2, \ldots, n$, no solo $1, \ldots, i$. Esta bidireccionalidad es precisamente lo que hace a los encoders poderosos para tareas de comprensión, porque la representación de un token puede incorporar información tanto de su contexto izquierdo como derecho simultáneamente.
Podemos ver la capa completa del encoder en un módulo compacto de PyTorch. El siguiente código apila $N$ capas del encoder y pasa una entrada de prueba a través de ellas.
import torch
import torch.nn as nn
class EncoderLayer(nn.Module):
def __init__(self, d_model=768, n_heads=12, d_ff=3072, dropout=0.1):
super().__init__()
self.self_attn = nn.MultiheadAttention(d_model, n_heads, batch_first=True)
self.ff = nn.Sequential(
nn.Linear(d_model, d_ff),
nn.GELU(),
nn.Linear(d_ff, d_model),
)
self.norm1 = nn.LayerNorm(d_model)
self.norm2 = nn.LayerNorm(d_model)
self.drop = nn.Dropout(dropout)
def forward(self, x):
# Multi-head self-attention + add & norm
attn_out, _ = self.self_attn(x, x, x) # Q, K, V all come from x
x = self.norm1(x + self.drop(attn_out)) # residual + layer norm
# Feed-forward + add & norm
ff_out = self.ff(x)
x = self.norm2(x + self.drop(ff_out)) # residual + layer norm
return x
class TransformerEncoder(nn.Module):
def __init__(self, n_layers=12, d_model=768, n_heads=12, d_ff=3072):
super().__init__()
self.layers = nn.ModuleList([
EncoderLayer(d_model, n_heads, d_ff) for _ in range(n_layers)
])
def forward(self, x):
for layer in self.layers:
x = layer(x)
return x
# Quick shape check: batch=2, seq_len=16, d_model=768
encoder = TransformerEncoder(n_layers=6)
dummy = torch.randn(2, 16, 768)
out = encoder(dummy)
print(f"Input shape: {dummy.shape}") # [2, 16, 768]
print(f"Output shape: {out.shape}") # [2, 16, 768] — same shape, richer representations
Observa que las formas de entrada y salida son idénticas. El encoder no cambia la longitud de la secuencia ni la dimensión; refina la representación de cada token en su lugar. El vector de cada token en la salida contiene información sobre toda la secuencia de entrada, porque cada capa de atención bidireccional permite que cada posición mezcle información con todas las demás posiciones.
¿Por qué enmascarar tokens en lugar de predecir el siguiente?
Apilar capas del encoder nos da una arquitectura, pero necesitamos un objetivo de entrenamiento que fuerce al modelo a aprender representaciones útiles. El modelo encoder más influyente, BERT (Devlin et al., 2018) , introdujo el Masked Language Modelling (MLM) : enmascarar aleatoriamente el 15% de los tokens de entrada y entrenar al modelo para predecirlos a partir del contexto circundante. Esto es fundamentalmente diferente de la predicción del siguiente token de izquierda a derecha usada en los decoders, y la diferencia importa.
Considera la oración "El gato se sentó en el [MASK]". Para predecir la palabra enmascarada, el modelo necesita considerar "El gato se sentó en el" desde la izquierda, pero también cualquier cosa que pueda seguir desde la derecha, como "... y ronroneó" o "... mientras llovía". Si el modelo solo pudiera mirar a la izquierda (como en un decoder causal), nunca vería el contexto del lado derecho que a menudo desambigua la respuesta. El MLM fuerza la comprensión bidireccional porque el token enmascarado puede aparecer en cualquier lugar de la secuencia, y el contexto de ambos lados importa para predecirlo con precisión.
La tasa de enmascaramiento del 15% es en sí misma una decisión de diseño. Devlin et al. encontraron que enmascarar muy pocos tokens hace el entrenamiento ineficiente (el modelo apenas tiene que trabajar en cada ejemplo), mientras que enmascarar demasiados tokens elimina tanto contexto que la predicción precisa se vuelve casi imposible. Dentro de ese 15%, BERT aplica una división adicional: el 80% de los tokens seleccionados se reemplazan con [MASK], el 10% se reemplaza con un token aleatorio, y el 10% se deja sin cambios. El reemplazo aleatorio y los subconjuntos sin cambios existen para evitar que el modelo aprenda a ignorar los tokens que no son [MASK] durante el fine-tuning (donde nunca aparecen tokens [MASK]), aunque trabajo posterior como RoBERTa (Liu et al., 2019) encontró que la división exacta importa menos que otras decisiones de entrenamiento como el tamaño de batch y el volumen de datos.
BERT fue originalmente entrenado con un segundo objetivo llamado Next Sentence Prediction (NSP) : dados dos segmentos A y B, predecir si B realmente sigue a A en el documento original o fue muestreado aleatoriamente. La idea era que NSP enseñaría al modelo sobre la coherencia entre oraciones, lo cual es útil para tareas como respuesta a preguntas donde la relación entre dos pasajes importa. Sin embargo, en la práctica, RoBERTa mostró que eliminar NSP mantenía o mejoraba el rendimiento en tareas posteriores, probablemente porque la señal que NSP proporciona es demasiado fácil (los pares de oraciones aleatorias son trivialmente distinguibles) y la tarea introduce ruido que interfiere con el aprendizaje del MLM. La mayoría de los modelos encoder modernos se entrenan solo con MLM.
¿Cómo usamos un encoder para tareas posteriores?
Una vez que el pre-entrenamiento termina, tenemos un modelo que produce representaciones contextuales ricas para cualquier texto de entrada, pero esas representaciones no son directamente respuestas a preguntas o etiquetas para clasificación. Necesitamos adaptarlas a tareas específicas, y el diseño de BERT incluye un mecanismo para esto: el token [CLS] .
Cada entrada de BERT comienza con un token especial [CLS] antepuesto a la secuencia. Después de pasar por todas las $N$ capas del encoder, el vector de salida en la posición [CLS] ha atendido a cada token en la secuencia a través de todas las capas, lo que lo convierte en un candidato natural para una representación de tamaño fijo a nivel de oración. Para tareas de clasificación (análisis de sentimiento, inferencia de lenguaje natural, detección de spam), tomamos el vector [CLS] y lo pasamos por una pequeña capa lineal que lo mapea al número de clases, luego entrenamos con pérdida de entropía cruzada. Esta es la receta estándar de fine-tuning: mantener todos los pesos pre-entrenados del encoder, añadir una cabeza específica para la tarea encima, y entrenar el modelo completo de extremo a extremo con datos etiquetados para la tarea objetivo.
Para tareas a nivel de token como el Reconocimiento de Entidades Nombradas (NER), usamos el vector de salida en cada posición de token en lugar de solo [CLS]. El vector contextual de cada token se alimenta a un clasificador por token que predice etiquetas como PERSON, ORGANIZATION, LOCATION u O (fuera de cualquier entidad). Debido a que cada token ha atendido a todos los demás tokens a través de la atención bidireccional, el modelo puede usar el contexto completo de la oración para decidir si "Washington" se refiere a una persona, una ciudad o un estado.
El código de fine-tuning para clasificación es compacto. Añadimos una sola capa lineal encima del encoder pre-entrenado y entrenamos con datos específicos de la tarea.
import torch.nn as nn
class BertClassifier(nn.Module):
def __init__(self, encoder, d_model=768, n_classes=2):
super().__init__()
self.encoder = encoder # pre-trained transformer encoder
self.classifier = nn.Linear(d_model, n_classes)
def forward(self, x):
hidden = self.encoder(x) # (batch, seq_len, d_model)
cls_vector = hidden[:, 0, :] # take [CLS] position (index 0)
return self.classifier(cls_vector) # (batch, n_classes)
# For NER, we'd classify every token position instead:
class BertNER(nn.Module):
def __init__(self, encoder, d_model=768, n_labels=5):
super().__init__()
self.encoder = encoder
self.token_classifier = nn.Linear(d_model, n_labels)
def forward(self, x):
hidden = self.encoder(x) # (batch, seq_len, d_model)
return self.token_classifier(hidden) # (batch, seq_len, n_labels)
Este patrón (pre-entrenar en un corpus grande no etiquetado, luego hacer fine-tuning en un conjunto de datos etiquetado pequeño) se convirtió en el paradigma dominante en NLP desde 2018 hasta aproximadamente 2022, porque las representaciones pre-entrenadas del encoder capturan tanto conocimiento lingüístico que incluso unos pocos cientos de ejemplos etiquetados suelen ser suficientes para un rendimiento sólido en la tarea.
¿Cuándo deberíamos elegir un encoder?
Los modelos encoder tienden a destacar en tareas donde necesitamos comprender texto de entrada en lugar de generar texto nuevo. Clasificación, NER, similitud textual semántica, respuesta a preguntas extractiva (resaltar un fragmento en un pasaje), y embedding de oraciones para recuperación son todas tareas donde el contexto bidireccional ayuda porque el modelo puede observar toda la entrada antes de tomar una decisión. Los modelos basados en encoder como BERT, RoBERTa y sus descendientes aún tienden a superar a los modelos solo-decoder de tamaño comparable en estas tareas, porque la atención bidireccional permite que cada token recopile señales de ambas direcciones en cada capa, mientras que un decoder causal solo puede condicionarse en el contexto izquierdo.
La contrapartida es que los encoders no están naturalmente adaptados para la generación. Un encoder procesa toda la entrada en una sola pasada y produce una representación de longitud fija, pero no tiene un mecanismo para producir tokens uno a la vez de forma izquierda a derecha. Generar texto requiere la estructura autoregresiva que veremos en el decoder (artículo 7), donde cada nuevo token se condiciona en todos los tokens generados previamente.
En la práctica, esta división se ha vuelto menos rígida. Los modelos grandes solo-decoder como GPT-4 pueden clasificar texto, extraer entidades y calcular puntuaciones de similitud mediante prompting, a menudo igualando o superando a los modelos de clase BERT en benchmarks. Pero para sistemas de producción sensibles a la latencia donde un BERT de 110M parámetros puede ejecutarse en unos pocos milisegundos en una CPU, los modelos encoder siguen siendo una opción práctica y eficiente. La pregunta generalmente no es qué arquitectura es más capaz en abstracto, sino cuál ofrece el mejor equilibrio entre precisión, latencia y costo para un despliegue específico.
Quiz
Pon a prueba tu comprensión del encoder del transformer y BERT.
¿Por qué BERT usa self-attention bidireccional en lugar de self-attention causal (enmascarada)?
En el objetivo de Masked Language Modelling de BERT, ¿qué ocurre con el 15% de tokens seleccionados?
¿Qué papel juega el token [CLS] en BERT?
¿Por qué RoBERTa eliminó el objetivo de Next Sentence Prediction (NSP)?