Reutilizar modelos: BERT, codificador-decodificador y ajuste fino

Tema 25 · Fase 5, Lenguaje y atención · Toda la ruta

Los 15 bloques de código de la teoría, en el mismo orden. Cópialos y córrelos en tu Python o en Colab; en la ficha Teoría está la explicación de cada uno.

BERT: aprender a leer tapando palabras

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)))
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()

Los datos: reseñas de Amazon

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)

El bucle, versión texto

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

cabeza = nuevo_clasificador()
for p in cabeza.distilbert.parameters():    # el codificador, congelado
    p.requires_grad = False
probar("Solo la cabeza", cabeza, tasa=1e-3)

Segunda forma: ajustar todo el modelo

completo = nuevo_clasificador()
probar("Completo", completo, tasa=5e-5)

Tercera forma: LoRA

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()
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)

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()
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()
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}")

Traducir con atención cruzada

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)
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()

Describir imágenes

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()