Reutilizar modelos: BERT, codificador-decodificador y ajuste fino

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

En esta ficha · 5 secciones
  1. Antes de empezar
  2. Ejercicios
  3. Mini-proyecto: ¿vale la pena el transformer?
  4. Errores comunes
  5. ¿Lo lograste?

Antes de empezar

Varios ejercicios usan las variables y funciones de la teoría: tokenizador, completar, entrenamiento, validacion, nuevo_clasificador, contar, lora (el modelo ajustado con LoRA), probabilidad_positiva, traducir, tok_en_es y en_es. Abre un cuaderno nuevo en Colab, pega y ejecuta en orden los bloques de la ficha Código y después trabaja debajo. Entrenar las tres formas tarda unos minutos sin GPU; si activas el entorno con GPU, mucho menos. Intenta cada ejercicio antes de abrir la respuesta.

Ejercicios

1. ¿Qué familia usarías?

Para cada tarea, di si usarías un codificador, un decodificador o un codificador-decodificador, y por qué.

  1. Decidir si un correo es spam.
  2. Resumir una noticia en dos frases.
  3. Continuar un cuento a partir de su primer párrafo.
  4. Encontrar, entre 1000 preguntas frecuentes, la más parecida a la de un usuario.
  5. Pasar un audio en español a texto escrito.
Respuesta
  1. Codificador: hay que entender el correo entero y dar una clase, como las reseñas de la teoría.
  2. Codificador-decodificador: la entrada (la noticia) y la salida (el resumen) son textos distintos. Un decodificador grande también puede hacerlo si le das la noticia en el prompt, como en el tema «Modelos de lenguaje».
  3. Decodificador: es continuar un texto, token a token.
  4. Codificador: se convierte cada pregunta en un embedding y se busca la más cercana (el buscador del tema «Embeddings»).
  5. Codificador-decodificador: el codificador lee el audio y el decodificador escribe el texto, con atención cruzada. Es como la descripción de imágenes, con sonido en lugar de píxeles (tema 28).

2. Contar los parámetros de LoRA

En la teoría, LoRA con r = 8 en q_lin y v_lin agregó 147 456 parámetros.

  1. Escribe la fórmula del número de parámetros de LoRA en función de r, para matrices de 768 × 768 en 6 capas y 2 matrices por capa.
  2. Calcula a mano cuántos son con r = 1, 4, 16 y 64.
  3. ¿Con qué r LoRA tendría tantos parámetros como una matriz completa de 768 × 768, en cada capa adaptada?
  4. Compruébalo con peft: crea el modelo con cada r (sin entrenarlo) y cuenta.
Respuesta
  1. Cada matriz adaptada suma B (768 × r) y A (r × 768): 2 · 768 · r = 1536 r. Con 12 matrices: 12 · 1536 r = 18 432 r.
  2. r = 1: 18 432; r = 4: 73 728; r = 16: 294 912; r = 64: 1 179 648.
  3. Cuando 1536 r = 768 · 768, o sea r = 384, la mitad del lado. Con r mayor, LoRA ya no ahorra nada, como en la actividad con r = 32 para la imagen de 64.
for r in [1, 4, 16, 64]:
    config_r = LoraConfig(task_type="SEQ_CLS", r=r, lora_alpha=2 * r,
                          target_modules=["q_lin", "v_lin"])
    modelo_r = get_peft_model(nuevo_clasificador(), config_r)
    de_lora = sum(p.numel() for n, p in modelo_r.named_parameters()
                  if "lora_" in n)
    print(f"r = {r:2d}: {de_lora:8d} de LoRA ({18432 * r:8d} a mano), "
          f"{contar(modelo_r)[0]:8d} entrenables con la cabeza")
r =  1:    18432 de LoRA (   18432 a mano),   610562 entrenables con la cabeza
r =  4:    73728 de LoRA (   73728 a mano),   665858 entrenables con la cabeza
r = 16:   294912 de LoRA (  294912 a mano),   887042 entrenables con la cabeza
r = 64:  1179648 de LoRA ( 1179648 a mano),  1771778 entrenables con la cabeza

Con r chico, la mayoría de los entrenables son los de la cabeza (592 130), que no dependen de r.

3. Lo que el modelo aprendió de sus textos

Un modelo preentrenado aprende lo que dicen sus textos, incluidos los estereotipos. Usa completar con estas frases y compara las probabilidades de he y she:

  • "[MASK] works as a nurse."
  • "[MASK] works as an engineer."

¿Qué ves? ¿Por qué importa si ese modelo se usa, por ejemplo, para ordenar postulaciones a un trabajo?

Respuesta
for oficio in ["a nurse", "an engineer"]:
    palabras, probs = completar(f"[MASK] works as {oficio}.", k=30)
    prob = dict(zip(palabras, probs))
    print(f"{oficio:12s}  he {prob.get('he', 0):.2f}"
          f"   she {prob.get('she', 0):.2f}")
a nurse       he 0.01   she 0.07
an engineer   he 0.06   she 0.01

Las probabilidades son chicas porque el modelo reparte el resto entre otras palabras posibles al comienzo de la frase. Lo que importa es la proporción: para nurse, she es unas 7 veces más probable que he; para engineer, al revés, unas 6 veces. Ninguna de las dos frases dice nada sobre si la persona es hombre o mujer, pero el modelo cambia mucho sus probabilidades según el oficio: repite la proporción con que aparecían esas frases en los libros y la Wikipedia con que se entrenó. Si un clasificador ajustado sobre este modelo ordenara postulaciones, podría arrastrar ese sesgo sin que nadie lo programara. El ajuste fino no lo borra solo: hay que medirlo, como acabas de hacer, y corregirlo con datos y pruebas.

4. ¿Cuántos ejemplos hacen falta?

Ajusta un modelo con 100, 500 y 1500 reseñas de entrenamiento y mide la exactitud en validacion. Para que corra rápido, usa BERT-mini (google/bert_uncased_L-4_H-256_A-4, de Google, Apache-2.0: 4 capas y 11 millones de parámetros, 45 MB), con ajuste completo, 2 épocas y tasa 0,0001. Necesita su propio tokenizador, así que escribe una función juntar para él.

¿Cómo cambia la exactitud? ¿Qué esperarías con 15 000 reseñas?

Respuesta
MINI = "google/bert_uncased_L-4_H-256_A-4"
tok_mini = AutoTokenizer.from_pretrained(MINI)


def juntar_mini(lote):
    x = tok_mini([t for t, _ in lote], truncation=True, max_length=128,
                 padding=True, return_tensors="pt")
    x["labels"] = torch.tensor([e for _, e in lote])
    return x


val_mini = DataLoader(validacion, batch_size=32, collate_fn=juntar_mini)
for n in [100, 500, 1500]:
    torch.manual_seed(0)
    mini = AutoModelForSequenceClassification.from_pretrained(
        MINI, num_labels=2).to(device)
    optimizador = torch.optim.AdamW(mini.parameters(), lr=1e-4)
    cargador = DataLoader(entrenamiento[:n], batch_size=16, shuffle=True,
                          collate_fn=juntar_mini,
                          generator=torch.Generator().manual_seed(0))
    for epoca in range(2):
        mini.train()
        for lote in cargador:
            lote = lote.to(device)
            optimizador.zero_grad()
            mini(**lote).loss.backward()
            optimizador.step()
    mini.eval()
    aciertos = 0
    with torch.no_grad():
        for lote in val_mini:
            lote = lote.to(device)
            pred = mini(**lote).logits.argmax(dim=1)
            aciertos += (pred == lote["labels"]).sum().item()
    print(f"{n:5d} reseñas: exactitud {aciertos / len(validacion):.3f}")
  100 reseñas: exactitud 0.520
  500 reseñas: exactitud 0.790
 1500 reseñas: exactitud 0.848

Con 100 reseñas el modelo no aprende nada útil: queda en el 52 % de decir siempre «negativa» (son solo 14 pasos del optimizador). Con 500 llega al 79 % y con 1500 al 84,8 %, casi lo mismo que DistilBERT con la cabeza sola (85,5 %). Cada vez que se triplican los datos se gana menos: con 15 000 esperarías una mejora más pequeña, y la única forma de saberlo es medirlo. Ojo: con tan pocas reseñas de validación (400), una diferencia de 1 o 2 puntos puede ser ruido.

5. Ida y vuelta

Traduce estas frases del inglés al español con en_es y después de vuelta al inglés con opus-mt-es-en (el modelo inverso, también de Helsinki-NLP). ¿Vuelven iguales? ¿Qué se pierde?

  • “The movie was not as good as the book.”
  • “She left the bank after a long day.”
  • “It’s raining cats and dogs.”
Respuesta
tok_es_en = AutoTokenizer.from_pretrained("Helsinki-NLP/opus-mt-es-en")
es_en = AutoModelForSeq2SeqLM.from_pretrained(
    "Helsinki-NLP/opus-mt-es-en").to(device).eval()

frases_ida = ["The movie was not as good as the book.",
              "She left the bank after a long day.",
              "It's raining cats and dogs."]
ida = traducir(frases_ida, tok_en_es, en_es)
vuelta = traducir(ida, tok_es_en, es_en)
for a, b, c in zip(frases_ida, ida, vuelta):
    print(a)
    print("  →", b)
    print("  →", c)
The movie was not as good as the book.
  → La película no era tan buena como el libro.
  → The movie wasn't as good as the book.
She left the bank after a long day.
  → Salió del banco después de un largo día.
  → He left the bank after a long day.
It's raining cats and dogs.
  → Está lloviendo gatos y perros.
  → It's raining cats and dogs.
  • La primera vuelta casi igual: es una frase literal.
  • En la segunda se pierde she: en español «Salió» no dice si fue él o ella, y al volver el modelo elige he. La información que no está en la frase intermedia no se puede recuperar. Además, bank podía ser un banco de dinero o la orilla de un río: el traductor eligió uno sin poder saber cuál era.
  • La tercera es un modismo («llueve a cántaros») traducido palabra por palabra. Que vuelva igual no significa que la traducción intermedia sea buena: la ida y vuelta esconde ese error.

6. ¿Qué forma de ajuste?

Para cada caso, elige entre entrenar solo la cabeza, LoRA o el ajuste completo, y justifica.

  1. Tienes 200 ejemplos etiquetados y una computadora sin GPU, y necesitas un resultado hoy.
  2. Una empresa quiere adaptar un mismo modelo de 7000 millones de parámetros a 50 clientes, cada uno con sus propios datos.
  3. Tienes 100 000 ejemplos, un modelo de 11 millones de parámetros y una GPU.
Respuesta
  1. Solo la cabeza: con 200 ejemplos, ajustar todo el modelo arriesga sobreajuste, y la cabeza se entrena rápido. Incluso se pueden calcular los vectores de [CLS] una sola vez y entrenar encima una regresión logística.
  2. LoRA: guardar 50 copias de 7000 millones de parámetros sería enorme; con LoRA se guarda un solo modelo base y 50 pares de matrices B y A pequeñas, que se cambian según el cliente.
  3. Completo: con muchos datos y un modelo chico, el costo es bajo y se aprovecha toda la flexibilidad del modelo; el riesgo de sobreajuste es menor.

Mini-proyecto: ¿vale la pena el transformer?

En el tema «Texto como datos» clasificaste textos con TF-IDF y regresión logística. Ahora compáralo con tu modelo LoRA, y prueba qué pasa con reseñas en español.

  1. Entrena TF-IDF con regresión logística sobre las mismas 1500 reseñas de entrenamiento y mide la exactitud en las mismas 400 de validacion. ¿Cuántos puntos gana LoRA?
  2. Busca reseñas de validación en las que los dos modelos discrepan. Lee algunas: ¿en qué tipo de frases acierta el transformer y falla TF-IDF? ¿Hay alguna al revés?
  3. Escribe diez reseñas cortas en español (cinco positivas y cinco negativas) y clasifícalas con tu modelo LoRA: primero tal como están, y después traducidas al inglés con opus-mt-es-en (del ejercicio 5). ¿Cuántas acierta en cada caso?
  4. Escribe un párrafo con tus conclusiones: cuándo usarías TF-IDF, cuándo el modelo ajustado y qué harías para clasificar reseñas en español.
Resultados de referencia, para comprobar los tuyos
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression

textos_ent = [t for t, _ in entrenamiento]
y_ent = [e for _, e in entrenamiento]
textos_val = [t for t, _ in validacion]
y_val = np.array([e for _, e in validacion])

# Paso 1: TF-IDF con regresión logística
tfidf = TfidfVectorizer(ngram_range=(1, 2), min_df=2, sublinear_tf=True)
clasificador = LogisticRegression(max_iter=1000)
clasificador.fit(tfidf.fit_transform(textos_ent), y_ent)
pred_tfidf = clasificador.predict(tfidf.transform(textos_val))

p_lora = np.array([p for i in range(0, len(textos_val), 32) for p in
                   probabilidad_positiva(lora, textos_val[i:i + 32])])
pred_lora = (p_lora > 0.5).astype(int)
print(f"TF-IDF: {np.mean(pred_tfidf == y_val):.3f}"
      f"   LoRA: {np.mean(pred_lora == y_val):.3f}")

# Paso 2: dónde discrepan
solo_lora = np.flatnonzero((pred_lora == y_val) & (pred_tfidf != y_val))
solo_tfidf = np.flatnonzero((pred_tfidf == y_val) & (pred_lora != y_val))
print("Acierta solo LoRA:", len(solo_lora),
      "  acierta solo TF-IDF:", len(solo_tfidf))
for i in solo_lora[:2]:
    print(f"[{y_val[i]}]", textwrap.shorten(textos_val[i], 74))

# Paso 3: reseñas en español (escritas para este ejercicio)
propias_es = [
    ("Me encantó, llegó rápido y funciona perfecto.", 1),
    ("Excelente calidad, lo volvería a comprar.", 1),
    ("Muy cómodo y el precio es justo.", 1),
    ("Superó lo que esperaba, mi hija está feliz.", 1),
    ("No es perfecto, pero por este precio es muy bueno.", 1),
    ("Se rompió a la semana, no lo recomiendo.", 0),
    ("Nunca llegó y nadie respondió mis mensajes.", 0),
    ("La batería dura muy poco, una decepción.", 0),
    ("Pésima calidad, parece de juguete.", 0),
    ("Lo devolví al día siguiente.", 0),
]
textos_es = [t for t, _ in propias_es]
y_es = np.array([e for _, e in propias_es])
en_ingles = traducir(textos_es, tok_es_en, es_en)
for nombre, textos in [("en español", textos_es),
                       ("traducidas", en_ingles)]:
    pred = np.array(probabilidad_positiva(lora, textos)) > 0.5
    print(f"LoRA {nombre}: {np.sum(pred == y_es)} de 10 aciertos")
TF-IDF: 0.838   LoRA: 0.890
Acierta solo LoRA: 42   acierta solo TF-IDF: 21
[0] like reading greek. If you love having no idea what someone is [...]
[1] A Classic of Modern Philosophy. Do not get confused. This book seems [...]
LoRA en español: 5 de 10 aciertos
LoRA traducidas: 9 de 10 aciertos
  • El transformer gana unos 5 puntos con los mismos datos: 42 reseñas que solo él acierta frente a 21 que solo acierta TF-IDF. Entre las que solo acierta él hay frases en sentido figurado, como «like reading greek» («como leer en griego», o sea, incomprensible), y reseñas que empiezan elogiando para después criticar, o al revés, donde las palabras sueltas engañan. Ninguno es perfecto.
  • En español, el modelo ajustado con reseñas en inglés acierta 5 de 10, lo mismo que tirar una moneda: DistilBERT casi no vio español en su preentrenamiento. Traducidas al inglés, acierta 9 de 10 (con solo 10 frases, escritas por nosotros, es una prueba chica).
  • Para clasificar reseñas en español hay dos caminos: traducir antes de clasificar (cuesta un modelo más y arrastra los errores de la traducción) o, mejor, ajustar con LoRA un modelo multilingüe o en español sobre reseñas reales en español.
  • TF-IDF entrena en menos de un segundo y se explica fácil (puedes ver el peso de cada palabra): sigue siendo una muy buena primera prueba.

Errores comunes

  • Usar una tasa de aprendizaje de «entrenar desde cero» en el ajuste completo. Con 0,001 en lugar de 0,00005, los primeros lotes pueden borrar lo que el modelo sabía. Empieza con tasas entre 0,00001 y 0,0001 para el ajuste completo.
  • Olvidar congelar al entrenar solo la cabeza. Si no pones requires_grad = False en el codificador, estás haciendo un ajuste completo con una tasa demasiado alta.
  • Mezclar tokenizadores. Cada modelo tiene el suyo: los IDs de DistilBERT no significan nada para Marian ni para otro modelo. Carga siempre el tokenizador con el mismo nombre que el modelo.
  • Asustarse por el aviso de pesos que faltan. Al crear AutoModelForSequenceClassification, el aviso de que classifier.weight falta es lo esperado: la cabeza es nueva. Sería grave si faltaran pesos del codificador.
  • Medir solo en entrenamiento. Con pocos datos, un modelo grande puede memorizar. Guarda siempre un conjunto de validación que el modelo no vea.
  • Rellenar todo a la longitud máxima. Rellenar cada lote solo hasta su texto más largo es igual de correcto y más rápido. Y recuerda que max_length=128 corta las reseñas largas: lo que viene después no lo ve el modelo.
  • Esperar que LoRA entrene mucho más rápido. Ahorra memoria y espacio, pero cada lote sigue pasando por todo el modelo.
  • Buscar pipeline("translation") o pipeline("image-to-text") en transformers 5. Ya no existen: se usan AutoModelForSeq2SeqLM o AutoModelForImageTextToText con generate, como en la teoría.
  • Generar con azar cuando quieres comparar. Con do_sample=True cada corrida da otra frase; para comparar modelos o depurar, usa do_sample=False.
  • Leer un mapa de atención como una explicación. Muestra adónde mira una capa, no por qué el modelo decide lo que decide.

¿Lo lograste?

Al terminar este tema deberías poder hacer lo siguiente. Al lado, dónde lo practicaste.

  • Explicar cómo se preentrena BERT con tokens enmascarados y para qué sirve [CLS] (sección «BERT» de la teoría y ejercicio 3).
  • Ajustar por completo un codificador pequeño para clasificar texto con transformers y un bucle propio (teoría y ejercicio 4).
  • Aplicar LoRA con peft y comparar parámetros entrenables, tiempo y exactitud con el ajuste completo y con la cabeza sola (ejercicios 2 y 6, y pasos 1 y 2 del mini-proyecto).
  • Usar un modelo codificador-decodificador para traducir y otro para describir imágenes, y explicar el flujo codificador → atención cruzada → decodificador (ejercicios 1 y 5, y paso 3 del mini-proyecto).