Files
Procesamiento-de-Lenguaje-N…/Labs/word2vec_Alejandro_Lembke.ipynb
T
2026-08-06 19:35:30 +00:00

556 KiB
Executable File

Laboratorio: entrenamiento de un modelo Word2Vec desde cero

Asignatura: Procesamiento de Lenguaje Natural. Duración estimada: 3 h (2 sesiones). Entorno: Google Colab, entorno de ejecución CPU. Entrega: este mismo .ipynb ejecutado.


Objetivos de aprendizaje

Al terminar este laboratorio serás capaz de:

  1. Explicar la hipótesis distribucional y por qué permite aprender representaciones sin supervisión.
  2. Construir manualmente el conjunto de entrenamiento de Word2Vec (pares centro–contexto).
  3. Entrenar modelos Skip-gram y CBOW con gensim y justificar la elección de cada hiperparámetro.
  4. Evaluar embeddings mediante analogías, vecinos más cercanos y métricas de exactitud.
  5. Cuantificar el efecto de los hiperparámetros mediante experimentos controlados.
  6. Proyectar el espacio vectorial a dos dimensiones e interpretar su geometría.
  7. Implementar Skip-gram con Negative Sampling en PyTorch.
  8. Detectar y discutir los sesgos que el modelo aprende del corpus.

Normas de trabajo

  • Los ejercicios están marcados con # TODO. Escribe código únicamente en esas zonas.
  • Después de cada ejercicio hay una celda de verificación automática que imprime [OK] o [FALLO].
  • Las preguntas numeradas se responden por escrito en la celda de texto de la Parte 9.
  • Ejecuta las celdas en orden. Si reinicias el entorno, vuelve a la Parte 0.
  • Guarda tu copia: Archivo → Guardar una copia en Drive y renómbrala word2vec_APELLIDO.ipynb.
Parte Contenido Puntos
1 Preprocesamiento del corpus (Ej. 1) 10
2 Construcción de pares centro–contexto (Ej. 2) 15
3 Entrenamiento con gensim (Ej. 3) 15
4 Evaluación con analogías (Ej. 4) 15
5 Experimento CBOW vs Skip-gram (Ej. 5) 10
6 Barrido de hiperparámetros (Ej. 6) 10
7 Visualización (Ej. 7) 5
8 Sesgos y preguntas de reflexión (Ej. 8) 20
Extra Ejercicio adicional: SGNS en PyTorch +15

Parte 0 · Preparación del entorno

Ejecuta las dos celdas siguientes. La instalación tarda ~30 s.

Si Colab te pide reiniciar el entorno de ejecución después del pip install, hazlo (Entorno de ejecución → Reiniciar sesión) y vuelve a ejecutar solo la celda de imports.

In [34]:
# Instalación de dependencias
# gensim >= 4.3.3 es necesario por compatibilidad con las versiones recientes de scipy/numpy
!pip install -q "gensim>=4.3.3" 2>/dev/null
print("Instalación terminada.")
Instalación terminada.
In [35]:
import os, re, time, random, itertools, warnings
warnings.filterwarnings("ignore")

# Reproducibilidad (ver nota al final del laboratorio sobre determinismo real)
SEMILLA = 42
os.environ["PYTHONHASHSEED"] = str(SEMILLA)
random.seed(SEMILLA)

import numpy as np
np.random.seed(SEMILLA)

import matplotlib.pyplot as plt
import pandas as pd

try:
    import gensim
    from gensim.models import Word2Vec
    from gensim.models.callbacks import CallbackAny2Vec
    import gensim.downloader as api
except Exception as e:
    raise SystemExit(
        "No se pudo importar gensim. Reinicia el entorno de ejecución "
        "(Entorno de ejecución -> Reiniciar sesión) y ejecuta de nuevo esta celda.\n"
        f"Error original: {e}"
    )

print("gensim:", gensim.__version__)
print("numpy :", np.__version__)

# ------------------------------------------------------------------
# Configuración global del laboratorio (puedes tocar estos valores)
# ------------------------------------------------------------------
CONFIG = {
    "workers": 2,            # hilos de CPU (Colab suele dar 2)
    "epocas": 5,             # épocas para el modelo principal
    "n_frases_completo": None,   # None = usar todo text8 (~17M palabras)
    "n_frases_rapido": 400,      # subconjunto para los experimentos (~4M palabras)
}

# ------------------------------------------------------------------
# Mini framework de tests para autoevaluarte
# ------------------------------------------------------------------
def test(nombre, fn):
    """Ejecuta fn() e informa del resultado sin detener el notebook."""
    try:
        fn()
        print(f"[OK]        {nombre}")
    except NotImplementedError:
        print(f"[PENDIENTE] {nombre}: sin implementar")
    except AssertionError as e:
        print(f"[FALLO]     {nombre}: {e}")
    except Exception as e:
        print(f"[ERROR]     {nombre}: {type(e).__name__}: {e}")

print("\\nEntorno preparado.")
gensim: 4.4.0
numpy : 2.1.0
\nEntorno preparado.

Parte 1 · El corpus y la hipótesis distribucional

"You shall know a word by the company it keeps" — J. R. Firth (1957)

La hipótesis distribucional sostiene que las palabras que aparecen en contextos similares tienen significados similares. Word2Vec la traduce en un problema de aprendizaje automático:

  • No requiere etiquetas humanas: es auto-supervisado.
  • La etiqueta la proporciona el propio texto: qué palabras aparecen alrededor.
  • El resultado son vectores densos en los que la proximidad aproxima la similitud semántica.

Usaremos text8: los primeros 100 MB de la Wikipedia en inglés, ya limpiados (~17 millones de palabras). Es el corpus clásico de los experimentos originales de Mikolov et al. (2013).

La descarga (~31 MB) tarda un par de minutos la primera vez.

In [36]:
t0 = time.time()

# Descargamos el fichero y lo leemos con Text8Corpus.
# (Nota: usamos return_path=True a propósito. El "loader" que gensim-data trae para text8
#  contiene un import obsoleto de smart_open y falla con las versiones actuales de la librería;
#  pedir la ruta y leer el fichero nosotros mismos es equivalente y no se rompe.)
from gensim.models.word2vec import Text8Corpus

ruta = api.load("text8", return_path=True)   # ~31 MB la primera vez
frases = list(Text8Corpus(ruta))             # cada elemento = lista de hasta 10 000 tokens
print(f"Descarga + carga: {time.time()-t0:.1f} s")

n_tokens = sum(len(f) for f in frases)
print(f"Número de 'frases' (bloques de 10 000 tokens): {len(frases):,}")
print(f"Número total de tokens: {n_tokens:,}")
print(f"Vocabulario bruto (tipos distintos): {len(set(itertools.chain.from_iterable(frases))):,}")
print("\\nPrimeros 40 tokens del corpus:")
print(frases[0][:40])
Descarga + carga: 1.1 s
Número de 'frases' (bloques de 10 000 tokens): 1,701
Número total de tokens: 17,005,207
Vocabulario bruto (tipos distintos): 253,854
\nPrimeros 40 tokens del corpus:
['anarchism', 'originated', 'as', 'a', 'term', 'of', 'abuse', 'first', 'used', 'against', 'early', 'working', 'class', 'radicals', 'including', 'the', 'diggers', 'of', 'the', 'english', 'revolution', 'and', 'the', 'sans', 'culottes', 'of', 'the', 'french', 'revolution', 'whilst', 'the', 'term', 'is', 'still', 'used', 'in', 'a', 'pejorative', 'way', 'to']

Ley de Zipf

La distribución de frecuencias del corpus justifica dos de los hiperparámetros del modelo: min_count, que descarta palabras poco frecuentes, y sample, que descarta ocurrencias de las hiperfrecuentes.

In [37]:
from collections import Counter

frec = Counter(itertools.chain.from_iterable(frases))
print("10 palabras más frecuentes:", frec.most_common(10))
print("Palabras que aparecen 1 sola vez:", sum(1 for w, c in frec.items() if c == 1), "tipos")

rangos = np.arange(1, 10001)
cuentas = np.array([c for _, c in frec.most_common(10000)])

plt.figure(figsize=(7, 4))
plt.loglog(rangos, cuentas)
plt.xlabel("Rango de la palabra (log)")
plt.ylabel("Frecuencia (log)")
plt.title("Ley de Zipf en text8")
plt.grid(True, which="both", alpha=0.3)
plt.show()
10 palabras más frecuentes: [('the', 1061396), ('of', 593677), ('and', 416629), ('one', 411764), ('in', 372201), ('a', 325873), ('to', 316376), ('zero', 264975), ('nine', 250430), ('two', 192644)]
Palabras que aparecen 1 sola vez: 118519 tipos

Pregunta 1. ¿Qué relación tiene la recta que acabas de ver con los parámetros min_count y sample de Word2Vec? Responde al final del notebook (Parte 9).


Ejercicio 1 — Preprocesamiento (10 pts)

text8 se distribuye ya normalizado, lo que no es habitual. Implementa una función de preprocesamiento reutilizable con los siguientes requisitos:

  1. Pasar todo a minúsculas.
  2. Eliminar cualquier carácter que no sea una letra (incluidos números, signos y emojis). Debe funcionar con acentos y ñ del español.
  3. Tokenizar por espacios en blanco.
  4. Descartar tokens de longitud menor que min_len.
  5. Si quitar_stopwords=True, eliminar las palabras de STOPWORDS.
In [38]:
STOPWORDS = frozenset("""
el la los las un una unos unas de del al a ante bajo con contra desde durante en entre hacia
hasta mediante para por segun sin sobre tras y o u ni que se su sus lo le les es son era eran
ser fue han he ha muy mas pero como cuando donde quien cual esta este esto estos estas
the a an and or but of to in on at for with from by as is are was were be been being this
that these those it its his her their our your not no so if then than there here what which
who whom will would can could should do does did have has had
""".split())

print(len(STOPWORDS), "stopwords cargadas")
118 stopwords cargadas
In [39]:
def preprocesar(texto, quitar_stopwords=True, min_len=2):
    """Convierte una cadena de texto en una lista de tokens limpios.

    Args:
        texto (str): texto crudo.
        quitar_stopwords (bool): si True, elimina las palabras de STOPWORDS.
        min_len (int): longitud mínima de token que se conserva.

    Returns:
        list[str]: lista de tokens.
    """
    texto = texto.lower()
    # Conserva letras (incluye acentos y ñ) y espacios; el resto se convierte en espacio.
    texto = re.sub(r"[^a-zà-öø-ÿñ\s]", " ", texto)
    tokens = texto.split()
    tokens = [t for t in tokens if len(t) >= min_len]
    if quitar_stopwords:
        tokens = [t for t in tokens if t not in STOPWORDS]
    return tokens
In [40]:
# --- Verificación automática del Ejercicio 1 ---
def _t1():
    out = preprocesar("El GATO, el Perro y 3 gatos!!!")
    assert isinstance(out, list) and all(isinstance(t, str) for t in out), "debe devolver list[str]"
    assert out == ["gato", "perro", "gatos"], f"esperaba ['gato','perro','gatos'], obtuve {out}"

def _t2():
    out = preprocesar("La NIÑA come MANZANAS en Málaga", quitar_stopwords=False)
    assert "niña" in out, "los acentos y la ñ deben conservarse"
    assert "málaga" in out, "las palabras con acento deben conservarse enteras"

def _t3():
    out = preprocesar("a b cd efg", quitar_stopwords=False, min_len=3)
    assert out == ["efg"], f"min_len no se aplica bien: {out}"

def _t4():
    assert preprocesar("") == [], "un texto vacío debe dar lista vacía"

for n, f in [("Ej1.a limpieza básica", _t1), ("Ej1.b acentos y ñ", _t2),
             ("Ej1.c min_len", _t3), ("Ej1.d caso vacío", _t4)]:
    test(n, f)
[OK]        Ej1.a limpieza básica
[OK]        Ej1.b acentos y ñ
[OK]        Ej1.c min_len
[OK]        Ej1.d caso vacío

Parte 2 · ¿Qué aprende exactamente Word2Vec?

Word2Vec no es una red profunda: consta de dos matrices de embeddings (entrada y salida), sin capas no lineales, entrenadas mediante una tarea auxiliar. Existen dos variantes:

CBOW Skip-gram
Tarea el contexto predice la palabra central la palabra central predice el contexto
Ejemplo [el, gato, sobre, la]salta salta[el, gato, sobre, la]
Ejemplos por posición 1 (promedia el contexto) 2 · ventana
Velocidad mayor menor
Palabras frecuentes resultados algo mejores
Palabras raras peor mejor

Parámetros principales

Parámetro (gensim) Qué controla Efecto típico
vector_size dimensión del embedding 50–300; aumentarlo da más capacidad, pero también más coste y más riesgo de sobreajuste
window radio del contexto valores pequeños (2) favorecen la similitud sintáctica; valores grandes (10), la temática
min_count frecuencia mínima filtra ruido y reduce el vocabulario
sg 0 = CBOW, 1 = Skip-gram véase la tabla anterior
negative nº de muestras negativas 5–20 en corpus pequeños, 2–5 en grandes
sample umbral de subsampling descarta ocurrencias de palabras muy frecuentes
epochs pasadas sobre el corpus 5–15
ns_exponent distribución del ruido (P(w)\propto f(w)^{0.75}) 0.75 es el valor clásico

Función de pérdida de Skip-gram con Negative Sampling (SGNS)

Para cada par observado (centro c, contexto o) y K negativos n_k muestreados del ruido:

\mathcal{L} = -\log \sigma(\mathbf{v}_c \cdot \mathbf{u}_o) - \sum_{k=1}^{K} \log \sigma(-\mathbf{v}_c \cdot \mathbf{u}_{n_k})

La actualización acerca el vector central a su contexto observado y lo aleja de K palabras muestreadas al azar. Las regularidades semánticas no se programan: emergen de aplicar esta regla sobre decenas de millones de pares.


Ejercicio 2 — Construir el dataset de entrenamiento (15 pts)

Antes de delegar esta tarea en gensim, construye los pares de entrenamiento manualmente. Implementa las dos funciones siguientes.

In [41]:
def generar_pares_skipgram(tokens, ventana=2):
    """Genera los pares (centro, contexto) de Skip-gram.

    Recorre la lista de tokens y, para cada posición i, empareja tokens[i]
    con cada token dentro de la ventana [i-ventana, i+ventana], excluyendo i.
    La ventana se recorta en los bordes de la secuencia.

    Args:
        tokens (list[str]): secuencia de tokens.
        ventana (int): radio de la ventana de contexto.

    Returns:
        list[tuple[str, str]]: pares (centro, contexto) en orden de aparición.
    """
    pares = []
    n = len(tokens)
    for i in range(n):
        inicio = max(0, i - ventana)
        fin = min(n, i + ventana + 1)
        for j in range(inicio, fin):
            if j == i:
                continue
            pares.append((tokens[i], tokens[j]))
    return pares


def generar_pares_cbow(tokens, ventana=2):
    """Genera los ejemplos de CBOW: (lista_de_contexto, centro).

    Returns:
        list[tuple[list[str], str]]
    """
    ejemplos = []
    n = len(tokens)
    for i in range(n):
        inicio = max(0, i - ventana)
        fin = min(n, i + ventana + 1)
        contexto = [tokens[j] for j in range(inicio, fin) if j != i]
        ejemplos.append((contexto, tokens[i]))
    return ejemplos
In [42]:
# --- Verificación automática del Ejercicio 2 ---
DEMO = ["el", "gato", "salta", "sobre", "la", "mesa"]

def _t1():
    pares = generar_pares_skipgram(["a", "b", "c", "d"], ventana=1)
    esperado = [("a","b"),("b","a"),("b","c"),("c","b"),("c","d"),("d","c")]
    assert pares == esperado, f"esperaba {esperado}, obtuve {pares}"

def _t2():
    pares = generar_pares_skipgram(DEMO, ventana=2)
    # 6 tokens, ventana 2 -> 2+3+4+4+3+2 = 18 pares
    assert len(pares) == 18, f"esperaba 18 pares, obtuve {len(pares)}"
    assert ("salta", "el") in pares and ("salta", "la") in pares
    assert ("salta", "salta") not in pares, "un token no puede ser su propio contexto"

def _t3():
    ej = generar_pares_cbow(["a","b","c","d"], ventana=1)
    esperado = [(["b"],"a"), (["a","c"],"b"), (["b","d"],"c"), (["c"],"d")]
    assert ej == esperado, f"esperaba {esperado}, obtuve {ej}"

def _t4():
    assert generar_pares_skipgram(["solo"], ventana=3) == [], "un único token no genera pares"

for n, f in [("Ej2.a skip-gram simple", _t1), ("Ej2.b skip-gram ventana 2", _t2),
             ("Ej2.c cbow", _t3), ("Ej2.d caso borde", _t4)]:
    test(n, f)
[OK]        Ej2.a skip-gram simple
[OK]        Ej2.b skip-gram ventana 2
[OK]        Ej2.c cbow
[OK]        Ej2.d caso borde
In [43]:
# Inspecciona el dataset que acabas de construir
try:
    pares = generar_pares_skipgram(DEMO, ventana=2)
    print(f"{len(pares)} pares a partir de {len(DEMO)} tokens\\n")
    for centro, ctx in pares[:8]:
        print(f"  centro={centro:<7} contexto={ctx}")

    # Escalado: ¿cuántos pares generaría el corpus completo?
    pares_por_token = len(pares) / len(DEMO)
    print(f"\\nPares por token (ventana=2): ~{pares_por_token:.1f}")
    print(f"Pares estimados en text8 completo: ~{pares_por_token * n_tokens/1e6:.0f} millones")
    print("Este volumen explica por qué se usa negative sampling y no un softmax")
    print("sobre todo el vocabulario.")
except NotImplementedError:
    print("Completa el Ejercicio 2 para ver esta celda.")
18 pares a partir de 6 tokens\n
  centro=el      contexto=gato
  centro=el      contexto=salta
  centro=gato    contexto=el
  centro=gato    contexto=salta
  centro=gato    contexto=sobre
  centro=salta   contexto=el
  centro=salta   contexto=gato
  centro=salta   contexto=sobre
\nPares por token (ventana=2): ~3.0
Pares estimados en text8 completo: ~51 millones
Este volumen explica por qué se usa negative sampling y no un softmax
sobre todo el vocabulario.

Parte 3 · Ejercicio 3 — Entrenar el modelo con gensim (15 pts)

Entrena el modelo principal. Debes completar los hiperparámetros con los valores de la tabla y ser capaz de justificar cada uno en la Parte 9.

Parámetro Valor pedido
vector_size 100
window 5
min_count 5
sg 1 (Skip-gram)
negative 5
sample 1e-3
epochs CONFIG["epocas"]
workers CONFIG["workers"]
seed SEMILLA
compute_loss True
callbacks [progreso]

El entrenamiento tarda entre 4 y 9 minutos en Colab. Es normal.

In [44]:
class Progreso(CallbackAny2Vec):
    """Callback que informa del tiempo y la pérdida al final de cada época."""
    def __init__(self):
        self.epoca = 0
        self.perdida_acumulada = 0.0
        self.historial = []
        self.t0 = None

    def on_epoch_begin(self, model):
        self.t0 = time.time()

    def on_epoch_end(self, model):
        self.epoca += 1
        total = model.get_latest_training_loss()      # es acumulada
        delta = total - self.perdida_acumulada        # -> pérdida de esta época
        self.perdida_acumulada = total
        dt = time.time() - self.t0
        self.historial.append({"epoca": self.epoca, "perdida": delta, "segundos": dt})
        print(f"  época {self.epoca}: pérdida={delta:,.0f}  ({dt:.1f} s)")

progreso = Progreso()
print("Callback listo.")
Callback listo.
In [45]:
t0 = time.time()

modelo = Word2Vec(
    sentences=frases,
    vector_size=100,
    window=5,
    min_count=5,
    sg=1,
    negative=5,
    sample=1e-3,
    epochs=CONFIG["epocas"],
    workers=CONFIG["workers"],
    seed=SEMILLA,
    compute_loss=True,
    callbacks=[progreso],
)

print(f"\nEntrenamiento total: {(time.time()-t0)/60:.1f} min")
print(f"Vocabulario final: {len(modelo.wv):,} palabras")
print(f"Forma de la matriz de embeddings: {modelo.wv.vectors.shape}")
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
  época 1: pérdida=48,672,852  (26.5 s)
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
  época 2: pérdida=19,068,380  (28.1 s)
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
  época 3: pérdida=1,495,560  (23.1 s)
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
  época 4: pérdida=1,352,584  (23.3 s)
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
  época 5: pérdida=1,128,728  (28.2 s)

Entrenamiento total: 2.2 min
Vocabulario final: 71,290 palabras
Forma de la matriz de embeddings: (71290, 100)
In [46]:
# --- Verificación automática del Ejercicio 3 ---
def _t1():
    assert "modelo" in globals(), "no existe la variable 'modelo'"
    assert modelo.wv.vector_size == 100, f"vector_size debe ser 100, es {modelo.wv.vector_size}"

def _t2():
    assert modelo.sg == 1, "sg debe ser 1 (Skip-gram)"
    assert modelo.window == 5, "window debe ser 5"
    assert modelo.negative == 5, "negative debe ser 5"

def _t3():
    assert 40_000 < len(modelo.wv) < 120_000, (
        f"vocabulario sospechoso ({len(modelo.wv)}): revisa min_count")

def _t4():
    v = modelo.wv["king"]
    assert v.shape == (100,), f"el vector de 'king' debería tener forma (100,), tiene {v.shape}"
    assert np.linalg.norm(v) > 0, "el vector no puede ser nulo"

for n, f in [("Ej3.a modelo entrenado", _t1), ("Ej3.b hiperparámetros", _t2),
             ("Ej3.c tamaño de vocabulario", _t3), ("Ej3.d vectores válidos", _t4)]:
    test(n, f)
[OK]        Ej3.a modelo entrenado
[OK]        Ej3.b hiperparámetros
[OK]        Ej3.c tamaño de vocabulario
[OK]        Ej3.d vectores válidos
In [47]:
# Curva de pérdida
if progreso.historial:
    h = pd.DataFrame(progreso.historial)
    display(h)
    plt.figure(figsize=(6, 4))
    plt.plot(h["epoca"], h["perdida"], marker="o")
    plt.xlabel("Época"); plt.ylabel("Pérdida de la época")
    plt.title("Curva de entrenamiento (SGNS)")
    plt.grid(alpha=0.3); plt.show()
else:
    print("No hay historial: ¿pasaste compute_loss=True y callbacks=[progreso]?")
epoca perdida segundos
0 1 48672852.0 26.538724
1 2 19068380.0 28.138281
2 3 1495560.0 23.067414
3 4 1352584.0 23.335629
4 5 1128728.0 28.173413

Sobre la métrica de pérdida. get_latest_training_loss() de gensim es una suma acumulada y su implementación tiene limitaciones conocidas (no se normaliza por número de pares y puede desbordar en corpus grandes). Sirve para ver la tendencia, no como métrica absoluta. En Word2Vec la evaluación real es extrínseca/intrínseca (analogías, similitud), no la pérdida.


Parte 4 · Explorar el espacio vectorial

Antes de la evaluación cuantitativa conviene inspeccionar el modelo de forma exploratoria.

In [48]:
def vecinos(palabra, topn=8):
    if palabra not in modelo.wv:
        print(f"'{palabra}' no está en el vocabulario"); return
    print(f"\\nVecinos de '{palabra}':")
    for w, s in modelo.wv.most_similar(palabra, topn=topn):
        print(f"    {w:<18} {s:.3f}")

for p in ["king", "paris", "computer", "music", "three", "physics"]:
    vecinos(p)
\nVecinos de 'king':
    prince             0.762
    pretender          0.735
    queen              0.732
    valdemar           0.728
    kings              0.726
    haakon             0.725
    canute             0.722
    stadtholder        0.709
\nVecinos de 'paris':
    rodin              0.742
    conservatoire      0.728
    brussels           0.728
    montparnasse       0.723
    universelle        0.722
    bologna            0.720
    france             0.715
    cimeti             0.715
\nVecinos de 'computer':
    computers          0.807
    hardware           0.773
    computing          0.769
    networked          0.762
    bootstrap          0.746
    pdas               0.744
    hypermedia         0.741
    minicomputer       0.730
\nVecinos de 'music':
    musical            0.802
    jazz               0.791
    folk               0.790
    reggae             0.776
    electronica        0.769
    dance              0.765
    pop                0.751
    improvisation      0.748
\nVecinos de 'three':
    four               0.980
    five               0.971
    two                0.963
    six                0.959
    seven              0.939
    one                0.936
    eight              0.929
    zero               0.897
\nVecinos de 'physics':
    electrodynamics    0.795
    mechanics          0.777
    chemistry          0.776
    electromagnetism   0.771
    quantum            0.754
    astrophysics       0.743
    feynman            0.729
    electrochemistry   0.722
In [49]:
# Similitud entre pares: ¿coincide con tu intuición?
pares_test = [("king","queen"), ("king","man"), ("king","banana"),
              ("paris","france"), ("paris","london"), ("dog","cat"), ("dog","car")]

df_sim = pd.DataFrame(
    [(a, b, modelo.wv.similarity(a, b)) for a, b in pares_test],
    columns=["palabra_1", "palabra_2", "similitud_coseno"]
).sort_values("similitud_coseno", ascending=False)
display(df_sim)

# El intruso
for grupo in [["breakfast","lunch","dinner","paris"],
              ["red","blue","green","dog"],
              ["monday","tuesday","january","wednesday"]]:
    print(f"{grupo}  →  intruso: {modelo.wv.doesnt_match(grupo)}")
palabra_1 palabra_2 similitud_coseno
0 king queen 0.731937
3 paris france 0.715271
5 dog cat 0.686618
4 paris london 0.565996
6 dog car 0.389345
1 king man 0.374814
2 king banana 0.168914
['breakfast', 'lunch', 'dinner', 'paris']  →  intruso: paris
['red', 'blue', 'green', 'dog']  →  intruso: dog
['monday', 'tuesday', 'january', 'wednesday']  →  intruso: january

Pregunta 2. ¿Los vecinos de three son sinónimos o algo distinto? ¿Y los de king? ¿Qué tipo de relación captura la similitud coseno aquí: sinonimia, relatedness o sustituibilidad sintáctica?


Ejercicio 4 — Analogías y evaluación cuantitativa (15 pts)

Ciertas relaciones semánticas se codifican como direcciones aproximadamente constantes del espacio vectorial:

\mathbf{v}_{rey} - \mathbf{v}_{hombre} + \mathbf{v}_{mujer} \approx \mathbf{v}_{reina}

Una analogía se lee a : b :: c : ?. Implementa la función y luego mide la exactitud.

In [50]:
def analogia(a, b, c, kv=None, topn=3):
    """Resuelve la analogía a : b :: c : ?

    Es decir, busca las palabras más parecidas a  (b - a + c).

    Args:
        a, b, c (str): palabras de la analogía.
        kv: los KeyedVectors a usar (por defecto, modelo.wv).
        topn (int): número de candidatos a devolver.

    Returns:
        list[tuple[str, float]]: candidatos (palabra, similitud).
        Debe devolver [] si alguna de las tres palabras no está en el vocabulario.
    """
    kv = modelo.wv if kv is None else kv
    if a not in kv or b not in kv or c not in kv:
        return []
    return kv.most_similar(positive=[b, c], negative=[a], topn=topn)
In [51]:
# --- Verificación automática del Ejercicio 4a ---
def _t1():
    r = analogia("man", "king", "woman", topn=3)
    assert isinstance(r, list) and len(r) == 3, f"debe devolver 3 tuplas, devolvió {r}"
    assert isinstance(r[0], tuple) and isinstance(r[0][0], str)

def _t2():
    r = analogia("man", "king", "woman", topn=5)
    palabras = [w for w, _ in r]
    assert "queen" in palabras, f"'queen' debería estar en el top-5; obtuve {palabras}"

def _t3():
    assert analogia("man", "king", "xyzzyqwe") == [], "palabra fuera de vocabulario -> []"

def _t4():
    r = analogia("man", "king", "woman", topn=3)
    assert "king" not in [w for w, _ in r], "most_similar debe excluir las palabras de entrada"

for n, f in [("Ej4.a formato", _t1), ("Ej4.b king-man+woman=queen", _t2),
             ("Ej4.c fuera de vocabulario", _t3), ("Ej4.d exclusión de entradas", _t4)]:
    test(n, f)
[OK]        Ej4.a formato
[OK]        Ej4.b king-man+woman=queen
[OK]        Ej4.c fuera de vocabulario
[OK]        Ej4.d exclusión de entradas
In [52]:
# Batería de analogías: semánticas y sintácticas
ANALOGIAS = [
    # (a, b, c, respuesta_esperada)
    ("man", "king", "woman", "queen"),
    ("france", "paris", "italy", "rome"),
    ("france", "paris", "japan", "tokyo"),
    ("spain", "madrid", "germany", "berlin"),
    ("boy", "brother", "girl", "sister"),
    ("man", "he", "woman", "she"),
    ("good", "better", "bad", "worse"),
    ("walk", "walking", "swim", "swimming"),
    ("big", "biggest", "small", "smallest"),
    ("mouse", "mice", "dog", "dogs"),
    ("copper", "cu", "silver", "ag"),
    ("einstein", "physics", "picasso", "painting"),
]

def evaluar_analogias(kv, analogias=ANALOGIAS, topn=5, verbose=True):
    """Devuelve (exactitud@1, exactitud@topn, tabla de resultados)."""
    filas, top1, topk, evaluadas = [], 0, 0, 0
    for a, b, c, esperada in analogias:
        cand = analogia(a, b, c, kv=kv, topn=topn)
        if not cand:
            filas.append({"analogia": f"{a}:{b}::{c}:?", "esperada": esperada,
                          "prediccion": "—(OOV)", "acierto@1": None, f"acierto@{topn}": None})
            continue
        evaluadas += 1
        palabras = [w for w, _ in cand]
        ok1 = palabras[0] == esperada
        okk = esperada in palabras
        top1 += ok1; topk += okk
        filas.append({"analogia": f"{a}:{b}::{c}:?", "esperada": esperada,
                      "prediccion": palabras[0], "acierto@1": ok1, f"acierto@{topn}": okk})
    tabla = pd.DataFrame(filas)
    acc1 = top1 / evaluadas if evaluadas else 0.0
    acck = topk / evaluadas if evaluadas else 0.0
    if verbose:
        display(tabla)
        print(f"Exactitud@1  = {acc1:.1%}   ({top1}/{evaluadas})")
        print(f"Exactitud@{topn} = {acck:.1%}   ({topk}/{evaluadas})")
    return acc1, acck, tabla

try:
    acc1_base, acc5_base, _ = evaluar_analogias(modelo.wv)
except NotImplementedError:
    print("Completa el Ejercicio 4a primero.")
analogia esperada prediccion acierto@1 acierto@5
0 man:king::woman:? queen queen True True
1 france:paris::italy:? rome venice False False
2 france:paris::japan:? tokyo tokyo True True
3 spain:madrid::germany:? berlin berlin True True
4 boy:brother::girl:? sister wife False False
5 man:he::woman:? she she True True
6 good:better::bad:? worse worse True True
7 walk:walking::swim:? swimming walkers False False
8 big:biggest::small:? smallest largest False False
9 mouse:mice::dog:? dogs dogs True True
10 copper:cu::silver:? ag mj False False
11 einstein:physics::picasso:? painting nouveau False False
Exactitud@1  = 50.0%   (6/12)
Exactitud@5 = 50.0%   (6/12)

Pregunta 3. Mira las analogías que ha fallado. ¿Fallan más las semánticas (capitales, géneros) o las sintácticas (plurales, comparativos)? Propón una hipótesis relacionada con window y con el tamaño del corpus.


Parte 5 · Ejercicio 5 — CBOW vs Skip-gram (10 pts)

Compara las dos arquitecturas en igualdad de condiciones, sobre el subconjunto del corpus definido en CONFIG["n_frases_rapido"] para acotar el tiempo de cálculo.

Completa el bucle: entrena un modelo por configuración, mide el tiempo de entrenamiento y las exactitudes @1 y @5, y guarda los resultados en resultados_arq.

In [53]:
frases_rapido = frases[:CONFIG["n_frases_rapido"]]
print(f"Subcorpus: {len(frases_rapido)} bloques ≈ {sum(len(f) for f in frases_rapido):,} tokens")

def entrenar(frases_in, **kwargs):
    """Entrena un Word2Vec con valores por defecto sensatos + los kwargs dados.
    Devuelve (modelo, segundos)."""
    base = dict(vector_size=100, window=5, min_count=5, negative=5, sample=1e-3,
                epochs=CONFIG["epocas"], workers=CONFIG["workers"], seed=SEMILLA)
    base.update(kwargs)
    t = time.time()
    m = Word2Vec(sentences=frases_in, **base)
    return m, time.time() - t

print("Helper 'entrenar' listo.")
Subcorpus: 400 bloques ≈ 4,000,000 tokens
Helper 'entrenar' listo.
In [54]:
resultados_arq = []

for nombre, sg in [("CBOW", 0), ("Skip-gram", 1)]:
    print(f"Entrenando {nombre}...")
    m, segundos = entrenar(frases_rapido, sg=sg)
    acc1, acc5, _ = evaluar_analogias(m.wv, verbose=False)
    resultados_arq.append({
        "arquitectura": nombre,
        "segundos": segundos,
        "vocabulario": len(m.wv),
        "acc@1": acc1,
        "acc@5": acc5,
    })

df_arq = pd.DataFrame(resultados_arq)
display(df_arq)
Entrenando CBOW...
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Entrenando Skip-gram...
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
arquitectura segundos vocabulario acc@1 acc@5
0 CBOW 6.882106 32080 0.250000 0.416667
1 Skip-gram 25.492793 32080 0.166667 0.250000
In [55]:
# --- Verificación automática del Ejercicio 5 ---
def _t1():
    assert len(resultados_arq) == 2, "debe haber 2 filas (CBOW y Skip-gram)"

def _t2():
    claves = {"arquitectura", "segundos", "vocabulario", "acc@1", "acc@5"}
    assert claves.issubset(resultados_arq[0].keys()), f"faltan claves: {claves - set(resultados_arq[0])}"

def _t3():
    assert all(r["segundos"] > 0 for r in resultados_arq), "los tiempos deben ser positivos"

for n, f in [("Ej5.a dos configuraciones", _t1), ("Ej5.b claves correctas", _t2),
             ("Ej5.c tiempos medidos", _t3)]:
    test(n, f)
[OK]        Ej5.a dos configuraciones
[OK]        Ej5.b claves correctas
[OK]        Ej5.c tiempos medidos
In [56]:
try:
    fig, ax = plt.subplots(1, 2, figsize=(11, 4))
    ax[0].bar(df_arq["arquitectura"], df_arq["segundos"])
    ax[0].set_ylabel("segundos"); ax[0].set_title("Tiempo de entrenamiento")
    x = np.arange(len(df_arq))
    ax[1].bar(x - 0.2, df_arq["acc@1"], width=0.4, label="acc@1")
    ax[1].bar(x + 0.2, df_arq["acc@5"], width=0.4, label="acc@5")
    ax[1].set_xticks(x); ax[1].set_xticklabels(df_arq["arquitectura"])
    ax[1].set_ylabel("exactitud"); ax[1].set_title("Analogías"); ax[1].legend()
    plt.tight_layout(); plt.show()
except NameError:
    print("Completa el Ejercicio 5 primero.")

Pregunta 4. ¿Qué arquitectura gana en tu experimento y a qué coste? ¿Cambiaría la conclusión con un corpus 100 veces mayor? ¿Y si solo te interesaran palabras muy raras?


Parte 6 · Ejercicio 6 — Barrido de hiperparámetros (10 pts)

Estudia de forma sistemática el efecto de un hiperparámetro:

  1. Barrer window ∈ {2, 5, 10} manteniendo el resto de parámetros fijos (Skip-gram, subcorpus).
  2. Guardar exactitud@1, exactitud@5 y tiempo en resultados_ventana.
  3. Además, para window=2 y window=10, imprimir los vecinos de "paris" y "physics" y observar cualitativamente la diferencia.
In [57]:
resultados_ventana = []
modelos_ventana = {}

for w in [2, 5, 10]:
    print(f"Entrenando window={w}...")
    m, segundos = entrenar(frases_rapido, sg=1, window=w)
    acc1, acc5, _ = evaluar_analogias(m.wv, verbose=False)
    resultados_ventana.append({
        "window": w,
        "segundos": segundos,
        "vocabulario": len(m.wv),
        "acc@1": acc1,
        "acc@5": acc5,
    })
    modelos_ventana[w] = m

df_ventana = pd.DataFrame(resultados_ventana)
display(df_ventana)

plt.figure(figsize=(6, 4))
plt.plot(df_ventana["window"], df_ventana["acc@1"], marker="o", label="acc@1")
plt.plot(df_ventana["window"], df_ventana["acc@5"], marker="s", label="acc@5")
plt.xlabel("window"); plt.ylabel("exactitud en analogías")
plt.title("Efecto del tamaño de ventana"); plt.legend(); plt.grid(alpha=0.3); plt.show()
Entrenando window=2...
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Entrenando window=5...
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Entrenando window=10...
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
window segundos vocabulario acc@1 acc@5
0 2 13.969611 32080 0.250000 0.416667
1 5 25.849061 32080 0.083333 0.250000
2 10 46.226222 32080 0.416667 0.583333
In [58]:
# Ejercicio 6b: compara cualitativamente los vecinos con ventana pequeña y grande.
for w in [2, 10]:
    print(f"\n=== window={w} ===")
    for palabra in ["paris", "physics"]:
        print(f"\nVecinos de '{palabra}' (window={w}):")
        for vecino, sim in modelos_ventana[w].wv.most_similar(palabra, topn=8):
            print(f"    {vecino:<18} {sim:.3f}")
=== window=2 ===

Vecinos de 'paris' (window=2):
    zurich             0.757
    bologna            0.756
    venice             0.751
    vienna             0.747
    lyon               0.743
    perth              0.741
    dublin             0.733
    francisco          0.730

Vecinos de 'physics' (window=2):
    mathematics        0.835
    chemistry          0.816
    mechanics          0.788
    mathematical       0.774
    logic              0.762
    sciences           0.759
    geometry           0.751
    relativity         0.735

=== window=10 ===

Vecinos de 'paris' (window=10):
    sur                0.734
    des                0.726
    mie                0.701
    france             0.699
    rodin              0.672
    recherche          0.671
    vevey              0.669
    nuremberg          0.668

Vecinos de 'physics' (window=10):
    mechanics          0.806
    quantum            0.752
    bose               0.744
    condensates        0.743
    electromagnetism   0.737
    chemistry          0.721
    theoretical        0.718
    gravitation        0.717
In [59]:
# --- Verificación automática del Ejercicio 6 ---
def _t1():
    assert len(resultados_ventana) == 3, "debe haber 3 configuraciones de ventana"

def _t2():
    assert sorted(r["window"] for r in resultados_ventana) == [2, 5, 10]

def _t3():
    assert set(modelos_ventana) >= {2, 10}, "guarda los modelos de window=2 y window=10"

for n, f in [("Ej6.a barrido completo", _t1), ("Ej6.b valores de window", _t2),
             ("Ej6.c modelos guardados", _t3)]:
    test(n, f)
[OK]        Ej6.a barrido completo
[OK]        Ej6.b valores de window
[OK]        Ej6.c modelos guardados

Pregunta 5. Con window=2 los vecinos suelen ser palabras intercambiables en la frase (otras ciudades, otras ciencias); con window=10 son palabras del mismo tema. Explica por qué, en términos de qué contextos comparten dos palabras en cada caso.


Parte 7 · Ejercicio 7 — Visualizar el espacio (5 pts)

Un espacio de 100 dimensiones no es representable directamente. Se proyecta a dos dimensiones con PCA, que es lineal y conserva las direcciones globales, y con t-SNE, que es no lineal y conserva los vecindarios locales.

Advertencia: t-SNE no conserva distancias globales. Dos clusters alejados en el dibujo no están necesariamente alejados en el espacio original.

In [60]:
GRUPOS = {
    "países":    ["france", "germany", "italy", "spain", "japan", "china", "russia"],
    "capitales": ["paris", "berlin", "rome", "madrid", "tokyo", "beijing", "moscow"],
    "números":   ["one", "two", "three", "four", "five", "six", "seven"],
    "animales":  ["dog", "cat", "horse", "cow", "bird", "fish", "mouse"],
    "ciencias":  ["physics", "chemistry", "biology", "mathematics", "geology"],
    "verbos":    ["walk", "run", "swim", "jump", "eat", "drink"],
}

palabras, etiquetas = [], []
for g, ws in GRUPOS.items():
    for w in ws:
        if w in modelo.wv:
            palabras.append(w); etiquetas.append(g)
print(f"{len(palabras)} palabras en {len(GRUPOS)} grupos")
39 palabras en 6 grupos
In [61]:
from sklearn.decomposition import PCA
from sklearn.manifold import TSNE

X = np.array([modelo.wv[w] for w in palabras])

XY_pca = PCA(n_components=2, random_state=SEMILLA).fit_transform(X)
XY_tsne = TSNE(n_components=2, random_state=SEMILLA, perplexity=8, init="pca").fit_transform(X)
In [62]:
def dibujar(XY, titulo):
    plt.figure(figsize=(9, 7))
    for g in GRUPOS:
        idx = [i for i, e in enumerate(etiquetas) if e == g]
        plt.scatter(XY[idx, 0], XY[idx, 1], s=60, label=g)
    for i, w in enumerate(palabras):
        plt.annotate(w, (XY[i, 0], XY[i, 1]), fontsize=8,
                     xytext=(4, 4), textcoords="offset points")
    plt.title(titulo); plt.legend(fontsize=8); plt.grid(alpha=0.2)
    plt.tight_layout(); plt.show()

dibujar(XY_pca, "Embeddings proyectados con PCA")
dibujar(XY_tsne, "Embeddings proyectados con t-SNE")
In [63]:
# La geometría de las analogías: país -> capital debería ser un vector casi constante
PAIS_CAPITAL = [("france","paris"), ("germany","berlin"), ("italy","rome"),
                ("spain","madrid"), ("japan","tokyo"), ("russia","moscow")]

sub = [w for par in PAIS_CAPITAL for w in par if w in modelo.wv]
Xs = np.array([modelo.wv[w] for w in sub])
XYs = PCA(n_components=2, random_state=SEMILLA).fit_transform(Xs)
pos = {w: XYs[i] for i, w in enumerate(sub)}

plt.figure(figsize=(8, 6))
for pais, cap in PAIS_CAPITAL:
    if pais in pos and cap in pos:
        plt.scatter(*pos[pais], color="tab:blue"); plt.scatter(*pos[cap], color="tab:orange")
        plt.annotate(pais, pos[pais], fontsize=9, xytext=(4,4), textcoords="offset points")
        plt.annotate(cap,  pos[cap],  fontsize=9, xytext=(4,4), textcoords="offset points")
        plt.arrow(*pos[pais], *(pos[cap]-pos[pais]), length_includes_head=True,
                  head_width=0.06, alpha=0.5, color="gray")
plt.title("¿Son paralelos los vectores país → capital?")
plt.grid(alpha=0.2); plt.tight_layout(); plt.show()

# Medida cuantitativa: coseno entre los vectores de desplazamiento
difs = np.array([modelo.wv[c] - modelo.wv[p] for p, c in PAIS_CAPITAL
                 if p in modelo.wv and c in modelo.wv])
difs_n = difs / np.linalg.norm(difs, axis=1, keepdims=True)
cos = difs_n @ difs_n.T
print(f"Coseno medio entre desplazamientos país→capital: "
      f"{(cos.sum()-len(cos))/(len(cos)**2-len(cos)):.3f}")
print("(1.0 = perfectamente paralelos, 0.0 = ortogonales)")
Coseno medio entre desplazamientos país→capital: 0.510
(1.0 = perfectamente paralelos, 0.0 = ortogonales)

Parte 8 · Ejercicio 8 — Sesgos aprendidos (20 pts junto a las preguntas)

El modelo aprende todas las regularidades estadísticas del corpus, incluidos los estereotipos presentes en el texto. No se trata de un defecto del algoritmo, sino de una propiedad de los datos de entrenamiento. La cuestión es relevante en la práctica: estos vectores se han usado como entrada en sistemas de cribado de currículos, moderación de contenidos y búsqueda.

En este ejercicio el sesgo se mide, no solo se comenta.

In [64]:
# Analogías reveladoras: man : X :: woman : ?
for x in ["doctor", "engineer", "programmer", "professor", "boss", "nurse", "genius"]:
    if x in modelo.wv:
        r = analogia("man", x, "woman", topn=3)
        print(f"man : {x:<11} :: woman : {[w for w, _ in r]}")
man : doctor      :: woman : ['nurse', 'child', 'midwives']
man : engineer    :: woman : ['physician', 'architect', 'educator']
man : programmer  :: woman : ['user', 'programmers', 'handler']
man : professor   :: woman : ['lecturer', 'researcher', 'emeritus']
man : boss        :: woman : ['prostitute', 'boyfriend', 'selina']
man : nurse       :: woman : ['midwives', 'pregnant', 'midwife']
man : genius      :: woman : ['bodybuilder', 'adolescent', 'fatale']
In [65]:
def sesgo_direccional(palabras, par=("he", "she")):
    """Proyecta cada palabra sobre la dirección (par[0] - par[1]).

    Valor > 0  -> más asociada a par[0];  < 0 -> más asociada a par[1].
    Devuelve un DataFrame ordenado.
    """
    a, b = par
    eje = modelo.wv[a] - modelo.wv[b]
    eje = eje / np.linalg.norm(eje)

    filas = []
    for w in palabras:
        v = modelo.wv[w]
        v = v / np.linalg.norm(v)
        proyeccion = float(np.dot(v, eje))
        filas.append({"palabra": w, "proyeccion": proyeccion})

    df = pd.DataFrame(filas).sort_values("proyeccion", ascending=False).reset_index(drop=True)
    return df

OCUPACIONES = ["nurse", "doctor", "teacher", "engineer", "secretary", "programmer",
               "librarian", "mechanic", "dancer", "scientist", "receptionist",
               "carpenter", "designer", "surgeon", "housekeeper", "pilot"]
df_sesgo = sesgo_direccional([w for w in OCUPACIONES if w in modelo.wv])
display(df_sesgo)
palabra proyeccion
0 engineer 0.060787
1 teacher 0.017524
2 surgeon 0.012540
3 scientist 0.005343
4 mechanic -0.008503
5 carpenter -0.019465
6 doctor -0.048736
7 secretary -0.064075
8 librarian -0.078206
9 designer -0.118456
10 programmer -0.126157
11 pilot -0.132322
12 housekeeper -0.223932
13 nurse -0.285991
14 dancer -0.324133
In [66]:
try:
    d = df_sesgo.sort_values("proyeccion")
    plt.figure(figsize=(7, 6))
    colores = ["tab:red" if v > 0 else "tab:blue" for v in d["proyeccion"]]
    plt.barh(d["palabra"], d["proyeccion"], color=colores)
    plt.axvline(0, color="black", lw=1)
    plt.xlabel("← más cercano a 'she'      proyección      más cercano a 'he'")
    plt.title("Proyección de ocupaciones sobre el eje he–she")
    plt.tight_layout(); plt.show()
except NameError:
    print("Completa el Ejercicio 8a primero.")
In [67]:
# --- Verificación automática del Ejercicio 8 ---
def _t1():
    d = sesgo_direccional(["doctor", "nurse"])
    assert list(d.columns) == ["palabra", "proyeccion"], f"columnas incorrectas: {list(d.columns)}"

def _t2():
    d = sesgo_direccional(["doctor", "nurse", "teacher"])
    assert d["proyeccion"].is_monotonic_decreasing, "el DataFrame debe estar ordenado descendente"

def _t3():
    d = sesgo_direccional(["he", "she"])
    assert d.iloc[0]["palabra"] == "he", "'he' debe proyectar más alto que 'she' en su propio eje"
    assert d["proyeccion"].abs().max() <= 1.01, "una proyección de vectores unitarios está en [-1, 1]"

for n, f in [("Ej8.a formato", _t1), ("Ej8.b orden", _t2), ("Ej8.c sanidad del eje", _t3)]:
    test(n, f)
[OK]        Ej8.a formato
[OK]        Ej8.b orden
[OK]        Ej8.c sanidad del eje

Pregunta 6. El sesgo que has medido está en el corpus (Wikipedia en inglés), no en el código de Word2Vec. Discute:

  • (a) ¿Por qué "quitar el sesgo" de los vectores (debiasing) no resuelve el problema de fondo?
  • (b) Un sistema que ordena CV usando estos embeddings: ¿en qué punto exacto se produce el daño?
  • (c) La analogía man : doctor :: woman : nurse refleja una asociación estadística real del texto. ¿Qué distingue "describir el mundo" de "reproducir una injusticia" en este caso?

Parte 9 · Preguntas de reflexión

Responde en la celda de texto siguiente, con tres a seis frases por pregunta. Se valora el razonamiento y el uso de los resultados que has obtenido, no la extensión.

  1. Relación entre la ley de Zipf y los parámetros min_count / sample.
  2. ¿Qué relación semántica captura realmente la similitud coseno? (vecinos de three y king)
  3. ¿Fallan más las analogías semánticas o las sintácticas? ¿Por qué?
  4. CBOW vs Skip-gram: quién gana, a qué coste, y cómo cambiaría con más datos.
  5. Por qué window pequeña da similitud sustituible y window grande similitud temática.
  6. Sesgos: debiasing, punto de daño, describir vs reproducir.
  7. Limitación fundamental: Word2Vec asigna un único vector por palabra. Busca en tu modelo los vecinos de una palabra polisémica (bank, apple, right, bat) y explica qué le pasa a su vector. ¿Cómo resuelven esto los modelos contextuales tipo BERT?
  8. Coste y beneficio: has entrenado 100 dimensiones sobre 17 millones de palabras en minutos y sin GPU. ¿En qué situaciones reales de 2026 seguirías eligiendo Word2Vec/FastText en lugar de un modelo de embeddings basado en transformers?
In [68]:
# Celda de apoyo para la pregunta 7: explora una palabra polisémica
for p in ["bank", "apple", "right", "bat", "spring"]:
    if p in modelo.wv:
        print(f"{p:<8} -> {[w for w, _ in modelo.wv.most_similar(p, topn=10)]}")
bank     -> ['banks', 'monetary', 'fund', 'hsbc', 'loans', 'banking', 'bundesbank', 'cemac', 'commerzbank', 'funds']
apple    -> ['macintosh', 'iigs', 'iic', 'amiga', 'hypercard', 'microsoft', 'ibm', 'intel', 'iie', 'wordperfect']
right    -> ['left', 'arctan', 'prod', 'parenthesis', 'wingers', 'leaning', 'proviso', 'inalienable', 'fraca', 'subtree']
bat      -> ['scooby', 'snail', 'saber', 'ox', 'darts', 'cock', 'leaping', 'snapping', 'flies', 'watermelon']
spring   -> ['autumn', 'summer', 'winter', 'litha', 'lughnasadh', 'freeze', 'wintertime', 'autumnal', 'thunderstorm', 'torrential']

Respuestas

1. La ley de Zipf muestra que unas pocas palabras concentran casi toda la masa de frecuencia (the: 1.061.396, of: 593.677, and: 416.629...) mientras que una cola enorme de tipos aparece casi nunca (118.519 de los 253.854 tipos del vocabulario bruto, ~47%, aparecen una sola vez). min_count ataca el extremo de baja frecuencia: descarta esos hápax porque no tienen suficientes co-ocurrencias para estimar un vector fiable (sería puro ruido y desperdicia cómputo). sample ataca el extremo opuesto: palabras como "the" o "of" co-ocurren con casi cualquier palabra, así que aportan poca señal discriminativa y, sin embargo, generarían la mayoría de los ~51 millones de pares centro-contexto estimados; el subsampling descarta parte de sus ocurrencias para que el entrenamiento se reparta hacia palabras más informativas.

2. Los vecinos de three (four, five, two, six, seven, one, eight, zero, similitudes 0.90-0.98) no son sinónimos: son co-hipónimos intercambiables en la misma posición sintáctica ("compré tres/cuatro/cinco gatos"). Los vecinos de king (prince, pretender, queen, valdemar, kings, haakon, canute, stadtholder) tampoco son sinónimos de "rey": son palabras del mismo campo semántico (monarquía, títulos nobiliarios, nombres de reyes históricos). En ambos casos la similitud coseno de Word2Vec captura relatedness (co-ocurrencia en contextos parecidos) y, en el caso de los números, además sustituibilidad sintáctica; no captura sinonimia en sentido estricto.

3. En la batería de 12 analogías, las semánticas (capitales, familia, química, cultura: france-paris-italy-rome, boy-brother-girl-sister, copper-cu-silver-ag, einstein-physics-picasso-painting) acertaron 3/7 (43%), mientras que las sintácticas (comparativos, gerundios, superlativos, plurales: good-better-bad-worse, mouse-mice-dog-dogs, man-he-woman-she) acertaron 3/5 (60%). Fallan más las semánticas. Hipótesis: las regularidades sintácticas (plural, comparativo, gerundio) se repiten en casi cualquier frase del corpus, así que se refuerzan con muchísimos ejemplos incluso con window=5; las relaciones semánticas factuales (una capital concreta, un símbolo químico, una asociación autor-obra) dependen de que esas entidades específicas aparezcan juntas con suficiente frecuencia, algo que un corpus de "solo" 17M de palabras y min_count=5 no siempre garantiza para nombres propios poco frecuentes (de ahí errores como "venice" en vez de "rome", o "ag" fallando a "mj").

4. En mi experimento (subcorpus de 4M tokens, 5 épocas) gana CBOW: entrena ~3.7 veces más rápido (6.9 s vs 25.5 s) y además obtiene mejor exactitud en este barrido puntual (acc@1 25.0% vs 16.7%; acc@5 41.7% vs 25.0%). Hay que ser cauto: la evaluación son solo 12 analogías, así que el margen es ruidoso. La literatura (y la tabla teórica de la Parte 2) predice que Skip-gram suele ganar en palabras raras y con poco dato porque genera más ejemplos de entrenamiento por posición (2·ventana vs 1), mientras que CBOW promedia el contexto y diluye la señal de las palabras infrecuentes. Con un corpus 100 veces mayor esperaría que Skip-gram se acerque o supere a CBOW, sobre todo si el interés está en el vocabulario de cola larga; si solo importan palabras muy frecuentes, CBOW seguiría siendo atractivo por su velocidad.

5. Con window=2, los vecinos de "paris" son otras ciudades (zurich, bologna, venice, vienna, lyon, perth, dublin) y los de "physics" son otras disciplinas (mathematics, chemistry, mechanics, logic, geometry): palabras que ocupan la misma posición local en frases similares ("la capital de ___", "estudió ___"), es decir, son sustituibles entre sí. Con window=10, "paris" se acerca a palabras que comparten el mismo artículo o tema (sur, des, france, rodin, recherche, nuremberg — vocabulario relacionado con Francia/cultura francesa) y "physics" a conceptos específicos del campo (quantum, bose, condensates, electromagnetism, gravitation). La razón es que una ventana pequeña solo captura co-ocurrencias con las palabras inmediatamente adyacentes, que tienden a compartir función sintáctica; una ventana grande capta co-ocurrencias con todo lo que aparece en el mismo bloque de texto, que tiende a compartir tema aunque no sean intercambiables gramaticalmente.

6.

  • (a) El sesgo no vive en una sola dirección del espacio (como "he-she"): está distribuido de forma redundante en múltiples ejes correlacionados con género, y sobre todo está en los datos (la Wikipedia en inglés que usamos). Proyectar y anular una dirección dice "he-she" dejo intactas otras correlaciones (p. ej. nombres, pronombres implícitos, contextos ocupacionales) y no cambia el corpus ni las decisiones que llevaron a esa distribución de datos; es un parche cosmético sobre el vector, no una corrección del proceso que generó el sesgo.
  • (b) El daño concreto se produce en el punto en que la asociación estadística se convierte en un criterio operativo sobre personas reales: un sistema de cribado de CV que usa estos embeddings como features y sistemáticamente puntúa más bajo currículos con vocabulario asociado a "nurse", "housekeeper" o "dancer" (que en mi medición del eje he–she quedaron con proyección muy negativa, -0.29, -0.22 y -0.32) está trasladando una regularidad de texto a una decisión de contratación, afectando oportunidades reales sin relación con el mérito individual.
  • (c) "Describir el mundo" sería registrar honestamente que, en el corpus, "nurse" co-ocurre más con contextos femeninos que "engineer" (que en mi medición quedó ligeramente del lado "he", +0.06) — un hecho estadístico sobre el texto. "Reproducir una injusticia" ocurre cuando esa asociación se usa como señal predictiva o de ranking sobre individuos concretos (p. ej. penalizar el CV de una mujer con experiencia como "programmer", palabra que en mi medición también cayó del lado "she" con -0.13, de forma poco intuitiva y potencialmente arbitraria). La frontera está en si la correlación se limita a describir el corpus o se usa para decidir sobre personas.

7. Al mirar los vecinos de palabras polisémicas se ve que Word2Vec les asigna un único vector que mezcla (o promedia) sus sentidos, dominado por el sentido más frecuente en el corpus: bank solo trae vecinos financieros (banks, monetary, fund, hsbc, loans, banking) y el sentido "orilla de río" no aparece; apple solo trae vecinos de computación (macintosh, iigs, amiga, microsoft, ibm) y el sentido "fruta" desaparece por completo, porque en Wikipedia (text8) predomina el uso tecnológico; spring es puramente estacional (autumn, summer, winter, thunderstorm) y no aparecen los sentidos "muelle" o "manantial". right es el caso más revelador: sus vecinos mezclan sentidos distintos sin resolverlos (left → dirección; wingers, leaning → política; inalienable, proviso → derecho/leyes; arctan, parenthesis → matemáticas), mostrando el vector como un promedio incoherente de varios significados. Los modelos contextuales tipo BERT resuelven esto porque no tienen "un vector por palabra del vocabulario": generan un vector distinto cada vez que la palabra aparece, calculado con atención sobre las palabras que realmente la rodean en esa oración concreta, de modo que "bank" en "river bank" y en "bank account" reciben representaciones diferentes.

8. Entrené 100 dimensiones sobre 17 millones de palabras en ~2 minutos sin GPU (frente a minutos/horas y GPU obligatoria para obtener embeddings contextuales de un transformer). En 2026 seguiría eligiendo Word2Vec/FastText cuando: (i) el presupuesto de cómputo es bajo o no hay GPU disponible en producción; (ii) se necesitan vectores estáticos para búsqueda de similitud a gran escala o recomendación (millones de ítems, latencia de milisegundos, sin poder permitirse inferencia de un transformer por cada consulta); (iii) el dominio o idioma tiene pocos datos o no existe un modelo preentrenado de calidad (FastText además maneja morfología y palabras fuera de vocabulario mediante n-gramas de caracteres, algo crítico en idiomas flexivos); (iv) se quiere una feature simple e interpretable para un pipeline de ML clásico (clustering temático, expansión de consultas, features para un clasificador); o (v) el caso de uso no requiere desambiguar sentido por contexto (justamente la limitación que discutí en la pregunta 7), sino solo capturar relación temática gruesa. Para tareas que sí dependen de comprender el significado en contexto (NLU, QA, análisis de sentimiento fino) seguiría prefiriendo embeddings contextuales pese a su coste.


Parte 11 · Ejercicio adicional: Skip-gram con Negative Sampling en PyTorch (+15 pts)

gensim ejecuta el algoritmo en C optimizado, lo que oculta su funcionamiento. En esta parte se implementa desde cero sobre un subcorpus reducido. El objetivo no es igualar la calidad de gensim, sino obtener vecinos razonables para las palabras frecuentes.

Función de pérdida:

\mathcal{L} = -\log \sigma(\mathbf{v}_c \cdot \mathbf{u}_o) - \sum_{k=1}^{K} \log \sigma(-\mathbf{v}_c \cdot \mathbf{u}_{n_k})

Distribución de muestreo del ruido: P_n(w) \propto f(w)^{0.75}.

In [69]:
import torch
import torch.nn as nn
import torch.nn.functional as F

torch.manual_seed(SEMILLA)
dispositivo = "cuda" if torch.cuda.is_available() else "cpu"
print("PyTorch", torch.__version__, "· dispositivo:", dispositivo)

# ---- Datos: subcorpus pequeño y vocabulario ----
tokens_extra = list(itertools.chain.from_iterable(frases[:60]))   # ~600k tokens
frec_e = Counter(tokens_extra)
MIN_COUNT, VENTANA, DIM, K_NEG = 20, 3, 64, 5

vocab = [w for w, c in frec_e.most_common() if c >= MIN_COUNT]
w2i = {w: i for i, w in enumerate(vocab)}
i2w = {i: w for w, i in w2i.items()}
V = len(vocab)
print(f"Tokens: {len(tokens_extra):,} · Vocabulario: {V:,}")

# ---- Subsampling de Mikolov: P(descartar w) = 1 - sqrt(t/f(w)) ----
total = sum(frec_e[w] for w in vocab)
t_sub = 1e-3
p_keep = {w: min(1.0, np.sqrt(t_sub * total / frec_e[w])) for w in vocab}
rng = np.random.default_rng(SEMILLA)
ids = [w2i[w] for w in tokens_extra if w in w2i and rng.random() < p_keep[w]]
print(f"Tras subsampling: {len(ids):,} tokens ({100*len(ids)/len(tokens_extra):.0f}%)")

# ---- Distribución de ruido f(w)^0.75 ----
frecs = np.array([frec_e[i2w[i]] for i in range(V)], dtype=np.float64)
p_ruido = frecs ** 0.75
p_ruido = torch.tensor(p_ruido / p_ruido.sum(), dtype=torch.float, device=dispositivo)
print("Distribución de ruido lista.")
PyTorch 2.10.0a0+b4e4ee81d3.nv25.12 · dispositivo: cuda
Tokens: 600,000 · Vocabulario: 3,363
Tras subsampling: 310,408 tokens (52%)
Distribución de ruido lista.
In [70]:
# ---- Pares de entrenamiento (reutilizando la función del Ejercicio 2) ----
pares_e = generar_pares_skipgram(ids, ventana=VENTANA)
pares_e = np.array(pares_e, dtype=np.int64)
print(f"{len(pares_e):,} pares (centro, contexto)")

centros_t  = torch.tensor(pares_e[:, 0], device=dispositivo)
contextos_t = torch.tensor(pares_e[:, 1], device=dispositivo)
1,862,436 pares (centro, contexto)
In [71]:
class SGNS(nn.Module):
    """Skip-gram con Negative Sampling: dos matrices de embeddings."""

    def __init__(self, V, D):
        super().__init__()
        self.centro = nn.Embedding(V, D)
        self.contexto = nn.Embedding(V, D)
        nn.init.uniform_(self.centro.weight, -0.5 / D, 0.5 / D)
        nn.init.zeros_(self.contexto.weight)

    def forward(self, centro, contexto, negativos):
        """
        centro:    (B,)      índices de la palabra central
        contexto:  (B,)      índices del contexto positivo
        negativos: (B, K)    índices de las muestras negativas
        Devuelve la pérdida escalar promediada sobre el batch.
        """
        v_c = self.centro(centro)                      # (B, D)
        u_o = self.contexto(contexto)                   # (B, D)
        u_neg = self.contexto(negativos)                 # (B, K, D)

        score_pos = torch.sum(v_c * u_o, dim=1)          # (B,)
        perdida_pos = F.logsigmoid(score_pos)             # (B,)

        score_neg = torch.bmm(u_neg, v_c.unsqueeze(2)).squeeze(2)  # (B, K)
        perdida_neg = F.logsigmoid(-score_neg).sum(dim=1)          # (B,)

        perdida = -(perdida_pos + perdida_neg)
        return perdida.mean()
In [72]:
modelo_pt = SGNS(V, DIM).to(dispositivo)
optim = torch.optim.Adam(modelo_pt.parameters(), lr=2e-3)

BATCH, EPOCAS_PT = 4096, 3
n = len(centros_t)
historial_pt = []

for ep in range(1, EPOCAS_PT + 1):
    perm = torch.randperm(n, device=dispositivo)
    total_loss, nb, t0 = 0.0, 0, time.time()
    for s in range(0, n, BATCH):
        idx = perm[s:s + BATCH]
        c, o = centros_t[idx], contextos_t[idx]
        # muestreo de negativos según p_ruido
        neg = torch.multinomial(p_ruido, len(idx) * K_NEG, replacement=True).view(len(idx), K_NEG)

        loss = modelo_pt(c, o, neg)
        optim.zero_grad(); loss.backward(); optim.step()
        total_loss += loss.item(); nb += 1
    historial_pt.append(total_loss / nb)
    print(f"época {ep}: pérdida media = {total_loss/nb:.4f}  ({time.time()-t0:.1f} s)")

plt.figure(figsize=(6, 4))
plt.plot(range(1, EPOCAS_PT + 1), historial_pt, marker="o")
plt.xlabel("Época"); plt.ylabel("Pérdida media"); plt.title("SGNS en PyTorch")
plt.grid(alpha=0.3); plt.show()
época 1: pérdida media = 2.8387  (1.4 s)
época 2: pérdida media = 2.6126  (0.9 s)
época 3: pérdida media = 2.5235  (0.9 s)
In [73]:
# Vecinos más cercanos calculados con los vectores obtenidos
E = modelo_pt.centro.weight.detach().cpu().numpy()
E_n = E / np.linalg.norm(E, axis=1, keepdims=True)

def vecinos_pt(palabra, topn=8):
    if palabra not in w2i:
        return f"'{palabra}' no está en el vocabulario de esta parte"
    sims = E_n @ E_n[w2i[palabra]]
    orden = np.argsort(-sims)[1:topn + 1]
    return [(i2w[i], round(float(sims[i]), 3)) for i in orden]

for p in ["king", "city", "war", "water", "music"]:
    print(f"{p:<7} -> {vecinos_pt(p)}")

print("\\nCompara estos vecinos con los de gensim sobre el corpus completo.")
print("¿Qué explica la diferencia: el algoritmo, el volumen de datos, las épocas")
print("o los detalles de implementación?")
king    -> [('honour', 0.92), ('commander', 0.917), ('colonel', 0.912), ('philip', 0.911), ('paris', 0.911), ('architect', 0.909), ('henry', 0.906), ('honor', 0.902)]
city    -> [('town', 0.903), ('mississippi', 0.891), ('county', 0.888), ('denmark', 0.885), ('lake', 0.884), ('wales', 0.882), ('aalborg', 0.882), ('ankara', 0.88)]
war     -> [('battles', 0.906), ('civil', 0.899), ('soldiers', 0.871), ('leaders', 0.871), ('confederacy', 0.869), ('nazi', 0.852), ('fought', 0.847), ('declared', 0.845)]
water   -> [('polar', 0.929), ('fresh', 0.914), ('temperatures', 0.908), ('temperature', 0.903), ('low', 0.9), ('snow', 0.885), ('alkanes', 0.868), ('chain', 0.866)]
music   -> [('cinema', 0.886), ('pop', 0.86), ('themes', 0.859), ('directors', 0.833), ('entertainment', 0.833), ('chart', 0.833), ('literature', 0.83), ('database', 0.829)]
\nCompara estos vecinos con los de gensim sobre el corpus completo.
¿Qué explica la diferencia: el algoritmo, el volumen de datos, las épocas
o los detalles de implementación?

Parte 10 · Guardar y entregar

In [ ]:
# Guardar el modelo completo (se puede seguir entrenando) y solo los vectores (más ligero)
modelo.save("word2vec_text8.model")
modelo.wv.save_word2vec_format("vectores_text8.txt", binary=False)

import os
for f in ["word2vec_text8.model", "vectores_text8.txt"]:
    print(f, "->", f"{os.path.getsize(f)/1e6:.1f} MB")

# Descomenta para descargar los vectores a tu ordenador:
# from google.colab import files
# files.download("vectores_text8.txt")

# Cómo recargarlos después:
# from gensim.models import KeyedVectors
# kv = KeyedVectors.load_word2vec_format("vectores_text8.txt", binary=False)
word2vec_text8.model -> 59.5 MB
vectores_text8.txt -> 83.3 MB

Comprobaciones antes de entregar

  • Todas las celdas se ejecutan de arriba abajo sin errores (Entorno de ejecución → Reiniciar y ejecutar todo).
  • Todas las verificaciones automáticas devuelven [OK] ([PENDIENTE] indica ejercicio sin hacer).
  • Las ocho preguntas de reflexión están respondidas en la Parte 9.
  • Las gráficas se ven en el notebook guardado.
  • El archivo se llama word2vec_APELLIDO_NOMBRE.ipynb.

Entrega: Archivo → Descargar → Descargar .ipynb y subida al aula virtual.


Referencias

  • Mikolov et al. (2013), Efficient Estimation of Word Representations in Vector Space — el artículo original.
  • Mikolov et al. (2013), Distributed Representations of Words and Phrases — negative sampling y subsampling.
  • Goldberg & Levy (2014), word2vec Explained — la derivación matemática paso a paso.
  • Levy & Goldberg (2014), Neural Word Embedding as Implicit Matrix Factorization — por qué SGNS ≈ factorizar PMI.
  • Bolukbasi et al. (2016), Man is to Computer Programmer as Woman is to Homemaker — sesgos.
  • Continuación recomendada: FastText (n-gramas de caracteres, maneja palabras fuera de vocabulario y morfología) y, después, embeddings contextuales.

Nota sobre reproducibilidad

Aunque fijemos seed, gensim solo es determinista con workers=1 (con varios hilos el orden de las actualizaciones asíncronas varía). Con workers=2 verás pequeñas diferencias entre ejecuciones: eso es esperado y no es un error tuyo. Si necesitas resultados exactamente reproducibles, usa workers=1 (mucho más lento).