Embeddings: representar cosas como vectores
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()