Hugging Face · LLM Course · Capítulo 7
Tareas clásicas de NLP: seis problemas, un mismo esqueleto
El capítulo más largo del curso original, a propósito — cada tarea es un tutorial completo e independiente, con bastante código repetido entre sí. Acá va lo que cada una enseña de verdad, sin repetir el mismo boilerplate seis veces.
Antes de leercap. 3, fine-tuning · cap. 5, Datasets · cap. 6, offset mapping y subtokens
🎯 Al terminar vas a poder
- Elegir, para cada una de las seis tareas, el head (
AutoModelFor…), el data collator y la métrica correctos — y explicar por qué esa combinación y no otra. - Escribir la función que realinea etiquetas a subtokens para NER, con
-100en los tokens que no cuentan yB-→I-en los subtokens de continuación. - Calcular perplejidad correctamente a partir de la loss, y saber por qué "promedio de exponenciales" da otro número.
- Explicar cómo QA extractivo elige el mejor span a partir de start/end logits, y por qué hace falta
stridecon contextos largos. - Diagnosticar los cinco errores silenciosos más comunes de este capítulo (etiquetas desalineadas, padding sin
-100, BLEU mal tokenizado, etc.) a partir de sus síntomas.
0. Seis tareas, un patrón común
Clasificación de tokens, fine-tuning de un masked LM, traducción, resumen, entrenar un LM causal desde
cero, y question answering extractivo. Las seis siguen el mismo esqueleto de siempre — datos →
tokenizer → modelo con el head correcto (Capítulo 2) → Trainer o loop manual (Capítulo 3)
→ métricas → push_to_hub() (Capítulo 4) — y lo único que cambia de una a otra es qué head,
qué arquitectura, y qué métrica de evaluación tiene sentido.
Como el esqueleto es el mismo, lo que conviene memorizar es la tabla de "qué cambia". Cada fila es una sección de este capítulo; cada columna es una decisión que tenés que tomar al arrancar cualquier tarea nueva:
| Tarea | Head (clase Auto…) | Data collator | Métrica | Arquitectura natural |
|---|---|---|---|---|
| NER | AutoModelForTokenClassification | DataCollatorForTokenClassification | seqeval (precision/recall/F1 por entidad) | Encoder |
| Masked LM | AutoModelForMaskedLM | DataCollatorForLanguageModeling(mlm_probability=0.15) | Perplejidad | Encoder |
| Traducción / resumen | AutoModelForSeq2SeqLM | DataCollatorForSeq2Seq | BLEU / ROUGE | Encoder-decoder |
| LM causal | GPT2LMHeadModel(config) (desde cero) o AutoModelForCausalLM | DataCollatorForLanguageModeling(mlm=False) | Loss / perplejidad | Decoder |
| QA extractivo | AutoModelForQuestionAnswering | El default (DataCollatorWithPadding) | Exact Match / F1 (SQuAD) | Encoder |
Fijate que el collator nunca es un detalle menor: es la pieza que decide qué tokens entran a la loss y cuáles no. Tres de los cinco errores silenciosos de la sección 7 viven ahí.
1. Clasificación de tokens (NER)
Named entity recognition asigna una etiqueta a cada token, no a la frase entera — el dataset
de referencia es CoNLL-2003, con etiquetas en formato BIO (B-PER/I-PER para
persona, B-ORG/I-ORG para organización, etc., ya visto en el Capítulo 1).
La lista completa de etiquetas del dataset, en este orden exacto, importa para el código de abajo:
label_names = ["O", "B-PER", "I-PER", "B-ORG", "I-ORG", "B-LOC", "I-LOC", "B-MISC", "I-MISC"]
# 0 1 2 3 4 5 6 7 8
# Toda etiqueta B-XXX tiene índice impar, y su I-XXX es el índice siguiente (par).
El detalle que realmente importa transferir de esta sección: un tokenizer de subpalabras puede partir
una sola palabra etiquetada en varios tokens. Hay que realinear las etiquetas usando
los word_ids() del Capítulo 6, con tres reglas:
- Los tokens especiales (
[CLS],[SEP]) tienenword_id = Noney reciben-100, el valor que la cross-entropy de PyTorch ignora (ignore_index). - El primer subtoken de cada palabra se queda con la etiqueta real de la palabra.
- Los subtokens de continuación heredan la etiqueta de la palabra, pero si era
B-XXXse convierte enI-XXX— porque una entidad no "empieza" dos veces. Con la lista de arriba eso es literalmente sumar 1 a una etiqueta impar.
def align_labels_with_tokens(labels, word_ids):
new_labels = []
current_word = None
for word_id in word_ids:
if word_id != current_word:
# Empieza una palabra nueva (o un token especial)
current_word = word_id
label = -100 if word_id is None else labels[word_id]
new_labels.append(label)
elif word_id is None:
# Token especial
new_labels.append(-100)
else:
# Mismo word_id que el token anterior: subtoken de continuación
label = labels[word_id]
if label % 2 == 1: # B-XXX (impar) → I-XXX (el par siguiente)
label += 1
new_labels.append(label)
return new_labels
Un ejemplo con números, para ver las tres reglas actuando a la vez. Supongamos que "Lamb" se parte en La + ##mb:
words = ["EU", "rejects", "German", "call", "to", "boycott", "British", "lamb"]
labels = [3, 0, 7, 0, 0, 0, 7, 0] # B-ORG, O, B-MISC, ...
word_ids = [None, 0, 1, 2, 3, 4, 5, 6, 7, 7, None] # [CLS] EU rejects German call to boycott British la ##mb [SEP]
align_labels_with_tokens(labels, word_ids)
[-100, 3, 0, 7, 0, 0, 0, 7, 0, 0, -100]
Si en vez de "lamb" (etiqueta O) la palabra partida fuera "Zwingmann" con etiqueta 1
(B-PER), el segundo subtoken saldría 2 (I-PER), no 1. Esa
es la diferencia con la versión ingenua que copia la misma etiqueta a todos los subtokens: con la ingenua,
el modelo aprende que una entidad puede tener dos B- seguidos, y seqeval después cuenta
entidades de más.
📐 Formulación tensorial de la loss con ignore_index=-100
Dado el tensor de estados ocultos del encoder , la capa lineal de clasificación por token proyecta cada posición a los logits de las clases:
La función de pérdida por secuencia es la Cross-Entropy calculada exclusivamente sobre los tokens con etiqueta válida ():
Mecánica del gradiente: Cuando PyTorch procesa , anula estrictamente el gradiente: para toda clase . Si los tokens de padding recibieran una clase dummy (ej. clase 0), la retropropagación intentaría forzar al modelo a predecir esa clase sobre el padding, diluyendo el gradiente de las entidades reales y desestabilizando los pesos. Además, normalizar por la cantidad de tokens activos (y no por ) evita que frases cortas con mucho padding tengan un paso de gradiente artificialmente atenuado.
-100, el primer subtoken de cada palabra se queda con la etiqueta real, y las
continuaciones heredan la etiqueta convirtiendo B- en I-, porque una
entidad no empieza dos veces.
La función se aplica sobre el dataset entero con map, porque word_ids(i) se pide por ejemplo dentro del batch:
def tokenize_and_align_labels(examples):
tokenized_inputs = tokenizer(examples["tokens"], truncation=True, is_split_into_words=True)
new_labels = []
for i, labels in enumerate(examples["ner_tags"]):
word_ids = tokenized_inputs.word_ids(i)
new_labels.append(align_labels_with_tokens(labels, word_ids))
tokenized_inputs["labels"] = new_labels
return tokenized_inputs
tokenized_datasets = raw_datasets.map(tokenize_and_align_labels, batched=True,
remove_columns=raw_datasets["train"].column_names)
Después, las tres piezas que distinguen a NER del fine-tuning de clasificación del Capítulo 3: el head
por token, el collator que rellena también las etiquetas con -100 (el
DataCollatorWithPadding normal no sabe que labels es una secuencia), y la
métrica seqeval, que evalúa por entidad completa y no por token:
from transformers import AutoModelForTokenClassification, DataCollatorForTokenClassification
import evaluate
id2label = {i: l for i, l in enumerate(label_names)}
label2id = {l: i for i, l in enumerate(label_names)}
model = AutoModelForTokenClassification.from_pretrained("bert-base-cased", id2label=id2label, label2id=label2id)
data_collator = DataCollatorForTokenClassification(tokenizer=tokenizer)
metric = evaluate.load("seqeval")
def compute_metrics(eval_preds):
logits, labels = eval_preds
predictions = logits.argmax(-1)
# seqeval quiere strings, y hay que saltear los -100
true_labels = [[label_names[l] for l in label if l != -100] for label in labels]
true_preds = [[label_names[p] for p, l in zip(pred, label) if l != -100]
for pred, label in zip(predictions, labels)]
r = metric.compute(predictions=true_preds, references=true_labels)
return {k: r[f"overall_{k}"] for k in ["precision", "recall", "f1", "accuracy"]}
Por qué seqeval y no accuracy por token: en CoNLL la mayoría de los tokens son O, así que un
modelo que predice O siempre tiene accuracy alta y no encontró ninguna entidad. seqeval
cuenta una entidad como acierto solo si el span completo (B- más sus I-)
coincide con la referencia — por eso el bug del B- duplicado le pega directo al F1.
Encoder-only (BERT) es la arquitectura natural acá: necesitás ver la frase completa para decidir la etiqueta de cada token, exactamente lo que la atención bidireccional del Capítulo 1 hace bien.
2. Fine-tuning de un masked LM
Esto es domain adaptation: seguir entrenando con el mismo objetivo de preentrenamiento
(predecir palabras enmascaradas) pero sobre texto de tu dominio — reseñas de películas, papers médicos,
lo que sea distinto al corpus genérico original. La métrica no es accuracy, es
perplejidad (exp(loss)) — cuánto le "sorprende" al modelo el texto real;
más baja es mejor.
El código que distingue a esta tarea está casi todo en el collator: no hay columna labels
en el dataset, porque las etiquetas se fabrican al vuelo enmascarando el 15 % de los tokens de
cada batch. Cada época ve máscaras distintas, y eso es deseable:
from transformers import AutoModelForMaskedLM, DataCollatorForLanguageModeling
import math
model = AutoModelForMaskedLM.from_pretrained("distilbert-base-uncased")
data_collator = DataCollatorForLanguageModeling(tokenizer=tokenizer, mlm_probability=0.15)
# El collator pone -100 en todo lo que NO fue enmascarado: la loss solo mira las posiciones [MASK]
batch = data_collator([lm_datasets["train"][i] for i in range(2)])
print((batch["labels"] != -100).float().mean()) # ≈ 0.15
trainer = Trainer(model=model, args=args, data_collator=data_collator,
train_dataset=lm_datasets["train"], eval_dataset=lm_datasets["test"])
eval_results = trainer.evaluate()
print(f"Perplejidad: {math.exp(eval_results['eval_loss']):.2f}")
📐 Distribución 80/10/10 y objetivo matemático de Masked LM
El enmascaramiento selecciona al azar un conjunto de posiciones tal que . Sobre estas posiciones seleccionadas se aplica la siguiente regla estocástica:
- 80 % de las veces: se sustituye el token original por el token especial
[MASK]. - 10 % de las veces: se sustituye por un token aleatorio muestreado uniformemente del vocabulario .
- 10 % de las veces: se mantiene el token original intacto.
¿Por qué esta partición 80/10/10 y no 100 % [MASK]? Si se reemplazara siempre por [MASK], ocurriría un severo desbalance de distribución (distributional shift): durante inferencia o downstream fine-tuning, el token [MASK] jamás aparece en el texto. El encoder aprendería a construir representaciones ricas únicamente cuando detecta [MASK] en su entrada. El 10 % aleatorio entrena a la red para sospechar del contexto y detectar inconsistencias léxicas; el 10 % sin cambio fuerza al modelo a preservar representaciones fieles del token real en presencia de ruido contextual.
La función de pérdida se evalúa estrictamente sobre :
Dos detalles del pipeline de datos que el curso resuelve y que reaparecen en el Capítulo 11 con otros
nombres: group_texts concatena todos los textos tokenizados y los corta en bloques de
chunk_size tokens (128 en el ejemplo), sin padding — es el antecesor directo del
packing de SFTTrainer. Y la perplejidad se reporta como
exp(loss_media), donde loss_media es la cross-entropy promedio por token
enmascarado sobre todo el set de evaluación. El orden importa: exp de la media, no la media
de los exp por batch (ver sección 7).
Un truco que vale la pena conocer: whole word masking enmascara todos los subtokens de una
palabra a la vez, no tokens sueltos al azar — si no, el modelo puede "adivinar" el resto de una palabra
parcialmente visible sin aprender nada útil sobre el contexto. Se implementa con un collator propio que
usa los mismos word_ids() de la sección 1 para agrupar subtokens antes de sortear qué
palabras tapar.
🔧 Tu Continued Pretraining es este mismo capítulo con el objetivo causal
La Generación 0 de interpretante-lacaniano
es domain adaptation en el sentido exacto de esta sección: seguir entrenando con el objetivo de
preentrenamiento sobre texto del dominio. La única diferencia es que Gemma es decoder-only, así que el
objetivo es "predecir el siguiente token" (mlm=False en el mismo collator) en vez de
"predecir el token tapado". La métrica, la curva y la forma de leerla son idénticas.
3. Traducción y resumen
Estas dos comparten arquitectura y forma de entrenar — ambas son secuencia a secuencia: el encoder lee el input completo, el decoder genera el output token a token (encoder-decoder, Capítulo 1). Traducción usa Marian sobre el dataset KDE4 (interfaces de software en varios idiomas); resumen usa mT5 (multilingüe) sobre reseñas de Amazon.
| Traducción | Resumen | |
|---|---|---|
| Métrica | BLEU (overlap de n-gramas con la referencia) | ROUGE (overlap de n-gramas, pensado para resúmenes) |
| Baseline | — | "lead-3": las primeras 3 oraciones del texto, sin modelo — si tu modelo no le gana a esto, algo anda mal |
El código que las distingue del resto tiene tres piezas nuevas, y las tres existen por la misma razón: el target es una secuencia, no una etiqueta.
from transformers import (AutoModelForSeq2SeqLM, DataCollatorForSeq2Seq,
Seq2SeqTrainingArguments, Seq2SeqTrainer)
model = AutoModelForSeq2SeqLM.from_pretrained("Helsinki-NLP/opus-mt-en-fr")
# Tokenizar input y target juntos: text_target va por el tokenizer del decoder
def preprocess(examples):
inputs = [ex["en"] for ex in examples["translation"]]
targets = [ex["fr"] for ex in examples["translation"]]
return tokenizer(inputs, text_target=targets, max_length=128, truncation=True)
# 1. El collator rellena los labels con -100 y arma decoder_input_ids (labels corridos un lugar)
data_collator = DataCollatorForSeq2Seq(tokenizer, model=model)
# 2. predict_with_generate: evaluar generando de verdad, no solo midiendo la loss
args = Seq2SeqTrainingArguments("marian-en-fr", eval_strategy="epoch", predict_with_generate=True,
learning_rate=2e-5, per_device_train_batch_size=32, fp16=True)
# 3. Seq2SeqTrainer sabe llamar a generate() en evaluate()/predict()
trainer = Seq2SeqTrainer(model=model, args=args, data_collator=data_collator,
train_dataset=tok["train"], eval_dataset=tok["validation"],
compute_metrics=compute_metrics)
📐 Mecánica tensorial de Cross-Attention y el sesgo de exposición (Exposure Bias)
En la arquitectura Encoder-Decoder, el decodificador realiza una operación de atención cruzada donde las Queries provienen de los estados decodificados , mientras que las Keys y Values se proyectan desde la salida final del encoder :
Teacher Forcing vs. Generación Autoregresiva: Durante el entrenamiento, los datos del target se desplazan: mientras que las etiquetas objetivo son . El modelo optimiza:
donde es el prefijo real perfecto (Teacher Forcing). Sin embargo, en inferencia real el modelo no dispone de , sino de sus propias predicciones previas . Si comete un error en el paso 2, ese error contamina el cálculo de atención de todos los pasos subsiguientes (Exposure Bias). Por esta razón exacta, medir solo la loss en validación es engañoso: predict_with_generate=True ejecuta el bucle autorregresivo real de generación y calcula BLEU/ROUGE sobre la salida no condicionada a la verdad de terreno.
Y la métrica, con el detalle que más gente se lleva por delante — a BLEU hay que darle referencias como lista de listas, porque cada ejemplo puede tener varias traducciones válidas:
metric = evaluate.load("sacrebleu")
def compute_metrics(eval_preds):
preds, labels = eval_preds
decoded_preds = tokenizer.batch_decode(preds, skip_special_tokens=True)
labels = np.where(labels != -100, labels, tokenizer.pad_token_id) # no se puede decodificar -100
decoded_labels = tokenizer.batch_decode(labels, skip_special_tokens=True)
decoded_preds = [p.strip() for p in decoded_preds]
decoded_labels = [[l.strip()] for l in decoded_labels] # lista de listas
return {"bleu": metric.compute(predictions=decoded_preds, references=decoded_labels)["score"]}
sacrebleu existe precisamente porque BLEU depende de cómo tokenizás antes de contar
n-gramas: dos papers con la "misma" BLEU sobre el mismo test set podían diferir varios puntos por usar
tokenizadores distintos. sacrebleu fija su propia tokenización interna — por eso le pasás texto crudo,
nunca texto ya tokenizado por vos. Para resumen, el mismo patrón con evaluate.load("rouge"),
que devuelve rouge1, rouge2, rougeL y rougeLsum (ROUGE-L
calculado oración por oración, que es el que se reporta para resúmenes de varias oraciones).
El baseline lead-3 es la idea más reusable de esta sección, más allá del resumen: antes de creer que tu modelo fine-tuneado "funciona", compará contra la heurística más tonta posible. Si no le gana con margen, la métrica está mal elegida o el modelo no aprendió nada real.
4. Entrenar un LM causal desde cero
La sección más distinta de las demás: en vez de partir de un checkpoint pre-entrenado, se inicializa
GPT-2 desde su config, con pesos al azar — no from_pretrained(), sino
GPT2LMHeadModel(config) — y se entrena desde cero sobre un corpus curado (funciones de
Python que usan librerías específicas como pandas/scikit-learn/matplotlib, filtradas del corpus
completo de GitHub).
from transformers import AutoConfig, GPT2LMHeadModel, DataCollatorForLanguageModeling
context_length = 128
config = AutoConfig.from_pretrained(
"gpt2", # solo la arquitectura (capas, dimensiones), no los pesos
vocab_size=len(tokenizer), # el tokenizer es propio, entrenado sobre código (Capítulo 6)
n_ctx=context_length,
bos_token_id=tokenizer.bos_token_id,
eos_token_id=tokenizer.eos_token_id,
)
model = GPT2LMHeadModel(config) # pesos aleatorios: NO es from_pretrained
model_size = sum(t.numel() for t in model.parameters())
print(f"GPT-2 size: {model_size/1000**2:.1f}M parameters")
# mlm=False: el collator copia input_ids a labels; el modelo los corre un lugar internamente
data_collator = DataCollatorForLanguageModeling(tokenizer, mlm=False)
GPT-2 size: 124.2M parameters
Ese conteo de parámetros — sum(t.numel() for t in model.parameters()) — es una línea que
vale la pena tener a mano siempre: es la forma más rápida de confirmar que cargaste el modelo que
creías, y de estimar memoria (cada parámetro ocupa 4 bytes en fp32, 2 en bf16, y el optimizador Adam
suma otros 8 por parámetro durante el entrenamiento). Para GPT-2 small son ~124 M; con un vocabulario
distinto al original el número cambia porque la matriz de embeddings es
vocab_size × n_embd.
📐 Desplazamiento tensorial de logits (Next-Token Shift) e inicialización residual
En un modelo causal, la salida del modelo son logits para cada posición . Dado que la posición debe predecir el token en la posición , los tensores deben recortarse y alinearse antes de evaluar la Cross-Entropy:
La función de pérdida causal sobre la secuencia de longitud es:
Escalamiento de pesos en inicialización (GPT-2 Scaling): Al entrenar desde cero, la suma de residuos provoca que la varianza de las activaciones en la corriente residual crezca proporcional a la profundidad: . Para impedir la explosión de gradientes en las capas superiores antes de que converja LayerNorm, GPT-2 escala las matrices de proyección de los bloques residuales por un factor de , muestreando pesos de una distribución normal:
🔧 Esto es exactamente lo que ya hiciste
Reemplazá "GPT-2 desde cero sobre funciones de Python" por "Gemma-4 con Continued Pretraining sobre
81 obras de Lacan y Freud", y tenés la Generación 0 de
interpretante-lacaniano
(13,9M tokens, loss 1.93→1.35). La diferencia técnica real: CPT no inicializa pesos al azar — parte de
un checkpoint ya entrenado y sigue entrenando sobre el corpus nuevo, más barato y estable que
arrancar de cero. El resto — curar el corpus, tokenizar, entrenar con Trainer o un loop
con Accelerate — es la misma mecánica exacta.
Otro detalle que vale la pena rescatar: la sección muestra cómo escribir una loss function
propia que le da más peso a los ejemplos que usan las librerías objetivo — una forma simple de
orientar qué aprende el modelo más allá de "minimizar la loss promedio a secas". La mecánica es
simple: se calcula la cross-entropy por token sin reducir (reduction="none"), se
multiplica cada ejemplo por un peso que depende de cuántos tokens "clave" (plt,
pd, sk, fit, predict) contiene, y recién ahí se
promedia. Es el mismo principio que la penalización de un token especial en cualquier loss ponderada.
5. Question answering extractivo
Distinto de generar una respuesta: acá el modelo señala dónde en el contexto está la respuesta, prediciendo dos posiciones (inicio y fin del span) sobre el dataset SQuAD. Dos problemas de ingeniería concretos que esta sección resuelve:
- Contextos más largos que el máximo del modelo: se parten con overflow (el mismo
return_overflowing_tokens=Truedel Capítulo 5), con una ventana de solapamiento para no cortar una respuesta justo en el límite. - Mapear la respuesta de vuelta al texto original: de nuevo el offset mapping del Capítulo 6 — sin él, convertir "el token 47 es el inicio de la respuesta" en "el carácter 213 del contexto original" requeriría reglas manuales por tokenizer.
from transformers import AutoModelForQuestionAnswering
model = AutoModelForQuestionAnswering.from_pretrained("bert-base-cased") # head: 2 logits por token (start, end)
inputs = tokenizer(
questions, contexts,
max_length=384,
truncation="only_second", # cortar el contexto, nunca la pregunta
stride=128, # solapamiento entre ventanas consecutivas
return_overflowing_tokens=True, # un contexto largo → varias "features"
return_offsets_mapping=True,
padding="max_length",
)
En entrenamiento, para cada ventana se busca si la respuesta cae entera adentro: si sí, los labels son
las posiciones del token de inicio y de fin; si no (la ventana no contiene la respuesta, o la corta),
los dos labels apuntan al [CLS] (posición 0) — así el modelo aprende a decir "acá no
está". Con stride=128 y max_length=384, cualquier respuesta de menos de 128
tokens cae completa en al menos una ventana.
stride tiene que ser al menos tan largo como la respuesta más larga que esperás.
Con ventanas de 384 y solapamiento de 128, toda respuesta de hasta 128 tokens cae entera en alguna
ventana; una más larga puede quedar cortada por una ventana y empezar antes que la siguiente.
En inferencia, el modelo devuelve un vector de start logits y otro de end logits por ventana. Elegir el mejor span no es tomar el argmax de cada uno por separado — eso puede dar un fin antes del inicio, o un span de 300 tokens. El curso lo hace así:
n_best = 20
max_answer_length = 30
for feature_index in features_del_ejemplo:
start_logit = start_logits[feature_index]
end_logit = end_logits[feature_index]
offsets = offset_mapping[feature_index] # None fuera del contexto (pregunta, tokens especiales)
start_indexes = np.argsort(start_logit)[-1 : -n_best - 1 : -1].tolist() # los 20 mejores inicios
end_indexes = np.argsort(end_logit)[-1 : -n_best - 1 : -1].tolist() # los 20 mejores finales
for s in start_indexes:
for e in end_indexes:
if offsets[s] is None or offsets[e] is None: # cae fuera del contexto
continue
if e < s or e - s + 1 > max_answer_length: # span inválido o demasiado largo
continue
answers.append({
"text": context[offsets[s][0] : offsets[e][1]], # offset mapping → texto original
"logit_score": start_logit[s] + end_logit[e],
})
best = max(answers, key=lambda a: a["logit_score"])
📐 Formulación probabilística del Head de QA y umbral nulo (SQuAD 2.0)
El head de Question Answering aplica dos vectores de pesos independientes sobre los vectores ocultos :
La pérdida de entrenamiento es la suma de dos Cross-Entropy independientes sobre las posiciones reales :
En inferencia, la probabilidad conjunta de un span delimitado por bajo asunción de independencia marginal es:
Detección de preguntas sin respuesta (SQuAD 2.0): El token especial [CLS] en la posición 0 actúa como sumidero nulo. El puntaje nulo es . La respuesta predicha es el mejor span válido que supere el umbral :
El puntaje de un span es la suma de su start logit y su end logit (equivale a multiplicar las
probabilidades, si las tomás como independientes), se comparan los 20 × 20 candidatos
válidos de cada ventana, y como un mismo ejemplo puede tener varias ventanas, gana el mejor span entre
todas ellas. Se evalúa con Exact Match (¿la respuesta es idéntica, carácter por
carácter, después de normalizar puntuación y artículos?) y F1 (overlap parcial de
palabras) — dos formas de ser "generoso" o "estricto" con una respuesta casi correcta. Ambas salen de
evaluate.load("squad").
6. De seis tareas a un solo modelo
🟢 La actualización que redefine el capítulo entero
El propio curso original ya lo admite al cierre de este capítulo ("Mastering LLMs"): estas seis tareas solían requerir seis modelos fine-tuneados por separado. Hoy, un solo LLM decoder-only instruction-tuned resuelve las seis con el prompt correcto — "extraé las entidades de este texto", "traducí esto al francés", "respondé según este contexto" — sin entrenar nada. Fine-tunear un modelo especializado para una sola tarea sigue teniendo sentido cuando necesitás latencia mínima, correr en hardware chico, o exprimir el último punto de precisión en un dominio muy específico — pero ya no es el camino por defecto.
Lo que no quedó obsoleto: las métricas. BLEU, ROUGE, Exact Match y F1 siguen siendo exactamente cómo evaluás la salida de un LLM moderno en estas mismas tareas — cambia cómo generás la respuesta, no cómo la medís. Y saber qué arquitectura calza con cada tarea (encoder para clasificar, encoder-decoder para transformar una secuencia en otra, decoder para generar) sigue siendo el mapa mental que te dice qué esperar de un modelo antes de probarlo.
Un ejemplo concreto de dónde el modelo chico especializado sigue ganando: un BERT fine-tuneado para NER procesa miles de frases por segundo en una GPU modesta y devuelve spans exactos con offsets; pedirle lo mismo a un LLM de 8B por prompt es dos órdenes de magnitud más caro, y el output hay que parsearlo. Si tu producto extrae entidades de millones de documentos por día, el Capítulo 7 sigue siendo el camino.
7. Errores comunes
Casi todos los bugs de este capítulo entrenan sin quejarse. La tabla va de síntoma a causa, que es el orden en que los vas a encontrar.
| Síntoma | Causa probable | Qué hacer |
|---|---|---|
| NER: accuracy alta pero F1 de seqeval bajo, o entidades "partidas" en dos. | Etiquetas desalineadas: se copió la misma etiqueta a todos los subtokens (dos B- seguidos), o se usaron índices de palabra como si fueran índices de token. | Usar word_ids() y la función de §1 tal cual: primer subtoken con la etiqueta real, continuación con B→I, especiales con -100. Imprimí tokens y labels lado a lado para un ejemplo antes de entrenar. |
Loss de NER o seq2seq que empieza altísima y baja "demasiado bien"; el modelo aprende a predecir O o <pad>. | Se usó DataCollatorWithPadding (o padding manual) y las etiquetas de padding quedaron como 0 o como pad_token_id en vez de -100: la loss está aprendiendo a predecir relleno. | Usar el collator de la tarea (ForTokenClassification, ForSeq2Seq). Verificá: (batch["labels"] == -100).sum() tiene que ser > 0 en un batch con padding. |
Perplejidad reportada que no coincide con exp(eval_loss) del Trainer, casi siempre más alta. | Se calculó mean(exp(loss_por_batch)) en vez de exp(mean(loss)). Por la convexidad de exp, el promedio de exponenciales siempre es ≥ la exponencial del promedio (desigualdad de Jensen). | Acumular la loss media por token sobre todo el set y aplicar math.exp una sola vez al final. Si los batches tienen distinto número de tokens, ponderar por tokens antes de promediar. |
| QA: Exact Match razonable en preguntas cortas, cae a cero en contextos largos; o la respuesta aparece cortada. | max_length truncó el contexto sin return_overflowing_tokens, así que la respuesta quedó fuera de la única ventana; o se usó truncation=True y recortó la pregunta. | truncation="only_second", return_overflowing_tokens=True y stride ≥ largo máximo de respuesta esperado. Contá cuántos ejemplos generan más de una feature: si es 0 con contextos largos, el overflow no está activo. |
| BLEU distinto al de otro paper/equipo sobre el mismo test set, por varios puntos. | Predicciones y referencias tokenizadas de forma distinta (o una de las dos ya tokenizada por vos), o referencias pasadas como lista plana en vez de lista de listas. | Pasar texto crudo a sacrebleu y dejar que tokenice él; referencias siempre como [[ref] for ref in refs]. Reportar la firma de sacrebleu junto con el número. |
| Seq2seq: la loss de evaluación baja pero BLEU/ROUGE no mejora. | Se está evaluando con teacher forcing (sin predict_with_generate), o generación con max_new_tokens demasiado corto que corta las salidas. | Seq2SeqTrainer con predict_with_generate=True; revisar generation_max_length contra la longitud real de los targets. |
8. Resumen
- Las seis tareas comparten esqueleto; lo que cambia es la terna head + collator + métrica de la tabla de §0.
- Realinear etiquetas a subtokens — primer subtoken con la etiqueta real, continuación con
B→I, especiales con-100— es la técnica que atraviesa NER y cualquier tarea de clasificación por token; seqeval evalúa por entidad, no por token. - Fine-tunear un masked LM sobre tu propio dominio se mide en perplejidad (
expde la loss media), no en accuracy; el collator fabrica las etiquetas enmascarando el 15 % de los tokens en cada batch. - Traducción y resumen comparten arquitectura (encoder-decoder), collator (
DataCollatorForSeq2Seq), la necesidad depredict_with_generate, y filosofía de evaluación (comparar siempre contra el baseline más tonto posible). - Entrenar un LM causal desde cero es, mecánicamente, el mismo proceso que un Continued Pretraining — la diferencia es de dónde parten los pesos:
GPT2LMHeadModel(config)vs.from_pretrained(). - QA extractivo depende enteramente de dos herramientas ya vistas: overflow de tokens con
stridey offset mapping; el mejor span es el par (inicio, fin) válido con mayor suma de logits. - Las seis tareas hoy caben en un solo LLM prompteado — pero las métricas para juzgar si lo hizo bien no cambiaron, y el modelo chico especializado sigue ganando en costo por ejemplo.
9. Ponete a prueba
Respondé antes de abrir cada respuesta. Si fallás una, volvé a la sección indicada.
1. La palabra "Zwingmann" tiene etiqueta 1 (B-PER) y el tokenizer la parte en tres subtokens. ¿Qué lista de etiquetas produce align_labels_with_tokens para esos tres tokens, y por qué?
[1, 2, 2]. El primer subtoken se queda con la etiqueta real (B-PER); los dos de continuación tienen el mismo word_id que el anterior, así que entran en la rama "misma palabra", donde una etiqueta impar (B-) se convierte en la par siguiente (I-PER = 2). Una entidad no puede empezar tres veces. (§1)
2. ¿Por qué el dataset de masked LM no tiene columna labels y el de NER sí?
Porque en MLM las etiquetas dependen de qué tokens se enmascaran, y eso lo decide el DataCollatorForLanguageModeling al azar en cada batch (con mlm_probability=0.15): las labels son los tokens originales en las posiciones tapadas y -100 en el resto. En NER la etiqueta de cada token es un dato fijo del dataset, así que se calcula una vez con map. (§1, §2)
3. Tenés dos batches de evaluación con loss media 1.0 y 3.0. ¿Cuál es la perplejidad correcta, y qué número (incorrecto) da promediar exponenciales?
Correcta (asumiendo igual cantidad de tokens): exp((1.0 + 3.0)/2) = exp(2) ≈ 7.39. Incorrecta: (exp(1) + exp(3))/2 = (2.72 + 20.09)/2 ≈ 11.40. La segunda siempre es mayor o igual (Jensen): la perplejidad es la exponencial de la cross-entropy media por token, no un promedio de perplejidades. (§2, §7)
4. ¿Qué pasa si entrenás un modelo de traducción con Trainer normal en vez de Seq2SeqTrainer y predict_with_generate=True?
Entrena igual (la loss con teacher forcing se calcula bien), pero evaluate() devuelve logits de teacher forcing en vez de texto generado: compute_metrics no puede calcular BLEU sobre lo que el modelo genera solo. Ves una loss que baja y ninguna métrica real de generación. (§3)
5. En QA, ¿por qué no alcanza con tomar argmax(start_logits) y argmax(end_logits) por separado?
Porque nada garantiza que el fin caiga después del inicio, que el span tenga una longitud razonable, ni que ambos caigan dentro del contexto (y no en la pregunta). Por eso se toman los n_best mejores inicios y finales, se descartan los pares inválidos (e < s, largo > max_answer_length, offset None), y se elige el par válido con mayor start_logit + end_logit. (§5)
6. Un contexto de 900 tokens, max_length=384, stride=128. ¿Cuántas ventanas se generan aproximadamente, y qué respuesta podría perderse igual?
Cada ventana avanza 384 − 128 = 256 tokens de contexto nuevo (menos los tokens de la pregunta), así que del orden de 4 ventanas. Una respuesta de más de 128 tokens podría no caer entera en ninguna ventana, porque el solapamiento es de 128: stride tiene que ser al menos tan largo como la respuesta más larga que esperás. (§5)
10. Para practicar
✏️ Ejercicio 1 — Ver la realineación con tus propios ojos
Corré la función de §1 sobre una frase real con un tokenizer de subpalabras e imprimí token y etiqueta lado a lado. CPU alcanza; baja el tokenizer de bert-base-cased (unos cientos de KB).
from transformers import AutoTokenizer
label_names = ["O", "B-PER", "I-PER", "B-ORG", "I-ORG", "B-LOC", "I-LOC", "B-MISC", "I-MISC"]
tokenizer = AutoTokenizer.from_pretrained("bert-base-cased")
words = ["Werner", "Zwingmann", "visitó", "Hugging", "Face", "en", "París"]
labels = [1, 2, 0, 3, 4, 0, 5]
# pegá acá align_labels_with_tokens de la sección 1
enc = tokenizer(words, is_split_into_words=True)
aligned = align_labels_with_tokens(labels, enc.word_ids())
for tok, lab in zip(tokenizer.convert_ids_to_tokens(enc["input_ids"]), aligned):
print(f"{tok:12s} {lab:5d} {label_names[lab] if lab != -100 else '(ignorado)'}")
Pista / qué esperar
"Zwingmann" y "visitó" se parten en varios subtokens (##). Los subtokens de continuación de Zwingmann tienen que salir con I-PER aunque la palabra ya tuviera I-PER (índice par, no cambia). Cambiá la etiqueta de Zwingmann a 1 (B-PER) y confirmá que sus continuaciones salen 2. [CLS] y [SEP] deben mostrar "(ignorado)".
✏️ Ejercicio 2 — Perplejidad: la forma correcta y la incorrecta
Sin modelo: simulá losses por batch y compará las dos formas de agregarlas. Solo NumPy.
import numpy as np
rng = np.random.default_rng(0)
losses = rng.normal(loc=2.0, scale=0.8, size=200) # loss media de 200 batches
ntoks = rng.integers(100, 500, size=200) # tokens por batch (distintos)
ppl_correcta = np.exp(np.average(losses, weights=ntoks)) # exp de la media ponderada por tokens
ppl_sin_pesos = np.exp(losses.mean())
ppl_incorrecta = np.exp(losses).mean() # media de exp: MAL
print(ppl_correcta, ppl_sin_pesos, ppl_incorrecta)
Pista / qué esperar
La tercera es claramente mayor que las otras dos (con esta varianza, del orden de un 35–40 % más). Subí scale a 1.5 y mirá cómo se dispara la diferencia: cuanto más variables las losses entre batches, más miente el promedio de exponenciales. La diferencia entre la primera y la segunda es chica pero no nula: es el efecto de ponderar por tokens.
✏️ Ejercicio 3 — Elegir el mejor span de QA a mano
Implementá la selección de span de §5 sobre logits inventados y confirmá que descarta los pares inválidos. Puro Python + NumPy.
import numpy as np
rng = np.random.default_rng(1)
L = 40
start_logit = rng.normal(size=L); end_logit = rng.normal(size=L)
start_logit[35] += 6; end_logit[10] += 6 # argmax individuales: fin ANTES del inicio
start_logit[12] += 4; end_logit[15] += 4 # el span válido bueno
offsets = [None] * 8 + [(i, i + 1) for i in range(L - 8)] # los primeros 8 tokens son "pregunta"
n_best, max_len = 20, 30
cands = []
for s in np.argsort(start_logit)[-1:-n_best-1:-1]:
for e in np.argsort(end_logit)[-1:-n_best-1:-1]:
if offsets[s] is None or offsets[e] is None or e < s or e - s + 1 > max_len:
continue
cands.append((start_logit[s] + end_logit[e], int(s), int(e)))
print("argmax ingenuo:", start_logit.argmax(), end_logit.argmax())
print("mejor span válido:", max(cands))
Pista / qué esperar
El argmax ingenuo da (35, 10): fin antes del inicio, un span imposible. El mejor span válido tiene que ser (12, 15). Probá bajar max_len a 3 y mirá cómo cambia el ganador; después poné offsets[12] = None para simular que ese token cae en la pregunta.
11. Fuentes y para seguir
- LLM Course, Chapter 7 — Token classification — el tutorial completo de NER, con la versión original de
align_labels_with_tokensy el loop con Accelerate; leelo cuando quieras ver el pipeline entero sin resumir. - LLM Course, Chapter 7 — Question answering — el postprocesado de spans en detalle, incluido el manejo de varias ventanas por ejemplo; es la parte más fácil de romper si la reimplementás.
- LLM Course, Chapter 7 — Training a causal language model from scratch — la sección de
AutoConfig+GPT2LMHeadModel(config)y la loss ponderada por tokens clave. - A Call for Clarity in Reporting BLEU Scores (Post, 2018) — el paper detrás de sacrebleu: por qué BLEU sin tokenización fija no es comparable entre trabajos.
- SQuAD: 100,000+ Questions for Machine Comprehension of Text (Rajpurkar et al., 2016) — el dataset de QA extractivo y la definición original de Exact Match y F1.
- seqeval — la librería que evalúa por entidad; su README explica cómo cuenta spans en formato BIO/IOB2, útil para entender por qué un
B-duplicado cuesta F1.