Embeddings: representar cosas como vectores

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

Los 20 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.

Por qué un número por palabra no basta

import numpy as np

palabras = ["perro", "gato", "mesa", "silla"]
one_hot = np.eye(len(palabras))      # una fila por palabra
for p, v in zip(palabras, one_hot):
    print(f"{p:6s} {v}")


def coseno(a, b):
    """Similitud coseno (tema 3): producto punto entre las normas."""
    return a @ b / (np.linalg.norm(a) * np.linalg.norm(b))


print("perro y gato:", coseno(one_hot[0], one_hot[1]))
print("perro y mesa:", coseno(one_hot[0], one_hot[2]))
# Tres características a mano: ser vivo, tiene patas, se usa en casa
a_mano = np.array([
    [1.0, 0.9, 0.2],   # perro
    [1.0, 0.7, 0.5],   # gato
    [0.0, 0.9, 0.8],   # mesa
    [0.0, 0.7, 1.0],   # silla
])
print(f"perro y gato:  {coseno(a_mano[0], a_mano[1]):.2f}")
print(f"perro y mesa:  {coseno(a_mano[0], a_mano[2]):.2f}")
print(f"mesa y silla:  {coseno(a_mano[2], a_mano[3]):.2f}")

Dime con quién andas: la hipótesis distribucional

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

# 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()

tokens = re.findall(r"\w+", quijote.lower())
conteo = Counter(tokens)
print(f"{len(tokens):,} palabras, {len(conteo):,} distintas")
def pares_de_contexto(lista, ventana=2):
    """Parejas (central, contexto) a distancia de hasta `ventana`."""
    pares = []
    for i, central in enumerate(lista):
        for j in range(i - ventana, i + ventana + 1):
            if j != i and 0 <= j < len(lista):
                pares.append((central, lista[j]))
    return pares


frase = ["en", "un", "lugar", "de", "la", "mancha"]
# Solo las parejas en las que «lugar» es la palabra central
print([par for par in pares_de_contexto(frase) if par[0] == "lugar"])
print("Parejas de toda la frase:", len(pares_de_contexto(frase)))

Skip-gram con muestreo negativo

import torch
from torch import nn

torch.manual_seed(0)
device = "cuda" if torch.cuda.is_available() else "cpu"
rng = np.random.default_rng(0)

vocab = [p for p, _ in conteo.most_common(3000)]
indice = {p: i for i, p in enumerate(vocab)}
frec = np.array([conteo[p] for p in vocab]) / len(tokens)

# El texto como números, sin las palabras fuera del vocabulario
ids = np.array([indice[p] for p in tokens if p in indice])
# Submuestreo: las palabras muy frecuentes se descartan casi siempre
conservar = rng.random(len(ids)) < np.sqrt(1e-4 / frec[ids])
ids = ids[conservar]

# Todas las parejas a distancia 1 o 2, en los dos sentidos
centrales, contextos = [], []
for d in (1, 2):
    centrales += [ids[:-d], ids[d:]]
    contextos += [ids[d:], ids[:-d]]
centrales = torch.tensor(np.concatenate(centrales))
contextos = torch.tensor(np.concatenate(contextos))
print(f"Quedan {len(ids):,} palabras y {len(centrales):,} parejas")
class SkipGram(nn.Module):
    def __init__(self, n_palabras, dim):
        super().__init__()
        self.central = nn.Embedding(n_palabras, dim)
        self.contexto = nn.Embedding(n_palabras, dim)
        # Empiezan con números chicos al azar y en cero
        nn.init.uniform_(self.central.weight, -0.5 / dim, 0.5 / dim)
        nn.init.zeros_(self.contexto.weight)

    def forward(self, c, x, negativos):
        e = self.central(c)                          # (lote, dim)
        # Producto punto con el contexto real: debe ser grande
        pos = (e * self.contexto(x)).sum(dim=1)      # (lote,)
        # Producto punto con los negativos: debe ser chico
        neg = (self.contexto(negativos) @ e.unsqueeze(2)).squeeze(2)
        f = nn.functional.logsigmoid
        return -(f(pos).mean() + f(-neg).sum(dim=1).mean())


modelo = SkipGram(len(vocab), dim=50).to(device)
optimizador = torch.optim.Adam(modelo.parameters(), lr=0.01)
# Los negativos se sortean con probabilidad proporcional a f^0,75
p_neg = torch.tensor(frec ** 0.75)
p_neg = p_neg / p_neg.sum()
g = torch.Generator().manual_seed(0)

for epoca in range(1, 6):
    orden = torch.randperm(len(centrales), generator=g)
    total = 0.0
    for k in range(0, len(orden), 1024):
        lote = orden[k:k + 1024]
        negativos = torch.multinomial(p_neg, 5 * len(lote),
                                      replacement=True, generator=g)
        negativos = negativos.view(len(lote), 5)
        optimizador.zero_grad()
        perdida = modelo(centrales[lote].to(device),
                         contextos[lote].to(device),
                         negativos.to(device))
        perdida.backward()
        optimizador.step()
        total += perdida.item() * len(lote)
    print(f"Época {epoca}: pérdida {total / len(orden):.3f}")
E = modelo.central.weight.detach().cpu()
E = E / E.norm(dim=1, keepdim=True)          # cada fila mide 1


def vecinas(palabra, k=5):
    """Las k palabras del vocabulario más parecidas por coseno."""
    sim = E @ E[indice[palabra]]
    mejores = sim.argsort(descending=True)[1:k + 1]   # sin ella misma
    return [(vocab[i], round(sim[i].item(), 2)) for i in mejores]


for p in ["caballero", "ínsula", "gigantes", "oro", "cura", "libros"]:
    lista = ", ".join(f"{v} {s:.2f}" for v, s in vecinas(p, k=4))
    print(f"{p}: {lista}")
import matplotlib.pyplot as plt
import umap

plano = umap.UMAP(n_neighbors=15, min_dist=0.1, metric="cosine",
                  random_state=0, n_jobs=1).fit_transform(E.numpy())

grupos = {
    "Números": ["tres", "cuatro", "seis", "mil"],
    "Personajes": ["cura", "barbero", "canónigo"],
    "Riquezas": ["oro", "plata", "perlas"],
    "Gobierno": ["ínsula", "gobierno", "vasallos"],
}
plt.scatter(plano[:, 0], plano[:, 1], s=4, color="C3")
for nombre, lista in grupos.items():
    # Un solo grupo va resaltado en coral
    color = "C1" if nombre == "Números" else "C0"
    xy = plano[[indice[p] for p in lista]]
    plt.scatter(xy[:, 0], xy[:, 1], s=30, color=color, zorder=3)
    # El nombre del grupo, un poco a la derecha de sus palabras
    x, y = np.median(xy, axis=0)
    plt.annotate(nombre, (x, y), xytext=(10, 0), color=color,
                 textcoords="offset points", va="center")
plt.axis("off")
plt.title("Las 3000 palabras del Quijote, con UMAP")
plt.show()

`nn.Embedding`: una tabla que se aprende

tabla = modelo.central                       # nn.Embedding(3000, 50)
print("Forma de la tabla:", tuple(tabla.weight.shape))

i = indice["sancho"]
uno = torch.zeros(len(vocab))
uno[i] = 1.0                                 # «sancho» en one-hot
por_matriz = uno @ tabla.weight              # one-hot por la matriz
por_tabla = tabla(torch.tensor(i))           # buscar la fila
print("¿Iguales?", torch.allclose(por_matriz, por_tabla))
def vector_frase(frase):
    """Promedio de los vectores de las palabras que están en el vocabulario."""
    ids_frase = [indice[p] for p in re.findall(r"\w+", frase.lower())
                 if p in indice]
    v = E[ids_frase].mean(dim=0)
    return v / v.norm()


a = vector_frase("el valeroso caballero andante")
b = vector_frase("un caballero valiente y famoso")
c = vector_frase("comieron pan queso y vino")
print(f"caballero y caballero: {(a @ b).item():.2f}")
print(f"caballero y comida:    {(a @ c).item():.2f}")

Embeddings de frases con un modelo preentrenado

from sentence_transformers import SentenceTransformer

nombre = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
frases_modelo = SentenceTransformer(nombre, device=device)

frases = [
    "¿Cómo me inscribo al curso?",
    "Quiero registrarme en las clases.",
    "Mañana va a llover en La Paz.",
    "El pronóstico anuncia tormenta para mañana.",
    "Mi perro duerme todo el día.",
    "El cachorro pasa la tarde durmiendo.",
]
V = frases_modelo.encode(frases, normalize_embeddings=True)
print("Forma:", V.shape)
similitud = V @ V.T            # cosenos: las filas ya miden 1
print(np.round(similitud, 2))
import seaborn as sns
from matplotlib.colors import LinearSegmentedColormap

cortas = ["inscribo", "registrarme", "llover", "tormenta", "perro",
          "cachorro"]
blanco_tinta = LinearSegmentedColormap.from_list("bt", ["white", "C0"])
plt.figure(figsize=(4.8, 4.8))
# Sumar 0 convierte los «-0.0» del redondeo en «0.0»
sns.heatmap(similitud, annot=np.round(similitud, 1) + 0, fmt=".1f",
            cmap=blanco_tinta, vmin=0, vmax=1, xticklabels=cortas,
            yticklabels=cortas, cbar=False, square=True)
plt.title("Similitud coseno entre frases")
plt.show()
from sklearn.feature_extraction.text import CountVectorizer

bolsa = CountVectorizer().fit_transform(frases[:2]).toarray()
print("Bolsa de palabras:", coseno(bolsa[0], bolsa[1]))
print(f"Embeddings:        {similitud[0, 1]:.2f}")
def capitulo(nombre, siguiente):
    """El texto de un capítulo, en una sola línea."""
    a = quijote.index(nombre)
    return " ".join(quijote[a:quijote.index(siguiente, a)].split())


texto = (capitulo("Capítulo primero", "Capítulo II")
         + " " + capitulo("Capítulo VIII", "Capítulo IX"))
# Partir en frases: después de un punto, punto y coma, «!» o «?»
oraciones = [f for f in re.split(r"(?<=[.;!?])\s+", texto)
             if 6 <= len(f.split()) <= 40]
D = frases_modelo.encode(oraciones, normalize_embeddings=True)
print(f"{len(oraciones)} frases, cada una con {D.shape[1]} números")


def buscar(pregunta, k=2):
    q = frases_modelo.encode(pregunta, normalize_embeddings=True)
    sim = D @ q
    for i in np.argsort(-sim)[:k]:
        print(f"  {sim[i]:.2f}  {oraciones[i][:68]}…")


for pregunta in ["Un hombre leyó tantos libros que perdió el juicio.",
                 "Ataca a unos gigantes que eran máquinas de viento."]:
    print(pregunta)
    buscar(pregunta)
lista = ("rey reina hombre mujer príncipe princesa padre madre tío tía "
         "París Francia Roma Italia Lima Perú Santiago Chile Madrid "
         "España caminar caminó comer comió").split()
W = frases_modelo.encode(lista, normalize_embeddings=True)
pos = {p: i for i, p in enumerate(lista)}


def analogia(a, b, c, k=3):
    """Las palabras más cercanas a a − b + c (sin a, b ni c)."""
    v = W[pos[a]] - W[pos[b]] + W[pos[c]]
    sim = W @ (v / np.linalg.norm(v))
    orden = [i for i in np.argsort(-sim) if lista[i] not in (a, b, c)]
    return [(lista[i], round(float(sim[i]), 2)) for i in orden[:k]]


for a, b, c in [("rey", "hombre", "mujer"), ("París", "Francia", "Italia"),
                ("caminó", "caminar", "comer")]:
    lista_r = ", ".join(f"{p} {s:.2f}" for p, s in analogia(a, b, c))
    print(f"{a} − {b} + {c} → {lista_r}")

Embeddings de imágenes

from torchvision import datasets, models

prueba = datasets.FashionMNIST(root="datos", train=False, download=True)
NOMBRES = ["Camiseta", "Pantalón", "Suéter", "Vestido", "Abrigo",
           "Sandalia", "Camisa", "Zapatilla", "Bolso", "Botín"]
elegidas = torch.randperm(len(prueba), generator=g)[:1000]
imagenes = prueba.data[elegidas].float() / 255     # (1000, 28, 28)
clases = prueba.targets[elegidas].numpy()

red = models.resnet18(weights="DEFAULT")
red.fc = nn.Identity()           # sin la capa final: devuelve 512 números
red = red.to(device).eval()

media = torch.tensor([0.485, 0.456, 0.406]).view(1, 3, 1, 1)
desv = torch.tensor([0.229, 0.224, 0.225]).view(1, 3, 1, 1)
partes = []
with torch.no_grad():
    for k in range(0, len(imagenes), 100):
        x = imagenes[k:k + 100].unsqueeze(1).repeat(1, 3, 1, 1)
        x = nn.functional.interpolate(x, size=64, mode="bilinear")
        x = ((x - media) / desv).to(device)
        partes.append(red(x).cpu())
F = torch.cat(partes)
F = (F / F.norm(dim=1, keepdim=True)).numpy()
print("Embeddings de las imágenes:", F.shape)
from sklearn.neighbors import NearestNeighbors


def vecina_igual(X, y):
    """Fracción de puntos cuya vecina más cercana es de su misma clase."""
    nn_ = NearestNeighbors(n_neighbors=2, metric="cosine").fit(X)
    i = nn_.kneighbors(X, return_distance=False)[:, 1]
    return np.mean(y[i] == y)


pixeles = imagenes.reshape(len(imagenes), -1).numpy()
print(f"Con los 784 píxeles:        {vecina_igual(pixeles, clases):.1%}")
print(f"Con los 512 de la resnet18: {vecina_igual(F, clases):.1%}")
from torch.utils.data import DataLoader, TensorDataset

entrenamiento = datasets.FashionMNIST(root="datos", train=True,
                                      download=True)
idx_ent = torch.randperm(len(entrenamiento), generator=g)[:10000]
x_ent = entrenamiento.data[idx_ent].float() / 255
m_px, d_px = x_ent.mean(), x_ent.std()
x_ent = ((x_ent - m_px) / d_px).unsqueeze(1)
cargador = DataLoader(TensorDataset(x_ent, entrenamiento.targets[idx_ent]),
                      batch_size=64, shuffle=True,
                      generator=torch.Generator().manual_seed(0))

torch.manual_seed(0)
cnn = nn.Sequential(
    nn.Conv2d(1, 16, kernel_size=3, padding=1), nn.ReLU(),
    nn.MaxPool2d(2),                              # (16, 14, 14)
    nn.Conv2d(16, 32, kernel_size=3, padding=1), nn.ReLU(),
    nn.MaxPool2d(2),                              # (32, 7, 7)
    nn.Flatten(),
    nn.Linear(32 * 7 * 7, 64), nn.ReLU(),         # el embedding
    nn.Linear(64, 10),                            # la clase
).to(device)
optimizador = torch.optim.Adam(cnn.parameters(), lr=1e-3)
for epoca in range(3):
    cnn.train()
    for x, y in cargador:
        optimizador.zero_grad()
        perdida = nn.functional.cross_entropy(cnn(x.to(device)),
                                              y.to(device))
        perdida.backward()
        optimizador.step()

cnn.eval()
with torch.no_grad():
    x = ((imagenes - m_px) / d_px).unsqueeze(1).to(device)
    acierta = (cnn(x).argmax(dim=1).cpu().numpy() == clases).mean()
    C = cnn[:-1](x).cpu()                 # sin la última capa: 64 números
C = (C / C.norm(dim=1, keepdim=True)).numpy()
print(f"La CNN clasifica bien el {acierta:.1%} de las 1000 imágenes")
print(f"Con los 64 de la CNN:       {vecina_igual(C, clases):.1%}")
mapa = umap.UMAP(n_neighbors=15, min_dist=0.1, metric="cosine",
                 random_state=0, n_jobs=1).fit_transform(C)

plt.figure(figsize=(4.8, 4.8))
plt.scatter(mapa[:, 0], mapa[:, 1], s=6, color="C2", alpha=0.4)
bolsos = mapa[clases == NOMBRES.index("Bolso")]
plt.scatter(bolsos[:, 0], bolsos[:, 1], s=6, color="C1")
# El nombre de cada clase va sobre la mayoría de sus puntos; si
# choca con otro nombre, se sube un poco hasta que no choque
ancho, alto = np.ptp(mapa, axis=0) * [0.22, 0.06]
puestos = []
for c, nombre in enumerate(NOMBRES):
    x = np.median(mapa[clases == c, 0])
    y = np.percentile(mapa[clases == c, 1], 90)
    while any(abs(x - a) < ancho and abs(y - b) < alto
              for a, b in puestos):
        y += alto
    puestos.append((x, y))
    plt.text(x, y, nombre, fontsize=13, ha="center", va="bottom",
             color="C1" if nombre == "Bolso" else "C0")
plt.axis("off")
plt.title("FashionMNIST según la CNN")
plt.show()
# La primera camisa, sandalia y bolso de las 1000 imágenes
consultas = [np.flatnonzero(clases == c)[0] for c in (6, 5, 8)]
tinta = LinearSegmentedColormap.from_list("tinta", ["white", "C0"])
fig, ejes = plt.subplots(3, 5, figsize=(4.8, 3.4))
for fila, q in enumerate(consultas):
    sim = C @ C[q]
    cercanas = np.argsort(-sim)[1:5]           # sin ella misma
    for col, k in enumerate([q, *cercanas]):
        eje = ejes[fila, col]
        eje.imshow(imagenes[k], cmap=tinta)
        eje.set_xticks([])
        eje.set_yticks([])
        eje.spines[:].set_visible(False)       # sin marco
    ejes[fila, 0].set_ylabel(NOMBRES[clases[q]], fontsize=11)
ejes[0, 0].set_title("Consulta", fontsize=12)
ejes[0, 2].set_title("Sus vecinas", fontsize=12)
plt.show()