Módulo 8 · Lenguaje y audio

Lección 1 de 5

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ñaconjuntoetiqueta
La sopa estaba rica y calienteentrenamiento1
El pan estaba duroentrenamiento0
No me gustó el arroz, muy saladoentrenamiento0
Deliciosa la ensaladaprueba1

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ódigoPython
Python 3 · ejemplo reproducible
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».

Python 3 · ejemplo reproducible
import re


def tokenizar(texto):
    return re.findall(r"\w+", texto.lower())


print(tokenizar("Rico el pollo, volveré mañana"))
Salida esperada

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?

Python 3 · ejemplo reproducible
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)
Salida esperada

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»?

Python 3 · ejemplo reproducible
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]})
Salida esperada

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?

Python 3 · ejemplo reproducible
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))
Salida esperada

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»?

Python 3 · ejemplo reproducible
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))
Salida esperada

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?

Python 3 · ejemplo reproducible
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))
Salida esperada

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?

Python 3 · ejemplo reproducible
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])
Salida esperada

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.

Python 3 · ejemplo reproducible
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)
Salida esperada

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ódigoPython
Python 3 · ejemplo reproducible
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?

Python 3 · ejemplo reproducible
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))
Salida esperada

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.

Lectura. La fila de <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.

semillaaciertos de la red en prueba
04 de 6
15 de 6
25 de 6
35 de 6
44 de 6
Lectura. La red acierta 4 o 5 de 6 según la semilla. El baseline no depende de una semilla: su ajuste da el mismo resultado cada vez. Con 6 reseñas de prueba, cada reseña vale 0.167 de exactitud, y la diferencia entre los dos modelos es de una reseña o ninguna. Con 20 reseñas de entrenamiento, la red no supera al baseline en esta prueba.

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.

Python 3 · ejemplo reproducible
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:

  1. construye el vocabulario solo con entrenamiento y codifica la prueba con <unk>
  2. entrena el baseline TF-IDF con regresión logística y reporta su exactitud de prueba
  3. entrena ClasificadorResenas con 3 salidas
  4. comprueba con assert la forma del lote de ids y la de las salidas de la red
  5. 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, pasaste tokenizer sin token_pattern=None.
  • Si el vocabulario de TfidfVectorizer no contiene «y», falta tokenizer=tokenizar y se usó el patrón predeterminado (?u)\b\w\w+\b, que solo acepta tokens de dos caracteres o más.
  • Si codificar lanza KeyError con una reseña de prueba, usaste indice[token] en lugar de indice.get(token, 1).
  • Si ninguna reseña de prueba produce el id 1, revisa que conteo y vocab se construyan solo con entrenamiento.
  • 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é el tiene un idf menor que horrible?
  • ¿Qué diferencia hay entre <pad> y <unk> en la máscara?
  • ¿Qué forma tiene un lote de ids (B, L) después de nn.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

Cargando progreso…