Módulo 8 · Lenguaje y audio
Clasificación de texto
En esta páginaEl problema
El problema
La cafetería de una escuela reunió reseñas cortas y las etiquetó: 1 si la reseña es positiva y 0 si es negativa. Hay 20 reseñas de entrenamiento y 6 de prueba. Estas son cuatro de ellas:
| reseña | conjunto | etiqueta |
|---|---|---|
| La sopa estaba rica y caliente | entrenamiento | 1 |
| El pan estaba duro | entrenamiento | 0 |
| No me gustó el arroz, muy salado | entrenamiento | 0 |
| Deliciosa la ensalada | prueba | 1 |
Un clasificador de scikit-learn o una red de PyTorch recibe una matriz de números con el mismo ancho en todas las filas. Una reseña es una cadena de texto: la primera tiene seis palabras y la segunda, cuatro. Además, «deliciosa» y «ensalada» no aparecen en ninguna reseña de entrenamiento.
Tokenización y vocabulario
Las reseñas están en dos listas, entrenamiento y prueba, de pares (texto, etiqueta). textos_ent y y_ent guardan por separado los textos y las etiquetas de entrenamiento; textos_prueba y y_prueba, los de prueba.
Mostrar códigoOcultar código
entrenamiento = [
("La sopa estaba rica y caliente", 1),
("Muy buena la comida de hoy", 1),
("El pan estaba fresco y rico", 1),
("Me gustó mucho el arroz", 1),
("La atención fue rápida y amable", 1),
("Excelente el postre, muy rico", 1),
("Buena porción y buen precio", 1),
("El agua de limón estaba fresca", 1),
("Todo muy limpio y ordenado", 1),
("Rico el pollo, volveré mañana", 1),
("La sopa estaba fría y salada", 0),
("Muy mala la comida de hoy", 0),
("El pan estaba duro", 0),
("No me gustó el arroz, muy salado", 0),
("La fila fue lenta y larga", 0),
("El postre estaba horrible", 0),
("Porción pequeña y precio alto", 0),
("El agua sabía rara", 0),
("Las mesas estaban sucias", 0),
("El pollo frío, no volveré", 0),
]
prueba = [
("La comida estaba rica", 1),
("El arroz estaba frío", 0),
("Muy amable y rápida la atención", 1),
("Pan duro y sopa salada", 0),
("Deliciosa la ensalada", 1),
("Horrible, tardaron muchísimo", 0),
]
textos_ent = [texto for texto, _ in entrenamiento]
y_ent = [etiqueta for _, etiqueta in entrenamiento]
textos_prueba = [texto for texto, _ in prueba]
y_prueba = [etiqueta for _, etiqueta in prueba]
Las dos listas tienen 20 y 6 pares. El primer par de entrenamiento es ('La sopa estaba rica y caliente', 1) y el último de prueba es ('Horrible, tardaron muchísimo', 0). Cada par guarda la reseña tal como se escribió, con mayúsculas y signos, y su etiqueta. En entrenamiento hay 10 reseñas positivas y 10 negativas; en prueba, 3 y 3.
Tokenizar es dividir un texto en unidades llamadas tokens. Aquí un token es una palabra: el texto pasa a minúsculas y la expresión regular \w+ toma cada racha de letras o dígitos. En Python, \w incluye letras con acento y la ñ. re.findall devuelve todas las coincidencias del patrón como una lista de cadenas.
Pregunta. Escribe la lista de tokens de «Rico el pollo, volveré mañana».
import re
def tokenizar(texto):
return re.findall(r"\w+", texto.lower())
print(tokenizar("Rico el pollo, volveré mañana"))
La salida esperada está oculta.
El vocabulario asigna un id entero a cada token distinto. Se construye solo con las reseñas de entrenamiento, porque las de prueba representan texto que el modelo no ha visto. Dos ids quedan reservados: 0 para <pad>, el relleno, y 1 para <unk>, cualquier palabra desconocida. Counter cuenta cuántas veces aparece cada token y most_common(5) devuelve los cinco más frecuentes. En codificar, indice.get(token, 1) devuelve el id del token o 1 si no está en el vocabulario.
Pregunta. ¿Cuántos ids tendrá el vocabulario? ¿Qué reseñas de prueba contendrán el id 1 y cuántas veces?
from collections import Counter
conteo = Counter(token for texto, _ in entrenamiento for token in tokenizar(texto))
vocab = ["<pad>", "<unk>"] + sorted(conteo) # ids 0 y 1 reservados
indice = {palabra: i for i, palabra in enumerate(vocab)}
def codificar(texto):
return [indice.get(token, 1) for token in tokenizar(texto)]
print(len(vocab))
print(conteo.most_common(5))
for texto, _ in prueba:
print(codificar(texto), texto)
La salida esperada está oculta.
Bolsa de palabras y TF-IDF
Una bolsa de palabras representa cada reseña con un vector de ancho fijo: una columna por palabra del vocabulario y, en cada columna, cuántas veces aparece esa palabra. CountVectorizer aprende las columnas con fit y cuenta con transform. Con tokenizer=tokenizar usa la misma tokenización de arriba; token_pattern=None indica que no hace falta su patrón propio. get_feature_names_out() devuelve el token de cada columna.
Pregunta. ¿Qué forma tendrá X_conteo? Para «Muy muy rica la sopa, deliciosa», ¿cuántas columnas serán distintas de cero y cuál valdrá 2? ¿Qué pasa con «deliciosa»?
from sklearn.feature_extraction.text import CountVectorizer
conteos = CountVectorizer(tokenizer=tokenizar, token_pattern=None)
X_conteo = conteos.fit_transform(textos_ent)
fila = conteos.transform(["Muy muy rica la sopa, deliciosa"])
columnas = conteos.get_feature_names_out()
print(X_conteo.shape)
print(fila.shape)
# nonzero()[1]: columnas con conteo distinto de cero
print({str(columnas[j]): int(fila[0, j]) for j in fila.nonzero()[1]})
La salida esperada está oculta.
Un conteo da el mismo peso a una palabra que aparece en casi todas las reseñas que a una que aparece en una sola. TF-IDF multiplica cada conteo por la frecuencia inversa de documento de su palabra:
idf = ln((1 + n) / (1 + df)) + 1
donde n es el número de reseñas y df el número de reseñas que contienen la palabra. Después, TfidfVectorizer divide cada fila entre su norma para que mida 1. idf_ guarda el idf de cada columna. El código lo compara con la fórmula, usando el df de cada columna de X_conteo. np.linalg.norm mide la fila. vocabulary_ da la columna de cada palabra.
Pregunta. Ordena el, estaba, sopa y horrible de menor a mayor idf. En «La comida estaba rica», ¿qué palabras tendrán el peso más bajo?
import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer
tfidf = TfidfVectorizer(tokenizer=tokenizar, token_pattern=None)
X_tfidf = tfidf.fit_transform(textos_ent)
n = X_tfidf.shape[0]
for palabra in ["el", "estaba", "sopa", "horrible"]:
df = int((X_conteo[:, conteos.vocabulary_[palabra]] > 0).sum())
idf = tfidf.idf_[tfidf.vocabulary_[palabra]]
a_mano = np.log((1 + n) / (1 + df)) + 1
print(palabra, df, round(idf, 3), round(a_mano, 3))
fila_tfidf = tfidf.transform(["La comida estaba rica"])
nombres = tfidf.get_feature_names_out()
print({str(nombres[j]): round(float(fila_tfidf[0, j]), 3) for j in fila_tfidf.nonzero()[1]})
print(round(float(np.linalg.norm(fila_tfidf.toarray())), 3))
La salida esperada está oculta.
Baseline con regresión logística
Las filas de TF-IDF tienen el mismo ancho, así que cualquier clasificador tabular puede usarlas. Un Pipeline ajusta el vectorizador solo con textos_ent y aplica la misma transformación a textos_prueba. C=10 regulariza menos que el valor predeterminado C=1: con 20 reseñas, C=1 deja todas las probabilidades de prueba entre 0.4 y 0.6. predict_proba devuelve una columna por clase y score da la exactitud.
Pregunta. ¿Qué reseña de prueba tiene más riesgo de quedar mal clasificada? ¿Qué probabilidad de clase positiva esperas para «Deliciosa la ensalada»?
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
baseline = make_pipeline(
TfidfVectorizer(tokenizer=tokenizar, token_pattern=None),
LogisticRegression(C=10),
)
baseline.fit(textos_ent, y_ent)
probabilidades = baseline.predict_proba(textos_prueba)[:, 1] # columna de la clase 1
for texto, p, etiqueta in zip(textos_prueba, probabilidades, y_prueba):
print(f"{p:.3f} {etiqueta} {texto}")
print(round(baseline.score(textos_prueba, y_prueba), 3))
La salida esperada está oculta.
Cada columna de TF-IDF tiene un coeficiente. Un coeficiente positivo empuja hacia la clase 1 y uno negativo, hacia la clase 0. baseline[0] es el vectorizador del pipeline y baseline[-1], la regresión. coef_[0] guarda un coeficiente por columna y np.argsort devuelve los índices que ordenan esos coeficientes de menor a mayor.
Pregunta. ¿Qué palabras tendrán los coeficientes más negativos y más positivos? ¿Tendrá y un coeficiente cercano a 0?
palabras = baseline[0].get_feature_names_out()
coeficientes = baseline[-1].coef_[0]
orden = np.argsort(coeficientes)
print([(str(palabras[j]), round(float(coeficientes[j]), 2)) for j in orden[:4]])
print([(str(palabras[j]), round(float(coeficientes[j]), 2)) for j in orden[-4:]])
print(round(float(coeficientes[baseline[0].vocabulary_["y"]]), 2))
La salida esperada está oculta.
Padding y máscaras
Una red con nn.Embedding recibe los ids de cada reseña en orden. Para formar un lote, todas las secuencias necesitan la misma longitud. pad_sequence(lista, batch_first=True, padding_value=0) completa cada secuencia con 0 hasta la longitud de la más larga, y batch_first=True deja las reseñas en la primera dimensión. Una máscara marca las posiciones reales; aquí se obtiene comparando cada id con 0.
Pregunta. ¿Qué forma tendrá X_ent_ids? ¿Cuántas posiciones reales cuenta la máscara en «El pan estaba duro»? En «Deliciosa la ensalada», ¿la máscara marca las posiciones de <unk> como reales o como relleno?
import torch
from torch import nn
from torch.nn.utils.rnn import pad_sequence
ids_ent = [torch.tensor(codificar(t)) for t in textos_ent]
ids_prueba = [torch.tensor(codificar(t)) for t in textos_prueba]
X_ent_ids = pad_sequence(ids_ent, batch_first=True, padding_value=0)
X_prueba_ids = pad_sequence(ids_prueba, batch_first=True, padding_value=0)
mascara_ent = X_ent_ids != 0
mascara_prueba = X_prueba_ids != 0
print(X_ent_ids.shape, X_prueba_ids.shape)
print(X_ent_ids[12], mascara_ent[12].sum().item())
print(X_prueba_ids[4])
print(mascara_prueba[4])
La salida esperada está oculta.
Clasificador con embeddings
El clasificador convierte cada id en un vector de 8 números con nn.Embedding, promedia los vectores de las posiciones reales con la máscara y pasa ese promedio a una capa lineal de 2 salidas. padding_idx=0 deja la fila de <pad> en ceros. En forward, unsqueeze(-1) añade una dimensión final a la máscara para que multiplique cada vector completo.
class ClasificadorResenas(nn.Module):
def __init__(self, palabras, dimension, clases):
super().__init__()
self.embedding = nn.Embedding(palabras, dimension, padding_idx=0)
self.salida = nn.Linear(dimension, clases)
def forward(self, ids):
mascara = (ids != 0).unsqueeze(-1).float() # (B, L) -> (B, L, 1)
# suma de los vectores reales dividida entre el número de posiciones reales
resena = (self.embedding(ids) * mascara).sum(dim=1) / mascara.sum(dim=1)
return self.salida(resena)
print(ClasificadorResenas(len(vocab), 8, 2)(X_prueba_ids).shape)
La salida esperada está oculta.
Un lote de 6 reseñas produce 6 filas de 2 logits, uno por clase. La función auxiliar entrenar fija la semilla, crea la red y minimiza la entropía cruzada (nn.CrossEntropyLoss) con Adam, lr=0.05, sobre las 20 reseñas a la vez durante 200 épocas. Devuelve la red y la última pérdida.
Mostrar códigoOcultar código
def entrenar(semilla, epocas=200):
torch.manual_seed(semilla)
modelo = ClasificadorResenas(len(vocab), 8, 2)
criterio = nn.CrossEntropyLoss()
optimizador = torch.optim.Adam(modelo.parameters(), lr=0.05)
y = torch.tensor(y_ent)
for _ in range(epocas):
optimizador.zero_grad()
perdida = criterio(modelo(X_ent_ids), y)
perdida.backward()
optimizador.step()
return modelo, perdida.item()
torch.no_grad() evalúa sin registrar gradientes. torch.softmax(..., dim=1) convierte los logits de cada reseña en probabilidades.
Pregunta. ¿Qué exactitud tendrá el modelo en entrenamiento? ¿Superará las 5 de 6 reseñas de prueba del baseline?
red, perdida_final = entrenar(0)
with torch.no_grad():
exactitud_ent = (red(X_ent_ids).argmax(dim=1) == torch.tensor(y_ent)).float().mean()
prob_prueba = torch.softmax(red(X_prueba_ids), dim=1)[:, 1]
exactitud_prueba = ((prob_prueba > 0.5).long() == torch.tensor(y_prueba)).float().mean()
print(round(perdida_final, 4), exactitud_ent.item())
for texto, p in zip(textos_prueba, prob_prueba):
print(f"{p.item():.3f} {texto}")
print(round(exactitud_prueba.item(), 3))
La salida esperada está oculta.
Un modelo nuevo creado con torch.manual_seed(0) empieza con los mismos pesos con los que empezó red, así que su fila de <unk> sirve de referencia para ver si el entrenamiento la movió.
Pregunta. Después de entrenar, ¿habrá cambiado la fila de <unk>?
Ver resultado
La comprobación compara la fila 1 de red.embedding.weight, la de <unk>, con la misma fila del modelo recién creado con la semilla 0. torch.equal devuelve True solo si los 8 números de las dos filas coinciden exactamente, y False si al menos uno cambió.
torch.equal entre la fila de <unk> de red entrenada y la del modelo inicial devuelve True.
<unk> sigue igual después de entrenar: ninguna reseña de entrenamiento contiene el id 1, así que esa fila nunca recibe gradiente. En «Deliciosa la ensalada», dos de las tres posiciones del promedio son ese vector aleatorio.Pregunta. Si solo cambia la semilla, ¿cambia la exactitud de prueba de la red? ¿Y la del baseline?
Ver resultado
Cada fila corresponde a una red entrenada con entrenar(semilla): mismos datos, épocas y tasa de aprendizaje, y solo cambian los pesos iniciales. La columna «semilla» es el valor que recibe torch.manual_seed y la columna «aciertos de la red en prueba» cuenta cuántas de las 6 reseñas de prueba clasifica bien esa red. Por ejemplo, la fila con 1 y 5 de 6 dice que con la semilla 1 la red acierta 5 reseñas de prueba, tantas como el baseline.
| semilla | aciertos de la red en prueba |
|---|---|
| 0 | 4 de 6 |
| 1 | 5 de 6 |
| 2 | 5 de 6 |
| 3 | 5 de 6 |
| 4 | 4 de 6 |
Práctica
Traza guiada
Antes de procesar, escribe mini_lote, mini_mascara.sum(dim=1) y la fila de bolsa de palabras de «Pan duro, duro» con las columnas el, pan, rico y duro.
mini_indice = {"<pad>": 0, "<unk>": 1, "el": 2, "pan": 3, "rico": 4, "duro": 5}
mini_textos = ["El pan rico", "Pan duro, duro", "El flan"]
mini_ids = [torch.tensor([mini_indice.get(t, 1) for t in tokenizar(texto)]) for texto in mini_textos]
mini_lote = pad_sequence(mini_ids, batch_first=True, padding_value=0)
mini_mascara = mini_lote != 0
Comprueba con print. Si la fila de «El flan» no coincide, revisa qué id recibe «flan» y qué id recibe el relleno.
Bigramas en el baseline
Cambia el vectorizador del baseline a TfidfVectorizer(tokenizer=tokenizar, token_pattern=None, ngram_range=(1, 2)). Un bigrama es un par de tokens consecutivos, como «no volveré». Antes de procesar, predice si el número de columnas crece y si cambia la probabilidad de «Deliciosa la ensalada». Comprueba con len(baseline[0].vocabulary_) y con las probabilidades de prueba.
Corpus de tres temas
Escribe 15 reseñas de entrenamiento sobre tres temas (comida, servicio y limpieza), cinco por tema, y tres reseñas de prueba con al menos una palabra nueva cada una. Con ese corpus:
- construye el vocabulario solo con entrenamiento y codifica la prueba con
<unk> - entrena el baseline TF-IDF con regresión logística y reporta su exactitud de prueba
- entrena
ClasificadorResenascon 3 salidas - comprueba con
assertla forma del lote de ids y la de las salidas de la red - compara las probabilidades de los dos modelos para cada reseña de prueba
Reto opcional
La fila de <unk> no se entrena porque ninguna reseña de entrenamiento contiene el id 1. Codifica el entrenamiento cambiando por <unk> las palabras con conteo[palabra] == 1, entrena otra vez y compara las probabilidades de «Deliciosa la ensalada» y «Horrible, tardaron muchísimo» con las de red.
Errores que revisar
- Si aparece
UserWarning: The parameter 'token_pattern' will not be used since 'tokenizer' is not None, pasastetokenizersintoken_pattern=None. - Si el vocabulario de
TfidfVectorizerno contiene «y», faltatokenizer=tokenizary se usó el patrón predeterminado(?u)\b\w\w+\b, que solo acepta tokens de dos caracteres o más. - Si
codificarlanzaKeyErrorcon una reseña de prueba, usasteindice[token]en lugar deindice.get(token, 1). - Si ninguna reseña de prueba produce el id
1, revisa queconteoyvocabse construyan solo conentrenamiento. - Si la predicción de una reseña cambia al añadir más ceros al final de su secuencia, el pooling no usa la máscara.
Cierre
Explica por qué «Deliciosa la ensalada» quedó mal clasificada en los dos modelos y qué tendría que contener el entrenamiento para que cada modelo la clasificara bien.
Lista de comprobación
- ¿Qué id recibe una palabra de prueba que no está en el vocabulario, y por qué el vocabulario se construye solo con entrenamiento?
- ¿Por qué
eltiene un idf menor quehorrible? - ¿Qué diferencia hay entre
<pad>y<unk>en la máscara? - ¿Qué forma tiene un lote de ids
(B, L)después denn.Embedding(V, 8)y después del pooling? - ¿Por qué 6 reseñas de prueba no bastan para decidir entre el baseline y la red?
Documentación oficial
- Extracción de características de texto: bolsa de palabras, tokenización, n-gramas y ponderación TF-IDF.
TfidfVectorizer: parámetrostokenizer,token_pattern,ngram_rangey el atributoidf_.LogisticRegression: regularización conC,predict_probaycoef_.pad_sequence: relleno de secuencias de distinta longitud conbatch_firstypadding_value.nn.Embedding: tabla de embeddings,padding_idxy formas de entrada y salida.