Antes de empezar
Varios ejercicios usan las variables y funciones de la teoría: quijote, todas, conteo, palabras, sin_tildes, construir_vocabulario, entrenar_bpe, tokenizar_bpe, tok_gpt2, contar_ngramas, perplejidad, textos, etiquetas, X_ent, X_pru, y_ent, y_pru y resenas. Abre un cuaderno nuevo en Colab, pega y ejecuta en orden los bloques de la ficha Código y después trabaja debajo. Intenta cada ejercicio antes de abrir la respuesta: equivocarse primero y corregir después es como se aprende de verdad.
Ejercicios
1. ¿Qué normalizar?
Para cada caso, decide si conviene pasar a minúsculas, quitar las tildes y quitar la puntuación, y explica por qué:
- Un buscador de recetas donde la gente escribe rápido desde el celular («receta de pique macho sin cebolla»).
- Un modelo que corrige la ortografía de tareas escolares.
- Un clasificador que decide si un mensaje es una pregunta o una afirmación.
- Un contador de cuántas veces se nombra a cada personaje en el Quijote.
Respuesta
- Minúsculas sí, tildes fuera, puntuación fuera. La gente escribe sin tildes desde el celular; para encontrar «limón» tiene que servir «limon». Aquí juntar variantes importa más que distinguir «él» de «el».
- Nada. Las mayúsculas, las tildes y la puntuación son justamente lo que se corrige: si las quitas, el modelo no puede ver los errores.
- La puntuación se queda. Los signos «¿» y «?» son la mejor pista. Las minúsculas no molestan.
- Minúsculas no. «Sancho» con mayúscula es el personaje; las tildes pueden quedarse. Pero cuidado: al principio de una oración cualquier palabra va con mayúscula, así que también hay que mirar el contexto.
No hay una normalización correcta para todo: se elige según la tarea.
2. BPE a mano, con 5 fusiones
Entrena BPE a mano con este texto de cinco palabras: «sol sola solo sol solos». Empieza partiendo cada palabra en letras con «_» al final, cuenta los pares de piezas vecinas (cada palabra cuenta tantas veces como aparece) y haz 5 fusiones. Si hay empate, gana el par que aparece primero leyendo las palabras en orden, de izquierda a derecha. Al final, ¿en qué tokens queda partida cada palabra? Después compruébalo con entrenar_bpe.
Respuesta
Las palabras distintas son sol (2 veces), sola, solo y solos (1 vez cada una).
- Los pares «s o» y «o l» aparecen 5 veces cada uno (en todas las palabras). Empate: gana «s o», que aparece primero. Nueva pieza: so.
- Ahora «so l» aparece 5 veces. Nueva pieza: sol.
- «sol _» aparece 2 veces (las dos de sol) y «sol o» también 2 (solo y solos). Empate: gana «sol », porque sol es la primera palabra. Nueva pieza: **sol**.
- «sol o» es el único par con 2. Nueva pieza: solo.
- Todos los pares que quedan aparecen 1 vez; el primero es «sol a», de sola. Nueva pieza: sola.
Resultado: sol = sol_, sola = sola + _, solo = solo + _, solos = solo + s + _.
fus, _ = entrenar_bpe(Counter("sol sola solo sol solos".split()), 5)
print(["".join(par) for par in fus])
for p in ["sol", "sola", "solo", "solos"]:
print(p, tokenizar_bpe(p, fus))
['so', 'sol', 'sol_', 'solo', 'sola']
sol ['sol_']
sola ['sola', '_']
solo ['solo', '_']
solos ['solo', 's', '_']
3. ¿Cuántas palabras raras dejar fuera?
Usa el primer 90 % del Quijote para construir el vocabulario y el último 10 % para probarlo. Con minimo igual a 1, 2, 5 y 20, calcula el tamaño del vocabulario y qué porcentaje de las palabras de la prueba quedan como <UNK>. ¿Qué gana y qué pierde un vocabulario chico?
Respuesta
corte = int(0.9 * len(todas))
conteo_ent = Counter(todas[:corte])
for minimo in [1, 2, 5, 20]:
v = construir_vocabulario(conteo_ent, minimo)
unk = sum(p not in v for p in todas[corte:]) / len(todas[corte:])
print(f"mínimo {minimo:>2}: vocabulario {len(v):>6,} "
f"desconocidas en la prueba: {unk:.1%}")
mínimo 1: vocabulario 21,723 desconocidas en la prueba: 3.9%
mínimo 2: vocabulario 11,009 desconocidas en la prueba: 6.1%
mínimo 5: vocabulario 4,929 desconocidas en la prueba: 10.1%
mínimo 20: vocabulario 1,532 desconocidas en la prueba: 18.0%
Con minimo=1 (todas las palabras del entrenamiento) el vocabulario casi se duplica respecto de minimo=2 (21 723 contra 11 009), pero las desconocidas solo bajan de 6,1 % a 3,9 %: casi todas las palabras que aparecen una sola vez en el entrenamiento no vuelven a aparecer en la prueba. Con minimo=20, el vocabulario es muy chico (un modelo más liviano, con menos que aprender), pero muchas palabras de la prueba se pierden como <UNK>. Por eso los modelos modernos usan subpalabras: con BPE no hay que tirar ninguna palabra.
4. Perplejidad con n = 1, 2 y 3
Calcula la perplejidad del modelo de la teoría sobre la prueba con n = 1, 2 y 3, primero con lam=0.6 y después con lam=0.3. ¿El trigrama es mejor que el bigrama? ¿Por qué crees que pasa eso?
Respuesta
for lam in [0.6, 0.3]:
resultado = [perplejidad(prueba, n, lam) for n in (1, 2, 3)]
print(f"lam = {lam}: " + " ".join(
f"n = {n}: {p:.0f}" for n, p in zip((1, 2, 3), resultado)))
trigramas, _ = cuentas[3]
nuevos = sum(tuple(prueba[i:i + 3]) not in trigramas
for i in range(len(prueba) - 2))
print(f"Trigramas de la prueba nunca vistos: "
f"{nuevos / (len(prueba) - 2):.0%}")
lam = 0.6: n = 1: 486 n = 2: 212 n = 3: 265
lam = 0.3: n = 1: 486 n = 2: 241 n = 3: 214
Trigramas de la prueba nunca vistos: 64%
El unigrama no usa lam, así que da lo mismo en las dos filas. El bigrama mejora mucho al unigrama. El trigrama, en cambio, empeora con lam=0.6 (265 contra 212), y con lam=0.3 llega a 214, casi lo mismo que el mejor bigrama. La razón está en la última línea: la mayoría de los trigramas de la prueba nunca aparecieron en el entrenamiento. Un solo libro no alcanza para estimar bien tantas combinaciones de tres palabras, y confiar mucho en esas cuentas (un lam alto) castiga al modelo. Más contexto solo ayuda si hay datos para aprenderlo; los modelos neuronales del tema 24 lo logran porque comparten lo aprendido entre palabras parecidas.
5. La bolsa no ve el orden
Estas dos reseñas dicen lo contrario: «no es bueno, es malo» y «no es malo, es bueno». Calcula su bolsa de palabras con CountVectorizer(). ¿Son distintas? Repite con CountVectorizer(ngram_range=(1, 2)), que además de las palabras cuenta los bigramas (pares de palabras seguidas).
Respuesta
dos = ["no es bueno, es malo", "no es malo, es bueno"]
for rango in [(1, 1), (1, 2)]:
cv = CountVectorizer(ngram_range=rango)
X2 = cv.fit_transform(dos).toarray()
print(rango, "¿iguales?", (X2[0] == X2[1]).all())
print(list(cv.get_feature_names_out()))
(1, 1) ¿iguales? True
(1, 2) ¿iguales? False
['bueno', 'bueno es', 'es', 'es bueno', 'es malo', 'malo', 'malo es', 'no', 'no es']
Con palabras sueltas, las dos reseñas son el mismo vector: ningún modelo podría separarlas. Con bigramas aparecen «no es», «es bueno» y «es malo», y ahora sí son distintas: «bueno es» solo está en la primera y «malo es» solo en la segunda. Los bigramas recuperan un poco de orden, a cambio de un vocabulario mucho más grande.
6. Bigramas en el clasificador
Entrena el clasificador de reseñas de la teoría con TfidfVectorizer(min_df=2, ngram_range=(1, 2)). Compara la exactitud y el tamaño del vocabulario con los del modelo de solo palabras. ¿Qué peso recibe el bigrama «not bad»?
Respuesta
con_bigramas = make_pipeline(
TfidfVectorizer(min_df=2, ngram_range=(1, 2)),
LogisticRegression(max_iter=1000))
con_bigramas.fit(X_ent, y_ent)
vocab_bi = con_bigramas[0].vocabulary_
print(f"Vocabulario: {len(vocab_bi):,}")
print(f"Exactitud: {con_bigramas.score(X_pru, y_pru):.3f}")
pesos_bi = con_bigramas[1].coef_[0]
for frase in ["not", "not bad", "not good", "not worth"]:
print(f"{frase:<10} {pesos_bi[vocab_bi[frase]]:+.2f}")
Vocabulario: 43,763
Exactitud: 0.858
not -5.96
not bad +0.15
not good -0.77
not worth -1.53
El vocabulario se multiplica por más de cuatro, pero la exactitud casi no cambia: con 4800 reseñas, la mayoría de los bigramas aparecen muy pocas veces para aprender bien su peso. Aun así, el modelo aprendió algo con sentido: «not bad» tiene peso positivo, al revés que «not» solo, y «not worth» (no vale la pena) es claramente negativo.
7. Formas con padding
Tienes tres secuencias de IDs de largos 4, 7 y 2. Sin computadora, responde: ¿qué forma tiene el lote que devuelve pad_sequence(..., batch_first=True)? ¿Cuántos ceros de relleno tiene? ¿Cuánto suma la máscara? ¿Y si después truncas el lote a 5 posiciones con lote[:, :5]? Compruébalo con código.
Respuesta
El lote tiene forma (3, 7): tres secuencias, rellenadas hasta 7, la más larga. Hay 3 + 0 + 5 = 8 ceros de relleno, y la máscara suma 4 + 7 + 2 = 13 (los tokens reales). Truncado a 5 queda (3, 5); la segunda secuencia pierde 2 tokens, y la máscara suma 4 + 5 + 2 = 11.
seqs = [torch.arange(1, n + 1) for n in [4, 7, 2]]
l = pad_sequence(seqs, batch_first=True)
m = l != 0
print(l.shape, "relleno:", (~m).sum().item(),
" máscara:", m.sum().item())
print(l[:, :5].shape, "máscara:", m[:, :5].sum().item())
torch.Size([3, 7]) relleno: 8 máscara: 13
torch.Size([3, 5]) máscara: 11
8. ¿Cuánto cuesta el español en GPT-2?
Cuenta cuántos tokens de GPT-2 hay por palabra en las primeras 2000 palabras del Quijote y en las primeras 2000 palabras de las reseñas (en inglés). ¿Cuántas veces más tokens necesita el español? Para comparar, haz lo mismo con el BPE de la teoría (300 fusiones).
Respuesta
texto_es = " ".join(quijote.split()[:2000])
texto_en = " ".join(" ".join(textos).split()[:2000])
for nombre, t in [("Quijote", texto_es), ("Reseñas", texto_en)]:
n_pal = len(palabras(t))
gpt2 = len(tok_gpt2.tokenize(t)) / n_pal
nuestro = sum(len(tokenizar_bpe(p, fusiones))
for p in palabras(t)) / n_pal
print(f"{nombre}: GPT-2 {gpt2:.2f} nuestro BPE {nuestro:.2f}"
" tokens por palabra")
Quijote: GPT-2 1.87 nuestro BPE 2.07 tokens por palabra
Reseñas: GPT-2 1.24 nuestro BPE 3.36 tokens por palabra
GPT-2 usa bastante más tokens por palabra en el español del Quijote que en las reseñas en inglés, y nuestro BPE hace lo contrario. (Los tokens de GPT-2 incluyen la puntuación, que palabras no cuenta, así que sus números salen un poco inflados en los dos idiomas.) Un modelo con un tokenizador entrenado en inglés lee menos texto en español con el mismo largo máximo, y le cuesta más tiempo procesarlo.
Mini-proyecto: un clasificador de sentimiento y sus errores
Construye el mejor clasificador de reseñas que puedas con TF-IDF y regresión logística, y después mira sus errores: es la parte que más enseña.
- Toma 20 000 reseñas de
resenas(conshuffle(seed=1), para que no sean las mismas de la teoría) y divídelas en entrenamiento (70 %), validación (15 %) y prueba (15 %), estratificando. - Elige con la validación la mejor combinación de
ngram_range((1, 1)o(1, 2)) y deCde la regresión logística (0.3, 1, 3 y 10; recuerda del tema 7 que unCmás chico regulariza más). - Con la mejor combinación, entrena con entrenamiento y validación juntos, y mide una sola vez en la prueba: exactitud, precisión y exhaustividad (tema 9).
- Busca los errores en los que el modelo estaba más seguro: las reseñas negativas con mayor probabilidad de ser positivas, y al revés. Léelas. ¿Por qué se equivocó? Busca negaciones, ironía, reseñas que hablan bien de una cosa y mal de otra, o etiquetas que parecen equivocadas.
- Escribe tres o cuatro reseñas propias en inglés que creas que van a engañar al modelo, y comprueba si lo logras.
Opcional: escribe 20 reseñas cortas en español (10 positivas y 10 negativas), entrena un clasificador solo con ellas y pruébalo con otras 5. ¿Qué pasa con tan pocos datos?
Resultados de referencia, para comprobar los tuyos
Este código hace los pasos 1 a 4; tus números deberían coincidir si usas la misma semilla y la misma división.
from sklearn.metrics import precision_score, recall_score
datos = resenas.shuffle(seed=1).select(range(20000))
txt = np.array([f"{t}. {c}" for t, c in zip(datos["title"],
datos["content"])])
lab = np.array(datos["label"])
txt_ent, txt_resto, lab_ent, lab_resto = train_test_split(
txt, lab, test_size=0.3, random_state=0, stratify=lab)
txt_val, txt_pru, lab_val, lab_pru = train_test_split(
txt_resto, lab_resto, test_size=0.5, random_state=0,
stratify=lab_resto)
resultados = {}
for rango in [(1, 1), (1, 2)]:
for C in [0.3, 1, 3, 10]:
m = make_pipeline(TfidfVectorizer(min_df=2, ngram_range=rango),
LogisticRegression(C=C, max_iter=1000))
resultados[rango, C] = m.fit(txt_ent, lab_ent).score(txt_val,
lab_val)
print(f"{rango} C = {C:<4} validación: "
f"{resultados[rango, C]:.3f}")
rango, C = max(resultados, key=resultados.get)
final = make_pipeline(TfidfVectorizer(min_df=2, ngram_range=rango),
LogisticRegression(C=C, max_iter=1000))
final.fit(np.concatenate([txt_ent, txt_val]),
np.concatenate([lab_ent, lab_val]))
pred = final.predict(txt_pru)
print(f"Elegido: {rango}, C = {C}")
print(f"Prueba: exactitud {(pred == lab_pru).mean():.3f} "
f"precisión {precision_score(lab_pru, pred):.3f} "
f"exhaustividad {recall_score(lab_pru, pred):.3f}")
(1, 1) C = 0.3 validación: 0.863
(1, 1) C = 1 validación: 0.881
(1, 1) C = 3 validación: 0.885
(1, 1) C = 10 validación: 0.882
(1, 2) C = 0.3 validación: 0.865
(1, 2) C = 1 validación: 0.880
(1, 2) C = 3 validación: 0.885
(1, 2) C = 10 validación: 0.891
Elegido: (1, 2), C = 10
Prueba: exactitud 0.901 precisión 0.898 exhaustividad 0.903
p_pos = final.predict_proba(txt_pru)[:, 1]
negativas_altas = np.argsort(np.where(lab_pru == 0, p_pos, -1))[::-1]
positivas_bajas = np.argsort(np.where(lab_pru == 1, p_pos, 2))
for titulo, indices in [("Negativas que parecían positivas",
negativas_altas),
("Positivas que parecían negativas",
positivas_bajas)]:
print(titulo)
# Saltamos las reseñas con insultos: son reales y algunas los tienen
limpias = [i for i in indices
if not set(palabras(txt_pru[i])) & {"stupid", "idiot",
"idiots", "dumb",
"crap", "hell",
"damn", "sucks"}]
for i in limpias[:3]:
print(f" {p_pos[i]:.2f} {txt_pru[i][:66]}")
Negativas que parecían positivas
0.99 good for a b-day prsent?. my brother loves these game. )hes favori
0.98 a good water saver. My well produces 6 GPM. I have a tankless wate
0.98 WARNING DONNIE DARKO FANS. Donnie Darko is one of the greatest mov
Positivas que parecían negativas
0.01 Amazon: Please Help!. This is not a book. It is not a publication.
0.01 Wrong Disc!. Of course, i'm a huge fan of Gilmore Girls. That's wh
0.04 EXCELENT BOOK. HAY THERE DO NOT LET THE TITLE OF THIS BOOK FOOL YO
Con validación se eligió (1, 2) y C = 10, aunque las ocho combinaciones quedan a menos de 3 puntos entre sí. En la prueba, con 14 000 + 3000 reseñas para entrenar, la exactitud sube a 0,90: más datos ayudan más que afinar el modelo. Al leer los errores más seguros aparecen los casos típicos:
- Reseñas con palabras positivas que critican otra cosa: el título de la segunda dice good (bueno) y la tercera empieza diciendo que una película es de las mejores (greatest), pero las dos están marcadas como negativas.
- Reseñas positivas llenas de not: la primera y la tercera de la segunda lista niegan cosas («no es un libro», «no dejes que el título te engañe») para defender el producto.
- Etiquetas que parecen equivocadas: la primera de la lista de arriba suena a una reseña contenta. En un dataset real siempre hay algo de ruido en las etiquetas.
Una bolsa de palabras no puede entender a qué se refiere cada palabra ni el tono de la frase; para eso hacen falta los modelos que leen el texto en orden, desde el tema 23.
Errores comunes
- Normalizar sin pensar. Quitar tildes, mayúsculas o puntuación a todo es una decisión que puede borrar justo la información que importa («él» y «el», o el «¿» de una pregunta).
- Construir el vocabulario con todos los datos. El vocabulario, los IDF y cualquier conteo se aprenden solo con el entrenamiento; si no, hay fuga de datos (tema 10). Un
Pipelinelo hace bien solo. - Llamar
fit_transformen la prueba. En la prueba vatransform: confit_transformel vectorizador aprende otro vocabulario y las columnas ya no significan lo mismo que en el entrenamiento. - Olvidar
<UNK>. Si el vocabulario no tiene un token para lo desconocido, la primera palabra nueva hace fallar el código con unKeyError. - Usar como ID de relleno un número que también es un token real. Si
<PAD>y una palabra comparten el 0, la máscara borra esa palabra. Reserva un ID solo para el relleno. - Rellenar y olvidar la máscara. Sin máscara, el modelo trata el relleno como si fueran palabras: un promedio de la frase sale distinto según cuánto relleno tenga.
- Mezclar tokenizadores. Los IDs de un tokenizador solo tienen sentido para el modelo que se entrenó con él: el 2000 de GPT-2 no es el 2000 de DistilBERT.
- Creer que un peso grande es una regla del idioma. «and» con peso positivo no significa que «y» sea una palabra alegre: el modelo encuentra correlaciones en sus datos, no significados.
- Usar un modelo en un idioma distinto del de su entrenamiento. Un clasificador entrenado con reseñas en inglés no sabe nada de una reseña en español: casi todas sus palabras le son desconocidas.
- Comparar perplejidades con vocabularios distintos. La perplejidad depende del vocabulario y de cómo se tratan los desconocidos; solo se comparan modelos medidos sobre el mismo texto y con el mismo vocabulario.
¿Lo lograste?
Al terminar este tema deberías poder hacer lo siguiente. Al lado, dónde lo practicaste.
- Normalizar un texto en español y decidir qué conservar (ejercicio 1).
- Tokenizar por palabras, por caracteres y por subpalabras con BPE (actividad, ejercicio 2 y ejercicio 8).
- Construir un vocabulario con un token desconocido y elegir su tamaño (ejercicio 3).
- Representar textos con bolsa de palabras y TF-IDF y clasificarlos con regresión logística (ejercicios 5 y 6, y el mini-proyecto).
- Aplicar padding, truncado y máscaras a secuencias de largo variable (ejercicio 7).
- Construir un modelo de lenguaje de n-gramas y medir su perplejidad (ejercicio 4).