Capítulo 10 de 15 8 secciones 14 min

Convertir palabras en números que signifiquen algo

One-hot dice que bodega y minimarket no se parecen en nada. Los embeddings arreglan eso, y traen su propio problema.

Un embedding es una lista corta de números que representa una palabra, construida a partir de con qué otras palabras aparece. Con one-hot, bodega y minimarket tienen parecido 0,000 igual que bodega y arroz. Con embeddings de cinco dimensiones, bodega y minimarket dan 0,849 y bodega y arroz 0,300. El problema conocido: completo e incompleto dan 0,975, porque aparecen en los mismos sitios.

En el capítulo 9 el dato tenía forma de rejilla. Aquí tiene forma de secuencia, y antes de meterla en cualquier red hay un problema que resolver: una red multiplica números y "bodega" no se multiplica 🔤

Lo que veníamos haciendo, y por qué no basta

En todo el libro de machine learning convertimos las palabras con OneHotEncoder:

  • Una columna por valor
  • Un 1 en la que toca
  • Ceros en las demás
import numpy as np

palabras = ['bodega', 'minimarket', 'mayorista', 'arroz', 'aceite']
posicion = {p: i for i, p in enumerate(palabras)}

def one_hot(p):
    v = np.zeros(len(palabras))
    v[posicion[p]] = 1
    return v

print('bodega    :', one_hot('bodega'))
print('minimarket:', one_hot('minimarket'))
print()
print('parecido bodega y minimarket:', float(one_hot('bodega') @ one_hot('minimarket')))
print('parecido bodega y arroz     :', float(one_hot('bodega') @ one_hot('arroz')))
bodega    : [1. 0. 0. 0. 0.]
minimarket: [0. 1. 0. 0. 0.]

parecido bodega y minimarket: 0.0
parecido bodega y arroz     : 0.0

Los dos parecidos dan 0,0. Exactamente el mismo número.

O sea que para el modelo, una bodega se parece a un minimarket lo mismo que se parece a un saco de arroz: nada. Cada palabra es una isla 🏝️

Con cuatro segmentos eso se aguanta, porque el modelo aprende cada uno por separado y hay datos de sobra. Con un vocabulario de 50.000 palabras de texto es imposible: no vas a tener ejemplos suficientes de cada una, y lo que aprendas sobre "retraso" no le servirá de nada a "tardanza".

La idea: mirar con quién anda

Un embedding es una lista corta de números que representa una palabra. Y la idea para construirlo es de 1957, mucho antes que las redes: una palabra se define por las palabras que la rodean.

Vamos con un puñado de comentarios de clientes. Los escribí yo para este capítulo, con la forma de los que llegan a una distribuidora:

COMENTARIOS = [
    'el pedido llego completo y a tiempo',
    'el pedido llego incompleto y con retraso',
    'la bodega pidio arroz y azucar',
    'el minimarket pidio aceite y azucar',
    'el mayorista pidio arroz en cantidad',
    'el pedido de la bodega llego tarde',
    'el pedido del minimarket llego tarde',
    'la bodega reclamo por el retraso del pedido',
    'el minimarket reclamo por el aceite roto',
    'el mayorista reclamo por la factura',
    'llego todo completo sin reclamo',
    'el arroz llego en buen estado',
    'el aceite llego en buen estado',
    'el azucar llego roto y con retraso',
    'la bodega pago la factura a tiempo',
    'el minimarket pago la factura con retraso',
    'el mayorista pago la factura a tiempo',
    'el vendedor visito la bodega el lunes',
    'el vendedor visito el minimarket el martes',
    'el vendedor visito al mayorista el lunes',
    'la bodega compro arroz el lunes',
    'el minimarket compro aceite el martes',
    'el mayorista compro arroz y aceite',
    'nadie visito la bodega esta semana',
    'el pedido de arroz llego completo',
    'el pedido de aceite llego incompleto',
]

vocabulario = sorted({p for f in COMENTARIOS for p in f.split()})
lugar = {p: i for i, p in enumerate(vocabulario)}
print('frases:', len(COMENTARIOS), ' palabras distintas:', len(vocabulario))
frases: 26  palabras distintas: 41

Contar quién aparece al lado de quién

V = len(vocabulario)
juntas = np.zeros((V, V))
VENTANA = 2                      # dos palabras a cada lado

for f in COMENTARIOS:
    ps = f.split()
    for i, p in enumerate(ps):
        for j in range(max(0, i - VENTANA), min(len(ps), i + VENTANA + 1)):
            if i != j:
                juntas[lugar[p], lugar[ps[j]]] += 1

for p in ['bodega', 'arroz']:
    vecinas = np.argsort(-juntas[lugar[p]])[:4]
    print(f'{p:10} aparece más al lado de:',
          ', '.join(f'{vocabulario[i]} ({int(juntas[lugar[p], i])})' for i in vecinas))
bodega     aparece más al lado de: la (8), arroz (2), visito (2), de (1)
arroz      aparece más al lado de: bodega (2), en (2), el (2), compro (2)

Esa matriz es todo lo que sabemos del idioma: quién anda con quién. No hay gramática, ni diccionario, ni nadie que haya explicado qué es una bodega 📋

Palabras colocadas como puntos en dos dimensiones: los animales juntos en una zona, las ciudades en otra, y las cuatro palabras rey, reina, hombre y mujer formando un paralelogramo, de modo que rey menos hombre más mujer cae en reina.
Un embedding es esto: cada palabra convertida en un punto, colocado de forma que lo parecido queda cerca. Y como es un espacio, se puede restar: la flecha que va de hombre a mujer es la misma que va de rey a reina. Sobre esa geometría se busca por significado en vez de por palabra exacta.

De la matriz a los vectores cortos

La matriz tiene 41 por 41. Lo que queremos son listas de cinco números, y la forma clásica de comprimir una matriz conservando lo que importa es la descomposición en valores singulares, que numpy trae hecha:

U, S, _ = np.linalg.svd(np.log1p(juntas))     # log1p suaviza las cuentas grandes
E = U[:, :5] * S[:5]                          # cinco números por palabra

def parecido(a, b):
    x, y = E[lugar[a]], E[lugar[b]]
    return float(x @ y / (np.linalg.norm(x) * np.linalg.norm(y) + 1e-12))

print('bodega    :', np.round(E[lugar['bodega']], 3))
print('arroz     :', np.round(E[lugar['arroz']], 3))
print()
for a, b in [('bodega', 'minimarket'), ('arroz', 'aceite'), ('bodega', 'arroz'),
             ('lunes', 'martes'), ('completo', 'incompleto')]:
    print(f'{a:12} y {b:12}: {parecido(a, b):+.3f}')
bodega    : [-2.354 -1.028  0.214 -0.414 -1.977]
arroz     : [-2.576  1.07   0.257  0.034  1.047]

bodega       y minimarket  : +0.849
arroz        y aceite      : +0.945
bodega       y arroz       : +0.300
lunes        y martes      : +0.968
completo     y incompleto  : +0.975

Ahí está lo que queríamos 🎉

bodega y minimarket dan 0,849, cuando con one-hot daban 0,0. Y arroz y aceite 0,945, y lunes y martes 0,968.

Y lo importante: bodega y arroz dan 0,300, o sea bajo. El método distinguió solo que hay palabras de "tipo de cliente" y palabras de "producto", sin que nadie se lo dijera. Solo mirando con quién andan.

Ese número, el parecido, es la similitud coseno: mide si dos vectores apuntan en la misma dirección, sin importar su tamaño. Es la medida estándar para embeddings y va de -1 a 1.

Una red recurrente desenrollada en cuatro pasos de tiempo: la misma celda repetida, cada una recibe su palabra de entrada y el estado oculto que le pasa la anterior, y produce una salida y el estado para la siguiente.
Las cuatro celdas son la misma celda con los mismos pesos, dibujada cuatro veces. Y ahí se ve el problema de un vistazo: para llegar a la cuarta hay que haber pasado por las tres anteriores, así que no hay forma de repartir el trabajo entre varias tarjetas.

Y ahora el problema, que es famoso

Vuelve a mirar la última línea: completo e incompleto dan 0,975. Casi idénticos.

Y no es un fallo de mi corpus ni de mi código. Es que completo e incompleto aparecen exactamente en los mismos sitios: después de "llego", antes de "y". Su compañía es la misma, así que su vector es el mismo.

Este es el agujero conocido de esta idea: los embeddings de coocurrencia no distinguen antónimos. Bueno y malo, subir y bajar, con y sin. Todos salen parecidísimos, y si construyes un detector de quejas encima, va a confundir el elogio con el reclamo 😬

La solución no llegó hasta el capítulo 11: en vez de un vector fijo por palabra, un vector que cambia según la frase. Eso es la atención, y es la idea que hizo posible todo lo que usas hoy.

Buscar lo más parecido

for p in ['bodega', 'arroz', 'retraso']:
    puntajes = sorted(((parecido(p, w), w) for w in vocabulario if w != p),
                      reverse=True)[:4]
    print(f'{p:9} se parece a: ' +
          ', '.join(f'{w} ({s:+.2f})' for s, w in puntajes))
bodega    se parece a: mayorista (+0.91), nadie (+0.90), minimarket (+0.85), vendedor (+0.74)
arroz     se parece a: aceite (+0.94), del (+0.92), tarde (+0.92), roto (+0.88)
retraso   se parece a: con (+0.84), roto (+0.82), pidio (+0.81), sin (+0.81)

Para bodega saca mayorista, nadie, minimarket y vendedor. Tres de las cuatro son exactamente lo que esperarías 👏

Y la cuarta explica el método: nadie aparece porque escribí "nadie visito la bodega esta semana", y en esa frase nadie ocupa el mismo hueco que ocuparía un vendedor. El método no sabe qué significa nadie: sabe en qué hueco cae.

Con arroz pasa lo mismo: sale aceite (perfecto) y también del y tarde, que son ruido de tener solo 26 frases. Los embeddings de verdad se entrenan con miles de millones de palabras, y ahí el ruido se diluye 📚

Ejercicios

1. Cuántas dimensiones conviene

Prueba con 2, 5 y 10 números por palabra.

for dims in [2, 5, 10]:
    Ed = U[:, :dims] * S[:dims]

    def par(a, b):
        x, y = Ed[lugar[a]], Ed[lugar[b]]
        return float(x @ y / (np.linalg.norm(x) * np.linalg.norm(y) + 1e-12))

    print(f'{dims:2d} dimensiones: bodega/minimarket {par("bodega", "minimarket"):+.3f}  '
          f'bodega/arroz {par("bodega", "arroz"):+.3f}')
 2 dimensiones: bodega/minimarket +0.955  bodega/arroz +0.693
 5 dimensiones: bodega/minimarket +0.849  bodega/arroz +0.300
10 dimensiones: bodega/minimarket +0.686  bodega/arroz +0.346

Con 2 dimensiones todo se parece a todo (bodega y arroz dan 0,693). Con 10, la distinción entre tipos es más nítida.

Es el mismo compromiso de siempre: pocas dimensiones aplastan las diferencias y muchas se aprenden el ruido. Los embeddings de verdad usan entre 100 y 1.000, y salen de probar 📏

2. La ventana también decide

Cuenta la coocurrencia con una ventana de 1 y de 5.

def con_ventana(v):
    M = np.zeros((V, V))
    for f in COMENTARIOS:
        ps = f.split()
        for i, p in enumerate(ps):
            for j in range(max(0, i - v), min(len(ps), i + v + 1)):
                if i != j:
                    M[lugar[p], lugar[ps[j]]] += 1
    Uu, Ss, _ = np.linalg.svd(np.log1p(M))
    Ev = Uu[:, :5] * Ss[:5]

    def par(a, b):
        x, y = Ev[lugar[a]], Ev[lugar[b]]
        return float(x @ y / (np.linalg.norm(x) * np.linalg.norm(y) + 1e-12))
    return par('bodega', 'minimarket'), par('bodega', 'arroz')

for v in [1, 2, 5]:
    a, b = con_ventana(v)
    print(f'ventana {v}: bodega/minimarket {a:+.3f}  bodega/arroz {b:+.3f}')
ventana 1: bodega/minimarket +0.609  bodega/arroz +0.433
ventana 2: bodega/minimarket +0.849  bodega/arroz +0.300
ventana 5: bodega/minimarket +0.823  bodega/arroz +0.781

Lo que hay que mirar es la distancia entre las dos columnas, o sea cuánto distingue.

Con ventana 5, bodega y arroz suben a 0,781: mirando casi la frase entera, todo aparece con todo y la distinción se pierde. Con ventana 1 tampoco va bien, porque con solo la palabra pegada hay tan pocas cuentas que el resultado es ruidoso (0,609 contra 0,433, casi sin separación).

La ventana 2 es la que más separa: 0,849 contra 0,300. Ventana grande agrupa por tema y ventana chica se queda sin datos, y el punto bueno está en medio, que es donde suele estar todo 🔍

3. Sumar vectores para representar una frase

La forma más simple de convertir una frase en números: sumar los de sus palabras.

def vector_de(frase):
    return np.mean([E[lugar[p]] for p in frase.split() if p in lugar], axis=0)

def parecido_frases(f1, f2):
    a, b = vector_de(f1), vector_de(f2)
    return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-12))

print('dos quejas parecidas:', round(parecido_frases(
    'la bodega reclamo por el retraso', 'el minimarket reclamo por el retraso'), 3))
print('queja contra elogio :', round(parecido_frases(
    'la bodega reclamo por el retraso', 'llego todo completo sin reclamo'), 3))
dos quejas parecidas: 0.969
queja contra elogio : 0.731

Las dos quejas dan 0,969 y la queja contra el elogio 0,731. Distingue, y mejor de lo que yo esperaba con 26 frases 👏

Pero mira el límite que tiene sumar: se pierde el orden. "llego completo" y "completo llego" dan exactamente el mismo vector, porque la media no sabe en qué orden venían. Y "no llego el pedido" se parece muchísimo a "llego el pedido", que es justo lo contrario.

El capítulo 11 arregla también esto 🔀

4. La aritmética famosa, a ver si sale

Lo de "rey menos hombre más mujer da reina", con nuestras palabras.

objetivo = E[lugar['bodega']] - E[lugar['arroz']] + E[lugar['aceite']]

puntajes = []
for w in vocabulario:
    x = E[lugar[w]]
    puntajes.append((float(objetivo @ x / (np.linalg.norm(objetivo) * np.linalg.norm(x) + 1e-12)), w))

for s, w in sorted(puntajes, reverse=True)[:4]:
    print(f'  {w:12} {s:+.3f}')
  bodega       +0.971
  nadie        +0.897
  mayorista    +0.877
  minimarket   +0.786

Sale bodega, que es hacer trampa: como arroz y aceite son casi idénticos, se cancelan y queda bodega.

La aritmética de vectores es de las cosas más citadas de este campo y también de las más exageradas. Con 26 frases no sale nada, y con corpus enormes sale a veces y con muchos ejemplos elegidos a mano 🎩

5. Por qué esto no sirve para la tabla de ventas

Arma el mismo procedimiento tratando cada fila del CSV como una frase y mira qué sale.

import pandas as pd

URL = 'https://missyera.com/static/datasets/ventas-miss-yera.csv'
ventas = pd.read_csv(URL).drop_duplicates()
ventas['ciudad'] = (ventas['ciudad'].str.strip().str.lower()
                    .str.normalize('NFKD')
                    .str.encode('ascii', 'ignore').str.decode('utf-8'))

filas = [f'{r.segmento} {r.ciudad} {r.canal} {r.categoria}'.lower()
         for r in ventas.itertuples()]
voc2 = sorted({p for f in filas for p in f.split()})
lug2 = {p: i for i, p in enumerate(voc2)}

M = np.zeros((len(voc2), len(voc2)))
for f in filas:
    ps = f.split()
    for i, p in enumerate(ps):
        for j, q in enumerate(ps):
            if i != j:
                M[lug2[p], lug2[q]] += 1

U2, S2, _ = np.linalg.svd(np.log1p(M))
E2 = U2[:, :5] * S2[:5]

def par2(a, b):
    x, y = E2[lug2[a]], E2[lug2[b]]
    return float(x @ y / (np.linalg.norm(x) * np.linalg.norm(y) + 1e-12))

for a, b in [('bodega', 'minimarket'), ('bodega', 'lima'), ('lima', 'arequipa')]:
    print(f'{a:12} y {b:12}: {par2(a, b):+.3f}')
bodega       y minimarket  : +1.000
bodega       y lima        : +0.676
lima         y arequipa    : +1.000

Bodega y minimarket dan 1,000 clavado, y lima y arequipa también. O sea que dentro de una misma columna, el método no distingue nada.

Y bodega con lima da 0,676, que sigue siendo alto. Lo único que aprendió es "esta palabra es de la misma columna o de otra", que es exactamente lo que el OneHotEncoder ya te da gratis.

La razón es que en esta tabla cada fila tiene una palabra de cada tipo y todas se cruzan con todas. Bodega aparece con las seis ciudades, con los cuatro canales y con las cinco categorías, igual que mayorista. No hay ningún patrón de compañía que separar.

Por eso el libro de machine learning usó OneHotEncoder y no embeddings: los embeddings necesitan un contexto que varíe, y en una tabla con columnas independientes no lo hay. Es una herramienta de texto y conviene no forzarla 🧰

6. Cuántos números ocupa cada cosa

Compara la memoria de one-hot contra embeddings para vocabularios reales.

for palabras_n in [41, 1_000, 50_000]:
    print(f'{palabras_n:7,} palabras: one-hot {palabras_n * palabras_n:14,} números   '
          f'embeddings de 100 {palabras_n * 100:12,}')
     41 palabras: one-hot          1,681 números   embeddings de 100        4,100
  1,000 palabras: one-hot      1,000,000 números   embeddings de 100      100,000
 50,000 palabras: one-hot  2,500,000,000 números   embeddings de 100    5,000,000

Con 50.000 palabras, one-hot necesitaría 2.500 millones de números y los embeddings 5 millones. Quinientas veces menos.

Y lo que ahorra no es solo memoria: con one-hot, cada palabra hay que aprenderla por separado. Con embeddings, lo que el modelo aprende sobre "retraso" aprovecha para "tardanza", porque están cerca 🤝

7. El error de la palabra que no está

Pídele el vector de una palabra que no salió en ninguna frase.

E[lugar['huancayo']]
KeyError: 'huancayo'

Y este error es el problema real de los embeddings clásicos, no una anécdota: solo saben las palabras que vieron. Una ciudad nueva, un nombre de producto nuevo, un error de tipeo, y no hay vector.

Es la misma avería silenciosa de la ciudad nueva del capítulo 14 del libro de machine learning, y aquí al menos revienta en vez de callarse.

Se resuelve partiendo las palabras en trozos más chicos, y eso es lo que hacen los modelos de hoy: "huancayo" se parte en pedacitos que sí conocen. Lo vemos en el capítulo 12 🧩

Comprueba que lo tienes

Los embeddings de coocurrencia dan a completo e incompleto un parecido de 0,975. ¿Qué está pasando?

  • Que aparecen en los mismos contextos, y eso es todo lo que el método mira
  • Que el cálculo está mal
  • Que hacen falta más dimensiones
  • Que faltan datos de entrenamiento

Lo que te llevas

  • 🏝️ Con one-hot, bodega y minimarket se parecen 0,0, igual que bodega y arroz. Cada palabra es una isla.
  • 👀 Un embedding se construye contando con qué palabras aparece cada una. No hay gramática ni diccionario.
  • 🎉 Con cinco dimensiones, bodega y minimarket dan 0,849 y bodega y arroz 0,300: distinguió tipos de palabra solo.
  • 😬 Y completo con incompleto dan 0,975. Los antónimos aparecen en los mismos sitios, así que salen iguales.
  • 🔍 La ventana decide qué agrupa: chica junta palabras que se sustituyen, grande junta palabras del mismo tema.
  • 🔀 Sumar los vectores de una frase pierde el orden: "no llego el pedido" se parece muchísimo a "llego el pedido".
  • 🧰 En la tabla de ventas, bodega y minimarket dan 1,000 y no se distinguen: cada columna se cruza con todas. Los embeddings necesitan contexto que varíe.
  • 🧩 Y solo saben las palabras que vieron: pedir una nueva revienta.

En el capítulo 11 llega la atención, que arregla las tres cosas que quedaron mal aquí: los antónimos, el orden y el vector fijo.

Que tengas lindo día! 🌸

¿Tienes alguna duda o consulta?