Texto como datos: tokens, vocabulario y clasificación

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

Los 24 bloques de código de la teoría, en el mismo orden. Cópialos y córrelos en tu Python o en Colab; en la ficha Teoría está la explicación de cada uno.

El texto: *Don Quijote*

import re
import urllib.request
from collections import Counter
from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np

# Don Quijote en el Proyecto Gutenberg (libro n.º 2000, dominio público).
# Se baja una sola vez y queda guardado en la carpeta «datos».
URL = "https://www.gutenberg.org/cache/epub/2000/pg2000.txt"
archivo = Path("datos/quijote-pg2000.txt")
if not archivo.exists():
    archivo.parent.mkdir(exist_ok=True)
    urllib.request.urlretrieve(URL, archivo)

crudo = archivo.read_text(encoding="utf-8")
# El libro está entre dos marcas: se quitan la cabecera y el pie
marca = crudo.index("*** START OF THE PROJECT GUTENBERG EBOOK")
inicio = crudo.index("\n", marca) + 1
fin = crudo.index("*** END OF")
quijote = crudo[inicio:fin].strip()

print(f"{len(crudo):,} caracteres con cabecera y pie")
print(f"{len(quijote):,} caracteres del libro")
k = quijote.index("En un lugar de la Mancha")
print(quijote[k:k + 70].replace("\n", " "))

Normalizar y partir en palabras

def palabras(texto):
    """Pasa a minúsculas y devuelve la lista de palabras."""
    return re.findall(r"\w+", texto.lower())

todas = palabras(quijote)
conteo = Counter(todas)
print(f"{len(todas):,} palabras, {len(conteo):,} distintas")
print(conteo.most_common(5))
def sin_tildes(palabra):
    """Quita las tildes (pero no la ñ)."""
    return palabra.translate(str.maketrans("áéíóúü", "aeiouu"))

conteo_sin = Counter(sin_tildes(p) for p in todas)
print(f"Distintas con tildes: {len(conteo):,}   sin tildes: "
      f"{len(conteo_sin):,}")
for a, b in [("el", "él"), ("si", "sí"), ("mas", "más"),
             ("esta", "está")]:
    print(f"{a:<5} {conteo[a]:>5} veces   {b:<5} {conteo[b]:>5} veces")

La ley de Zipf

frecuencias = np.array(sorted(conteo.values(), reverse=True))
rangos = np.arange(1, len(frecuencias) + 1)

print("Las 10 más frecuentes cubren el "
      f"{frecuencias[:10].sum() / len(todas):.0%} del texto")
print("Las 100 más frecuentes cubren el "
      f"{frecuencias[:100].sum() / len(todas):.0%} del texto")
una_vez = (frecuencias == 1).sum()
print(f"Aparecen una sola vez: {una_vez:,} palabras "
      f"({una_vez / len(conteo):.0%} del vocabulario)")

plt.loglog(rangos, frecuencias, color="C0", label="Don Quijote")
plt.loglog(rangos, frecuencias[0] / rangos, color="C2",
           linestyle="--", label="Zipf: $f_1$ / rango")
plt.xlabel("Rango (1 = la más frecuente)")
plt.ylabel("Veces que aparece")
plt.title("La ley de Zipf en el Quijote")
plt.legend()
plt.show()

El vocabulario y el token desconocido

def construir_vocabulario(conteo, minimo=2):
    """Diccionario palabra -> número, con las palabras frecuentes."""
    vocab = {"<PAD>": 0, "<UNK>": 1}
    for palabra, n in conteo.most_common():
        if n >= minimo:
            vocab[palabra] = len(vocab)
    return vocab

def codificar(tokens, vocab):
    """Cambia cada token por su número; los desconocidos, por <UNK>."""
    return [vocab.get(t, vocab["<UNK>"]) for t in tokens]

vocab = construir_vocabulario(conteo)
print(f"Vocabulario: {len(vocab):,} tokens")
frase = "Don Quijote vio un dinosaurio en la computadora"
ids = codificar(palabras(frase), vocab)
print(palabras(frase))
print(ids)

Caracteres: el otro extremo

letras = sorted(set(quijote.lower()))
print(f"Caracteres distintos: {len(letras)}")
print("".join(letras[22:]))
print(len(palabras(frase)), "palabras y", len(frase), "caracteres")

Subpalabras: BPE

def contar_pares(partes):
    """Cuántas veces aparece cada par de piezas vecinas."""
    pares = Counter()
    for piezas, n in partes.items():
        for par in zip(piezas, piezas[1:]):
            pares[par] += n
    return pares

def fusionar(piezas, par):
    """Junta en una sola pieza cada aparición del par."""
    nuevas, i = [], 0
    while i < len(piezas):
        if i + 1 < len(piezas) and (piezas[i], piezas[i + 1]) == par:
            nuevas.append(piezas[i] + piezas[i + 1])
            i += 2
        else:
            nuevas.append(piezas[i])
            i += 1
    return tuple(nuevas)

def entrenar_bpe(conteo, n_fusiones):
    """Aprende las fusiones de BPE a partir de un conteo de palabras."""
    # Cada palabra empieza partida en letras, con «_» al final
    partes = {tuple(p) + ("_",): n for p, n in conteo.items()}
    fusiones, tokens = [], []
    for _ in range(n_fusiones):
        pares = contar_pares(partes)
        if not pares:
            break
        mejor = max(pares, key=pares.get)       # el par más frecuente
        fusiones.append(mejor)
        partes = {fusionar(p, mejor): n for p, n in partes.items()}
        tokens.append(sum(len(p) * n for p, n in partes.items()))
    return fusiones, tokens

def tokenizar_bpe(palabra, fusiones):
    """Parte una palabra aplicando las fusiones en el orden aprendido."""
    piezas = tuple(palabra) + ("_",)
    for par in fusiones:
        piezas = fusionar(piezas, par)
    return list(piezas)
fusiones, tokens_bpe = entrenar_bpe(conteo, 300)
print("Primeras fusiones:")
print(["".join(par) for par in fusiones[:12]])
for p in ["quijote", "molinos", "computadora", "boliviano"]:
    print(f"{p:>12} -> {tokenizar_bpe(p, fusiones)}")
letras_texto = sum(len(p) + 1 for p in todas)    # letras más el «_»
por_palabra = np.array([letras_texto] + tokens_bpe) / len(todas)
print(f"Sin fusiones: {por_palabra[0]:.2f} tokens por palabra")
print(f"Con 300: {por_palabra[-1]:.2f} tokens por palabra")

plt.plot(range(len(por_palabra)), por_palabra, color="C0")
plt.xlabel("Número de fusiones")
plt.ylabel("Tokens por palabra")
plt.title("Más fusiones, menos tokens")
plt.show()

Tokenizadores de verdad

from transformers import AutoTokenizer

tok_gpt2 = AutoTokenizer.from_pretrained("openai-community/gpt2")
tok_bert = AutoTokenizer.from_pretrained(
    "distilbert/distilbert-base-uncased")
print(f"Vocabulario de GPT-2: {len(tok_gpt2):,}   "
      f"de DistilBERT: {len(tok_bert):,}")

es = "Mañana iré a la panadería con mi hermano."
print(" ".join(tok_gpt2.tokenize(es)))
print(" ".join(tok_bert.tokenize(es)))
ids = tok_gpt2(es)["input_ids"]
print(ids[:6])
print([tok_gpt2.decode([i]) for i in ids[:6]])
en = "Tomorrow I will go to the bakery with my brother."
nombres = ["Palabras", "Caracteres", "Nuestro BPE", "GPT-2",
           "DistilBERT"]

def contar_tokens(texto):
    """Cuántos tokens da cada tokenizador para un texto."""
    bpe = sum(len(tokenizar_bpe(p, fusiones)) for p in palabras(texto))
    return [len(palabras(texto)), len(texto), bpe,
            len(tok_gpt2.tokenize(texto)), len(tok_bert.tokenize(texto))]

n_es, n_en = contar_tokens(es), contar_tokens(en)
for nombre, a, b in zip(nombres, n_es, n_en):
    print(f"{nombre:<12} español: {a:>3}   inglés: {b:>3}")

y = np.arange(len(nombres))
plt.barh(y - 0.2, n_es, height=0.4, color="C0", label="Español")
plt.barh(y + 0.2, n_en, height=0.4, color="C2", label="Inglés")
plt.yticks(y, nombres)
plt.gca().invert_yaxis()
plt.xlabel("Número de tokens")
plt.title("Cinco tokenizadores")
plt.legend()
plt.show()

Un modelo de lenguaje con n-gramas

corte = int(0.9 * len(todas))
entren, prueba = todas[:corte], todas[corte:]
vocab_ng = {p for p, n in Counter(entren).items() if n >= 2}

def con_unk(tokens):
    return [t if t in vocab_ng else "<UNK>" for t in tokens]

entren, prueba = con_unk(entren), con_unk(prueba)
V = len(vocab_ng) + 1
print(f"Vocabulario: {V:,}   desconocidas en la prueba: "
      f"{prueba.count('<UNK>') / len(prueba):.1%}")
def contar_ngramas(tokens, n):
    """Cuenta los n-gramas y sus contextos (los n - 1 tokens de antes)."""
    ngramas = Counter(tuple(tokens[i:i + n])
                      for i in range(len(tokens) - n + 1))
    contextos = Counter()
    for ng, c in ngramas.items():
        contextos[ng[:-1]] += c
    return ngramas, contextos

cuentas = {n: contar_ngramas(entren, n) for n in (1, 2, 3)}

def probabilidad(contexto, palabra, lam=0.6):
    """P(palabra | contexto), mezclando con un contexto más corto."""
    if not contexto:
        ngramas, _ = cuentas[1]
        return (ngramas[(palabra,)] + 1) / (len(entren) + V)
    ngramas, contextos = cuentas[len(contexto) + 1]
    c = contextos[contexto]
    directa = ngramas[contexto + (palabra,)] / c if c else 0
    return (lam * directa
            + (1 - lam) * probabilidad(contexto[1:], palabra, lam))

def perplejidad(tokens, n, lam=0.6):
    log_total = 0
    for i in range(n - 1, len(tokens)):
        contexto = tuple(tokens[i - n + 1:i])
        log_total += np.log(probabilidad(contexto, tokens[i], lam))
    return np.exp(-log_total / (len(tokens) - n + 1))

for n in (1, 2):
    print(f"n = {n}: perplejidad {perplejidad(prueba, n):.0f}")
ngramas2, contextos2 = cuentas[2]
for previa in ["don", "sancho"]:
    siguientes = Counter({ng[1]: c for ng, c in ngramas2.items()
                          if ng[0] == previa})
    total = contextos2[(previa,)]
    print(previa, "->", [(p, round(c / total, 2))
                         for p, c in siguientes.most_common(4)])
rng = np.random.default_rng(0)
texto = ["dijo"]
for _ in range(14):
    siguientes = [(ng[1], c) for ng, c in ngramas2.items()
                  if ng[0] == texto[-1]]
    opciones = [p for p, c in siguientes]
    pesos = np.array([c for p, c in siguientes], dtype=float)
    texto.append(rng.choice(opciones, p=pesos / pesos.sum()))
print(" ".join(texto))

Bolsa de palabras y TF-IDF

import pandas as pd
from sklearn.feature_extraction.text import CountVectorizer

frases = ["el perro come pan",
          "el gato come pescado",
          "el perro y el gato duermen"]
bolsa = CountVectorizer()
X_bolsa = bolsa.fit_transform(frases)
print(pd.DataFrame(X_bolsa.toarray(),
                   columns=bolsa.get_feature_names_out()))
from sklearn.feature_extraction.text import TfidfVectorizer

tfidf = TfidfVectorizer()
X_tfidf = tfidf.fit_transform(frases)
print(pd.DataFrame(X_tfidf.toarray(),
                   columns=tfidf.get_feature_names_out()).round(2))

Clasificar reseñas

from datasets import load_dataset
from huggingface_hub import hf_hub_download

ruta = hf_hub_download("fancyzhx/amazon_polarity",
                       "amazon_polarity/test-00000-of-00001.parquet",
                       repo_type="dataset")
resenas = load_dataset("parquet", data_files=ruta, split="train")
muestra = resenas.shuffle(seed=0).select(range(6000))

textos = [f"{t}. {c}" for t, c in zip(muestra["title"],
                                      muestra["content"])]
etiquetas = np.array(muestra["label"])
print(f"{len(resenas):,} reseñas; usamos {len(textos):,}")
print("Positivas:", etiquetas.sum(), "  negativas:",
      (etiquetas == 0).sum())
print(etiquetas[0], textos[0][:70])
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import confusion_matrix
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline

X_ent, X_pru, y_ent, y_pru = train_test_split(
    textos, etiquetas, test_size=0.2, random_state=0, stratify=etiquetas)

modelo = make_pipeline(TfidfVectorizer(min_df=2),
                       LogisticRegression(max_iter=1000))
modelo.fit(X_ent, y_ent)
print(f"Vocabulario aprendido: {len(modelo[0].vocabulary_):,} palabras")
print(f"Exactitud en la prueba: {modelo.score(X_pru, y_pru):.3f}")
print(confusion_matrix(y_pru, modelo.predict(X_pru)))
pesos = modelo[1].coef_[0]
vocab_tfidf = modelo[0].get_feature_names_out()
orden = np.argsort(pesos)
elegidas = np.concatenate([orden[:6], orden[-6:]])
print("Más negativas:", list(vocab_tfidf[orden[:6]]))
print("Más positivas:", list(vocab_tfidf[orden[-6:][::-1]]))

# Tinta para las negativas y coral para las positivas
colores = ["C0"] * 6 + ["C1"] * 6
plt.barh(range(12), pesos[elegidas], color=colores)
plt.yticks(range(12), vocab_tfidf[elegidas])
plt.axvline(0, color="C2", linewidth=1)
plt.xlabel("Peso en la regresión logística")
plt.title("Las palabras que más pesan")
plt.show()
nuevas = ["Great book, I loved every page.",
          "It broke after two days. Waste of money.",
          "Me encantó, es un libro excelente."]
for texto, p in zip(nuevas, modelo.predict_proba(nuevas)[:, 1]):
    conocidas = modelo[0].transform([texto]).nnz
    print(f"P(positiva) = {p:.2f}   conocidas: {conocidas:>2}   {texto}")

Lotes de largo variable: *padding* y máscara

lote = pad_sequence(secuencias, batch_first=True,
                    padding_value=vocab["<PAD>"]).to(device)
mascara = lote != vocab["<PAD>"]
print(lote.shape)
print(lote.tolist())
print(mascara.int().tolist())
from matplotlib.colors import ListedColormap

inverso = {i: p for p, i in vocab.items()}
# Papel para el relleno y tinta para los tokens reales
papel_tinta = ListedColormap(["C3", "C0"])
fig, (arriba, abajo) = plt.subplots(2, 1)
for eje, valores in [(arriba, lote), (abajo, mascara.int())]:
    eje.imshow(mascara.cpu(), cmap=papel_tinta, aspect="auto")
    for i in range(lote.shape[0]):
        for j in range(lote.shape[1]):
            v = valores[i, j].item()
            txt = inverso[v] if eje is arriba else str(v)
            eje.text(j, i, txt, ha="center", va="center", fontsize=8,
                     color="white" if mascara[i, j] else "C0")
    eje.set_xticks([])
    eje.set_yticks([])
    eje.grid(False)
arriba.set_title("El lote con padding")
abajo.set_title("Máscara: 1 = real, 0 = relleno")
plt.show()
x_bert = tok_bert(lote_frases, padding=True, truncation=True,
                  max_length=8, return_tensors="pt")
print(x_bert["input_ids"].shape)
for fila, mask in zip(x_bert["input_ids"], x_bert["attention_mask"]):
    print(" ".join(tok_bert.convert_ids_to_tokens(fila)))
    print(mask.tolist())