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?

Cuaderno del tema. La teoría y los ejercicios con todo su código, listos para ejecutar en Colab sin instalar nada.

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; el ejercicio 7 usa además red, lotes, evaluar, funcion_perdida, X_val, y_val y promedio_ent, de la sección «Cuando los datos cambian». Abre el cuaderno del tema en Colab (botón «Abrir en Colab», en la ficha Código), ejecuta las celdas de la teoría y trabaja en los ejercicios, que vienen 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] trabaja de enfermero o enfermera»)
  • "[MASK] works as an engineer." («[MASK] trabaja de ingeniero o ingeniera»)

He es «él» y she, «ella».

¿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 vuelve 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.

7. Voltear las fotos

Usa la red de prendas de la sección «Cuando los datos cambian» y dos dominios nuevos hechos con las fotos de validación: el espejo (izquierda y derecha intercambiadas) y de cabeza (arriba y abajo intercambiados). Para cada uno calcula la exactitud, la confianza media y la distancia a la foto promedio del entrenamiento. ¿Cuál le hace más daño? ¿Te sorprende el espejo? ¿Lo detectaría la confianza? ¿Y la distancia?

Respuesta
volteos = {
    # flip invierte el orden en el eje dado: 3 es el ancho, 2 el alto
    "Espejo": torch.flip(X_val, dims=(3,)),
    "De cabeza": torch.flip(X_val, dims=(2,)),
}
for nombre, X_d in volteos.items():
    _, exactitud = evaluar(red, lotes(X_d, y_val), funcion_perdida)
    with torch.no_grad():
        confianza = red(X_d).softmax(dim=1).max(dim=1).values
    distancia = (X_d.mean(dim=0) - promedio_ent).abs().mean()
    print(f"{nombre:10} exactitud {exactitud:.3f}, "
          f"confianza {confianza.mean():.2f}, distancia {distancia:.3f}")
Espejo     exactitud 0.587, confianza 0.77, distancia 0.058
De cabeza  exactitud 0.242, confianza 0.66, distancia 0.061

Las dos le hacen daño, y el espejo más de lo que uno esperaría: una polera o un pantalón se ven casi igual en el espejo, pero la exactitud cae del 84,9 % al 58,7 %. La razón está en las fotos: en FashionMNIST el calzado (sandalias, zapatillas y botines) mira siempre hacia el mismo lado, y la red aprendió esa posición: casi todo el error del espejo está en el calzado. De cabeza, todo se ve raro y cae al 24,2 %. La confianza apenas baja con el espejo (0,77 frente a 0,82): no lo delataría. La distancia sí: 0,058 y 0,061, frente a 0,004 con las fotos normales. Si quisieras una red a la que no le importe el espejo, podrías usar aumentación de datos (tema 20): entrenar también con fotos volteadas al azar.

8. ¿Qué está pasando?

Para cada caso, di si es cambio de dominio, olvido o algo que hay que desaprender, y propone un remedio.

  1. Un modelo que reconoce enfermedades de la papa se entrenó con fotos de hojas tomadas en un laboratorio. En el campo, con sol fuerte y fondo de tierra, acierta mucho menos.
  2. Un asistente que respondía preguntas sobre los trámites de una universidad se ajustó con preguntas de una carrera nueva. Ahora responde bien sobre la carrera nueva, pero mal sobre los trámites de siempre.
  3. Una estudiante pide que sus fotos se borren del conjunto con el que se entrenó un modelo que reconoce caras.
Respuesta
  1. Cambio de dominio: la tarea es la misma, pero las fotos se ven distintas. Se detecta comparando las fotos del campo con las del laboratorio (y etiquetando a mano unas pocas). Remedio: etiquetar algunas fotos del campo y ajustar el modelo con ellas, mezcladas con fotos del laboratorio para no olvidar.
  2. Olvido catastrófico: al ajustarse solo con la tarea nueva, perdió la vieja. Remedio: repasar, es decir, mezclar en el ajuste una parte de las preguntas de siempre; o ajustar con LoRA y guardar un par B y A por carrera, con el modelo original intacto.
  3. Desaprender: lo exacto es volver a entrenar el modelo sin sus fotos. Los métodos aproximados son más baratos, pero no garantizan que no quede rastro.

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. Desde la versión 5 ya no existen (si tu Colab trae la 4, actualízala como dice la teoría): 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.
  • Confiar en la confianza. Un modelo puede estar muy seguro y muy equivocado con datos de otro dominio (las fotos rotadas). Vigila también cómo se ven las entradas y mide con unos pocos ejemplos nuevos etiquetados.
  • Ajustar solo con lo nuevo. Si en el ajuste solo hay datos nuevos, el modelo puede olvidar lo viejo; mezcla una parte de los datos viejos (repaso).

¿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).
  • Reconocer un cambio de dominio y el olvido catastrófico, detectarlos y elegir un remedio (repaso, ajuste con pocos ejemplos nuevos, LoRA) (sección «Cuando los datos cambian» y ejercicios 7 y 8).