Word2Vec: skip-gram y CBOW
26 min de lectura
La lección anterior, sobre representaciones densas, dejó la tabla montada y vacía: la matriz de embeddings tiene forma, tamaño y una geometría que ya sabes leer, y ni un número puesto. La hipótesis distribucional afirma dónde vive el significado y no dice cómo calcularlo. Word2Vec fue el primero que salvó ese hueco a escala real, en 2013, y llega por donde no se espera: no calcula las coordenadas de nadie, las deja caer como residuo de otra tarea.
La tarea es un juego de adivinar. Toma el perro ladra en el patio de la escuela, tapa todo menos perro y pregunta qué había alrededor; un modelo que responda ladra antes que escuela ha aprendido algo, y para responder necesita números con los que puntuar candidatos. Esos números están en la matriz , que entra en un modelo con valores al azar y este los va corrigiendo hasta que acierta —eso es entrenar—; luego se tira el modelo y se guarda . Word2Vec es esa idea convertida en técnica, y son tres piezas: una tarea de predicción sobre ventanas de texto, dos maneras de resolverla —skip-gram y CBOW— y una simplificación que hace viable el cálculo: el muestreo negativo. Queda decidir con qué parámetros se puntúa y qué quiere decir «acertar» medido en una cuenta que, escrita con cuidado, sale impagable.
Desliza por el corpus una ventana de tokens a cada lado: el del medio es el centro, los de los lados su contexto, y sale un par por cada vecino. Con , el centro patio de la frase de arriba produce cuatro: con en, el, de y la. El corpus crudo ya trae los pares dentro: la promesa de la hipótesis distribucional, cobrada.
Predecir el contexto: el objetivo del skip-gram
Fija la ventana en y recorre el corpus. El skip-gram toma cada posición , con su token central , y pide predecir cada uno de sus vecinos por separado. Acertar se escribe como una pérdida: un número, calculado de los parámetros y del corpus entero, que mide lo mal que lo hace el modelo; corregirlos hasta que acierte es hacerlo pequeño. La del skip-gram es la log-verosimilitud negativa media de los pares:
donde recorre las posiciones del corpus y el signo menos da la vuelta al criterio: la probabilidad del par real hay que subirla, así que hay que bajarla. Falta lo esencial: qué es .
Predecir es elegir entre candidatos, y elegir exige puntuarlos. Hace falta un número que diga cómo de bien encajan el centro y un candidato a contexto . Word2Vec toma el más simple entre dos vectores, su producto escalar: crece cuando apuntan en direcciones parecidas y cuando son largos. Llamémoslo la afinidad del par.
Por ahora solo tenemos la tabla con un vector por entrada; la afinidad de un par sale de leer dos de sus filas. Eso fuerza a cada entrada a usar el mismo vector de centro y de candidato, y el defecto se ve en el par de una entrada consigo misma: , positiva y tanto mayor cuanto más largo sea el vector. El coseno de la lección anterior lo anulaba al dividir por las normas; en la afinidad no hay división. Y el corpus pide lo contrario: perro casi nunca cae dentro de su propia ventana, y hay que poder puntuarla baja. Pero bajar obliga a encoger , y eso baja a la vez todas las afinidades en las que hace de centro.
De ahí la decisión de diseño que define a Word2Vec: cada entrada recibe dos vectores de coordenadas, el para cuando actúa de centro y uno nuevo, , para cuando actúa de contexto. Viven en dos tablas de la misma forma, , con la fila de igual a tal como la de es . La afinidad pasa a ser , y la de una entrada consigo misma, , puede bajar sin que se encoja.
Leer afinidad como probabilidad pide volverla positiva y que todas sumen 1; el softmax hace las dos:
La función exponencial conserva el orden, así que el candidato con más afinidad se lleva la probabilidad más alta. Y guarda esto del denominador: está ahí para que las afinidades sean una distribución de probabilidad sobre , y para nada más.
Los parámetros son , y no depende de nada más. Para minimizarla usamos el descenso de gradiente, que veremos en detalle en el bloque 2, sobre el perceptrón multicapa.
El gradiente indica la dirección de máximo crecimiento de la pérdida. Por eso, en cada paso , actualizamos los parámetros en sentido opuesto,
donde el subíndice cuenta pasos del descenso, no posiciones del corpus, y es la tasa de aprendizaje, el tamaño del paso. Elegirla es cosa de aquel bloque: demasiado grande y el descenso diverge.
CBOW: la misma idea con los papeles cambiados
CBOW (continuous bag of words) invierte la pregunta: adivina el centro desde el contexto. Los vecinos entran promediados en un solo vector,
y la probabilidad del centro es el mismo softmax con en el lugar de . El promedio pierde el orden: la bolsa de palabras de la lección sobre TF-IDF en pequeño, y de ahí el nombre.
Las dos se usan: CBOW va más rápido —una predicción por posición en lugar de — y el skip-gram le saca más partido a las entradas raras, que pierden al quedar promediadas. Lo que comparten es lo que importa ahora: un softmax sobre .
La suma que hace impagable el objetivo
Cuenta las operaciones de una sola probabilidad. El numerador es un producto escalar, multiplicaciones; el denominador, uno por cada entrada del vocabulario:
Quince millones por un solo par. Con pares por token y un corpus de , salen pares, y una pasada entera cuesta operaciones, y el entrenamiento da varias. Ninguna máquina iba a pagar eso por un embedding, ni en 2013 ni ahora.
Mira dónde está el gasto, porque de ahí sale la salida. Que ladra puntúe alto junto a perro cuesta 300 multiplicaciones; los otros 14 999 700 se van en una suma cuyo único trabajo es que las probabilidades sumen uno. Pagas por una distribución sobre 50 000 entradas que no vas a usar: al terminar tiras el modelo y te quedas con .
Así que el objetivo exacto se abandona, y conviene decirlo con todas sus letras, porque es un movimiento que el curso repetirá —lo verás en el bloque 5, con la atención—: cuando la cantidad correcta es impagable, se sustituye por otra que se pueda pagar y tenga óptimos parecidos, y luego se comprueba si el cambio ha estropeado algo. Lo que no se hace es fingir que la segunda es la primera.
Muestreo negativo: de repartir probabilidad a distinguir pares
La sustitución cambia la pregunta. En vez de «dada perro, ¿qué probabilidad tiene cada una de las 50 000 entradas de acompañarla?», pregunta: «este par elegido al azar, ¿sale del corpus o me lo he inventado?».
Hacen falta pares falsos, y se fabrican: al par real se le añaden pares con el contexto sacado al azar. El número lo fijó el artículo original probando: de a con corpus pequeños, de a con los grandes. Toma . A cada par se le calcula su probabilidad aplastando su afinidad al intervalo con la función logística
La pérdida de un par real y sus negativos es
y se lee de izquierda a derecha. El primer término baja cuando sube hacia : cuando el centro se acerca al contexto real. En el segundo, es la probabilidad de «no», y cada sumando baja cuando la afinidad con un contexto inventado se hace negativa. Acercarse a uno, alejarse de los otros cinco.
Los negativos no se sacan al azar uniforme, sino de
con la frecuencia del tipo en el corpus. El exponente comprime la distribución sin aplanarla, entre dos extremos malos: con los negativos serían las palabras vacías de la lección sobre la bolsa de palabras, con entradas rarísimas, y contra ninguna cuesta acertar. No hay desarrollo detrás; es el valor que funcionó.
Ahora la cuenta, el motivo de todo esto. Un paso toca el centro, el contexto real y los negativos, un producto escalar por vector:
frente a los 15 millones de antes. El factor es , y la pasada entera baja de a : de imposible a una tarde. Fíjate en qué ha desaparecido y qué no: el coste por par deja de depender de , y los pares siguen ahí.
La concesión, en voz alta: esto no es el softmax calculado más rápido, es otra pérdida, y subir no la hace converger a . Que sus óptimos coloquen los vectores donde nos sirven se comprueba mirando los vecinos que salen. El artículo trae un segundo atajo con el mismo fin, el softmax jerárquico, que esta lección no recorre.
El paso necesita , y sale de dos derivadas que dicen, de paso, adónde empuja cada término.
De dónde sale el paso: el gradiente de la pérdida de un par respecto al vector del centro
La derivada de la logística es , de donde
Llama y , con y . Por la regla de la cadena, cada término aporta
usando en el segundo. Sumando:
El coeficiente de es negativo, así que el paso mueve hacia el contexto real; los de los negativos son positivos y empujan en contra. Y se apagan solos: si el par real ya puntúa alto, y su coeficiente se va a cero.
Entrenar un skip-gram diminuto en NumPy
La celda entrena de verdad, en pequeño: 800 tokens de un corpus de juguete por plantillas, 21
entradas, , , cuatro pasadas. La actualización es el
gradiente del desplegable: g = s - etiquetas, donde s es la salida de la sigmoide aplicada
a la afinidad de cada par y etiquetas es el vector con 1 para el par real y 0 para los negativos.
Restárselo escalado a cada vector es el descenso gradiente. La tasa de aprendizaje arranca en
y decae hasta casi cero: los últimos pasos afinan en lugar de saltar. Ejecútala y mira los vecinos
de perro antes y después.
rng = np.random.default_rng(7)
# --- Un corpus de juguete, escrito por plantillas para que haya qué aprender.
animales = ["perro", "gato", "caballo", "ratón"]
comidas = ["carne", "pescado", "hierba", "queso"]
lugares = ["patio", "cocina", "establo", "granero"]
bebidas = ["agua", "leche"]
frases = []
for a in animales:
for l in lugares:
for c in comidas:
frases.append(f"el {a} come {c} en el {l}")
for b in bebidas:
frases.append(f"el {a} bebe {b} en el {l}")
frases.append(f"la niña ve el {a} en el {l}")
corpus = " ".join(frases).split()
V = sorted(set(corpus))
pos = {w: i for i, w in enumerate(V)}
ids = np.array([pos[w] for w in corpus])
d_model, m, n_neg, eta0, epocas = 12, 2, 5, 0.05, 4
# Los pares (centro, contexto): el corpus crudo, sin que nadie anote nada.
centros, contextos = [], []
for t, w in enumerate(ids):
for j in range(max(0, t - m), min(len(ids), t + m + 1)):
if j != t:
centros.append(w)
contextos.append(ids[j])
centros, contextos = np.array(centros), np.array(contextos)
print("T =", len(corpus), "tokens |", len(V), "entradas |", len(centros), "pares")
# P_neg(w) proporcional a f_w^0.75, y todos los negativos de una vez.
f = np.bincount(ids, minlength=len(V)).astype(float)
P_neg = f ** 0.75
P_neg = P_neg / P_neg.sum()
n_pasos = epocas * len(centros)
negativos = rng.choice(len(V), size=(n_pasos, n_neg), p=P_neg)
E = 0.1 * rng.standard_normal((len(V), d_model))
U = 0.1 * rng.standard_normal((len(V), d_model))
# Un 1 para el par real y un 0 para cada negativo: esa es toda la supervisión.
etiquetas = np.zeros(n_neg + 1)
etiquetas[0] = 1.0
def vecinos(w, k=3):
N = E / np.linalg.norm(E, axis=1, keepdims=True)
s = N @ N[pos[w]]
return [(V[i], round(float(s[i]), 2)) for i in np.argsort(-s) if i != pos[w]][:k]
print("antes perro ->", vecinos("perro"))
print(" carne ->", vecinos("carne"))
print()
paso = 0
for epoca in range(epocas):
perdida = 0.0
for idx in rng.permutation(len(centros)):
w = centros[idx]
filas = np.concatenate(([contextos[idx]], negativos[paso]))
eta = eta0 * max(1e-4, 1.0 - paso / n_pasos) # decae hasta casi cero
paso += 1
v = E[w]
# Afinidad del par real y de los negativos, aplastada por la logística.
s = 1.0 / (1.0 + np.exp(-np.clip(U[filas] @ v, -6.0, 6.0)))
perdida += -np.log(np.where(etiquetas == 1.0, s, 1.0 - s) + 1e-10).sum()
# El gradiente derivado arriba: (sigma - etiqueta) multiplica al otro vector.
g = s - etiquetas
E[w] = v - eta * (g @ U[filas])
np.add.at(U, filas, -eta * g[:, None] * v)
print("época", epoca + 1, " pérdida media por par", round(perdida / len(centros), 3))
print()
print("después perro ->", vecinos("perro"))
print(" carne ->", vecinos("carne"))
print(" patio ->", vecinos("patio"))
print(" niña ->", vecinos("niña"))
print()
# Lo que ha costado, y lo que habría costado el objetivo exacto.
print("productos escalares en", n_pasos, "pasos")
print(" con negativos ", n_pasos * (n_neg + 1))
print(" softmax exacto", n_pasos * len(V), " (|V| =", len(V), ")")
print(" a |V| = 50 000 la razón sería", 50000 // (n_neg + 1))
La primera ejecución descarga el intérprete de Python (~15 MB). Después queda en la caché del navegador.
Antes de entrenar, el vecino más cercano a perro es establo con : puro ruido. Cuatro pasadas después son ratón, gato y caballo, por encima de ; los de carne son las otras tres comidas y los de patio, los otros tres lugares. Nadie ha dicho que se parezcan: solo han entrado pares de ventanas y un o un por par.
Dos cosas sin maquillar. El corpus es artificial y los animales son intercambiables por construcción: de ahí unos cosenos que rozan el y con texto de verdad no salen. Y niña se queda con la y ve: aparece en un solo molde, y es el molde lo aprendido.
Pon n_neg = 0 y ejecuta otra vez. La pérdida se desploma hasta —sin negativos, decir que
sí a todo es perfecto— y los vecinos se vuelven basura: perro con en y con el,
todo cerca de todo. Los negativos no eran solo el ahorro: sin algo contra lo que empujar, la
pérdida baja y la representación no existe.
Comprueba tu intuición
Cinco preguntas: qué se conserva al terminar, cuántos pares da una ventana, dónde estaba el gasto, qué cambia el muestreo negativo y qué hace el exponente .
Terminas de entrenar un skip-gram. Tienes dos tablas, con las filas y con las filas , y un modelo que predice contextos. ¿Qué te llevas como embedding?
Una frase de 12 tokens, ventana de a cada lado. ¿Cuántos pares (centro, contexto) produce el skip-gram? Cuenta los bordes: los tokens del principio y del final tienen menos vecinos.
Se acepta un margen de ±0.
¿Qué es exactamente lo que hace impagable el objetivo exacto del skip-gram?
Marca todo lo que sea cierto del muestreo negativo.
Marca todas las opciones correctas. Se corrige todo o nada: no hay puntuación parcial.
Sacar los negativos de , en lugar de hacerlo de la frecuencia tal cual, hace que las entradas raras aparezcan como negativas más a menudo de lo que les tocaría por frecuencia.
La matriz ya no está vacía, la deuda con la que terminó la lección anterior queda saldada. Queda una peculiaridad. Word2Vec no ve el corpus, ve una ventana cada vez, y que perro y gato se parecen lo concluye tropezándose con miles de ellas. Pero eso ya está contado antes de entrenar nada: una pasada anotando cuántas veces cada entrada cayó dentro de la ventana de otra da una tabla de coocurrencias donde la fila de perro y la de gato ya se parecen. Word2Vec nunca llega a escribirla.
Aprovecharla es la lección sobre GloVe y los límites de los embeddings estáticos: GloVe (global vectors) construye esa tabla y busca los vectores que la explican, otro objetivo con resultados de la misma familia. Y con las dos técnicas sobre la mesa, esa lección cierra el bloque cobrando lo que esconde la palabra «estáticos»: cada entrada tiene un vector, uno solo, para siempre.
Para profundizar3 fuentes · 2 papers, 1 interactivo
De dónde sale lo de esta lección, y dónde seguir si quieres más. Nada de aquí hace falta para continuar el curso.
- Efficient Estimation of Word Representations in Vector Space
Presenta skip-gram y CBOW, corto y legible. Su tabla de complejidad es la cuenta de operaciones que aquí haces a mano. No trae el muestreo negativo; eso llega en el otro artículo de Mikolov de ese mismo año.
- Distributed Representations of Words and Phrases and their Compositionality
De aquí salen el muestreo negativo y el exponente 3/4, en su §2.2. Confirma lo que dice la lección: el 3/4 se eligió porque funcionó, y el artículo no da más razón que esa.
- Word2Vec Explorer
Vectores word2vec preentrenados de 300 dimensiones, no tu corpus de juguete: busca vecinos por coseno como en la celda y prueba la aritmética rey - hombre + mujer ≈ reina, la analogía que el bloque deja para más adelante.