Módulo 6 · Representaciones y atención
Embeddings y autoencoders
En esta páginaEl problema
El problema
Un modelo de texto recibe frases como listas de ids enteros. El vocabulario asigna un id a cada palabra en el orden en que se registró:
| palabra | id |
|---|---|
<pad> | 0 |
| el | 1 |
| gato | 2 |
| auto | 3 |
| perro | 5 |
| maúlla | 7 |
| frena | 9 |
Así, «el gato maúlla» llega como [1, 2, 7]. Si los ids se usan como números, |2 − 3| = 1 y |2 − 5| = 3: gato quedaría más cerca de auto que de perro. El id es una etiqueta de registro. Su valor numérico no dice nada de la palabra.
Con imágenes aparece una pregunta parecida. Cada dígito de load_digits tiene 64 píxeles, y se busca resumirlo con pocos números sin usar su etiqueta.
Tabla de embeddings
nn.Embedding(num_embeddings, embedding_dim) guarda una matriz de parámetros weight. Recibe un tensor de ids enteros, con valores de 0 a num_embeddings − 1, y devuelve un vector de embedding_dim números por id. torch.equal devuelve True solo si dos tensores tienen la misma forma y los mismos valores.
Pregunta. Con 12 palabras y dimensión 4, escribe la forma de tabla.weight y la de vectores. Decide si tabla(ids) coincide con indexar las filas de tabla.weight y si los dos vectores de gato son iguales.
import torch
from torch import nn
import torch.nn.functional as F
vocab = ["<pad>", "el", "gato", "auto", "corre", "perro", "tren", "maúlla", "ladra", "frena", "pita", "rápido"]
indice = {palabra: i for i, palabra in enumerate(vocab)} # palabra → id
torch.manual_seed(1)
tabla = nn.Embedding(len(vocab), 4)
ids = torch.tensor([indice["gato"], indice["perro"], indice["gato"]])
vectores = tabla(ids)
print(ids)
print(tabla.weight.shape)
print(vectores.shape)
print(torch.equal(vectores, tabla.weight[ids]))
print(torch.equal(vectores[0], vectores[2]))
La salida esperada está oculta.
F.one_hot convierte cada id en un vector de 12 posiciones con un 1 en la posición del id y 0 en las demás. El producto de esos vectores por weight se escribe así:
uno_caliente = F.one_hot(ids, num_classes=len(vocab)).float()
uno_caliente @ tabla.weight
Pregunta. ¿Qué forma tiene uno_caliente? ¿Coincide uno_caliente @ tabla.weight con vectores?
Ver resultado
La tabla reúne dos medidas de uno_caliente y una comparación con vectores: la primera columna nombra la medida y la segunda da su valor. Por ejemplo, la fila de gato dice que su vector one-hot tiene un 1 en la posición 2, que es su id.
| medida | valor |
|---|---|
forma de uno_caliente | (3, 12) |
fila de gato, uno_caliente[0] | [0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0] |
torch.allclose(uno_caliente @ tabla.weight, vectores) | True |
uno_caliente tiene forma (3, 12). Multiplicar (3, 12) por (12, 4) da (3, 4), y el 1 de cada fila selecciona una fila de weight. Una embedding es una capa lineal sin sesgo aplicada a vectores one-hot. nn.Embedding obtiene el mismo resultado leyendo la fila, sin construir el vector one-hot.Los valores de weight son parámetros con requires_grad=True. Una pérdida calculada con vectores deja gradiente en la tabla.
Pregunta. Después de vectores.sum().backward(), ¿qué filas de tabla.weight.grad son distintas de cero y cuánto suma cada una?
vectores.sum().backward()
print(tabla.weight.grad.sum(dim=1))
La salida esperada está oculta.
Similitud coseno
Para comparar dos vectores se usa la similitud coseno:
cos(a, b) = (a · b) / (‖a‖ ‖b‖)
Es el coseno del ángulo entre los dos vectores. v.norm() da la longitud ‖v‖, así que la fórmula se puede escribir a mano. F.cosine_similarity(a, b, dim=0) la calcula para vectores de un eje. torch.dist(a, b) da la distancia euclidiana.
Pregunta. Para a = [1, 2, 0], calcula la similitud coseno con 2a, con c = [0, 0, 3] y con −a. ¿Cuál de los tres está más lejos de a en distancia euclidiana?
a = torch.tensor([1.0, 2.0, 0.0])
otros = {"2a": 2 * a, "c": torch.tensor([0.0, 0.0, 3.0]), "-a": -a}
for nombre, v in otros.items():
a_mano = (a @ v) / (a.norm() * v.norm()) # (a · b) / (‖a‖ ‖b‖)
coseno = F.cosine_similarity(a, v, dim=0)
distancia = torch.dist(a, v)
print(nombre, round(a_mano.item(), 3), round(coseno.item(), 3), round(distancia.item(), 3))
La salida esperada está oculta.
Ahora compara palabras en tabla, que todavía no se ha entrenado. torch.no_grad() hace las cuentas sin registrar operaciones para el gradiente:
with torch.no_grad():
F.cosine_similarity(tabla.weight[indice["gato"]], tabla.weight[indice["perro"]], dim=0)
Pregunta. ¿Será cos(gato, perro) mayor que cos(gato, auto)?
Ver resultado
La tabla muestra la similitud coseno entre las filas de dos palabras de tabla, que aún no se ha entrenado. Cada fila nombra una pareja y su similitud; por ejemplo, la fila de gato y auto dice que sus dos vectores tienen similitud 0.188.
| pareja | similitud coseno |
|---|---|
| gato y perro | -0.148 |
| gato y auto | 0.188 |
torch.manual_seed(1): con otra semilla el orden puede cambiar. Una tabla sin entrenar no representa nada; la similitud solo gana significado cuando una tarea ajusta las filas.Pooling por media
Las frases tienen longitudes distintas. Para formar un tensor, las cortas se completan con el id 0, <pad>, hasta la longitud de la más larga. padding_idx=0 le indica a la tabla qué id es padding. .detach() separa un tensor del registro de gradiente para imprimirlo como valores simples.
Pregunta. Escribe la forma de lote, la de secuencia y la de media_simple. ¿Qué valores tiene tabla_frases.weight[0]?
frases_ejemplo = [["el", "gato", "maúlla"], ["el", "tren", "frena", "rápido"]]
largo = max(len(f) for f in frases_ejemplo)
# cada frase se completa con <pad> (id 0) hasta `largo`
lote = torch.tensor([[indice[p] for p in f] + [0] * (largo - len(f)) for f in frases_ejemplo])
torch.manual_seed(0)
tabla_frases = nn.Embedding(len(vocab), 4, padding_idx=0)
with torch.no_grad():
secuencia = tabla_frases(lote)
media_simple = secuencia.mean(dim=1)
print(lote)
print(secuencia.shape)
print(media_simple.shape)
print(tabla_frases.weight[0].detach())
La salida esperada está oculta.
Una máscara marca con 1 las posiciones reales y con 0 el padding. La media con máscara suma solo las posiciones reales y divide entre su número.
Pregunta. Para «el gato maúlla», ¿qué relación hay entre media_simple[0] y media_mascara[0]?
with torch.no_grad():
mascara = (lote != 0).unsqueeze(-1).float() # (2, 4, 1)
media_mascara = (secuencia * mascara).sum(dim=1) / mascara.sum(dim=1)
print(mascara.sum(dim=1).squeeze(1))
print(media_simple[0].round(decimals=3))
print(media_mascara[0].round(decimals=3))
La salida esperada está oculta.
Ahora cambia el orden de las palabras.
Pregunta. ¿Da maúlla gato el el mismo vector que el gato maúlla?
Ver resultado
La línea verificada aplica torch.allclose, que da True si los dos vectores coinciden valor a valor.
torch.allclose entre la media de maúlla gato el y media_mascara[0] da True.
Una embedding con pooling por media y una capa lineal forman un clasificador de frases. La celda plegada genera frases sintéticas: combinan cada sujeto con cada verbo de su grupo. La clase 0 es animal y la 1, vehículo. frases guarda cada frase como lista de palabras y etiquetas, su clase. X_frases guarda los ids con padding y y_frases, las clases.
Mostrar códigoOcultar código
grupos = [
(["gato", "perro"], ["maúlla", "ladra", "corre"]),
(["auto", "tren"], ["frena", "pita", "corre"]),
]
frases, etiquetas = [], []
for clase, (sujetos, verbos) in enumerate(grupos):
for sujeto in sujetos:
for verbo in verbos:
for final in [[], ["rápido"]]:
frases.append(["el", sujeto, verbo] + final)
etiquetas.append(clase)
X_frases = torch.tensor([[indice[p] for p in f] + [0] * (4 - len(f)) for f in frases])
y_frases = torch.tensor(etiquetas)
La primera frase y su fila de ids con padding:
print(frases[0], X_frases[0])
La salida esperada está oculta.
Pregunta. ¿Qué forma tiene X_frases? ¿Qué palabras deciden la clase y cuáles aparecen en las dos?
Ver resultado
La línea verificada da la forma de X_frases. La tabla lista, para cada clase, todas las palabras que aparecen en alguna de sus frases, en orden alfabético. Por ejemplo, la fila 0 dice que maúlla y ladra aparecen en frases de animal.
X_frases tiene forma (24, 4).
| clase | palabras de sus frases |
|---|---|
| 0 | corre, el, gato, ladra, maúlla, perro, rápido |
| 1 | auto, corre, el, frena, pita, rápido, tren |
X_frases tiene 24 frases de hasta 4 posiciones. gato, perro, maúlla y ladra solo aparecen en la clase 0; auto, tren, frena y pita, solo en la 1. el, corre y rápido aparecen en ambas.ClasificadorFrases junta las dos piezas: la misma media con máscara que media_mascara dentro de forward y una capa nn.Linear(dimension, clases) que convierte el vector de la frase en una puntuación por clase.
class ClasificadorFrases(nn.Module):
def __init__(self, palabras, dimension, clases):
super().__init__()
self.embedding = nn.Embedding(palabras, dimension, padding_idx=0)
self.salida = nn.Linear(dimension, clases)
def forward(self, ids):
secuencia = self.embedding(ids)
mascara = (ids != 0).unsqueeze(-1).float()
frase = (secuencia * mascara).sum(dim=1) / mascara.sum(dim=1)
return self.salida(frase)
El entrenamiento usa nn.CrossEntropyLoss y torch.optim.Adam durante 100 épocas, con las 24 frases en un solo batch. Al final, argmax(dim=1) elige la clase con mayor puntuación para medir la exactitud.
torch.manual_seed(0)
clasificador = ClasificadorFrases(len(vocab), 4, 2)
criterio = nn.CrossEntropyLoss()
optimizador = torch.optim.Adam(clasificador.parameters(), lr=0.05)
for epoca in range(100):
optimizador.zero_grad()
perdida = criterio(clasificador(X_frases), y_frases)
perdida.backward()
optimizador.step()
with torch.no_grad():
exactitud = (clasificador(X_frases).argmax(dim=1) == y_frases).float().mean()
print(round(perdida.item(), 4), exactitud.item())
La salida esperada está oculta.
El clasificador separa las 24 frases de entrenamiento: pérdida 0.0007 y exactitud 1.0. Para lograrlo, el optimizador movió las filas de la tabla.
F.normalize(filas, dim=1) divide cada fila entre su norma. El producto de dos filas normalizadas es su similitud coseno, así que filas @ filas.T da todas las parejas a la vez.
Pregunta. Después del entrenamiento, ¿qué signo tendrá cos(gato, perro)? ¿Y cos(gato, auto)? ¿Cerca de qué valor quedará el frente a gato y a auto?
palabras = ["gato", "perro", "auto", "tren", "el"]
with torch.no_grad():
filas = F.normalize(clasificador.embedding.weight[[indice[p] for p in palabras]], dim=1)
similitud = filas @ filas.T
print(palabras)
print(similitud.round(decimals=2))
La salida esperada está oculta.
Autoencoder y reconstrucción
Un autoencoder aprende una representación sin etiquetas. Tiene dos partes. El codificador convierte la entrada en un código de k números. El decodificador intenta reconstruir la entrada desde ese código. El objetivo de la pérdida es la propia entrada: MSELoss entre la reconstrucción y la imagen original. Cuando k es menor que el tamaño de la entrada, el código es un cuello de botella.
Los píxeles de load_digits van de 0 a 16. Divididos entre 16 quedan entre 0 y 1, el mismo rango que la sigmoide al final del decodificador. train_test_split separa entrenamiento y prueba; stratify mantiene la misma proporción de cada dígito en los dos conjuntos.
Pregunta. Escribe la forma de digitos.images, la de digitos.data y la de los dos conjuntos con test_size=0.25.
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split
digitos = load_digits()
X_ent, X_prueba, y_ent, y_prueba = train_test_split(
digitos.data / 16, digitos.target, test_size=0.25, random_state=0, stratify=digitos.target
)
X_ent = torch.tensor(X_ent, dtype=torch.float32)
X_prueba = torch.tensor(X_prueba, dtype=torch.float32)
print(digitos.images.shape, digitos.data.shape)
print(X_ent.shape, X_prueba.shape)
print(X_ent.min().item(), X_ent.max().item())
La salida esperada está oculta.
Autoencoder(codigo) define el codificador 64 → 32 → codigo y el decodificador codigo → 32 → 64, que termina en nn.Sigmoid(). forward encadena las dos partes.
class Autoencoder(nn.Module):
def __init__(self, codigo):
super().__init__()
self.codificador = nn.Sequential(nn.Linear(64, 32), nn.ReLU(), nn.Linear(32, codigo))
self.decodificador = nn.Sequential(
nn.Linear(codigo, 32), nn.ReLU(), nn.Linear(32, 64), nn.Sigmoid()
)
def forward(self, x):
return self.decodificador(self.codificador(x))
sin_entrenar.codificador(x) aplica solo la primera mitad y devuelve el código. F.mse_loss mide el error cuadrático medio entre la reconstrucción y la entrada.
Pregunta. Con un código de 8 números, ¿qué forma tiene z para cinco imágenes? ¿Y reconstruccion?
torch.manual_seed(0)
sin_entrenar = Autoencoder(8)
with torch.no_grad():
z = sin_entrenar.codificador(X_ent[:5])
reconstruccion = sin_entrenar(X_ent[:5])
print(z.shape, reconstruccion.shape)
print(round(F.mse_loss(reconstruccion, X_ent[:5]).item(), 4))
La salida esperada está oculta.
Con 1347 imágenes, todo el conjunto de entrenamiento cabe en un solo batch. entrenar_autoencoder(codigo) crea un autoencoder con ese código y lo entrena 500 épocas con Adam. La pérdida compara la salida con la propia entrada.
def entrenar_autoencoder(codigo, epocas=500):
torch.manual_seed(0)
modelo = Autoencoder(codigo)
optimizador = torch.optim.Adam(modelo.parameters(), lr=0.01)
for epoca in range(epocas):
optimizador.zero_grad()
perdida = F.mse_loss(modelo(X_ent), X_ent)
perdida.backward()
optimizador.step()
return modelo
La imagen media sirve de referencia: reconstruye todas las imágenes con el mismo promedio, X_ent.mean(dim=0), repetido en cada una de las 450 filas de prueba.
Con la imagen media, el error de prueba es 0.074.
Con esa referencia se entrenan tres autoencoders, con códigos de 2, 8 y 32 números, y cada uno se mide sobre X_prueba.
Pregunta. Ordena los errores de prueba de los códigos de 2, 8 y 32 números. ¿Supera el código de 2 a la imagen media?
autoencoders = {}
for codigo in [2, 8, 32]:
autoencoders[codigo] = entrenar_autoencoder(codigo)
with torch.no_grad():
error = F.mse_loss(autoencoders[codigo](X_prueba), X_prueba)
print(codigo, round(error.item(), 4))
La salida esperada está oculta.
La función plegada dibujar convierte 64 valores en una cuadrícula de 8 × 8 con cinco niveles de tinta, de " " a "#", y devuelve una cadena por fila.
Mostrar códigoOcultar código
def dibujar(pixeles):
niveles = " .:o#"
posiciones = (pixeles.reshape(8, 8) * 4).round().int() # 0–1 → nivel 0–4
return ["".join(niveles[i] for i in fila) for fila in posiciones]
zip(*columnas) recorre fila por fila las cuadrículas de la imagen original y de sus reconstrucciones con códigos de 2 y 8, para imprimirlas lado a lado.
Pregunta. Para la primera imagen de prueba, ¿cuál reconstrucción conservará la forma del dígito: la del código de 2 o la del de 8?
with torch.no_grad():
columnas = [dibujar(X_prueba[0])]
# X_prueba[:1] es un lote de una imagen; [0] saca su reconstrucción
columnas += [dibujar(autoencoders[k](X_prueba[:1])[0]) for k in [2, 8]]
print("etiqueta:", y_prueba[0])
print("original código 2 código 8")
for filas in zip(*columnas):
print(" ".join(filas))
La salida esperada está oculta.
Código latente
Después del entrenamiento, el decodificador se deja de lado. El codificador convierte cada imagen en k números: el código latente. Es una representación aprendida sin etiquetas. Para comprobar qué conserva, cada imagen de prueba busca la imagen de entrenamiento con mayor similitud coseno y toma su etiqueta. Es K-NN con un solo vecino. Las etiquetas solo se usan para medir. exactitud_vecino(A_ent, A_prueba) normaliza las filas, obtiene todas las similitudes con un producto de matrices y compara la etiqueta del vecino elegido con la real.
Pregunta. Los píxeles tienen 64 números. ¿Qué código se acercará más a su exactitud? ¿Bastan 2 números?
def exactitud_vecino(A_ent, A_prueba):
similitud = F.normalize(A_prueba, dim=1) @ F.normalize(A_ent, dim=1).T
vecino = similitud.argmax(dim=1) # índice del vecino de entrenamiento más parecido
return (torch.tensor(y_ent)[vecino] == torch.tensor(y_prueba)).float().mean().item()
with torch.no_grad():
print("píxeles (64)", round(exactitud_vecino(X_ent, X_prueba), 3))
for codigo in [2, 8, 32]:
codificador = autoencoders[codigo].codificador
print(f"código ({codigo})", round(exactitud_vecino(codificador(X_ent), codificador(X_prueba)), 3))
La salida esperada está oculta.
Práctica
Traza guiada
Antes de procesar, escribe la forma de salida_traza y la de frase_traza, el divisor de cada frase y qué filas de traza.weight.grad serán distintas de cero. Indica qué fila recibe el gradiente más grande.
torch.manual_seed(0)
traza = nn.Embedding(6, 3, padding_idx=0)
ids_traza = torch.tensor([[4, 2, 0], [1, 1, 5]])
salida_traza = traza(ids_traza)
mascara_traza = (ids_traza != 0).unsqueeze(-1).float()
frase_traza = (salida_traza * mascara_traza).sum(dim=1) / mascara_traza.sum(dim=1)
frase_traza.sum().backward()
Comprueba con print. Si el gradiente de una fila no coincide, revisa cuántas veces aparece su id y entre cuánto se divide su frase.
Código de 4 números
Entrena entrenar_autoencoder(4). Antes de procesar, predice dónde quedará su error de prueba respecto a los códigos de 2 y 8, y su exactitud de vecino más cercano. Comprueba ambas con el mismo cálculo de «Autoencoder y reconstrucción» y de «Código latente», y dibuja la primera imagen de prueba con dibujar.
Buscador de frases
Usa la tabla de clasificador para buscar frases parecidas a «el gato pita», que mezcla un sujeto animal con un verbo de vehículo:
- escribe una función que reciba una lista de palabras y devuelva su media con máscara usando
clasificador.embedding - calcula el vector de las 24 frases de
frasesy el de «el gato pita» - antes de procesar, predice a qué clase pertenecerán las tres frases más similares
- ordena las 24 frases por similitud coseno y muestra las tres primeras con su valor
- comprueba con
assertque el vector de cada frase tiene forma(4,)
Reto opcional
Entrena un autoencoder de 8 números que reciba imágenes con ruido y reconstruya las originales. Genera el ruido con torch.randn(X_ent.shape, generator=torch.Generator().manual_seed(0)), multiplícalo por 0.2 y recorta la suma a [0, 1] con clamp. La pérdida compara la reconstrucción con X_ent sin ruido. Para X_prueba con ruido, usa el mismo procedimiento con X_prueba.shape y manual_seed(1). Antes de procesar, predice cuál de los dos modelos reconstruye mejor X_prueba con ruido: este o autoencoders[8].
Errores que revisar
- Si
nn.EmbeddinglanzaExpected tensor for argument #1 'indices' to have one of the following scalar types: Long, Int; but got torch.FloatTensor instead, los ids llegaron como decimales. Conviértelos con.long(). - Si aparece
IndexError: index out of range in self, algún id es mayor o igual quenum_embeddings. Comparaids.max()conlen(vocab). - Si los vectores de las frases cortas tienen normas menores que los de las largas, la media incluyó el padding. Divide entre el número de posiciones reales de la máscara.
- Si
F.cosine_similaritylanzaDimension out of range (expected to be in range of [-1, 0], but got 1), recibió vectores de un eje con eldim=1predeterminado. Usadim=0;dim=1sirve para lotes de forma(N, D). - Si el error de reconstrucción es casi 0, revisa que lo midas sobre
X_pruebay que alguna capa entre la entrada y la salida tenga menos de 64 unidades. Si el código y todas las capas intermedias tienen al menos 64, la red puede copiar la entrada sin comprimirla.
Cierre
Explica qué hizo que gato y perro quedaran cerca en la tabla del clasificador y qué hizo que el código de 8 números conservara la clase de los dígitos, si en ningún caso se le dio a la red una definición de «parecido».
Lista de comprobación
- ¿Qué forma devuelve
nn.Embedding(V, D)para ids de forma(B, L)y qué filas deweightreciben gradiente? - ¿Por qué la similitud coseno entre
ay2aes 1 aunque su distancia euclidiana no sea 0? - ¿Qué error produce promediar una frase sin excluir el padding?
- ¿Por qué un código más pequeño aumenta el error de reconstrucción?
- ¿Qué parte del autoencoder produce la representación y por qué no necesita etiquetas para entrenarse?
Documentación oficial
- nn.Embedding: tabla de embeddings,
padding_idxy forma de entrada y salida. - cosine_similarity: similitud coseno a lo largo de un eje.
- normalize: división de cada vector entre su norma.
- load_digits: dataset de dígitos de 8 × 8 incluido en scikit-learn.