Curso / fast.ai / Lección 4
fast.ai · 4 de 14

fast.ai · Practical Deep Learning for Coders · Lección 4

NLP: la lección que el propio curso ya tuvo que reescribir

Esta es la lección más interesante del curso para leer con perspectiva de 2026 — no porque su contenido original esté mal, sino porque quedó tan superado que la página web del curso ya no sigue al libro: usa Hugging Face Transformers en vez del enfoque RNN que enseña el Capítulo 10.

Antes de leerlección 3, SGD y redes · HF cap. 6, tokenización (opcional)

🎯 Al terminar vas a poder

  • Explicar las tres etapas de ULMFiT (LM genérico → LM adaptado al dominio → clasificador) y reconocer el mismo patrón en un pipeline moderno de CPT + SFT.
  • Leer el código fastai de un language model y de un clasificador de texto — TextBlock, vocab, save_encoder/load_encoder, freeze_to — y decir qué hace cada llamada.
  • Definir perplexity en una frase, explicar por qué to_fp16() acelera, y justificar discriminative learning rates y gradual unfreezing.
  • Escribir el esqueleto de un fine-tuning con AutoModelForSequenceClassification + Trainer para la competencia de patentes, con un split que no filtre anchors entre train y validación.

1. Dos lecciones en una: el libro (RNN) y la web (Transformers)

El libro es de 2020. En ese momento, BERT y GPT-2 ya existían, pero fastai todavía enseñaba NLP con su propio enfoque insignia: ULMFiT, basado en RNNs (LSTM), que había sido estado del arte en 2018. Para cuando se grabó la versión 2022 del curso — la que ves en course.fast.ai hoy — los Transformers ya habían desplazado por completo a las RNN en NLP, y el proyecto de la lección cambió para reflejar eso: clasificar similitud entre frases de patentes de EE.UU. usando la librería transformers de Hugging Face en vez de AWD_LSTM.

Como ya hiciste el recorrido completo de Transformers en el curso de HF, esta página se enfoca en lo que el libro enseña (ULMFiT/RNN) — que sigue siendo un capítulo brillante sobre transfer learning en NLP, y vale la pena entender aunque la arquitectura de base haya cambiado — y lo contrasta explícitamente con lo que ya sabés de transformers. La versión web queda resumida en §5, con el puente al Capítulo 3 de HF donde ya viste Trainer en detalle.

2. Tokenización con reglas, no solo minúsculas

La tokenización de fastai usa spaCy como base pero le agrega una capa de reglas propias diseñada para preservar información en vez de descartarla. En lugar de simplemente poner todo en minúsculas (perdiendo la señal de énfasis o nombres propios), inserta tokens especiales con prefijo xx: xxbos (inicio de documento — le dice al modelo "olvidate del contexto anterior"), xxmaj (la palabra siguiente empezaba con mayúscula), xxup (la palabra entera estaba en mayúsculas), xxunk (palabra fuera del vocabulario), y variantes que comprimen repeticiones de caracteres (xxrep) en vez de gastar un token distinto por cada variante de "!!!!".

from fastai.text.all import *
spacy = WordTokenizer()
tkn = Tokenizer(spacy)         # spaCy + las reglas de fastai
txt = "The U.S. dollar $1 is $1.00. THIS is a test!!!!"
print(coll_repr(tkn(txt), 31))
→ (#25) ['xxbos','xxmaj','the','xxmaj','u.s.','dollar','$','1','is','$','1.00','.','xxup','this','is','a','test','xxrep','4','!'...]

Después de tokenizar viene la numericalización: armar el vocabulario (fastai se queda con los tokens que aparecen al menos 3 veces, hasta un máximo de 60.000, y manda el resto a xxunk) y reemplazar cada token por su índice. Ese vocabulario es el objeto vocab que aparece en el código de §3 — y la razón por la que hay que pasárselo explícitamente al clasificador.

La idea de fondo, que sigue siendo relevante hoy: en vez de perder información para simplificar el vocabulario, se traduce el texto a un "lenguaje" auxiliar diseñado para que el modelo aprenda un concepto general (la capitalización) con una matriz de embeddings chica, en vez de necesitar una entrada distinta por cada palabra en cada variante de mayúsculas posible.

🟢 El contraste con lo que ya sabés

En el Capítulo 6 de HF viste BPE byte-level: en vez de reglas explícitas sobre mayúsculas, un tokenizer BPE moderno aprende directamente del corpus qué unidades sub-palabra son frecuentes, sin necesitar tokens especiales xx* diseñados a mano. Es un cambio de filosofía completo: de reglas lingüísticas escritas por humanos a patrones estadísticos aprendidos — el mismo movimiento general que separa todo este capítulo (ULMFiT) de los Transformers. Lo que sí sobrevive: la idea de tokens especiales ([CLS], [SEP], <s>) y la de un vocabulario fijo con un token para lo desconocido.

3. ULMFiT: transfer learning en NLP, en 3 pasos

La idea central del capítulo, y del paper Universal Language Model Fine-tuning for Text Classification (Howard & Ruder, 2018, arXiv:1801.06146), es que el transfer learning en NLP no es un salto directo como en visión (donde ImageNet → tu dataset funciona en un solo paso). Hacen falta tres etapas:

  1. Language model preentrenado en un corpus genérico grande (Wikipedia, WikiText-103), con la tarea self-supervised de predecir la siguiente palabra — sin necesitar ninguna etiqueta humana, porque la etiqueta ya está "embebida" en el propio texto.
  2. Fine-tuning del language model en el corpus específico del dominio final (reviews de IMDb, incluyendo las que no tienen etiqueta de sentimiento). Este es el paso que el libro insiste en que la gente se salta y no debería: Wikipedia tiene un registro completamente distinto al de reviews de cine informales con nombres de actores. Saltarse este paso intermedio empeora significativamente los resultados, según el propio paper.
  3. Fine-tuning del clasificador, reusando el encoder del language model ya afinado (sin su capa final de predecir la próxima palabra) como cuerpo, con una cabeza nueva para la tarea final.
# etapa 2: afinar el LM en el dominio (todas las reviews, con y sin etiqueta)
dls_lm = DataBlock(
    blocks=TextBlock.from_folder(path, is_lm=True),
    get_items=partial(get_text_files, folders=['train', 'test', 'unsup']),
    splitter=RandomSplitter(0.1)
).dataloaders(path, path=path, bs=128, seq_len=80)

learn = language_model_learner(dls_lm, AWD_LSTM, drop_mult=0.3,
                                metrics=[accuracy, Perplexity()]).to_fp16()
learn.fit_one_cycle(1, 2e-2)                 # solo los embeddings nuevos
learn.unfreeze(); learn.fit_one_cycle(10, 2e-3)
learn.save_encoder('finetuned')               # el cuerpo, sin la cabeza de "próxima palabra"

# etapa 3: el clasificador, sobre el mismo vocab
dls_clas = DataBlock(
    blocks=(TextBlock.from_folder(path, vocab=dls_lm.vocab), CategoryBlock),
    get_y=parent_label,
    get_items=partial(get_text_files, folders=['train', 'test']),
    splitter=GrandparentSplitter(valid_name='test')
).dataloaders(path, path=path, bs=128, seq_len=72)

learn = text_classifier_learner(dls_clas, AWD_LSTM, drop_mult=0.5, metrics=accuracy).to_fp16()
learn = learn.load_encoder('finetuned')

Notá que el vocab del language model se pasa explícitamente al DataBlock del clasificador — imprescindible para que los embeddings ya afinados sigan correspondiendo a los mismos índices. Resultado del libro con este código: 94,3% de accuracy en IMDb, y 95,1% promediando con un segundo modelo entrenado sobre el texto leído al revés (un ensemble de dos, no un modelo bidireccional); el paper de 2018 reportaba un error de 4,6% en IMDb, estado del arte en ese momento.

Las tres etapas de ULMFiT y el mismo patrón en un pipeline de LLM Arriba, ULMFiT en 2018: un language model AWD-LSTM preentrenado en Wikipedia, después afinado como language model sobre todas las reseñas de IMDb incluidas las sin etiqueta, y por último un clasificador que reusa ese encoder con una cabeza nueva, compartiendo el vocabulario. Abajo, el mismo patrón en 2026: Gemma-4 base preentrenado, continued pretraining sobre el corpus de Lacan, y SFT sobre la tarea final. ULMFIT · 2018 · AWD-LSTM 1 · language model genérico Wikipedia (WikiText-103) predecir la siguiente palabra, sin etiquetas 2 · LM afinado al dominio todas las reseñas de IMDb, con y sin etiqueta el paso que la gente se salta y no debería 3 · clasificador el encoder de 2 + una cabeza nueva mismo vocab · gradual unfreezing · 94,3 % save_encoder('finetuned') en 2 → load_encoder en 3: el cuerpo viaja, la cabeza de «próxima palabra» se descarta misma estructura, otros nombres TU PIPELINE · 2026 · GEMMA-4 modelo base preentrenado corpus genérico masivo, de Google decoder-only, 31B CPT · continued pretraining 81 obras de Lacan y Freud, 13,9M tokens mismo objetivo: el siguiente token SFT · la tarea final responder en el estilo del intérprete LoRA en vez de unfreezing gradual lo que cambió es la arquitectura (LSTM → transformer) y la escala; no cambió la forma del recorrido
Por qué ULMFiT importa aunque nadie entrene un AWD-LSTM hoy. Genérico, dominio, tarea: el paso del medio es el que la gente omite, y es el mismo que la generación 0 de tu propio proyecto. Cambió la arquitectura debajo; la forma del transfer learning en texto es la de 2018.

Derivación Matemática de la Perplejidad (Perplexity)

En modelos de lenguaje autoregresivos, la métrica de accuracy (predecir con exactitud el siguiente token) resulta engañosa: un accuracy de 35% puede ser extraordinario en un vocabulario de 60.000 palabras, mientras que en un contexto predecible un 50% puede ser mediocre. La métrica canónica es la Perplejidad (PPL).

Consideremos una secuencia de N tokens W=(w1,w2,…,wN). Por la regla de la cadena de probabilidades, la probabilidad conjunta asignada por el modelo autoregresivo es:

P(W)=∏i=1NP(wi∣w<i)

La función de pérdida de entropía cruzada promedio por token (L) se define como el logaritmo negativo de verosimilitud:

L=-1N∑i=1NlogP(wi∣w<i)=-1Nlog∏i=1NP(wi∣w<i)=-1NlogP(W)

La perplejidad se define matemáticamente como la exponencial de la pérdida de entropía cruzada:

PPL(W)=exp(L)=exp-1N∑i=1NlogP(wi∣w<i)=∏i=1NP(wi∣w<i)-1N=1P(W)N

Interpretación Física y Teórica de la Información:

  • La perplejidad es la media geométrica inversa de la probabilidad asignada a cada token.
  • Representa el factor de ramificación efectivo (branching factor) del lenguaje. Si un modelo tiene PPL=55, significa que al generar el siguiente token, el sistema está en promedio tan perplejo/incierto como si tuviera que elegir uniformemente al azar entre 55 palabras igualmente probables.
  • Si el modelo fuera totalmente ignorante y predijera una distribución uniforme sobre un vocabulario de |V|=60.000, su perplejidad sería exactamente 60.000. Un modelo que predice la secuencia con certeza absoluta (P(W)=1) tiene PPL=1.0.

En cuanto al rendimiento computacional, to_fp16() activa mixed precision: las operaciones de multiplicación matricial en el forward y backward pass se ejecutan en FP16 sobre los Tensor Cores, duplicando el throughput y reduciendo el consumo de VRAM a la mitad, mientras se mantiene una copia maestra de los pesos en FP32 (con dynamic loss scaling para evitar que gradientes subnormales colapsen a cero por underflow). En hardware moderno (GPUs NVIDIA Ampere, Hopper o Blackwell), se reemplaza por to_bf16(), cuyo rango dinámico idéntico a FP32 elimina la necesidad de loss scaling.

El backbone es AWD-LSTM (ASGD Weight-Dropped LSTM; Merity, Keskar & Socher, 2017, arXiv:1708.02182): una LSTM estándar con varias técnicas de regularización superpuestas (dropout en distintos puntos — embeddings, pesos recurrentes, capas ocultas — más una variante de optimización promediada) para evitar el sobreajuste típico de RNNs sobre texto. El drop_mult del código escala todos esos dropouts a la vez.

4. Discriminative learning rates y gradual unfreezing

Al afinar el clasificador, fastai recomienda algo distinto a lo que hiciste en visión: no descongelar todo el modelo de una vez. La receta:

  1. Entrenar 1 época con solo la cabeza nueva descongelada.
  2. learn.freeze_to(-2): descongelar los últimos 2 grupos de parámetros, con discriminative learning rates — un rango de LR donde las capas más profundas/tempranas reciben un learning rate más bajo y las capas finales uno más alto, porque las capas tempranas capturan features más generales que conviene perturbar menos.
  3. freeze_to(-3): un grupo más, LR aún más bajo.
  4. learn.unfreeze(): finalmente todo el modelo, con LR mínimo.
learn.fit_one_cycle(1, 2e-2)                                       # solo la cabeza
learn.freeze_to(-2); learn.fit_one_cycle(1, slice(1e-2/(2.6**4), 1e-2))
learn.freeze_to(-3); learn.fit_one_cycle(1, slice(5e-3/(2.6**4), 5e-3))
learn.unfreeze();    learn.fit_one_cycle(2, slice(1e-3/(2.6**4), 1e-3))

Formulación Matemática de Discriminative Learning Rates

El slice(lr_min, lr_max) formaliza la hipótesis de que las distintas profundidades de una red neuronal capturan abstracciones con niveles de generalidad radicalmente dispares. Las capas iniciales (embeddings y primeras capas recurrentes o de atención) codifican invariantes léxicas y gramaticales universales; actualizarlas con un learning rate agresivo destruye estas representaciones (un fenómeno conocido como olvido catastrófico o catastrophic forgetting). En cambio, la cabeza de clasificación recién inicializada necesita pasos de optimización enérgicos para converger.

Howard & Ruder (2018) derivaron la regla de decaimiento geométrico para L grupos de capas. Para una capa l∈{1,…,L} (donde l=L representa la cabeza final y l=1 las capas tempranas):

ηl=ηL⋅βL-l

donde β es el multiplicador de atenuación inter-capa, fijado empíricamente en β=12.6≈0.3846. Para un modelo particionado en L=5 grupos de parámetros, la tasa de aprendizaje asignada a las capas base es:

η1=η5⋅12.65-1=η52.64=η545.6976

Slanted Triangular Learning Rates (STLR)

Junto con las tasas discriminativas, ULMFiT introdujo el cronograma STLR (Slanted Triangular Learning Rates), precursor del One-Cycle Policy de Leslie Smith:

  1. Fase de Warmup Inclinado (Cut fraction): Durante el primer 10% de las iteraciones (t≤⌊0.1T⌋), la tasa de aprendizaje se incrementa linealmente desde ηmin≈ηmax/32 hasta ηmax. Esto estabiliza los momentos de primer y segundo orden del optimizador cuando los gradientes iniciales son altamente oscilatorios.
  2. Fase de Annealing Largo: Durante el 90% restante del entrenamiento, el learning rate decae linealmente (o por coseno) hacia cero, permitiendo que el optimizador escape de mínimos locales agudos y se asiente en valles planos y generalizables.
Gradual unfreezing y discriminative learning rates, paso a paso Cuatro corridas sobre cinco grupos de capas. Primero solo la cabeza se entrena; después los dos últimos grupos; después tres; por último todo el modelo. En cada corrida, el learning rate máximo va a la cabeza y cada grupo anterior recibe uno 2,6 veces menor, así que entre extremos hay un factor 46. Las capas tempranas, que capturan features generales, se mueven poco y tarde. capas tempranas grupo 2 grupo 3 grupo 4 cabeza fit_one_cycle(1, 2e-2) solo la cabeza congelado congelado congelado congelado entrena lr 2.0e-2 freeze_to(-2) últimos 2 grupos congelado congelado congelado entrena lr 3.8e-3 entrena lr 1.0e-2 freeze_to(-3) últimos 3 grupos congelado congelado entrena lr 7.4e-4 entrena lr 1.9e-3 entrena lr 5.0e-3 unfreeze() todo el modelo entrena lr 2.2e-5 entrena lr 5.7e-5 entrena lr 1.5e-4 entrena lr 3.8e-4 entrena lr 1.0e-3 slice(lr_max/2.6⁴, lr_max): el lr se reparte geométricamente entre grupos, cada uno 2,6× menor que el siguiente · 2,6⁴ ≈ 46 entre extremos por qué así en texto y no en visión: una RNN afinada de golpe con una cabeza aleatoria puede destruir lo que el language model aprendió (catastrophic forgetting)
La receta de §4 como tabla de quién se entrena y a qué velocidad. Cada corrida descongela un grupo más y baja el learning rate máximo; dentro de una corrida, las capas tempranas reciben una fracción del learning rate de la cabeza. Es la intuición que LoRA resuelve hoy con otra herramienta.

Este descongelamiento gradual, capa por capa, es lo que el paper ULMFiT llama gradual unfreezing, y el libro es explícito en que para NLP hace una diferencia real frente a descongelar todo de una — a diferencia de visión, donde fine_tune descongela todo después de la primera época sin mayor drama. La razón es de catastrophic forgetting: una RNN afinada agresivamente con una cabeza nueva aleatoria puede destruir lo que el language model aprendió antes de que la cabeza se estabilice.

5. La versión web: similaridad de patentes con Transformers

La Lección 4 grabada en 2022 no usa nada de lo anterior. Su notebook, Getting started with NLP for absolute beginners, resuelve la competencia de Kaggle U.S. Patent Phrase to Phrase Matching: dado un par de frases cortas (anchor, target) y un código de contexto de la clasificación de patentes, predecir un score de similaridad entre 0 y 1. Es una regresión sobre texto, evaluada con correlación de Pearson entre el score predicho y el real. El esqueleto completo, con transformers y datasets:

import numpy as np, pandas as pd
from datasets import Dataset, DatasetDict
from transformers import (AutoTokenizer, AutoModelForSequenceClassification,
                          TrainingArguments, Trainer)

df = pd.read_csv('train.csv')
# un solo string por fila, con separadores que el modelo pueda aprender
df['input'] = 'TEXT1: ' + df.context + '; TEXT2: ' + df.target + '; ANC1: ' + df.anchor

model_nm = 'microsoft/deberta-v3-small'
tokz = AutoTokenizer.from_pretrained(model_nm)
ds = Dataset.from_pandas(df).map(lambda x: tokz(x['input']), batched=True)
ds = ds.rename_columns({'score': 'labels'})     # Trainer busca la columna 'labels'

# split por grupos de anchor (ver el callout de abajo), no por fila
anchors = df.anchor.unique()
rng = np.random.default_rng(42); rng.shuffle(anchors)
val_anchors = set(anchors[:len(anchors)//4])
is_val = df.anchor.isin(val_anchors).values
dds = DatasetDict(train=ds.select(np.where(~is_val)[0]), test=ds.select(np.where(is_val)[0]))

def corr_d(eval_pred):
    preds, labels = eval_pred
    return {'pearson': np.corrcoef(preds.squeeze(), labels)[0, 1]}

args = TrainingArguments('outputs', learning_rate=8e-5, warmup_ratio=0.1,
    lr_scheduler_type='cosine', bf16=True, eval_strategy='epoch',
    per_device_train_batch_size=128, per_device_eval_batch_size=256,
    num_train_epochs=4, weight_decay=0.01, report_to='none')

model = AutoModelForSequenceClassification.from_pretrained(model_nm, num_labels=1)  # 1 salida = regresión
trainer = Trainer(model, args, train_dataset=dds['train'], eval_dataset=dds['test'],
                  processing_class=tokz, compute_metrics=corr_d)
trainer.train()

Cada pieza la viste en el Capítulo 3 de HF, así que acá solo lo que es específico de este problema. num_labels=1 convierte la cabeza de clasificación en una regresión (un solo logit, loss MSE). El "prompt" con TEXT1:, TEXT2:, ANC1: es la forma más simple de meter tres campos en una sola secuencia; el modelo aprende qué significan los separadores. deberta-v3-small es la elección del notebook por ser chico y bueno para tareas de este tipo; y los nombres eval_strategy y processing_class son los de versiones recientes de transformers (antes se llamaban evaluation_strategy y tokenizer; si tu versión se queja, usá los viejos).

⚠️ Cuidado: el split por fila filtra información

El notebook original hace train_test_split(0.25) por fila, y Jeremy lo señala como una simplificación con un problema real: cada anchor aparece en muchas filas (con distintos target), así que con un split por fila el mismo anchor queda en train y en validación. El modelo puede "recordar" cómo se comporta ese anchor, y la métrica de validación sale más alta de lo que va a salir en el test set de Kaggle, donde los anchors son otros. Es el mismo problema que un split aleatorio de pacientes con varias radiografías cada uno, o de reviews de un mismo usuario: cuando hay grupos, el split se hace por grupo. El código de arriba separa por anchor por eso — equivalente a GroupShuffleSplit de scikit-learn.

Lo que este notebook comparte con el capítulo del libro, y que es el punto pedagógico: en ambos casos hay un modelo preentrenado de lenguaje, un paso de tokenización que tenés que entender (las reglas xx* antes, el tokenizer de DeBERTa ahora), un cuerpo reusado y una cabeza nueva, y un validation set que hay que diseñar con cuidado. Cambió la arquitectura; no cambió la estructura.

6. De ULMFiT a los LLMs Modernos: La Matriz de Correspondencia Técnica

Nadie entrena un AWD-LSTM en 2026 para un clasificador de texto nuevo: un modelo de atención densa tipo BERT o un LLM moderno le gana ampliamente en capacidad de representación y paralelismo en GPU. Sin embargo, la topología de entrenamiento en 3 etapas de ULMFiT es exactamente la misma que gobierna los pipelines modernos de LLMs.

Fase de IngenieríaParadigma ULMFiT (Howard & Ruder, 2018)Paradigma LLM Moderno (2026)
Fase 1: Preentrenamiento Base AWD-LSTM autoregresivo entrenado en WikiText-103 (Wikipedia en inglés, ~100M tokens). Objetivo: Predecir la siguiente palabra. Transformer Decoder-only (Llama-3, Gemma-4, Mistral) preentrenado en 15+ billones de tokens web. Objetivo: Next-token prediction autoregresivo.
Fase 2: Adaptación de Dominio Language Model fine-tuning sobre el corpus específico no etiquetado (ej. todas las reviews de IMDb). Minimiza Perplejidad en el dominio. Continued Pre-Training (CPT) sobre corpus corporativo, legal, médico o filosófico crudo (sin etiquetas de instrucción). Adapta el vocabulario latente.
Fase 3: Especialización en Tarea Reemplazo de cabeza de LM por clasificador densamente conectado + Gradual Unfreezing sobre los grupos de capas del backbone. Supervised Fine-Tuning (SFT) con prompts de instrucciones + LoRA / PEFT (matrices de bajo rango BA) para preservar los pesos base intactos.
Control de Olvido Catastrófico Gradual Unfreezing secuencial (freeze_to(-2), freeze_to(-3)) y tasas de aprendizaje discriminativas (β=1/2.6). Congelamiento absoluto del 100% de pesos preentrenados; inyección de adaptadores de bajo rango (h=Wx+αrBAx) que garantiza matemáticamente perturbación cero en t=0.

🔧 Es el mismo patrón que ya corre en tus proyectos

Preentrenar en Wikipedia → afinar en el dominio (reviews de IMDb) → afinar en la tarea final (clasificación) es, estructuralmente, el mismo ciclo CPT + SFT (Continued Pre-Training + Supervised Fine-Tuning) que corre en interpretante-lacaniano: el modelo base Gemma-4 ya viene preentrenado en un corpus genérico; el CPT lo adapta al "dominio" (textos lacanianos/freudianos, el estilo del corpus curado); y el SFT final lo afina a la tarea concreta (responder en el estilo del intérprete). ULMFiT en 2018 y tu pipeline en 2026 resuelven el mismo problema estructural — que un modelo genérico no rinde bien de una en un dominio muy específico sin un paso intermedio de adaptación — con arquitecturas completamente distintas (LSTM vs. transformer) pero la misma lógica de tres etapas.

Y en cuanto a discriminative learning rates y gradual unfreezing: la idea de "las capas tempranas cambian menos, las capas finales cambian más" es precisamente la intuición detrás de por qué LoRA (que viste en el Capítulo 11 de HF) suele aplicarse selectivamente sobre ciertas proyecciones de atención y no sobre el modelo entero — no es una técnica nueva, es la misma pregunta ("¿qué tanto debería moverse cada parte del modelo pretrained?") resuelta con una herramienta distinta. Y la perplexity que medías en el LM de IMDb es la misma que hoy comparás antes y después de un CPT para saber si el modelo "absorbió" el dominio.

7. Errores comunes

Los errores de esta lección son en su mayoría de diseño del experimento, no de código — y por eso se ven recién en producción, cuando la métrica de validación no se cumple.

SíntomaCausa probableQué hacer
El clasificador entrena bien pero queda varios puntos por debajo de lo esperado, sobre todo en textos con jerga del dominio.Te salteaste la etapa 2 (fine-tuning del LM en el dominio) y fuiste directo de Wikipedia al clasificador.Afiná primero el language model sobre todo el texto del dominio, incluido el que no tiene etiqueta (es gratis: la etiqueta es la próxima palabra). Recién después save_encoder / load_encoder.
Accuracy del clasificador cerca del azar, o peor que entrenar desde cero, pese a cargar el encoder afinado.No pasaste vocab=dls_lm.vocab al TextBlock del clasificador: se construyó un vocabulario nuevo, y el índice 1.234 ya no es la misma palabra que en el LM. Los embeddings cargados apuntan a palabras equivocadas.Siempre el mismo vocab entre LM y clasificador. En Transformers, el equivalente es usar el mismo AutoTokenizer del checkpoint.
La accuracy de validación empeora en la primera época de fine-tuning del clasificador y después se recupera a medias.Descongelaste todo de golpe con la cabeza nueva todavía aleatoria: los gradientes ruidosos de la cabeza pasaron al encoder y destruyeron parte de lo aprendido (catastrophic forgetting).Gradual unfreezing (§4): cabeza sola → freeze_to(-2) → freeze_to(-3) → unfreeze(), con discriminative LRs.
Métricas excelentes en validación, mucho peores con datos nuevos, y muchos xxunk en inferencia.El texto de test se tokenizó con reglas distintas (otro tokenizer, sin las reglas xx*, otro lowercasing) o con otro vocabulario.El mismo pipeline de tokenización + numericalización para train, valid y test. Con fastai, exportá el learner (incluye las transforms); con HF, guardá tokenizer y modelo juntos.
Pearson de 0,85 en validación; 0,78 en el leaderboard de Kaggle.Data leakage por split por fila cuando hay grupos: los mismos anchors están en train y en validación (§5). La validación no mide lo que el test mide.Split por grupo (anchor, paciente, usuario, documento). Si la métrica de validación baja al hacerlo, no es que el modelo empeoró: es que antes estaba mintiendo.
to_fp16() produce nan en el loss.Overflow en float16 (rango máximo ~65.504) en alguna activación o gradiente, sin loss scaling suficiente.En GPUs Ampere o más nuevas, to_bf16(); si no, bajá la LR o dejá el modelo en float32 y aceptá la lentitud.

8. Resumen

  1. Esta lección es un caso real de un curso que se actualizó más rápido que su propio libro: la web ya usa Transformers (patentes con DeBERTa + Trainer), el libro sigue enseñando RNN/ULMFiT.
  2. La tokenización de fastai preserva información (mayúsculas, énfasis) con tokens especiales xx* en vez de descartarla — un enfoque basado en reglas, contra el enfoque estadístico de BPE que ya conocés. El vocabulario que sale de ahí tiene que ser el mismo en el LM y en el clasificador.
  3. ULMFiT (Howard & Ruder, 2018) formalizó el transfer learning en NLP en tres etapas: LM genérico → LM adaptado al dominio → clasificador — el paso intermedio es el que la mayoría se salta y no debería. Perplexity (e^loss) es la métrica del LM; to_fp16()/to_bf16() es mixed precision.
  4. Discriminative learning rates + gradual unfreezing: capas tempranas se mueven poco, capas finales se mueven más — la misma intuición que hoy motiva dónde aplicar LoRA.
  5. Cuando los datos tienen grupos (anchors, pacientes, usuarios), el split se hace por grupo; un split por fila infla la métrica de validación y se descubre recién en el test.
  6. La arquitectura (LSTM) es historia; la estructura de tres etapas sigue viva en cualquier pipeline moderno de CPT + SFT.

9. Ponete a prueba

Respondé antes de abrir cada respuesta. Si fallás una, volvé a la sección indicada.

1. ¿Qué información guarda xxmaj, y por qué es mejor que dejar la palabra con mayúscula como token aparte?

Marca que la palabra siguiente empezaba con mayúscula. Como token separado, el modelo aprende "capitalización" una sola vez (un embedding) en vez de necesitar un embedding distinto para "the" y "The" — el vocabulario queda más chico y cada palabra recibe todo su conteo de apariciones. (§2)

2. ¿Por qué el paso 2 de ULMFiT puede usar las reviews de IMDb que no tienen etiqueta de sentimiento?

Porque la tarea del language model es predecir la próxima palabra: la "etiqueta" es el propio texto (self-supervised). Toda review, etiquetada o no, sirve para adaptar el LM al registro del dominio. (§3)

3. Un LM tiene loss 3.0 por token en validación. ¿Cuál es su perplexity y cómo se lee?

e³ ≈ 20: en promedio, el modelo "duda" entre unas 20 palabras igualmente plausibles en cada posición. Bajar la loss a 2.5 (perplexity ≈ 12) es una mejora grande aunque la accuracy de próxima palabra apenas se mueva. (§3)

4. ¿Qué pasa si armás dls_clas sin vocab=dls_lm.vocab y cargás el encoder afinado?

El clasificador construye un vocabulario nuevo con otros índices; la matriz de embeddings cargada del LM asigna a cada índice el vector de una palabra distinta. El encoder "afinado" pasa a ser ruido: accuracy cerca del azar o peor que sin transfer learning. (§3, §7)

5. ¿Qué significa slice(1e-2/(2.6**4), 1e-2) en fit_one_cycle?

Discriminative learning rates: el grupo de capas más temprano usa 1e-2/46 ≈ 2e-4, la cabeza usa 1e-2, y los grupos intermedios se reparten geométricamente entre ambos. El factor 2,6 por grupo viene del paper ULMFiT. (§4)

6. En el dataset de patentes, ¿por qué un split aleatorio por fila da un Pearson de validación demasiado optimista?

Cada anchor aparece en muchas filas; con split por fila, el mismo anchor cae en train y en validación, y el modelo puede memorizar cómo se puntúa ese anchor. El test de Kaggle tiene anchors nuevos, así que la validación no representa el test. Se resuelve separando por grupo (por anchor). (§5)

10. Para practicar

✏️ Ejercicio 1 — Ver las reglas de tokenización de fastai

Corre en CPU y en segundos (fastai instala spaCy como dependencia). Probá el ejemplo de §2 y después tres textos propios: uno todo en mayúsculas, uno con una URL, uno con emojis. Contá cuántos tokens xx* aparecen y qué información preserva cada uno.

from fastai.text.all import *
tkn = Tokenizer(WordTokenizer())
for txt in ["The U.S. dollar $1 is $1.00. THIS is a test!!!!",
            "ESTO ES UN GRITO", "Mirá https://course.fast.ai ahora"]:
    print(tkn(txt))
Pista / qué esperar

xxbos al inicio de cada uno; xxup antes de cada palabra en mayúsculas; xxrep 4 ! para los cuatro signos. Notá que los tokens en sí quedan en minúscula: la información de mayúsculas se movió a los marcadores.

✏️ Ejercicio 2 — Medir el leakage de un split por fila

Sin entrenar nada, con pandas solo. Descargá train.csv de la competencia de patentes (o generá un dataframe sintético con la misma estructura, como abajo) y compará cuántos anchors de validación aparecen también en train con un split por fila vs. uno por grupo.

import numpy as np, pandas as pd
rng = np.random.default_rng(0)
anchors = [f'anchor_{i}' for i in range(700)]
df = pd.DataFrame({'anchor': rng.choice(anchors, 36000),
                   'target': [f'target_{i}' for i in range(36000)],
                   'score': rng.choice([0, .25, .5, .75, 1], 36000)})

# split por fila
is_val_row = rng.random(len(df)) < 0.25
leak_row = df[is_val_row].anchor.isin(df[~is_val_row].anchor).mean()

# split por grupo
val_anchors = set(rng.permutation(df.anchor.unique())[:175])
is_val_grp = df.anchor.isin(val_anchors).values
leak_grp = df[is_val_grp].anchor.isin(df[~is_val_grp].anchor).mean()

print(f'anchors de valid que también están en train — por fila: {leak_row:.0%}, por grupo: {leak_grp:.0%}')
Pista / qué esperar

Por fila: cerca del 100% (casi todos los anchors de validación también están en train). Por grupo: 0%. Con el train.csv real el resultado es el mismo; son ~36.000 filas y ~730 anchors únicos.

✏️ Ejercicio 3 — Comparar los dos tokenizers sobre la misma frase

Tokenizá una frase de patente con el tokenizer de DeBERTa y con el de fastai, y compará. Requiere pip install transformers sentencepiece; el tokenizer se descarga una vez (unos MB) y corre en CPU.

from transformers import AutoTokenizer
tokz = AutoTokenizer.from_pretrained('microsoft/deberta-v3-small')
s = 'TEXT1: A47; TEXT2: abatement of pollution; ANC1: abatement'
print(tokz.tokenize(s))
print(tokz(s)['input_ids'][:10], tokz.cls_token, tokz.sep_token)
Pista / qué esperar

Subpalabras con el marcador ▁ de SentencePiece (por ejemplo ▁abate, ment), sin ningún xxmaj: la capitalización queda dentro de las piezas. Los ids empiezan con el [CLS] y terminan con [SEP] — el equivalente moderno de xxbos.

11. Fuentes y para seguir