Embeddings: representar cosas como vectores
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()¿Terminaste este tema?
Márcalo y en la ruta verás cuánto llevas. Se guarda solo en este navegador.