La pregunta
Tienes 1500 reseñas de productos, cada una marcada como positiva o negativa, y quieres un programa que clasifique reseñas nuevas. En el tema «Texto como datos» lo hiciste con TF-IDF y regresión logística: funciona, pero solo cuenta palabras. No sabe que «not bad» es casi un elogio ni que «returned it the next day» es una queja.
Un transformer como los del tema «Atención y transformers» sí podría captar eso, pero entrenarlo desde cero con 1500 textos no alcanza: tendría que aprender el idioma entero (qué palabras existen, cómo se ordenan, qué significan) a partir de unos pocos párrafos. Los modelos que entienden texto de verdad se entrenaron con miles de millones de palabras, durante días, en muchas GPU.
La salida es no empezar de cero. Alguien ya entrenó un modelo grande que «sabe leer» y lo publicó con una licencia abierta. Tú lo descargas y lo ajustas a tu problema con tus pocos ejemplos, en tu computadora y en minutos. Eso es el ajuste fino (fine-tuning), y es como se usa hoy la mayor parte de la inteligencia artificial que lee, traduce o describe imágenes.
En este tema vas a:
- distinguir codificadores, decodificadores y modelos codificador-decodificador, y saber para qué sirve cada uno;
- ver cómo se preentrena BERT (adivinando palabras tapadas) y para qué sirve su token
[CLS]; - ajustar DistilBERT para clasificar reseñas de tres maneras: entrenando solo la cabeza, entrenando todo el modelo y con LoRA, y comparar la exactitud con el número de parámetros que se entrenan;
- traducir con un modelo codificador-decodificador y mirar su atención cruzada;
- describir imágenes con otro modelo codificador-decodificador.
Necesitas el bucle de entrenamiento del tema 18, los tokens del tema «Texto como datos», los embeddings del tema «Embeddings», el bloque transformer del tema «Atención y transformers» y lo que viste sobre modelos preentrenados en el tema «Modelos de lenguaje».
Actividad: rango bajo
LoRA, la técnica estrella de este tema, se apoya en una idea que puedes ver con los ojos. Una foto en blanco y negro de 64 × 64 píxeles es una matriz de 4096 números (cada uno, qué tan claro es un píxel). Esa matriz se puede escribir, casi igual, como el producto de dos matrices delgadas: B, de 64 filas y r columnas, por A, de r filas y 64 columnas. Guardar B y A cuesta 64·r·2 números. El número r se llama rango.
Rango bajo
Sin JavaScript se ve la aproximación de rango 4. Con JavaScript puedes mover el rango de 1 a 32.
Guardas 64·4·2 = 512 números, frente a 4 096 (12,5 %).
Con r = 4 ya se adivina la pagoda, con la octava parte de los números. Mueve el deslizador.
Prueba esto:
- Empieza con r = 1. ¿Qué ves? ¿Por qué salen franjas horizontales y verticales?
- Sube r poco a poco. ¿Desde qué rango reconoces la pagoda? ¿Cuántos números guardas ahí, comparados con 4096?
- Mira cómo baja el error. ¿Baja igual de rápido al principio que al final?
- Llega a r = 32. ¿Cuántos números guardas? ¿Vale la pena?
Con r = 1 la imagen es una sola columna por una sola fila: cada píxel es «lo claro de su fila por lo claro de su columna», y por eso solo hay franjas. Cada rango que sumas agrega un patrón nuevo, y los primeros patrones son los que más ayudan. Con un rango bajo ya está casi todo, guardando una fracción de los números.
LoRA aplica esa idea a los cambios que el ajuste fino hace en las matrices de pesos de un modelo: en lugar de cambiar cada uno de los 589 824 números de una matriz de 768 × 768, aprende un cambio de la forma B·A con r = 8, que tiene 12 288 números. Al final del tema verás que eso alcanza.
La foto es china.jpg, una de las imágenes de ejemplo que trae scikit-learn (de danielbuechele en Flickr, licencia CC BY 2.0), pasada a grises y reducida a 64 × 64.
Tres familias de transformers
En el tema «Atención y transformers» armaste un bloque transformer y viste que se usa de tres maneras. Ahora vas a usar un modelo real de cada familia:
| Familia | Qué hace | Cada token mira |
|---|---|---|
| Codificador (BERT) | entiende un texto entero | a todos |
| Decodificador (GPT) | genera, token a token | solo hacia atrás |
| Codificador-decodificador (Marian) | transforma una entrada en una salida | también a la entrada (atención cruzada) |
En este tema clasificas reseñas con un codificador (DistilBERT) y traduces y describes imágenes con dos codificadores-decodificadores (Marian y Florence-2). Los decodificadores fueron el tema «Modelos de lenguaje».
La regla práctica: si la tarea es entender (clasificar, encontrar, comparar), usa un codificador. Si es continuar un texto o conversar, un decodificador. Si es transformar una entrada en otra salida (un idioma en otro, una imagen en una frase), un codificador-decodificador.
BERT: aprender a leer tapando palabras
BERT (2018) es un codificador. Se preentrenó con un juego sencillo: se toma un texto, se tapa al azar un 15 % de sus tokens con el token especial [MASK] y el modelo tiene que adivinar cuál era cada uno. Eso se llama modelado de lenguaje enmascarado (MLM, por sus siglas en inglés). Para adivinar bien, el modelo tiene que usar el contexto de los dos lados: en «el gato se subió al [MASK] para dormir», lo que viene después («para dormir») ayuda tanto como lo que viene antes. Por eso un codificador no usa máscara causal.
Lo mejor del juego es que no necesita etiquetas: cualquier texto sirve. BERT se entrenó con libros y con la Wikipedia en inglés, unos 3300 millones de palabras.
Vamos a usar DistilBERT, una versión de BERT con la mitad de las capas (6 en lugar de 12) que conserva casi todo lo que sabe BERT y es el doble de rápida. Viene con su cabeza de MLM, así que puedes jugar al mismo juego con él. Es de Hugging Face, con licencia Apache-2.0, y pesa 268 MB; está en inglés, como las reseñas que vas a clasificar.
import numpy as np
import matplotlib.pyplot as plt
import torch
from transformers import AutoTokenizer, AutoModelForMaskedLM
torch.manual_seed(0)
device = "cuda" if torch.cuda.is_available() else "cpu"
NOMBRE = "distilbert/distilbert-base-uncased"
tokenizador = AutoTokenizer.from_pretrained(NOMBRE)
bert_mlm = AutoModelForMaskedLM.from_pretrained(NOMBRE).to(device).eval()
def completar(frase, k=5):
"""Las k palabras más probables para el [MASK] de la frase."""
x = tokenizador(frase, return_tensors="pt").to(device)
with torch.no_grad():
logits = bert_mlm(**x).logits
# La posición del [MASK] dentro de la frase tokenizada
pos = (x["input_ids"][0] == tokenizador.mask_token_id).nonzero()[0, 0]
probs = logits[0, pos].softmax(dim=-1)
mejores = probs.topk(k)
palabras = tokenizador.convert_ids_to_tokens(mejores.indices.tolist())
return palabras, mejores.values.tolist()
print(tokenizador.convert_ids_to_tokens(
tokenizador("I loved this book.")["input_ids"]))
for frase in ["I loved this book, it was [MASK].",
"The cat sat on the [MASK].",
"The capital of France is [MASK]."]:
palabras, probs = completar(frase)
print(frase)
print(" ", ", ".join(f"{p} {q:.2f}" for p, q in zip(palabras, probs)))
['[CLS]', 'i', 'loved', 'this', 'book', '.', '[SEP]']
I loved this book, it was [MASK].
amazing 0.17, incredible 0.06, wonderful 0.04, beautiful 0.03, fantastic 0.03
The cat sat on the [MASK].
sofa 0.13, couch 0.12, floor 0.07, bench 0.06, ground 0.04
The capital of France is [MASK].
marseille 0.14, nantes 0.09, toulouse 0.09, paris 0.09, lyon 0.08
Tres cosas para notar:
- El tokenizador (
uncased: pasa todo a minúsculas) agrega dos tokens especiales:[CLS]al principio y[SEP]al final. Ya los viste en el tema «Texto como datos». - Las palabras propuestas encajan con la gramática y el sentido de la frase: después de «I loved this book, it was» van elogios.
- En la tercera frase, «paris» sale cuarta. El modelo aprendió que ahí va una ciudad francesa, pero no es una base de datos de hechos: predice palabras plausibles, igual que los modelos del tema «Modelos de lenguaje». Un modelo más grande habría acertado más, pero el riesgo es el mismo.
palabras, probs = completar("I loved this book, it was [MASK].")
colores = ["C1"] + ["C0"] * (len(palabras) - 1)
plt.barh(palabras[::-1], probs[::-1], color=colores[::-1])
plt.xlabel("Probabilidad")
plt.title("«I loved this book, it was ___.»")
plt.show()
El token [CLS] y la cabeza de clasificación
Un codificador entrega un vector por token: para «I loved this book.» son 7 vectores de 768 números cada uno (los embeddings «en contexto» del tema «Embeddings»). Para clasificar el texto entero hace falta un solo vector. BERT usa el del primer token, [CLS] (de classification): como en cada capa ese token mira a todos los demás con atención, termina resumiendo la frase.
Sobre ese vector se pone una cabeza: una red pequeña, como las del tema 16, que convierte los 768 números en los logits de las clases. Al cargar el modelo con AutoModelForSequenceClassification, transformers reemplaza la cabeza de MLM por una cabeza de clasificación nueva, con pesos al azar. Por eso avisa (en la salida de errores) que hay pesos que «faltan» (MISSING): es lo esperado.
Los datos: reseñas de Amazon
Vamos a usar amazon_polarity, un conjunto de reseñas de productos de Amazon en inglés marcadas como positivas (1) o negativas (0), preparado por Zhang, Zhao y LeCun (2015) a partir de un corpus de McAuley y Leskovec (2013). La ficha en Hugging Face declara licencia Apache-2.0. Son reseñas reales escritas por personas, así que alguna puede tener lenguaje ofensivo. Bajamos solo el archivo de prueba (400 000 reseñas, 117 MB) y tomamos una muestra al azar: 1500 para entrenar y 400 para validar.
¿Por qué tan pocas? Para que todo el tema corra en unos minutos en una computadora sin GPU. Con más reseñas, el ajuste mejoraría (lo compruebas en el ejercicio 4).
import textwrap
from huggingface_hub import hf_hub_download
from datasets import load_dataset
ruta = hf_hub_download("fancyzhx/amazon_polarity",
"amazon_polarity/test-00000-of-00001.parquet",
repo_type="dataset")
resenas = load_dataset("parquet", data_files=ruta, split="train")
muestra = resenas.shuffle(seed=0).select(range(1900))
# Título y texto de cada reseña juntos; la etiqueta 1 es positiva
datos = [(f"{r['title']}. {r['content']}", r["label"]) for r in muestra]
entrenamiento, validacion = datos[:1500], datos[1500:]
print(len(resenas), "reseñas en el archivo")
for nombre, parte in [("Entrenamiento", entrenamiento),
("Validación", validacion)]:
positivas = sum(etiqueta for _, etiqueta in parte)
print(f"{nombre}: {len(parte)} reseñas, {positivas} positivas")
texto, etiqueta = entrenamiento[0]
print(textwrap.fill(texto[:230] + "…", 76), "→", etiqueta)
400000 reseñas en el archivo
Entrenamiento: 1500 reseñas, 759 positivas
Validación: 400 reseñas, 193 positivas
Very Awesome!. My son is very happy to have this game! It was used but the
disc had no singe scratch on it! Very awesome game!… → 1
Las clases están casi equilibradas: un modelo que siempre dijera «negativa» acertaría 207 de 400, casi un 52 %. Ese es el piso a superar.
El bucle, versión texto
El bucle es el del tema 18, con dos cambios. Primero, cada lote es un diccionario con los IDs de los tokens (input_ids), la máscara de padding (attention_mask, tema «Texto como datos») y las etiquetas (labels). Segundo, los modelos de transformers calculan la pérdida ellos mismos cuando reciben labels: es la misma entropía cruzada de siempre, en salida.loss.
Cada lote se rellena solo hasta su reseña más larga (y se corta en 128 tokens), para no gastar cálculos en padding. Guardamos la pérdida de cada lote para poder dibujar la curva, porque vamos a entrenar una sola época.
from torch.utils.data import DataLoader
def juntar(lote):
"""Tokeniza una lista de (texto, etiqueta) y la rellena al más largo."""
textos = [texto for texto, _ in lote]
x = tokenizador(textos, truncation=True, max_length=128,
padding=True, return_tensors="pt")
x["labels"] = torch.tensor([etiqueta for _, etiqueta in lote])
return x
def cargador_entrenamiento():
# El mismo orden de lotes cada vez, para comparar en igualdad
return DataLoader(entrenamiento, batch_size=16, shuffle=True,
collate_fn=juntar,
generator=torch.Generator().manual_seed(0))
cargador_val = DataLoader(validacion, batch_size=32, collate_fn=juntar)
def ajustar(modelo, tasa):
"""Una época del bucle; devuelve la pérdida de cada lote."""
modelo.to(device)
# Solo se optimizan los parámetros que no están congelados
entrenables = [p for p in modelo.parameters() if p.requires_grad]
optimizador = torch.optim.AdamW(entrenables, lr=tasa)
modelo.train()
perdidas = []
for lote in cargador_entrenamiento():
lote = lote.to(device)
optimizador.zero_grad()
salida = modelo(**lote) # con labels, trae la pérdida
salida.loss.backward()
optimizador.step()
perdidas.append(salida.loss.item())
return perdidas
def medir(modelo):
"""Pérdida media y exactitud en el conjunto de validación."""
modelo.eval()
suma, aciertos = 0.0, 0
with torch.no_grad():
for lote in cargador_val:
lote = lote.to(device)
salida = modelo(**lote)
etiquetas = lote["labels"]
suma += salida.loss.item() * len(etiquetas)
aciertos += (salida.logits.argmax(dim=1) == etiquetas).sum()
return suma / len(validacion), aciertos.item() / len(validacion)
Dos funciones más: una que cuenta parámetros (los entrenables y el total) y otra que entrena, mide y guarda los resultados de cada manera de ajustar, para compararlas al final.
from transformers import AutoModelForSequenceClassification
def nuevo_clasificador():
"""DistilBERT preentrenado con una cabeza nueva de 2 clases."""
torch.manual_seed(0) # la misma cabeza inicial cada vez
return AutoModelForSequenceClassification.from_pretrained(
NOMBRE, num_labels=2)
def contar(modelo):
entrenables = sum(p.numel() for p in modelo.parameters()
if p.requires_grad)
total = sum(p.numel() for p in modelo.parameters())
return entrenables, total
resultados = {}
def probar(nombre, modelo, tasa):
perdidas = ajustar(modelo, tasa)
perdida_val, exactitud = medir(modelo)
entrenables, total = contar(modelo)
resultados[nombre] = {"entrenables": entrenables,
"perdidas": perdidas, "exactitud": exactitud}
print(f"{nombre}: {entrenables:,} de {total:,} entrenables"
.replace(",", " "))
print(f" validación: pérdida {perdida_val:.3f}, "
f"exactitud {exactitud:.3f}")
Primera forma: entrenar solo la cabeza
La forma más barata de reutilizar un modelo es congelarlo: sus pesos no cambian (requires_grad = False) y solo se entrena la cabeza nueva. El codificador funciona como un extractor de variables: convierte cada reseña en un vector de 768 números, y la cabeza aprende a separarlas, como la regresión logística del tema 8 sobre variables que tú no tuviste que diseñar.
Como la cabeza empieza de cero y es pequeña, admite una tasa de aprendizaje relativamente alta (0,001).
cabeza = nuevo_clasificador()
for p in cabeza.distilbert.parameters(): # el codificador, congelado
p.requires_grad = False
probar("Solo la cabeza", cabeza, tasa=1e-3)
Solo la cabeza: 592 130 de 66 955 010 entrenables
validación: pérdida 0.378, exactitud 0.855
Con solo una época y 1500 reseñas, 85,5 % de acierto: el codificador ya sabía distinguir mucho de lo que importa. La cabeza de DistilBERT son dos capas lineales (768 → 768 → 2); por eso tiene 592 130 parámetros: 768 · 768 + 768 en la primera y 768 · 2 + 2 en la segunda. Es menos del 1 % del modelo.
Segunda forma: ajustar todo el modelo
En el ajuste fino completo se descongela todo: los 67 millones de parámetros cambian un poco para acomodarse a tu tarea. Lo importante es cuánto: con una tasa de aprendizaje grande, los primeros lotes (con la cabeza todavía al azar) empujarían los pesos lejos de lo que el modelo sabía, y se perdería lo aprendido en el preentrenamiento. A eso se le llama olvido catastrófico. Por eso el ajuste completo usa una tasa unas 20 veces menor: 0,00005.
completo = nuevo_clasificador()
probar("Completo", completo, tasa=5e-5)
Completo: 66 955 010 de 66 955 010 entrenables
validación: pérdida 0.304, exactitud 0.885
Mejor: 88,5 %. Al poder cambiar todas sus capas, el modelo afina cómo lee las reseñas, no solo cómo decide al final. Pero hay un costo. Hay que calcular y guardar un gradiente por cada uno de los 67 millones de parámetros, el optimizador AdamW guarda dos números más por parámetro, y al final tienes una copia nueva de todo el modelo (268 MB) por cada tarea que ajustes. Con modelos de miles de millones de parámetros, eso ya no entra en una computadora común.
Tercera forma: LoRA
LoRA (Low-Rank Adaptation, «adaptación de rango bajo», Hu y otros, 2021) busca lo mejor de los dos mundos: tocar todas las capas que importan, pero entrenando pocos números.
La idea es la de la actividad. Ajustar una matriz de pesos W es sumarle un cambio ΔW. LoRA congela W y escribe el cambio como el producto de dos matrices delgadas:
- W es la matriz original (en DistilBERT, de 768 × 768), congelada.
- B es de 768 × r y A de r × 768. Son lo único que se entrena. Con r = 8, son 2 · 768 · 8 = 12 288 números en lugar de 589 824: un 2 %.
- B empieza en ceros, así que al principio B·A = 0 y el modelo es exactamente el preentrenado. El ajuste parte de lo que el modelo ya sabe.
- α (alfa) es una constante que regula cuánto pesa el cambio; con α = 16 y r = 8, el cambio se multiplica por 2.
La apuesta de LoRA es que el cambio que necesita una tarea nueva tiene rango bajo: se describe con pocos patrones, como la pagoda con pocos rangos. El artículo original mostró que suele ser así.
from matplotlib.patches import Rectangle
fig, ax = plt.subplots(figsize=(4.8, 2.6))
# Dibujo esquemático: r está exagerado para que se vea
r_dibujo = 0.16
piezas = [(0.0, 0.0, 1.0, 1.0, "C3", "W\n768 × 768\ncongelada"),
(1.45, 0.0, r_dibujo, 1.0, "C0", "B"),
(1.85, 1.0 - r_dibujo, 1.0, r_dibujo, "C2", "A")]
for x, y, ancho, alto, color, texto in piezas:
ax.add_patch(Rectangle((x, y), ancho, alto, color=color))
ax.text(0.5, 0.5, piezas[0][5], ha="center", va="center", fontsize=13)
ax.text(1.45 + r_dibujo / 2, -0.1, "B\n768 × r", ha="center", va="top",
fontsize=13)
ax.text(2.35, 0.75, "A\nr × 768", ha="center", va="top", fontsize=13)
ax.text(1.22, 0.5, "+", ha="center", va="center", fontsize=22)
ax.text(1.73, 0.5, "·", ha="center", va="center", fontsize=34)
ax.set_xlim(-0.05, 2.9)
ax.set_ylim(-0.45, 1.05)
ax.set_aspect("equal")
ax.axis("off")
ax.set_title("LoRA: W + B·A (solo B y A se entrenan)")
plt.show()
¿Dónde se ponen las matrices B y A? En la atención del tema «Atención y transformers», cada capa tiene matrices que calculan las consultas (Q), las claves (K) y los valores (V). El artículo de LoRA encontró que adaptar Q y V ya funciona bien. En DistilBERT esas capas se llaman q_lin y v_lin, y hay una de cada una en cada una de las 6 capas. La librería peft de Hugging Face (Apache-2.0) agrega B y A a esas capas y congela todo lo demás.
from peft import LoraConfig, get_peft_model
config = LoraConfig(task_type="SEQ_CLS", r=8, lora_alpha=16,
lora_dropout=0.1, target_modules=["q_lin", "v_lin"])
lora = get_peft_model(nuevo_clasificador(), config)
# Cuántos números agrega LoRA: 6 capas, 2 matrices, B y A
solo_lora = sum(p.numel() for n, p in lora.named_parameters()
if "lora_" in n)
print("Parámetros de LoRA:", solo_lora, "=", 6 * 2 * (768 * 8 + 8 * 768))
probar("LoRA (r = 8)", lora, tasa=5e-4)
Parámetros de LoRA: 147456 = 147456
LoRA (r = 8): 739 586 de 67 694 596 entrenables
validación: pérdida 0.280, exactitud 0.890
Con task_type="SEQ_CLS", peft también entrena la cabeza de clasificación (que es nueva y no puede quedar al azar): por eso los entrenables son 147 456 de LoRA más 592 130 de la cabeza. El total sube un poco porque B y A se suman a los pesos originales. LoRA usa una tasa intermedia (0,0005): B y A empiezan en cero y tienen que crecer, pero el resto del modelo no se mueve.
Resultado: 89 %, lo mismo que el ajuste completo (la diferencia, 2 reseñas de 400, es ruido), entrenando el 1,1 % de los parámetros. Y lo que hay que guardar para esta tarea son solo B, A y la cabeza: unos 3 MB en lugar de 268 MB.
Comparar las tres formas
nombres = list(resultados)
plt.figure()
for nombre, color in zip(nombres, ["C2", "C0", "C1"]):
perdidas = np.array(resultados[nombre]["perdidas"])
# Promedio móvil de 10 lotes, para ver la tendencia
suave = np.convolve(perdidas, np.ones(10) / 10, mode="valid")
plt.plot(np.arange(10, len(perdidas) + 1), suave, color=color,
label=nombre)
plt.xlabel("Lote")
plt.ylabel("Pérdida de entrenamiento")
plt.title("Una época sobre 1500 reseñas")
# Espacio arriba para la leyenda
plt.ylim(0.2, 0.95)
plt.legend(loc="upper right")
plt.show()
Las tres curvas empiezan cerca de 0,69, la pérdida de un modelo que dice «50 % y 50 %» a todo (ln 2, tema 8). La de solo la cabeza baja más despacio y se queda más arriba; la del ajuste completo y la de LoRA bajan juntas. Las curvas saltan porque cada lote tiene solo 16 reseñas, unas más fáciles que otras. La pérdida en validación cuenta lo mismo que la de entrenamiento: 0,378 con la cabeza sola, 0,304 con el ajuste completo y 0,280 con LoRA, así que no hay sobreajuste en una sola época.
exactitudes = [resultados[n]["exactitud"] for n in nombres]
entrenables = [resultados[n]["entrenables"] for n in nombres]
plt.figure()
plt.barh(nombres, exactitudes, color=["C0", "C0", "C1"])
for i, (e, n) in enumerate(zip(exactitudes, entrenables)):
texto = f"{n / 1e6:.2f} M" if n < 1e7 else f"{n / 1e6:.0f} M"
plt.text(0.03, i, f"{e:.1%} · {texto} entrenables", va="center",
color="white", fontsize=13)
plt.xlim(0, 1)
plt.xlabel("Exactitud en validación")
plt.title("Exactitud según la forma")
plt.show()
| Forma | Parámetros entrenables | Exactitud | Una época con 2000 reseñas* |
|---|---|---|---|
| Solo la cabeza | 592 130 (0,9 %) | 85,5 % | unos 55 s |
| Completo | 66 955 010 (100 %) | 88,5 % | unos 124 s |
| LoRA, r = 8 | 739 586 (1,1 %) | 89,0 % | unos 103 s |
* Tiempos medidos al preparar este tema, en una computadora sin GPU con 2 hilos de CPU, entrenando con 2000 reseñas en lugar de 1500 y rellenando todas a 128 tokens. Tu computadora o Colab darán otros números; lo que importa es la proporción.
Mira la última columna con cuidado: LoRA no es mucho más rápido que el ajuste completo. Aunque entrena pocos parámetros, cada lote tiene que pasar por todo el modelo hacia adelante y el gradiente tiene que volver por todas las capas hasta llegar a las B y A de la primera. Lo que LoRA ahorra es memoria (gradientes y estado del optimizador solo para 0,74 M de números) y espacio: un mismo modelo base sirve para muchas tareas, cada una con sus 3 MB de B y A. Por eso se usa para ajustar modelos de miles de millones de parámetros en una sola GPU.
¿Y cuál usar?
- Solo la cabeza: cuando tienes muy pocos datos, poco cómputo o quieres probar rápido. También es la opción si vas a calcular los embeddings una vez y reutilizarlos.
- LoRA: la opción por defecto para ajustar modelos grandes; casi siempre cerca del ajuste completo.
- Completo: cuando tienes bastantes datos, el modelo es chico y quieres exprimir el último punto.
Una última prueba: el modelo con LoRA frente a reseñas que escribimos nosotros (no son de Amazon).
def probabilidad_positiva(modelo, textos):
modelo.eval()
x = tokenizador(textos, truncation=True, max_length=128,
padding=True, return_tensors="pt").to(device)
with torch.no_grad():
probs = modelo(**x).logits.softmax(dim=-1)[:, 1]
return probs.tolist()
propias = ["Not bad at all, it works better than I expected.",
"I returned it the next day.",
"Great price, but it broke after a week."]
for texto, p in zip(propias, probabilidad_positiva(lora, propias)):
print(f"{p:.2f} {texto}")
0.45 Not bad at all, it works better than I expected.
0.38 I returned it the next day.
0.38 Great price, but it broke after a week.
Las tres quedan por debajo de 0,5, así que el modelo las llama negativas. Acierta las dos últimas, aunque con poca seguridad, y se equivoca con «Not bad at all», una negación que engaña. Son frases mucho más cortas que las reseñas con que se entrenó (título y texto, de unas 90 palabras), y una época con 1500 reseñas no alcanza para todo. Probar el modelo con casos propios, y no solo mirar la exactitud, es como se encuentran estos huecos. En el mini-proyecto vas a ver en qué reseñas sí le gana a TF-IDF.
Traducir con atención cruzada
Ahora la tercera familia. Un modelo codificador-decodificador tiene dos transformers:
- El codificador lee la entrada completa (una frase en inglés) y produce un vector por token, como BERT.
- El decodificador genera la salida (la frase en español) un token a la vez, como el modelo de lenguaje del tema «Modelos de lenguaje», con máscara causal.
- En cada capa del decodificador hay, además, una atención cruzada: las consultas (Q) salen del decodificador y las claves y valores (K y V) salen de los vectores del codificador. Así, para escribir cada palabra, el decodificador «mira» las partes de la entrada que le sirven.
Vamos a usar Marian opus-mt-en-es, de Helsinki-NLP (proyecto OPUS-MT, Tiedemann y Thottingal, 2020; licencia Apache-2.0; 312 MB). En transformers 5 se carga con AutoModelForSeq2SeqLM y se usa con generate. Usamos generación codiciosa (do_sample=False), como en el tema «Modelos de lenguaje»: siempre se elige el token más probable, así que el resultado sale igual cada vez.
from transformers import AutoModelForSeq2SeqLM
tok_en_es = AutoTokenizer.from_pretrained("Helsinki-NLP/opus-mt-en-es")
# "eager": la atención clásica, que puede devolver sus pesos
en_es = AutoModelForSeq2SeqLM.from_pretrained(
"Helsinki-NLP/opus-mt-en-es", attn_implementation="eager"
).to(device).eval()
def traducir(frases, tok, modelo):
x = tok(frases, return_tensors="pt", padding=True).to(device)
with torch.no_grad():
ids = modelo.generate(**x, do_sample=False, num_beams=1,
max_new_tokens=60)
return tok.batch_decode(ids, skip_special_tokens=True)
frases = ["The black cat sleeps on the old chair.",
"Not bad at all, it works better than I expected.",
"I returned it the next day."]
for original, traduccion in zip(frases, traducir(frases, tok_en_es,
en_es)):
print(original)
print(" →", traduccion)
The black cat sleeps on the old chair.
→ El gato negro duerme en la silla vieja.
Not bad at all, it works better than I expected.
→ No está mal, funciona mejor de lo que esperaba.
I returned it the next day.
→ Lo devolví al día siguiente.
Fíjate en «black cat» → «gato negro» y «old chair» → «silla vieja»: el orden cambia entre los idiomas. Un traductor palabra por palabra no podría hacerlo; la atención cruzada sí, porque en cada paso puede mirar a cualquier parte de la entrada. Podemos ver adónde mira: pasamos la traducción por el modelo (el decodificador recibe los tokens ya generados) y pedimos los pesos de atención.
from matplotlib.colors import LinearSegmentedColormap
x = tok_en_es(frases[0], return_tensors="pt").to(device)
with torch.no_grad():
ids = en_es.generate(**x, do_sample=False, num_beams=1)
salida = en_es(**x, decoder_input_ids=ids[:, :-1],
output_attentions=True)
# Cuarta capa de 6, promedio de sus 8 cabezas: (salida, entrada)
pesos = salida.cross_attentions[3][0].mean(dim=0).cpu().numpy()
entrada = [t.replace("▁", "") for t in
tok_en_es.convert_ids_to_tokens(x["input_ids"][0])]
generada = [t.replace("▁", "") for t in
tok_en_es.convert_ids_to_tokens(ids[0, 1:])]
print("Entrada:", " ".join(entrada))
print("Salida: ", " ".join(generada))
tinta = LinearSegmentedColormap.from_list("tinta", ["white", "C0"])
plt.figure(figsize=(4.8, 4.8))
plt.imshow(pesos[:-1], cmap=tinta) # sin la fila de </s>
plt.xticks(range(len(entrada)), entrada, rotation=90)
plt.yticks(range(len(generada) - 1), generada[:-1])
plt.xlabel("Entrada (inglés)")
plt.ylabel("Token que escribe (español)")
plt.title("Atención cruzada")
plt.grid(False)
plt.show()
Entrada: The black cat sleep s on the old chair . </s>
Salida: El gato negro duerme en la silla vieja . </s>
Cada fila es un token que el decodificador está escribiendo, y cada celda oscura, la parte de la entrada que más miró para escribirlo. Al escribir «gato» mira a cat, y al escribir «negro», a black: las dos celdas se cruzan, porque el orden se invierte. Elegimos la cuarta capa porque ahí la alineación es más clara; en otras capas y cabezas la atención se reparte de otras formas (mucha va al token de fin </s>), así que un mapa de atención es una pista de lo que hace el modelo, no una explicación completa.
Describir imágenes
El codificador no tiene que leer texto. En un modelo visión-lenguaje el codificador es un transformer de visión que parte la imagen en parches, como el ViT del tema «Atención y transformers», y el decodificador escribe una frase sobre la imagen mirando esos parches con atención cruzada. Es el mismo esquema que la traducción: de «imagen» a «inglés».
Vamos a usar Florence-2 (Florence-2-base-ft, de Microsoft, en la versión para transformers de florence-community; licencia MIT; 230 M de parámetros, 463 MB). Florence-2 hace varias tareas según el texto que recibe junto con la imagen; <CAPTION> le pide una descripción corta. Lo cargamos con AutoModelForImageTextToText y su AutoProcessor, que prepara la imagen y el texto. Las fotos son las dos de ejemplo de scikit-learn. Para generar usamos búsqueda en haz (num_beams=3): en cada paso se siguen las 3 frases parciales más probables y al final se elige la mejor. Tampoco usa azar, así que el resultado es siempre el mismo.
from PIL import Image
from sklearn.datasets import load_sample_image
from transformers import AutoProcessor, AutoModelForImageTextToText
FLORENCE = "florence-community/Florence-2-base-ft"
procesador = AutoProcessor.from_pretrained(FLORENCE)
descriptor = AutoModelForImageTextToText.from_pretrained(FLORENCE)
descriptor = descriptor.to(device).eval()
fotos = {n: load_sample_image(n) for n in ["china.jpg", "flower.jpg"]}
descripciones = []
for nombre, foto in fotos.items():
x = procesador(text="<CAPTION>", images=Image.fromarray(foto),
return_tensors="pt").to(device)
with torch.no_grad():
ids = descriptor.generate(**x, do_sample=False, num_beams=3,
max_new_tokens=30)
texto = procesador.batch_decode(ids, skip_special_tokens=True)[0]
descripciones.append(texto)
print(f"{nombre}: {texto}")
# Y en español, con el traductor de la sección anterior
for texto in traducir(descripciones, tok_en_es, en_es):
print("→", texto)
fig, ejes = plt.subplots(2, 1, figsize=(4.8, 4.8))
for eje, (nombre, foto) in zip(ejes, fotos.items()):
eje.imshow(foto)
eje.set_title(nombre, fontsize=14)
eje.axis("off")
plt.show()
china.jpg: A pagoda building overlooks a body of water.
flower.jpg: A close up of a flower with a blurry background.
→ Un edificio pagoda tiene vistas a un cuerpo de agua.
→ Un primer plano de una flor con un fondo borroso.
Las dos descripciones son correctas y generales. Encadenamos dos modelos codificador-decodificador (imagen → inglés → español), y la traducción es literal («un cuerpo de agua» viene de a body of water): los errores de cada paso se suman. Las fotos de scikit-learn son de Flickr, con licencia CC BY 2.0.
Límites de reutilizar modelos
- Costo. Ajustar sigue costando: aquí, unos minutos por forma con 1500 reseñas cortas. Con más datos, textos largos o modelos grandes, se necesita una GPU (en Colab, la del entorno con GPU) o técnicas como LoRA.
- Olvido catastrófico. Un ajuste fuerte (tasa alta, muchas épocas) puede borrar lo que el modelo sabía y dejarlo bueno solo en tu tarea, o en nada. LoRA lo limita, porque los pesos originales quedan intactos.
- Sobreajuste con pocos datos. Con 100 reseñas, un modelo de 67 millones de parámetros puede memorizarlas. Por eso se mide siempre en validación, como en el tema 7.
- El idioma y los datos del preentrenamiento. DistilBERT leyó inglés; con reseñas en español rinde mucho peor (lo pruebas en el mini-proyecto). Para español conviene un modelo multilingüe o uno entrenado en español. Y el modelo hereda los sesgos de los textos con que se entrenó (ejercicio 3).
- Licencias. «Descargable» no significa «se puede usar para cualquier cosa». Antes de usar un modelo o un dataset, lee su ficha: aquí todo es Apache-2.0, MIT o CC BY, que permiten el uso educativo y comercial citando la fuente.
Lo que aprendiste y lo que sigue
- Hay tres familias de transformers: los codificadores (BERT) entienden, los decodificadores (GPT) generan y los codificadores-decodificadores (Marian, Florence-2) transforman una entrada en una salida, conectados por la atención cruzada.
- BERT se preentrena adivinando tokens tapados con
[MASK], sin etiquetas. Para clasificar, se usa el vector del token[CLS]con una cabeza nueva. - Hay tres formas de ajustar: entrenar solo la cabeza (barato, 85,5 % aquí), el ajuste completo (88,5 %, con una tasa de aprendizaje pequeña) y LoRA (89 % entrenando el 1,1 % de los parámetros).
- LoRA congela W y aprende un cambio de rango bajo B·A. Ahorra memoria y espacio, no tanto tiempo.
- Para traducir y describir imágenes en
transformers5 se usan las clasesAuto…congenerate, y condo_sample=Falsela salida es la misma cada vez.
En el tema 26 vas a reutilizar modelos de visión de la misma manera: congelar una red preentrenada, ajustarla con pocas fotos y conocer CLIP, un modelo que pone imágenes y textos en el mismo espacio de embeddings.





