Curso / HF LLM Course / Capítulo 7
HF LLM Course · 7 de 12

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 -100 en los tokens que no cuentan y B-→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 stride con 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:

TareaHead (clase Auto…)Data collatorMétricaArquitectura natural
NERAutoModelForTokenClassificationDataCollatorForTokenClassificationseqeval (precision/recall/F1 por entidad)Encoder
Masked LMAutoModelForMaskedLMDataCollatorForLanguageModeling(mlm_probability=0.15)PerplejidadEncoder
Traducción / resumenAutoModelForSeq2SeqLMDataCollatorForSeq2SeqBLEU / ROUGEEncoder-decoder
LM causalGPT2LMHeadModel(config) (desde cero) o AutoModelForCausalLMDataCollatorForLanguageModeling(mlm=False)Loss / perplejidadDecoder
QA extractivoAutoModelForQuestionAnsweringEl 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:

  1. Los tokens especiales ([CLS], [SEP]) tienen word_id = None y reciben -100, el valor que la cross-entropy de PyTorch ignora (ignore_index).
  2. El primer subtoken de cada palabra se queda con la etiqueta real de la palabra.
  3. Los subtokens de continuación heredan la etiqueta de la palabra, pero si era B-XXX se convierte en I-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 H∈RB×N×dmodel, la capa lineal de clasificación por token proyecta cada posición a los logits de las C clases:

Z=HWNER+bNER∈RB×N×C

La función de pérdida por secuencia es la Cross-Entropy calculada exclusivamente sobre los tokens con etiqueta válida (yi≠−100):

LNER=−1∑i=1NI[yi≠−100]∑i=1,yi≠−100Nlog(exp(zi,yi)∑c=1Cexp(zi,c))

Mecánica del gradiente: Cuando PyTorch procesa yi=−100, anula estrictamente el gradiente: ∂L∂zi,c=0 para toda clase c. 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 ∑I[yi≠−100] (y no por N) evita que frases cortas con mucho padding tengan un paso de gradiente artificialmente atenuado.

Realinear etiquetas de palabras a subtokens Once tokens con su word id y su etiqueta alineada. Los tokens especiales tienen word id None y reciben menos cien. La palabra lamb se partió en la y mb, los dos con word id siete: el primero se queda con la etiqueta de la palabra y el de continuación la hereda. Si la palabra fuera una entidad B, la continuación pasa a I: Zwingmann da 1, 2, 2. tokens word_ids labels [CLS] None -100 ign. EU 0 3 B-ORG rejects 1 0 O German 2 7 B-MISC call 3 0 O to 4 0 O boycott 5 0 O British 6 7 B-MISC la 7 0 O ##mb 7 0 O [SEP] None -100 ign. misma palabra (word_id 7): el 2º es continuación reglas: word_id None → -100 (la loss lo ignora) · primer subtoken → etiqueta de la palabra · continuación → hereda, y si era B-XXX pasa a I-XXX Si la palabra partida es una entidad: Zw 1 B-PER ##ing 2 I-PER ##mann 2 I-PER B-PER es impar (1): las continuaciones suman 1 → I-PER (2). Copiar «1, 1, 1» haría que la entidad empiece tres veces y seqeval contaría entidades de más.
El ejemplo de §1 con las tres reglas actuando a la vez. Los tokens especiales salen de la loss con -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 M⊂{1,…,N} tal que |M|≈0.15×N. 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 v∼U(V).
  • 10 % de las veces: se mantiene el token original xi 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 LMLM se evalúa estrictamente sobre M:

LMLM=−1|M|∑i∈MlogP(xi∣x~)=−1|M|∑i∈Mlog(exp(hi⊤wxi+bxi)∑v∈Vexp(hi⊤wv+bv))

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ónResumen
MétricaBLEU (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 Hdec∈RB×Ndec×d, mientras que las Keys y Values se proyectan desde la salida final del encoder Henc∈RB×Nenc×d:

Qdec=HdecWQ,Kenc=HencWK,Venc=HencWV
Across=softmax(QdecKenc⊤dk)∈RB×Ndec×Nenc,Hcross=AcrossVenc∈RB×Ndec×d

Teacher Forcing vs. Generación Autoregresiva: Durante el entrenamiento, los datos del target se desplazan: yin=[⟨bos⟩,y1,y2,…,yT−1] mientras que las etiquetas objetivo son ytarget=[y1,y2,…,yT,⟨eos⟩]. El modelo optimiza:

LSeq2Seq=−∑t=1TlogP(yt∣y<t*,x)

donde y<t* es el prefijo real perfecto (Teacher Forcing). Sin embargo, en inferencia real el modelo no dispone de y<t*, sino de sus propias predicciones previas y^<t. 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 Z∈RB×N×|V|. Dado que la posición t debe predecir el token en la posición t+1, los tensores deben recortarse y alinearse antes de evaluar la Cross-Entropy:

Zshift=Z[:,:−1,:],Yshift=Y[:,1:]

La función de pérdida causal sobre la secuencia de longitud N es:

LCausal=−1∑t=1N−1I[yt+1≠−100]∑t=1N−1log(exp(zt,yt+1)∑v∈Vexp(zt,v))

Escalamiento de pesos en inicialización (GPT-2 Scaling): Al entrenar desde cero, la suma de residuos xl+1=xl+f(xl) provoca que la varianza de las activaciones en la corriente residual crezca proporcional a la profundidad: Var(xL)≈L⋅σ2. 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 12×nlayer, muestreando pesos de una distribución normal:

Wproj∼N(0,0.0222×nlayer)

🔧 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=True del 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.

Un contexto de 900 tokens partido en ventanas de 384 con stride 128 Cada ventana avanza 256 tokens y comparte 128 con la anterior. Una respuesta de 60 tokens cae entera en al menos una ventana. Una respuesta de 160 tokens que empieza en el token 230 queda cortada por la primera ventana y fuera de la segunda: no cae entera en ninguna. contexto 0 256 512 768 900 ventana 1 ventana 2 ventana 3 ventana 4 60 tokens: entera en las ventanas 2 y 3 160 tokens desde el 230: cortada en la 1, fuera de la 2 max_length = 384 por ventana (pregunta incluida) · stride = 128 de solapamiento (la franja oscura) · cada ventana avanza 384 − 128 = 256 tokens
Por qué 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 wstart,wend∈Rd sobre los vectores ocultos hi:

si=wstart⊤hi+bstart,ej=wend⊤hj+bend

La pérdida de entrenamiento es la suma de dos Cross-Entropy independientes sobre las posiciones reales ystart,yend:

LQA=−log(exp(systart)∑k=1Nexp(sk))−log(exp(eyend)∑k=1Nexp(ek))

En inferencia, la probabilidad conjunta de un span delimitado por (i,j) bajo asunción de independencia marginal es:

P(span=(i,j))=P(start=i)⋅P(end=j)⟹logP(span)∝si+ej=S(i,j)

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 Snull=s0+e0. La respuesta predicha es el mejor span válido que supere el umbral τ:

span^={(i*,j*)=argmaxi≤j,j−i+1≤LmaxS(i,j)si maxS(i,j)−Snull>τ∅ (pregunta sin respuesta)en caso contrario

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").

Elegir el mejor span de QA: por qué no alcanza con dos argmax Dos filas de barras, start logits y end logits, sobre dieciséis posiciones; las primeras cuatro son la pregunta. El argmax de inicio está en la posición 13 y el de fin en la 6: fin antes del inicio, inválido. El mejor par válido es inicio 8 y fin 10, con suma de logits 6,1. start logits 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 end logits pregunta offset None ✗ argmax por separado: inicio 13, fin 6 → el fin cae antes del inicio ✓ mejor par válido: (8, 10) 3,1 + 3,0 = 6,1 se prueban los n_best inicios × n_best finales; se descartan e < s, largo > max_answer_length y offset None; gana la mayor suma start + end
El argmax de cada vector por separado puede dar un fin antes del inicio, un span de 300 tokens o una posición dentro de la pregunta. Por eso se comparan los pares candidatos válidos y gana el de mayor suma de logits; el offset mapping convierte ese par en caracteres del contexto original.

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íntomaCausa probableQué 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

  1. Las seis tareas comparten esqueleto; lo que cambia es la terna head + collator + métrica de la tabla de §0.
  2. 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.
  3. Fine-tunear un masked LM sobre tu propio dominio se mide en perplejidad (exp de la loss media), no en accuracy; el collator fabrica las etiquetas enmascarando el 15 % de los tokens en cada batch.
  4. Traducción y resumen comparten arquitectura (encoder-decoder), collator (DataCollatorForSeq2Seq), la necesidad de predict_with_generate, y filosofía de evaluación (comparar siempre contra el baseline más tonto posible).
  5. 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().
  6. QA extractivo depende enteramente de dos herramientas ya vistas: overflow de tokens con stride y offset mapping; el mejor span es el par (inicio, fin) válido con mayor suma de logits.
  7. 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