Word2Vec: skip-gram y CBOW

Word2Vec: skip-gram y CBOW

26 min read

La lección anterior, sobre representaciones densas, dejó la tabla montada y vacía: la matriz de embeddings E\mathbf{E} 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 E\mathbf{E}, 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 E\mathbf{E}. 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 mm tokens a cada lado: el del medio es el centro, los de los lados su contexto, y sale un par por cada vecino. Con m=2m = 2, 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 mm y recorre el corpus. El skip-gram toma cada posición tt, con su token central wtw_t, 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:

L=1Tt=1T  mjmj0logP(wt+jwt),\mathcal{L} = -\frac{1}{T} \sum_{t=1}^{T} \; \sum_{\substack{-m \leq j \leq m \\ j \neq 0}} \log P(w_{t+j} \mid w_t),

donde tt recorre las TT posiciones del corpus y el signo menos da la vuelta al criterio: la probabilidad del par real hay que subirla, así que L\mathcal{L} hay que bajarla. Falta lo esencial: qué es P(cw)P(c \mid w).

Predecir es elegir entre candidatos, y elegir exige puntuarlos. Hace falta un número que diga cómo de bien encajan el centro ww y un candidato a contexto cc. 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 E\mathbf{E} 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: ewew=ew2\mathbf{e}_w^{\top}\mathbf{e}_w = \lVert\mathbf{e}_w\rVert^2, 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 ew2\lVert\mathbf{e}_w\rVert^2 obliga a encoger ew\mathbf{e}_w, y eso baja a la vez todas las afinidades en las que ww hace de centro.

De ahí la decisión de diseño que define a Word2Vec: cada entrada recibe dos vectores de dmodeld_{\text{model}} coordenadas, el ew\mathbf{e}_w para cuando actúa de centro y uno nuevo, uc\mathbf{u}_c, para cuando actúa de contexto. Viven en dos tablas de la misma forma, E,URV×dmodel\mathbf{E}, \mathbf{U} \in \mathbb{R}^{\lvert V \rvert \times d_{\text{model}}}, con la fila ii de U\mathbf{U} igual a uwi\mathbf{u}_{w_i}^{\top} tal como la de E\mathbf{E} es ewi\mathbf{e}_{w_i}^{\top}. La afinidad pasa a ser ucew\mathbf{u}_c^{\top}\mathbf{e}_w, y la de una entrada consigo misma, uwew\mathbf{u}_w^{\top}\mathbf{e}_w, puede bajar sin que ew\mathbf{e}_w se encoja.

Leer afinidad como probabilidad pide volverla positiva y que todas sumen 1; el softmax hace las dos:

P(cw)=exp ⁣(ucew)wVexp ⁣(uwew).P(c \mid w) = \frac{\exp\!\left(\mathbf{u}_c^{\top}\mathbf{e}_w\right)}{\sum_{w' \in V} \exp\!\left(\mathbf{u}_{w'}^{\top}\mathbf{e}_w\right)}.

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 V\lvert V \rvert afinidades sean una distribución de probabilidad sobre VV, y para nada más.

Los parámetros son θ=(E,U)\theta = (\mathbf{E}, \mathbf{U}), y L\mathcal{L} 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 θL\nabla_{\theta}\mathcal{L} indica la dirección de máximo crecimiento de la pérdida. Por eso, en cada paso tt, actualizamos los parámetros en sentido opuesto,

θt+1=θtηθL(θt),\theta_{t+1} = \theta_t - \eta\,\nabla_{\theta}\mathcal{L}(\theta_t),

donde el subíndice tt cuenta pasos del descenso, no posiciones del corpus, y η\eta 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 2m2m vecinos entran promediados en un solo vector,

eˉt=12mmjmj0ewt+j,\bar{\mathbf{e}}_t = \frac{1}{2m} \sum_{\substack{-m \leq j \leq m \\ j \neq 0}} \mathbf{e}_{w_{t+j}},

y la probabilidad del centro es el mismo softmax con eˉt\bar{\mathbf{e}}_t en el lugar de ew\mathbf{e}_w. 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 2m2m— 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 VV.

La suma que hace impagable el objetivo

Cuenta las operaciones de una sola probabilidad. El numerador es un producto escalar, dmodeld_{\text{model}} multiplicaciones; el denominador, uno por cada entrada del vocabulario:

Vdmodel=50000300=1.5×107operaciones.\lvert V \rvert \cdot d_{\text{model}} = 50\,000 \cdot 300 = 1.5 \times 10^{7} \quad \text{operaciones}.

Quince millones por un solo par. Con 2m=42m = 4 pares por token y un corpus de T=1010T = 10^{10}, salen 4×10104 \times 10^{10} pares, y una pasada entera cuesta 6×10176 \times 10^{17} 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 E\mathbf{E}.

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 (w,c)(w, c) se le añaden nnegn_{\text{neg}} pares (w,cj)(w, c_j) con el contexto sacado al azar. El número lo fijó el artículo original probando: de 55 a 2020 con corpus pequeños, de 22 a 55 con los grandes. Toma nneg=5n_{\text{neg}} = 5. A cada par se le calcula su probabilidad aplastando su afinidad al intervalo (0,1)(0,1) con la función logística

σ(x)=11+ex.\sigma(x) = \frac{1}{1 + e^{-x}}.

La pérdida de un par real y sus negativos es

Lpar=logσ ⁣(ucew)j=1nneglogσ ⁣(ucjew),\mathcal{L}_{\text{par}} = -\log \sigma\!\left(\mathbf{u}_c^{\top}\mathbf{e}_w\right) - \sum_{j=1}^{n_{\text{neg}}} \log \sigma\!\left(-\mathbf{u}_{c_j}^{\top}\mathbf{e}_w\right),

y se lee de izquierda a derecha. El primer término baja cuando σ(ucew)\sigma(\mathbf{u}_c^{\top} \mathbf{e}_w) sube hacia 11: cuando el centro se acerca al contexto real. En el segundo, σ(x)\sigma(-x) 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

Pneg(w)=fw3/4wVfw3/4,P_{\text{neg}}(w) = \frac{f_w^{3/4}}{\sum_{w' \in V} f_{w'}^{3/4}},

con fwf_w la frecuencia del tipo ww en el corpus. El exponente 3/43/4 comprime la distribución sin aplanarla, entre dos extremos malos: con 11 los negativos serían las palabras vacías de la lección sobre la bolsa de palabras, con 00 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 nnegn_{\text{neg}} negativos, un producto escalar por vector:

(nneg+1)dmodel=(5+1)300=1800operaciones,(n_{\text{neg}} + 1) \cdot d_{\text{model}} = (5 + 1) \cdot 300 = 1\,800 \quad \text{operaciones},

frente a los 15 millones de antes. El factor es V/(nneg+1)8333\lvert V \rvert / (n_{\text{neg}} + 1) \approx 8\,333, y la pasada entera baja de 6×10176 \times 10^{17} a 7.2×10137.2 \times 10^{13}: de imposible a una tarde. Fíjate en qué ha desaparecido y qué no: el coste por par deja de depender de V\lvert V \rvert, y los 4×10104 \times 10^{10} 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 nnegn_{\text{neg}} no la hace converger a L\mathcal{L}. 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 Lpar/ew\partial \mathcal{L}_{\text{par}} / \partial \mathbf{e}_w, 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 σ(x)=σ(x)(1σ(x))\sigma'(x) = \sigma(x)\left(1 - \sigma(x)\right), de donde

xlogσ(x)=σ(x)(1σ(x))σ(x)=1σ(x).\frac{\partial}{\partial x} \log \sigma(x) = \frac{\sigma(x)\left(1 - \sigma(x)\right)}{\sigma(x)} = 1 - \sigma(x).

Llama s=ucews = \mathbf{u}_c^{\top}\mathbf{e}_w y sj=uwjews_j = \mathbf{u}_{w_j}^{\top}\mathbf{e}_w, con s/ew=uc\partial s / \partial \mathbf{e}_w = \mathbf{u}_c y (sj)/ew=uwj\partial(-s_j) / \partial \mathbf{e}_w = -\mathbf{u}_{w_j}. Por la regla de la cadena, cada término aporta

ew(logσ(s))=(1σ(s))uc=(σ(s)1)uc,\frac{\partial}{\partial \mathbf{e}_w}\left(-\log \sigma(s)\right) = -\left(1 - \sigma(s)\right)\mathbf{u}_c = \left(\sigma(s) - 1\right)\mathbf{u}_c,ew(logσ(sj))=(1σ(sj))uwj=σ(sj)uwj,\frac{\partial}{\partial \mathbf{e}_w}\left(-\log \sigma(-s_j)\right) = \left(1 - \sigma(-s_j)\right)\mathbf{u}_{w_j} = \sigma(s_j)\,\mathbf{u}_{w_j},

usando 1σ(x)=σ(x)1 - \sigma(-x) = \sigma(x) en el segundo. Sumando:

Lparew=(σ(s)1)uc+j=1nnegσ(sj)uwj.\frac{\partial \mathcal{L}_{\text{par}}}{\partial \mathbf{e}_w} = \left(\sigma(s) - 1\right)\mathbf{u}_c + \sum_{j=1}^{n_{\text{neg}}} \sigma(s_j)\,\mathbf{u}_{w_j}.

El coeficiente de uc\mathbf{u}_c es negativo, así que el paso mueve ew\mathbf{e}_w 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, σ(s)1\sigma(s) \approx 1 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, dmodel=12d_{\text{model}} = 12, nneg=5n_{\text{neg}} = 5, 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 0.050.05 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.

import numpy as np

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))
numpy

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 0.680.68: puro ruido. Cuatro pasadas después son ratón, gato y caballo, por encima de 0.970.97; 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 11 o un 00 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 11 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 0.020.02 —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 3/43/4.

Terminas de entrenar un skip-gram. Tienes dos tablas, E\mathbf{E} con las filas ew\mathbf{e}_w^{\top} y U\mathbf{U} con las filas uc\mathbf{u}_c^{\top}, y un modelo que predice contextos. ¿Qué te llevas como embedding?

Una frase de 12 tokens, ventana de m=3m = 3 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.

A margin of ±0 is accepted.

¿Qué es exactamente lo que hace impagable el objetivo exacto del skip-gram?

Marca todo lo que sea cierto del muestreo negativo.

Select every correct option. This is graded all-or-nothing: there is no partial credit.

Sacar los negativos de Pneg(w)fw3/4P_{\text{neg}}(w) \propto f_w^{3/4}, en lugar de hacerlo de la frecuencia fwf_w tal cual, hace que las entradas raras aparezcan como negativas más a menudo de lo que les tocaría por frecuencia.


La matriz E\mathbf{E} 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 V×V\lvert V \rvert \times \lvert V \rvert 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.

Further reading3 sources · 2 papers, 1 interactive

Where this lesson comes from, and where to go next. None of it is needed to carry on with the course.

  • Efficient Estimation of Word Representations in Vector Space
    paperMikolov, Chen, Corrado y Dean, 2013arXiv:1301.3781EN

    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
    paperMikolov, Sutskever, Chen, Corrado y Dean, 2013arXiv:1310.4546EN

    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
    interactiveDaniel Guettadaniel.guetta.comEN

    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.