Embeddings: representar cosas como vectores

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

En esta ficha · 5 secciones
  1. Antes de empezar
  2. Ejercicios
  3. Mini-proyecto: un buscador de preguntas frecuentes
  4. Errores comunes
  5. ¿Lo lograste?

Antes de empezar

Varios ejercicios usan las variables y funciones de la teoría: coseno, vocab, indice, E, vecinas, modelo (el skip-gram), frases_modelo (el modelo de frases), imagenes, clases, C (los embeddings de la CNN), mapa y vecina_igual. Abre un cuaderno nuevo en Colab, pega y ejecuta en orden los bloques de la ficha Código y después trabaja debajo. Intenta cada ejercicio antes de abrir la respuesta.

Ejercicios

1. El coseno a mano

Tres vectores: a = (1, 2, 2), b = (2, 1, 2) y c = (−1, −2, −2).

  1. Calcula sin computadora el producto punto y la similitud coseno entre a y b, y entre a y c.
  2. ¿Cuál es la similitud coseno entre a y 2a = (2, 4, 4)? ¿Y su producto punto?
  3. Comprueba todo con la función coseno de la teoría.
Respuesta
  1. a · b = 2 + 2 + 4 = 8. Los dos vectores miden √(1 + 4 + 4) = 3, así que el coseno es 8 / 9 ≈ 0,89: apuntan casi hacia el mismo lado. a · c = −1 − 4 − 4 = −9 y el coseno es −9 / 9 = −1: c es a dado vuelta.
  2. El coseno entre a y 2a es 1: apuntan exactamente hacia el mismo lado, aunque uno mida el doble. El producto punto, en cambio, se duplica: 2 · 9 = 18. Por eso para comparar embeddings se usa el coseno (o se normalizan los vectores antes): solo importa la dirección.
a = np.array([1, 2, 2])
b = np.array([2, 1, 2])
c = np.array([-1, -2, -2])
print(f"a·b = {a @ b},  coseno(a, b) = {coseno(a, b):.2f}")
print(f"a·c = {a @ c},  coseno(a, c) = {coseno(a, c):.2f}")
print(f"a·2a = {a @ (2 * a)}, coseno(a, 2a) = {coseno(a, 2 * a):.2f}")
a·b = 8,  coseno(a, b) = 0.89
a·c = -9,  coseno(a, c) = -1.00
a·2a = 18, coseno(a, 2a) = 1.00

2. One-hot contra embeddings en un clasificador

Abajo hay 18 frases cortas sintéticas (las escribimos para este ejercicio) de tres temas, y otras 9 para probar, escritas a propósito con palabras distintas. Entrena una regresión logística (tema 8) de dos maneras:

  • con la bolsa de palabras de cada frase (CountVectorizer(binary=True): es la suma de los vectores one-hot de sus palabras);
  • con el embedding de cada frase del modelo multilingüe.

Compara la exactitud en las 9 frases de prueba. Cuenta también cuántas palabras de cada frase de prueba estaban en el vocabulario de entrenamiento.

from sklearn.linear_model import LogisticRegression

entrenamiento_frases = [
    ("Hoy hace mucho frío en la ciudad", "clima"),
    ("Va a llover toda la tarde", "clima"),
    ("El sol quema al mediodía", "clima"),
    ("Se viene una tormenta con granizo", "clima"),
    ("Hay neblina en la carretera", "clima"),
    ("El viento sopla fuerte esta noche", "clima"),
    ("La sopa de maní está deliciosa", "comida"),
    ("Comimos salteñas en el desayuno", "comida"),
    ("El pan recién horneado huele rico", "comida"),
    ("Preparé arroz con pollo", "comida"),
    ("Este postre lleva mucho azúcar", "comida"),
    ("La fruta del mercado está fresca", "comida"),
    ("El delantero metió dos goles", "deporte"),
    ("Entrenamos básquet todos los martes", "deporte"),
    ("El equipo ganó el campeonato", "deporte"),
    ("Corrí cinco kilómetros en el parque", "deporte"),
    ("El árbitro pitó un penal", "deporte"),
    ("Nadamos en la piscina del colegio", "deporte"),
]
prueba_frases = [
    ("Mañana amanecerá nublado y helado", "clima"),
    ("Anuncian chubascos para el fin de semana", "clima"),
    ("Qué calor tan sofocante", "clima"),
    ("Me encantan las empanadas de queso", "comida"),
    ("La cena estuvo sabrosa", "comida"),
    ("Cocinaremos fideos con carne", "comida"),
    ("La selección venció tres a uno", "deporte"),
    ("Me inscribí en un torneo de ajedrez", "deporte"),
    ("El ciclista llegó primero a la meta", "deporte"),
]
Respuesta
X_ent = [t for t, _ in entrenamiento_frases]
y_ent = [e for _, e in entrenamiento_frases]
X_pru = [t for t, _ in prueba_frases]
y_pru = np.array([e for _, e in prueba_frases])

# Bolsa de palabras: un 1 por cada palabra presente (suma de one-hot)
bolsa = CountVectorizer(binary=True).fit(X_ent)
print("Palabras conocidas por frase:",
      bolsa.transform(X_pru).sum(axis=1).A1)
clf = LogisticRegression(max_iter=1000)
clf.fit(bolsa.transform(X_ent), y_ent)
acierto = np.mean(clf.predict(bolsa.transform(X_pru)) == y_pru)
print(f"Bolsa de palabras: {acierto:.0%}")

# Embeddings de frases
V_ent = frases_modelo.encode(X_ent, normalize_embeddings=True)
V_pru = frases_modelo.encode(X_pru, normalize_embeddings=True)
clf = LogisticRegression(max_iter=1000).fit(V_ent, y_ent)
pred = clf.predict(V_pru)
print(f"Embeddings:        {np.mean(pred == y_pru):.0%}")
for frase, p, e in zip(X_pru, pred, y_pru):
    if p != e:
        print(f"Error: «{frase}» -> {p} (era {e})")
Palabras conocidas por frase: [0 2 0 1 1 1 1 3 2]
Bolsa de palabras: 33%
Embeddings:        89%
Error: «Anuncian chubascos para el fin de semana» -> comida (era clima)

La bolsa de palabras acierta solo 3 de las 9 frases (33 %, lo mismo que adivinar al azar entre tres temas). Las frases de prueba tienen entre 0 y 3 palabras conocidas, y todas son palabras que no dicen nada del tema («el», «la», «de», «en», «con», «un»): para el clasificador, «Qué calor tan sofocante» es una fila de ceros. Con embeddings acierta 8 de 9 (89 %), aunque nunca vio «nublado», «empanadas» ni «ciclista»: el modelo de frases ya sabe que «sabrosa» está cerca de «deliciosa» y «torneo» cerca de «campeonato». Su único error es la frase de los chubascos, que tomó por comida. Es la ventaja de un embedding: lo que se aprende de una palabra sirve para sus vecinas.

3. Un sesgo de género

En la teoría y en la actividad viste que la dirección que va de «hombre» a «mujer» tiene significado. Úsala para medir un sesgo:

  1. Calcula con el modelo de frases el vector g = mujer − hombre y normalízalo.
  2. Para cada palabra de esta lista, que en español sirve igual para hombres y mujeres, calcula el producto punto de su embedding normalizado con g: piloto, chef, líder, policía, juez, periodista, cantante, estudiante, modelo. Un valor positivo dice que la palabra queda más del lado de «mujer»; uno negativo, del lado de «hombre».
  3. Como referencia, calcula lo mismo para «él» y «ella».
  4. Prueba también la analogía «médico − hombre + mujer» con estas palabras: médico, médica, enfermero, enfermera, hombre, mujer.
Respuesta
pares = "hombre mujer él ella".split()
neutras = ("piloto chef líder policía juez periodista cantante "
           "estudiante modelo").split()
G = frases_modelo.encode(pares + neutras, normalize_embeddings=True)
genero = G[1] - G[0]                # mujer − hombre
genero = genero / np.linalg.norm(genero)
for p, v in zip(pares[2:] + neutras, G[2:]):
    print(f"{p:10s} {v @ genero:+.2f}")

# La analogía médico − hombre + mujer, paso a paso
lista = "médico médica enfermero enfermera hombre mujer".split()
W = frases_modelo.encode(lista, normalize_embeddings=True)
v = W[0] - W[4] + W[5]
sim = W @ (v / np.linalg.norm(v))
for i in np.argsort(-sim):
    if lista[i] not in ("médico", "hombre", "mujer"):
        print(f"médico − hombre + mujer ≈ {lista[i]:10s} {sim[i]:.2f}")
él         -0.14
ella       +0.34
piloto     -0.27
chef       -0.29
líder      -0.25
policía    -0.11
juez       -0.01
periodista -0.05
cantante   -0.03
estudiante +0.06
modelo     +0.07
médico − hombre + mujer ≈ médica     0.76
médico − hombre + mujer ≈ enfermera  0.75
médico − hombre + mujer ≈ enfermero  0.75

«él» queda del lado de «hombre» (−0,14) y «ella» del lado de «mujer» (+0,34), como se esperaba. Pero palabras que en español sirven para los dos también se inclinan: «chef» (−0,29), «piloto» (−0,27) y «líder» (−0,25) quedan del lado de «hombre», incluso más que el propio «él»; «estudiante» y «modelo» quedan apenas del lado de «mujer» (+0,06 y +0,07). Nadie programó eso: el modelo lo aprendió de los textos con que se entrenó, donde esas palabras aparecían más junto a hombres o a mujeres.

En la analogía, «médica» sale primera (0,76), pero «enfermera» queda casi empatada (0,75). En la actividad, con los vectores comprimidos a 50 números, «enfermera» llega a salir primera. Un sistema que use estos vectores, por ejemplo para ordenar postulantes a un trabajo, podría repetir esas asociaciones sin que nadie lo note.

4. La tabla de nn.Embedding

  1. ¿Cuántos parámetros entrenables tiene el modelo skip-gram de la teoría? Calcúlalo a mano y compruébalo.
  2. Un lote de 4 frases, cada una con 7 números de palabra, entra a modelo.central. ¿Qué forma tiene la salida? ¿Y si después promedias a lo largo de las palabras?
  3. ¿Qué pasa si le das a la tabla el número 3000?
Respuesta
  1. Dos tablas de 3000 filas por 50 columnas: 2 · 3000 · 50 = 300 000 parámetros. Son los mismos pesos que tendrían dos capas nn.Linear(3000, 50) sin sesgo que recibieran vectores one-hot; la diferencia es que nn.Embedding no hace las multiplicaciones por cero.
  2. La salida es (4, 7, 50): un vector de 50 números por cada palabra de cada frase. Al promediar con mean(dim=1) queda (4, 50): un vector por frase.
  3. Las filas van de 0 a 2999, así que 3000 no existe y PyTorch da un error. Es el mismo problema que el <UNK> del tema 21: el modelo solo conoce las palabras de su tabla.
total = sum(p.numel() for p in modelo.parameters())
print(f"Parámetros del skip-gram: {total:,}")

lote = torch.randint(0, 3000, (4, 7), generator=g)
salida = modelo.central(lote)
print("Salida:", tuple(salida.shape))
print("Promedio por frase:", tuple(salida.mean(dim=1).shape))
modelo.central(torch.tensor([3000]))
Parámetros del skip-gram: 300,000
Salida: (4, 7, 50)
Promedio por frase: (4, 50)
Traceback (most recent call last):
  ...
IndexError: index out of range in self

5. Una palabra que el Quijote no conoce

Llama a vecinas("computadora"). ¿Qué pasa y por qué? Después busca con el modelo de frases cuál de estas palabras se parece más a «computadora»: laptop, ordenador, teclado, caballo, molino.

Respuesta

«computadora» no está en el Quijote, así que no tiene fila en la tabla y el diccionario indice no la encuentra:

vecinas("computadora")
Traceback (most recent call last):
  ...
KeyError: 'computadora'

El modelo de frases, en cambio, no trabaja con palabras enteras sino con pedazos de palabras (como el BPE del tema 21), así que puede armar un vector para cualquier palabra, aunque nunca la haya visto completa:

otras = ["laptop", "ordenador", "teclado", "caballo", "molino"]
q = frases_modelo.encode("computadora", normalize_embeddings=True)
O = frases_modelo.encode(otras, normalize_embeddings=True)
for p, s in sorted(zip(otras, O @ q), key=lambda t: -t[1]):
    print(f"{p:10s} {s:.2f}")
ordenador  0.99
laptop     0.78
teclado    0.55
molino     0.25
caballo    0.19

«ordenador» (la palabra que se usa en España para computadora) tiene similitud 0,99: es casi un sinónimo. Le siguen «laptop» (0,78) y «teclado» (0,55), y muy abajo «molino» y «caballo». El modelo de frases nunca tuvo un problema de palabra desconocida.

6. ¿Cuánto distorsiona el mapa?

El mapa de UMAP de las prendas parecía separar bien las clases. Mídelo: calcula vecina_igual con los 64 números de la CNN (C) y con las 2 coordenadas del mapa (mapa). Haz lo mismo con un PCA a 2 dimensiones de C. ¿Qué se pierde al pasar a 2 dimensiones, y qué método pierde más?

Respuesta
from sklearn.decomposition import PCA

plano_pca = PCA(n_components=2).fit_transform(C)
print(f"64 dimensiones: {vecina_igual(C, clases):.1%}")
print(f"UMAP a 2:       {vecina_igual(mapa, clases):.1%}")
print(f"PCA a 2:        {vecina_igual(plano_pca, clases):.1%}")
64 dimensiones: 82.6%
UMAP a 2:       70.7%
PCA a 2:        52.0%

Con las 64 dimensiones, la vecina más cercana es de la misma clase el 82,6 % de las veces; en el mapa de UMAP, el 70,7 %; con PCA a 2 dimensiones, el 52,0 %. Al pasar a dos dimensiones se pierden vecindarios. UMAP conserva bastante más que PCA, porque busca justamente conservar los vecinos, pero ninguno conserva todo. Por eso las vecinas de verdad se buscan con todas las dimensiones, como en la actividad, y el mapa sirve solo para mirar.

Mini-proyecto: un buscador de preguntas frecuentes

Vas a construir un buscador que responde preguntas escritas con tus propias palabras. Usa las preguntas frecuentes de un club de robótica imaginario (las escribimos nosotros; no son de ninguna institución real), o mejor aún, escribe las de tu colegio o de un club que conozcas.

faq = [
    ("¿Cómo me inscribo en el club?",
     "Llena el formulario de inscripción en la secretaría del colegio."),
    ("¿Cuánto cuesta participar?",
     "Participar es gratis; solo pedimos que traigas tu cuaderno."),
    ("¿Qué días se reúne el club?",
     "Nos reunimos los martes y jueves de 15:00 a 17:00."),
    ("¿Necesito saber programar?",
     "No. Empezamos desde cero con bloques y después Python."),
    ("¿Puedo llevar mi propia computadora?",
     "Sí, pero el club presta computadoras a quien no tenga."),
    ("¿Hay un límite de edad?",
     "El club es para estudiantes de 12 a 18 años."),
    ("¿Dónde queda el laboratorio?",
     "En el segundo piso del bloque B, al lado de la biblioteca."),
    ("¿Qué pasa si falto a una sesión?",
     "Avísale a tu tutor; las guías quedan en la carpeta compartida."),
]
  1. Calcula una sola vez el embedding de cada pregunta frecuente, normalizado.
  2. Escribe una función responder(pregunta) que calcule el embedding de la pregunta, busque la pregunta frecuente más parecida por coseno y devuelva su respuesta junto con la similitud.
  3. Pruébala con preguntas escritas de otra forma, sin repetir palabras: «quiero registrarme», «¿es pagado?», «no sé nada de código», «¿en qué aula es?».
  4. Pruébala con una pregunta que no está en la lista, como «¿venden uniformes?». ¿Qué responde? Agrega un umbral: si la mejor similitud no llega a, por ejemplo, 0,35, que diga «No encontré una respuesta; pregunta a un tutor».
  5. Compara con un buscador por palabras: busca la pregunta frecuente que comparte más palabras con la consulta (con CountVectorizer). ¿Con cuáles de tus consultas falla?
  6. Opcional: en vez de comparar solo con las preguntas, compara con «pregunta + respuesta» juntas. ¿Mejora?
Resultados de referencia, para comprobar los tuyos
preguntas = [p for p, _ in faq]
P = frases_modelo.encode(preguntas, normalize_embeddings=True)


def responder(pregunta, umbral=0.35):
    """La respuesta de la pregunta frecuente más parecida."""
    q = frases_modelo.encode(pregunta, normalize_embeddings=True)
    sim = P @ q
    i = int(np.argmax(sim))
    if sim[i] < umbral:
        return sim[i], i, "No encontré una respuesta; pregunta a un tutor."
    return sim[i], i, faq[i][1]


# Buscador por palabras, para comparar
contador = CountVectorizer().fit(preguntas)
B = contador.transform(preguntas).toarray()

for consulta in ["quiero registrarme", "¿es pagado?",
                 "no sé nada de código", "¿en qué aula es?",
                 "¿venden uniformes?"]:
    sim, i, respuesta = responder(consulta)
    comunes = B @ contador.transform([consulta]).toarray()[0]
    por_palabras = (preguntas[int(np.argmax(comunes))]
                    if comunes.max() > 0 else "(ninguna palabra en común)")
    print(f"{consulta}\n  Más parecida: {preguntas[i]} ({sim:.2f})\n"
          f"  Responde: {respuesta}\n  Por palabras: {por_palabras}")
quiero registrarme
  Más parecida: ¿Cómo me inscribo en el club? (0.46)
  Responde: Llena el formulario de inscripción en la secretaría del colegio.
  Por palabras: (ninguna palabra en común)
¿es pagado?
  Más parecida: ¿Cuánto cuesta participar? (0.44)
  Responde: Participar es gratis; solo pedimos que traigas tu cuaderno.
  Por palabras: (ninguna palabra en común)
no sé nada de código
  Más parecida: ¿Necesito saber programar? (0.53)
  Responde: No. Empezamos desde cero con bloques y después Python.
  Por palabras: ¿Hay un límite de edad?
¿en qué aula es?
  Más parecida: ¿Dónde queda el laboratorio? (0.44)
  Responde: En el segundo piso del bloque B, al lado de la biblioteca.
  Por palabras: ¿Cómo me inscribo en el club?
¿venden uniformes?
  Más parecida: ¿Cómo me inscribo en el club? (0.26)
  Responde: No encontré una respuesta; pregunta a un tutor.
  Por palabras: (ninguna palabra en común)
  • Las cuatro consultas encuentran la pregunta frecuente correcta, aunque no comparten palabras con ella, con similitudes moderadas: entre 0,44 y 0,53. «¿venden uniformes?» llega solo a 0,26 y el umbral la deja sin respuesta, que es lo correcto.
  • El umbral sale de mirar estos números: con 0,5 se habrían perdido tres de las cuatro respuestas buenas. Elige el tuyo probando con tus propias consultas, buenas y malas.
  • El buscador por palabras falla en todas: en dos no encuentra ninguna palabra en común, y en las otras dos elige una pregunta que comparte solo «de» o «en».

Errores comunes

  • Comparar embeddings con la distancia o el producto punto sin normalizar. Un vector más largo no es «más parecido». Usa la similitud coseno, o normaliza los vectores (normalize_embeddings=True) y después usa el producto punto.
  • Creer que el mapa 2D muestra las distancias reales. UMAP y t-SNE conservan algo de los vecindarios, no las distancias ni los tamaños de los grupos. Las vecinas se buscan con todas las dimensiones (ejercicio 6).
  • Mezclar embeddings de modelos distintos. Los 50 números del skip-gram y los 384 del modelo de frases viven en espacios distintos: compararlos no tiene sentido, aunque tuvieran el mismo largo.
  • Pedir una palabra que no está en el vocabulario. Un modelo de palabras enteras no tiene fila para ella (ejercicio 5). Los modelos que trabajan con pedazos de palabras no tienen ese problema.
  • Esperar que las analogías siempre funcionen. Salen bien con relaciones muy frecuentes en el texto (países y capitales, masculino y femenino) y mal con muchas otras. Además, se excluyen a mano las palabras de la pregunta: sin eso, la respuesta más cercana a «rey − hombre + mujer» suele ser el mismo «rey».
  • Olvidar el modo de evaluación. Antes de sacar embeddings de una red con capas como Dropout o BatchNorm (la resnet18 tiene BatchNorm), llama a .eval() y usa torch.no_grad().
  • Olvidar la normalización de la red preentrenada. La resnet18 espera imágenes de tres canales normalizadas con la media y la desviación de ImageNet; sin eso, sus embeddings pierden sentido.
  • Usar los embeddings sin mirar sus sesgos. Un buscador o un clasificador construido con ellos hereda los prejuicios de los textos con que se entrenaron (ejercicio 3).

¿Lo lograste?

Al terminar este tema deberías poder hacer lo siguiente. Al lado, dónde lo practicaste.

  • Explicar por qué un vector one-hot no captura el significado y un embedding sí (ejercicios 1 y 2).
  • Entrenar embeddings de palabras con skip-gram y muestreo negativo en PyTorch (teoría y ejercicio 5).
  • Usar nn.Embedding y promediar embeddings para representar una frase (ejercicio 4).
  • Extraer embeddings de frases e imágenes con modelos preentrenados y buscar vecinos por similitud coseno (ejercicio 2 y mini-proyecto).
  • Visualizar embeddings con PCA o UMAP y saber qué no concluir del dibujo (ejercicio 6).
  • Reconocer los límites de las analogías y los sesgos de los embeddings (ejercicio 3).