En el capítulo 11 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 🔤
Antes de seguir, piénsalo tú: ¿cómo le explicarías a una máquina que bodega y minimarket se parecen, y que arroz no tiene nada que ver con ninguna de las dos? 🔤
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 📋
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.
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 16: 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 📚
Y si en vez de palabras fueran fotos
Acá está el salto que explica la palabra multimodal, que vas a oír mucho y que suena a más de lo que es.
Un embedding no sabe que está hecho de palabras. Es una lista de números y un parecido que se mide con un ángulo. Nada de eso pide que el origen sea texto. Si consigues meter una foto en el mismo espacio, la foto y la palabra se pueden comparar entre ellas.
Vamos con tres productos y unas fotos de mentira, que acá son rejillas de 6 por 6. Lo que hace falta es aprender a pasar de los 36 números de la foto a los 5 números de la palabra, y para eso basta un ajuste por mínimos cuadrados:
rng = np.random.default_rng(0) def foto(fila, columna): im = np.zeros((6, 6)) im[fila] = 1.0 im[:, columna] = 1.0 return im FOTOS = {'arroz': foto(1, 1), 'aceite': foto(3, 3), 'azucar': foto(4, 0)} Xi = np.array([FOTOS[p].ravel() for p in FOTOS]) Yt = np.array([E[lugar[p]] for p in FOTOS]) W, *_ = np.linalg.lstsq(Xi, Yt, rcond=None) print('cada foto son', Xi.shape[1], 'numeros y cada palabra', Yt.shape[1]) def coseno(x, y): return float(x @ y / (np.linalg.norm(x) * np.linalg.norm(y) + 1e-12)) for p in FOTOS: sucia = (FOTOS[p] + rng.normal(0, .15, (6, 6))).ravel() v = sucia @ W print(f'foto de {p:8}', sorted(((round(coseno(v, E[lugar[q]]), 3), q) for q in FOTOS), reverse=True))
cada foto son 36 numeros y cada palabra 5 foto de arroz [(1.0, 'arroz'), (0.943, 'aceite'), (0.809, 'azucar')] foto de aceite [(0.999, 'aceite'), (0.955, 'arroz'), (0.916, 'azucar')] foto de azucar [(1.0, 'azucar'), (0.918, 'aceite'), (0.813, 'arroz')]
Cada foto, y encima ensuciada con ruido, cae más cerca de su propia palabra que de las otras dos. Nadie le enseñó a la foto qué es el arroz: se le enseñó a aterrizar donde ya estaba la palabra 🔤
Mira los márgenes antes de emocionarte: 1,0 contra 0,943 es ganar por poco. Con tres ejemplos y una transformación lineal no da para más, y eso también es el mecanismo de verdad. Los modelos que hacen esto en serio usan una red por cada lado y millones de pares foto-texto, pero la idea que los sostiene es esta y cabe en veinte líneas.
De ahí sale todo lo que llaman multimodal: buscar fotos escribiendo, describir una imagen, encontrar el producto del catálogo con una foto del cliente. No son tres inventos, es el mismo espacio compartido tres veces 📋
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 16 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 21 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 17 🧩
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
Y los vectores que vieron demasiado
La trampa
Entrenas los embeddings con todo el texto que tienes, que es lo lógico porque cuanto más texto mejor, y después repartes para evaluar.
vectores = entrena_embeddings(todo_el_texto) X = [vectores[p] for p in frases] X_tr, X_te, y_tr, y_te = train_test_split( X, y, test_size=0.25)
Qué está mal
Los vectores se construyeron mirando con qué palabras aparece cada palabra, y en ese conteo entraron las frases de la prueba 🔤 No copiaste la respuesta, pero sí metiste en el modelo información que el día de mañana no vas a tener.
Y hay un caso donde esto deja de ser sutil y se vuelve grave: si tus frases de prueba se parecen mucho a las de entrenamiento, los vectores aprenden esa coincidencia y el modelo la aprovecha. El número sube y nadie sabe explicar por qué.
La regla es la de siempre y no cambia porque el dato sea texto: todo lo que aprenda algo de los datos se aprende solo con el trozo de entrenamiento. Vale para la media, para las categorías y también para los embeddings.
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.
Y si de todo el capítulo te llevas una sola frase, que sea esta:
Para una máquina, dos palabras del mismo mundo son tan distintas como dos que no tienen nada que ver. Salvo que se lo enseñes.
Los embeddings son también lo que hay debajo de un buscador de documentos. Si eso es lo que te toca montar en tu empresa, en soluciones de IA cuento cómo se aterriza 🏭
En el capítulo 16 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! 🌸
Practica este capítulo 📓
Todo el código de arriba en un cuaderno que corre de principio a fin, y los ejercicios con una celda vacía para que los hagas tú. Se abre en Google Colab de un clic y no hay que instalar nada. Donde veas %%revisa, escribe tu respuesta y el cuaderno te dice si te salió.
¿Prefieres trabajar en tu máquina? Bájate el cuaderno de práctica o el de soluciones. Todos están también en github.com/soymissyera/MissYeraEjercicios.
Los términos que salen en este capítulo
Están todos en el glosario de IA, con su definición corta.