Embeddings: representar cosas como vectores

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

Cuaderno del tema. La teoría y los ejercicios con todo su código, listos para ejecutar en Colab sin instalar nada.

Los 20 bloques de código de la teoría, en el mismo orden. Si prefieres armar tu propio cuaderno, cópialos y córrelos en orden; 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, sin bucle
# por palabra: si ids = [a, b, c, d] y d = 1, ids[:-1] = [a, b, c] e
# ids[1:] = [b, c, d], y juntos dan (a, b), (b, c), (c, d)
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 5 negativos: debe ser chico.
        # unsqueeze(2) agrega un eje: e pasa a (lote, dim, 1).
        # @ multiplica una matriz por cada fila del lote:
        # (lote, 5, dim) @ (lote, dim, 1) -> (lote, 5, 1)
        neg = self.contexto(negativos) @ e.unsqueeze(2)
        neg = neg.squeeze(2)                         # quita el eje: (lote, 5)
        f = nn.functional.logsigmoid                 # f(z) = ln σ(z)
        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
# (frecuencia elevada a 0,75), no a f: ver el texto de abajo
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())

# Solo para dibujar: la nube y cuatro grupos de palabras marcados
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 = ["Polera", "Pantalón", "Chompa", "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)
        # Agranda a 64 × 64: cada píxel nuevo es un promedio de sus vecinos
        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")
# Solo para dibujar: el nombre de cada clase va sobre la mayoría
# de sus puntos; si choca con otro nombre, se sube 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)]
# Solo para dibujar: cada consulta con sus 4 vecinas, en tinta
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()