Ir al contenido
wannacode

Embeddings: el significado como vectores

En resumen: Un embedding es un vector de números para una palabra o un texto, de modo que los textos de significado cercano tienen vectores cercanos, y la cercanía se mide con el coseno del ángulo.

Problema

Búsqueda por palabras

El bot de soporte tiene una base de respuestas ya hechas: un texto para la entrega, otro para la devolución y otro para el pago. El cliente escribe una pregunta, y el bot tiene que encontrar el texto que la responde.

# la base de respuestas del soporte
DOCS = [
("entrega", "la entrega tarda hasta dos días"),
("devolver", "devuelve el producto en 14 días y recibe el dinero"),
("pago", "paga con tarjeta o contra reembolso"),
]
# las preguntas de los clientes
QUESTIONS = [
"cuándo recibo mi paquete",
"por qué rechazaron mi pago",
]

Un compañero busca por palabras en común: cuantas más palabras de la pregunta estén en el texto, mejor.

# cuántas palabras de la pregunta están también en el texto
def common(question, text):
ws = text.split(" ")
return sum(1 for word in question.split(" ") if word in ws)
for q in QUESTIONS:
print(q)
best = None
top = 0
for topic, text in DOCS:
n = common(q, text)
print(f" {topic:<10}{n}")
if n > top:
top = n
best = topic
print(" →", best or "ninguno")
Salida
cuándo recibo mi paquete
entrega 0
devolver 0
pago 0
→ ninguno
por qué rechazaron mi pago
entrega 0
devolver 0
pago 0
→ ninguno

Ninguna de las dos preguntas encuentra nada. «paquete» no está en ningún texto, y para el programa «pago» y «paga» son palabras distintas. Los clientes preguntan con sus propias palabras, y los textos de la base están escritos con otras.

Hace falta comparar el significado, no las letras.

La idea

El significado como dirección

A una palabra se la conoce por la compañía que tiene. «paquete» y «pedido» aparecen junto a las mismas palabras («mensajero», «días», «llegará»), así que significan algo cercano, aunque sus letras sean distintas.

Esto se escribe con números. Cada palabra se convierte en un vector: una lista de números, uno por cada eje. Las palabras de significado cercano reciben vectores cercanos, es decir, flechas que apuntan casi en la misma dirección. Un vector así, que representa significado, se llama embedding.

En la lección los ejes son las ocho palabras más frecuentes del texto de la tienda, y el número de cada eje es en cuántas frases aparece la palabra junto a la palabra del eje. Los modelos reales aprenden los vectores con una red neuronal y tienen cientos o miles de ejes, pero la idea es la misma: significado cercano, dirección cercana.

Míralo

Palabras como vectores

Las palabras cortas como «el», «la» y «de» están en todas partes y no dicen nada del tema. No son ejes y no entran en los vectores:

# las palabras cortas que están en todas partes y no dicen nada del tema
STOP = {
"el", "la", "los", "las", "un", "una", "de", "del",
"a", "al", "en", "con", "por", "para", "y", "o",
"pero", "es", "se", "te", "tu", "me", "mi", "muy",
"hasta", "antes", "después", "está", "estoy", "fue", "que", "lo",
"dos", "puedes", "cuándo", "quiero",
}

El espacio: los ejes y el vector de una palabra. El vector de un texto es la suma de los vectores de sus palabras.

# las palabras como vectores: un número por cada eje
class Space:
# los ejes: las palabras más frecuentes, sin las cortas
def __init__(self, text, stop, n):
self.text = text
self.stop = stop
freq = {}
for line in text:
for w in line.split(" "):
if w not in stop:
freq[w] = freq.get(w, 0) + 1
self.vocab = list(freq)
self.axes = sorted(freq, key=freq.get, reverse=True)[:n]
# en cuántas frases la palabra aparece junto a la palabra de cada eje
def of(self, word):
v = [0] * len(self.axes)
for line in self.text:
ws = line.split(" ")
if word not in ws:
continue
for w in ws:
if w in self.axes and w != word:
v[self.axes.index(w)] += 1
return v
# el vector de un texto: la suma de los vectores de sus palabras
def of_text(self, s):
v = [0] * len(self.axes)
for w in s.split(" "):
if w not in self.stop:
v = [x + y for x, y in zip(v, self.of(w))]
return v
space = Space(TEXT, STOP, 8)
print("ejes:", " ".join(space.axes))
for word in ["paquete", "pedido", "pago"]:
print(f"{word:<12}{space.of(word)}")
Salida
ejes: producto pedido mensajero días entrega tarjeta llegará paquete
paquete [0, 0, 1, 1, 0, 0, 1, 0]
pedido [0, 0, 1, 1, 0, 0, 2, 0]
pago [0, 0, 0, 0, 0, 2, 0, 0]

«paquete» está en una frase con «mensajero», una con «días» y una con «llegará». «pedido» está una, una y dos veces: los números son distintos, pero la dirección es casi la misma. «pago» está solo con «tarjeta»: una dirección completamente distinta.

Míralo

Similitud: el coseno del ángulo

Lo cerca que están dos vectores lo dice el ángulo entre ellos. El coseno del ángulo es 1 cuando apuntan en la misma dirección, y 0 cuando no tienen ningún eje en común. Dividir por las longitudes hace que solo cuente la dirección.

# coseno del ángulo: 1 misma dirección, 0 nada en común
def cosine(a, b):
dot = sum(x * y for x, y in zip(a, b))
na = sum(x * x for x in a)
nb = sum(y * y for y in b)
if na == 0 or nb == 0:
return 0.0
return dot / (math.sqrt(na) * math.sqrt(nb))
space = Space(TEXT, STOP, 8)
v = space.of("paquete")
print(f"paquete y pedido: {cosine(v, space.of('pedido')):.3f}")
print(f"paquete y pago: {cosine(v, space.of('pago')):.3f}")
others = [o for o in space.vocab if o != "paquete"]
others.sort(key=lambda o: cosine(v, space.of(o)), reverse=True)
print("más cerca de paquete:")
for o in others[:5]:
print(f" {o:<12}{cosine(v, space.of(o)):.3f}")
Salida
paquete y pedido: 0.943
paquete y pago: 0.000
más cerca de paquete:
pedido 0.943
entrega 0.816
producto 0.816
tres 0.730
dirección 0.577

«paquete» y «pedido» tienen 0,943: casi la misma dirección. Con «pago» son 0,000. Las palabras más cercanas a «paquete» son «pedido» y «entrega». También aparecen «producto» y «tres»: en un texto pequeño también tiran las vecinas por casualidad.

Pruébalo

Predice la salida

¿Cuál es el coseno de [1, 1, 0] y [1, 0, 0]?

print(f"{cosine([1, 1, 0], [1, 0, 0]):.2f}")
Respuesta

Míralo

Búsqueda por significado

Ahora la pregunta y cada texto de la base se convierten en vectores, y la respuesta es el texto con el coseno más grande:

space = Space(TEXT, STOP, 8)
for q in QUESTIONS:
v = space.of_text(q)
print(q)
best = DOCS[0]
top = -1.0
for topic, text in DOCS:
c = cosine(v, space.of_text(text))
print(f" {topic:<10}{c:.3f}")
if c > top:
top = c
best = (topic, text)
print(f" → {best[0]}: {best[1]}")
Salida
cuándo recibo mi paquete
entrega 0.722
devolver 0.562
pago 0.000
→ entrega: la entrega tarda hasta dos días
por qué rechazaron mi pago
entrega 0.000
devolver 0.162
pago 1.000
→ pago: paga con tarjeta o contra reembolso

La primera pregunta encuentra «entrega» con 0,722, mientras que «devolver» tiene 0,562. La segunda encuentra «pago» con 1,000: «pago» está en frases con «tarjeta», igual que «contra» y «reembolso» del texto de pago. Ninguna de las dos preguntas tiene una palabra en común con su respuesta.

Aquí está la primera pregunta como direcciones. Los vectores tienen 8 dimensiones y el dibujo es plano, por eso conserva solo el ángulo de cada texto con la pregunta:

# coseno del ángulo: 1 misma dirección, 0 nada en común
def cosine(a, b):
dot = sum(x * y for x, y in zip(a, b))
na = sum(x * x for x in a)
nb = sum(y * y for y in b)
if na == 0 or nb == 0:
return 0.0
return dot / (math.sqrt(na) * math.sqrt(nb))
space = Space(TEXT, STOP, 8)
for q in QUESTIONS:
v = space.of_text(q)
print(q)
best = DOCS[0]
top = -1.0
for topic, text in DOCS:
c = cosine(v, space.of_text(text))
print(f" {topic:<10}{c:.3f}")
if c > top:
top = c
best = (topic, text)
print(f" → {best[0]}: {best[1]}")
Paso a paso, en palabras
  1. La pregunta «cuándo recibo mi paquete» se convierte en un vector: [0, 0, 1, 1, 0, 0, 1, 0]. El dibujo conserva solo el ángulo hasta él.
  2. «pago»: coseno 0,000, ángulo 90°. Ningún eje en común: las flechas forman un ángulo recto.
  3. «devolver»: coseno 0,562, ángulo 56°. Tienen algunos ejes en común, pero está lejos.
  4. «entrega»: coseno 0,722, ángulo 44°. El ángulo más pequeño.
  5. Lo más cercano es «entrega»: «la entrega tarda hasta dos días». Ni una sola palabra en común con la pregunta.

Pruébalo

Completa

El coseno divide el producto escalar por las dos longitudes. ¿Qué va en lugar de ___?

return dot / (math.sqrt(na) * math.sqrt())

Pruébalo

Encuentra el error

Un compañero calcula la similitud más rápido, pero obtiene 5 para la primera pregunta y el texto de la entrega, y la similitud debe estar entre 0 y 1. ¿En qué línea está el error?

Elige la línea con el error.

La idea

La atención como idea

En la lección «tarjeta» tiene un solo vector, esté donde esté. Pero en «paga con tarjeta» y «una tarjeta de cumpleaños» la palabra significa cosas distintas: una tarjeta bancaria y una tarjeta de felicitación.

Los modelos reales resuelven esto con la atención (attention). Cada token mira a los demás tokens del texto y mezcla en su propio vector un poco de los de ellos, más de los que le importan. «tarjeta» junto a «paga» toma de «paga», junto a «cumpleaños» de «cumpleaños», y los dos vectores se vuelven distintos. Esto se repite a lo largo de muchas capas, y así el vector de cada token lleva su significado en justo esta frase. El vector del texto entero se calcula a partir de ellos.

Aquí la atención es solo una idea: cómo se calcula exactamente no forma parte del curso.

La idea

Cuándo duele

  • El modelo pequeño conoce palabras, no significado. Un cliente no quiere el producto, y la búsqueda lo manda a la entrega:

    space = Space(TEXT, STOP, 8)
    for q in ["no quiero el producto"]:
    v = space.of_text(q)
    print(q)
    best = DOCS[0]
    top = -1.0
    for topic, text in DOCS:
    c = cosine(v, space.of_text(text))
    print(f" {topic:<10}{c:.3f}")
    if c > top:
    top = c
    best = (topic, text)
    print(f" → {best[0]}: {best[1]}")
    Salida
    no quiero el producto
    entrega 0.707
    devolver 0.688
    pago 0.000
    → entrega: la entrega tarda hasta dos días

    «no» no está en el texto y no le dice nada al modelo: no entiende la negación. Queda «producto», y sus vecinas en el texto son «días» y «llegará». Además la palabra no cuenta su propio eje, y justo «producto» es el eje más fuerte del texto de devolución. El vector de la pregunta sale [0, 0, 0, 1, 0, 0, 1, 0] y apunta a la entrega (0,707), no a la devolución (0,688), que es la respuesta correcta. Con otro texto, la misma pregunta iría a otro sitio: los fallos dependen del texto.

    Un modelo de embeddings real está entrenado con una cantidad enorme de texto. Sabe que «no quiero el producto» pide una devolución, y ve la pregunta entera, no solo las palabras sueltas. Aun así puede equivocarse, pero mucho menos. Cuántas veces encuentra el texto correcto se mide: en el módulo de búsqueda por significado es recall@k, la parte de las preguntas cuyo texto correcto está entre los primeros k encontrados.

  • Cerca no significa igual. Las palabras de significado contrario suelen estar en las mismas frases:

    space = Space(TEXT, STOP, 8)
    for x, y in [["gratis", "cuesta"], ["rápido", "tarde"]]:
    print(f"{x} y {y}: {cosine(space.of(x), space.of(y)):.3f}")
    print("color:", space.of("color"))
    Salida
    gratis y cuesta: 0.707
    rápido y tarde: 0.707
    color: [0, 0, 0, 0, 0, 0, 0, 0]

    «gratis» y «cuesta» tienen 0,707, porque las dos están junto a «entrega». Y «rápido» y «tarde» también tienen 0,707, porque las dos están junto a «producto». Los modelos reales lo hacen mejor, pero «puedo» y «no puedo» siguen saliendo cerca.

  • Una palabra desconocida es cero. «color» no está en el texto y su vector son solo ceros. Una pregunta solo con palabras desconocidas tiene 0,000 con todo, pero la búsqueda igual devuelve algún texto. Por eso hace falta un umbral: mira el ejercicio.

  • Los vectores salen del texto. Un texto pequeño da vecinas por casualidad («tres» junto a «paquete»). Modelos distintos dan vectores distintos que no se pueden comparar entre sí: la base y la pregunta deben usar el mismo modelo.

  • Muchos textos, muchos vectores. Con miles de documentos los vectores se guardan en una base de datos que encuentra rápido los más cercanos: bases de datos vectoriales. La búsqueda por significado sobre los documentos de la tienda es más adelante todo un módulo del curso. Cómo ejecutar un modelo de embeddings en un servidor y cómo mantener el índice al día es trabajo del curso ZeroToOps.

Más precisamente, para curiosos

Embeddings reales. Los modelos de embeddings reciben texto y devuelven cientos o miles de números. Se llaman por una API como los demás modelos, o se ejecutan en local, casi siempre desde Python. Los vectores suelen tener longitud 1, y entonces el coseno es simplemente el producto escalar.

Los ceros en los ejes. En la lección una palabra no cuenta su propio eje. Por eso la palabra de un eje que aparece solo con palabras fuera de los ejes es solo ceros: «tarjeta» es un eje, pero su vector es [0, 0, 0, 0, 0, 0, 0, 0], y la propia palabra «tarjeta» no ayuda a la búsqueda. El texto de pago se encuentra gracias a «contra» y «reembolso», que están en frases con «tarjeta». Si una palabra contara también su eje, ese sería el número más grande de su vector y tiraría de todo hacia sí: «paquete» y «pedido» bajarían de 0,943 a 0,165. Las dos elecciones son un compromiso del modelo pequeño; los modelos reales no tienen ejes que sean palabras.

Por qué coseno y no distancia. La distancia entre las puntas depende también de la longitud, y la longitud aquí es cuántas palabras tiene el texto. El coseno mira solo la dirección. Con vectores de longitud 1 los dos dan el mismo orden.

Los k más cercanos. La búsqueda de la lección es k-NN de la lección sobre los vecinos más cercanos con k = 1: solo cambian los vectores y la medida de cercanía.

En Python. math.sqrt es la raíz cuadrada, como Math.sqrt en Java y JavaScript. sum en Python suma con más exactitud que un bucle simple, pero aquí los números son enteros y el resultado es el mismo en los cuatro lenguajes.

Ejercicio

No estar seguro también es una respuesta

Resuélvelo en tu editor.

El bot no debe responder al azar. Escribe una función que devuelva el texto más cercano solo si el coseno es al menos 0,5, y si no, «no estoy seguro, se lo paso a un compañero». Pruébala con las dos preguntas de la lección y con «quiero otro color».

Salida
cuándo recibo mi paquete → la entrega tarda hasta dos días
por qué rechazaron mi pago → paga con tarjeta o contra reembolso
quiero otro color → no estoy seguro, se lo paso a un compañero

La tercera pregunta tiene 0,000 con todo, y en lugar de una respuesta equivocada el bot la pasa a una persona. El umbral es un compromiso: un umbral alto pasa preguntas cuya respuesta el bot conoce, y uno bajo deja pasar respuestas equivocadas.

Más tareas

  • Más ejes. Construye el espacio con 16 ejes. ¿Cómo cambian las similitudes de la lección?
  • Los dos primeros. Imprime los dos textos más cercanos para cada pregunta y a qué distancia está el segundo del primero.

Test

Ponte a prueba

1. ¿Qué es un embedding?
2. Dos vectores apuntan en la misma dirección, pero uno es tres veces más largo. ¿Cuál es su coseno?
3. ¿Por qué la búsqueda por significado encuentra «la entrega tarda hasta dos días» para «cuándo recibo mi paquete»?
4. «gratis» y «cuesta» tienen una similitud de 0,707. ¿Qué dice eso?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·