Files
IA-Para-Aplicaciones-del-Mu…/Labs/Lab2.ipynb
T
2026-08-26 14:28:22 -06:00

178 KiB

Tarea: Embeddings, Búsqueda Semántica y Self-Query

Universidad Galileo — IA para Aplicaciones del Mundo Real Unidad 10 · Embeddings y Búsqueda Semántica

Qué vas a construir

Un buscador sobre un catálogo de películas que entiende lo que le pides aunque no uses sus palabras, y que además sabe separar la parte semántica de la parte que es un filtro.

Al terminar vas a tener medido, con tus propios números:

  1. qué le pasa al coseno con textos parecidos, opuestos y sin relación,
  2. si los grupos semánticos aparecen solos al proyectar a 2D,
  3. cómo se busca por vecinos más cercanos,
  4. por qué el orden entre filtrar y buscar cambia el resultado,
  5. y cómo un LLM parte una pregunta en query + filtros.

Lo que ya está hecho (no lo toques)

  • El catálogo de películas con su metadata
  • La carga del modelo de embeddings y del LLM
  • Todas las gráficas
  • El reporte final

Lo que tienes que implementar

Seis bloques marcados con # TU CODIGO AQUI. Cada uno va seguido de una celda de verificación con assert: si pasa, puedes seguir.

Cómo se entrega

Entorno de ejecución → Reiniciar y ejecutar todo, y que corra de principio a fin sin errores. Las tres preguntas escritas cuentan igual que el código: se responden en la celda de texto que está debajo de cada una, con los números que tú mediste.

El catálogo está en inglés a propósito. El modelo all-MiniLM-L6-v2 es un modelo de inglés y queremos medir el comportamiento del embedding, no pelear con el idioma. Tu código, tus comentarios y tus respuestas van en español.


0. Preparación (dado)

In [1]:
try:
    import sentence_transformers, sklearn  # noqa: F401
except ImportError:
    %pip install -q sentence-transformers scikit-learn

import json, re, textwrap
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sentence_transformers import SentenceTransformer

OKABE = ["#0072B2", "#D55E00", "#009E73", "#CC79A7", "#E69F00", "#56B4E9", "#F0E442"]
np.random.seed(0)

modelo = SentenceTransformer("all-MiniLM-L6-v2")
print("modelo cargado ·", modelo.get_sentence_embedding_dimension(), "dimensiones")
/usr/local/lib/python3.12/dist-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html
  from .autonotebook import tqdm as notebook_tqdm
/usr/local/lib/python3.12/dist-packages/torch/cuda/__init__.py:1007: UserWarning: Can't initialize NVML
  raw_cnt = _raw_device_count_nvml()
Warning: You are sending unauthenticated requests to the HF Hub. Please set a HF_TOKEN to enable higher rate limits and faster downloads.
Loading weights: 100%|██████████| 103/103 [00:00<00:00, 22767.50it/s]
modelo cargado · 384 dimensiones
/tmp/ipykernel_304/1546073338.py:16: FutureWarning: The `get_sentence_embedding_dimension` method has been renamed to `get_embedding_dimension`.
  print("modelo cargado ·", modelo.get_sentence_embedding_dimension(), "dimensiones")
In [2]:
# ---- el catálogo (dado) ----
CATALOGO = [
 # --- science fiction ---
 ("Echoes of Tomorrow","science fiction",2019,7.8,124,"English","A physicist discovers that every choice she makes splits reality into a new timeline."),
 ("The Last Signal","science fiction",2021,8.1,138,"English","Astronauts receive a transmission from a probe that was lost forty years earlier."),
 ("Silicon Dawn","science fiction",2016,6.9,111,"English","An engineer realizes the assistant she built has started rewriting its own goals."),
 ("Orbital Decay","science fiction",2023,7.2,129,"English","A repair crew is stranded when their station begins falling out of orbit."),
 ("Paper Suns","science fiction",1998,7.5,142,"Japanese","In a city under an artificial sky, a technician questions who controls the weather."),
 ("Vanishing Point Nine","science fiction",2012,6.4,98,"English","A pilot keeps waking up on the same doomed flight with slightly different crew."),
 ("The Copenhagen Protocol","science fiction",2020,8.4,151,"English","Two scientists must decide whether to publish a discovery that could end scarcity."),
 ("Grain of the Void","science fiction",2005,7.0,117,"French","A cartographer maps a region of space where distance stops behaving normally."),
 ("Third Body Problem","science fiction",2024,8.7,161,"English","First contact arrives as a mathematical proof nobody on Earth can finish."),
 # --- horror ---
 ("The Quiet Floor","horror",2018,6.8,96,"English","A night nurse notices that one hospital wing is never listed on any schedule."),
 ("Hollow Season","horror",2022,7.4,104,"English","A family returns to a lake house where the water level never changes."),
 ("Salt and Ash","horror",2015,6.1,88,"Spanish","A fishing village burns its records every decade and no one remembers why."),
 ("Static Hour","horror",2009,5.7,92,"English","A radio host starts receiving calls from listeners who died years earlier."),
 ("The Lending Library","horror",2023,7.9,109,"Korean","Every book returned to this library comes back with an extra chapter."),
 ("Beneath the Orchard","horror",2001,6.5,101,"English","Two brothers dig up their family's land and find the harvest was never plants."),
 ("Nine Nights of Rain","horror",2020,7.1,113,"Japanese","A storm traps a film crew in a shrine that appears on no map."),
 ("The Long Hallway","horror",2013,6.3,94,"English","A hotel corridor gets longer every night and the guests stop leaving."),
 ("Cellar Door","horror",2024,7.6,99,"Spanish","A locked basement in a new house answers when someone knocks twice."),
 # --- comedy ---
 ("Tax Season","comedy",2017,7.3,95,"English","An auditor and a con artist accidentally swap client folders and lives."),
 ("The Understudy","comedy",2021,6.6,102,"English","A stagehand is forced on stage and turns out to be a much better lead."),
 ("Wedding by Committee","comedy",2013,6.2,108,"Spanish","Four siblings plan their mother's third wedding with four incompatible visions."),
 ("Return Policy","comedy",2019,7.7,91,"English","A store clerk tries to return a decision he made fifteen years ago."),
 ("Two Left Feet","comedy",2004,6.0,97,"English","A dance instructor who cannot dance must win a competition to save the studio."),
 ("The Group Project","comedy",2023,7.5,88,"English","Five strangers must finish an assignment none of them signed up for."),
 ("Neighbors Downstairs","comedy",2011,6.7,99,"French","A composer and a drummer wage a polite war through a very thin ceiling."),
 ("Overqualified","comedy",2022,7.1,94,"English","A retired surgeon takes a job at a coffee shop and cannot stop diagnosing customers."),
 ("The Reunion Committee","comedy",2008,6.4,105,"English","Old classmates discover none of them remembers high school the same way."),
 # --- documentary ---
 ("The Long Count","documentary",2018,8.2,118,"English","Statisticians spend a decade recounting a census nobody believed the first time."),
 ("Cold Storage","documentary",2020,7.9,96,"English","Inside the seed vaults built to outlive the institutions that funded them."),
 ("Paper Trails","documentary",2016,8.0,124,"English","How a single misfiled form changed immigration policy for a generation."),
 ("The Repair Shop Wars","documentary",2022,7.4,88,"English","Volunteers fight manufacturers for the right to fix what they already own."),
 ("Salt Roads","documentary",2014,8.5,132,"Spanish","Following the trade routes that shaped three continents, told through cooks."),
 ("Signal to Noise","documentary",2021,7.6,105,"English","Why most published findings in one field could not be reproduced."),
 ("The Quiet Grid","documentary",2023,8.3,110,"English","Engineers keep a national power grid stable with tools older than they are."),
 ("Two Degrees","documentary",2019,8.1,101,"French","Glaciologists drill ice cores that record every summer for eight hundred years."),
 ("The Last Mile","documentary",2024,7.7,93,"Korean","How packages actually reach a door, told by the people who carry them."),
 # --- drama ---
 ("Winter Term","drama",2015,8.1,127,"English","A teacher and a student both fail the same exam, for very different reasons."),
 ("The Inheritance Clause","drama",2019,7.8,141,"English","Three siblings discover their father left the estate to a stranger."),
 ("Low Tide","drama",2007,7.2,116,"English","A fishing family decides whether to sell the boat that defines them."),
 ("Letters Not Sent","drama",2021,8.4,134,"French","A widow finds forty years of letters her husband wrote but never mailed."),
 ("The Night Shift","drama",2018,7.5,109,"Spanish","Two hospital cleaners hold a friendship together across opposite schedules."),
 ("The Quietest Room","drama",2022,8.0,118,"Korean","A sound engineer loses her hearing and rebuilds her work from vibration."),
 ("The Understory","drama",2024,8.6,152,"English","A forest ranger and a logger discover they are protecting the same thing."),
 ("Second Language","drama",2020,7.9,113,"English","An interpreter starts changing what people say to keep a peace talk alive."),
 ("The Waiting List","drama",2017,8.2,138,"English","Two families are matched by an organ registry and must decide what to say."),
]
COLUMNAS = ["titulo","genero","anio","calificacion","duracion_min","idioma","sinopsis"]
df = pd.DataFrame(CATALOGO, columns=COLUMNAS)
GENEROS = sorted(df["genero"].unique())
COLOR_GENERO = {g: OKABE[i] for i, g in enumerate(GENEROS)}

print(f"{len(df)} películas · {len(GENEROS)} géneros")
print(df["genero"].value_counts().to_string())
df.head(3)
Out [2]:
45 películas · 5 géneros
genero
science fiction    9
horror             9
comedy             9
documentary        9
drama              9
titulo genero anio calificacion duracion_min idioma sinopsis
0 Echoes of Tomorrow science fiction 2019 7.8 124 English A physicist discovers that every choice she ma...
1 The Last Signal science fiction 2021 8.1 138 English Astronauts receive a transmission from a probe...
2 Silicon Dawn science fiction 2016 6.9 111 English An engineer realizes the assistant she built h...

Ejercicio 1 · Codificar y medir el parecido

Todo lo demás depende de estas dos funciones. codificar convierte una lista de textos en una matriz de vectores normalizados (norma 1), y coseno mide el parecido entre dos vectores.

Si los vectores están normalizados, el coseno es simplemente el producto punto. Aprovéchalo.

In [3]:
def codificar(textos):
    '''Devuelve un array (n, 384) con los vectores NORMALIZADOS de cada texto.'''
    # TU CODIGO AQUI
    # normalize_embeddings=True hace que el modelo devuelva cada vector ya dividido
    # por su propia norma, así el coseno se puede calcular como un simple producto punto.
    vectores_normalizados = modelo.encode(textos, normalize_embeddings=True)
    return vectores_normalizados


def coseno(a, b):
    '''Coseno entre dos vectores 1-D ya normalizados.'''
    # TU CODIGO AQUI
    # con vectores de norma 1, el coseno del ángulo entre ellos es exactamente su producto punto.
    coseno_entre_vectores = float(np.dot(a, b))
    return coseno_entre_vectores
In [4]:
# ---- verificación ----
_v = codificar(["a cat on a sofa", "a dog on a rug"])
assert _v.shape == (2, 384), f"esperaba (2, 384), obtuve {_v.shape}"
assert np.allclose(np.linalg.norm(_v, axis=1), 1.0, atol=1e-4), "los vectores no están normalizados"
assert abs(coseno(_v[0], _v[0]) - 1.0) < 1e-5, "el coseno de un vector consigo mismo debe ser 1"
assert -1.01 < coseno(_v[0], _v[1]) < 1.01, "el coseno debe caer en [-1, 1]"
print("✔ ejercicio 1 correcto")
✔ ejercicio 1 correcto

Ejercicio 2 · Parecidos, opuestos y sin relación

Aquí viene la parte interesante. La intuición dice:

familia coseno esperado
casi sinónimos cerca de +1
opuestos cerca de −1
sin relación cerca de 0

Mídelo y comprueba si es cierto. Completa la función que calcula el coseno de cada par y la media por familia. No cambies los pares.

In [5]:
PARES = {
 "casi sinónimos": [
   ("The movie was excellent", "The film was outstanding"),
   ("A large dog runs fast", "A big dog runs quickly"),
   ("She bought a car", "She purchased an automobile"),
   ("The plot is confusing", "The storyline is very confusing")],
 "opuestos": [
   ("The movie was excellent", "The movie was terrible"),
   ("The room is very hot", "The room is very cold"),
   ("He always tells the truth", "He always tells lies"),
   ("The product is cheap", "The product is expensive"),
   ("I love this restaurant", "I hate this restaurant")],
 "negación": [
   ("The cat is sleeping", "The cat is not sleeping"),
   ("This solution works", "This solution does not work")],
 "sin relación": [
   ("The movie was excellent", "Photosynthesis converts light into sugar"),
   ("A large dog runs fast", "The mortgage rate rose last quarter"),
   ("She bought a car", "Volcanic ash reached the stratosphere")],
}
In [6]:
def medir_familias(pares):
    '''Para cada familia devuelve (lista_de_cosenos, media).

    Devuelve un dict: {nombre_familia: (cosenos, media)}
    '''
    # TU CODIGO AQUI
    resultados_por_familia = {}
    for nombre_familia, lista_de_pares in pares.items():
        # separamos cada lista de tuplas (texto_a, texto_b) en dos listas paralelas
        # para poder codificar cada lado con una sola llamada al modelo
        textos_del_primer_elemento = [par[0] for par in lista_de_pares]
        textos_del_segundo_elemento = [par[1] for par in lista_de_pares]
        vectores_del_primer_elemento = codificar(textos_del_primer_elemento)
        vectores_del_segundo_elemento = codificar(textos_del_segundo_elemento)

        cosenos_de_la_familia = [
            coseno(vectores_del_primer_elemento[i], vectores_del_segundo_elemento[i])
            for i in range(len(lista_de_pares))
        ]
        media_de_la_familia = float(np.mean(cosenos_de_la_familia))
        resultados_por_familia[nombre_familia] = (cosenos_de_la_familia, media_de_la_familia)
    return resultados_por_familia


resultados = medir_familias(PARES)
for familia, (cs, m) in resultados.items():
    print(f"{familia:<16} media {m:+.3f}   rango [{min(cs):+.3f}, {max(cs):+.3f}]")
casi sinónimos   media +0.839   rango [+0.723, +0.935]
opuestos         media +0.812   rango [+0.703, +0.891]
negación         media +0.821   rango [+0.736, +0.905]
sin relación     media +0.034   rango [-0.007, +0.115]
In [7]:
# ---- verificación ----
assert set(resultados) == set(PARES), "faltan familias en el resultado"
for f, (cs, m) in resultados.items():
    assert len(cs) == len(PARES[f]), f"faltan cosenos en «{f}»"
    assert abs(m - np.mean(cs)) < 1e-9, f"la media de «{f}» no cuadra con sus cosenos"
assert resultados["sin relación"][1] < resultados["casi sinónimos"][1], \
    "lo no relacionado debería dar menos que lo sinónimo"
print("✔ ejercicio 2 correcto")
✔ ejercicio 2 correcto
In [8]:
# ---- gráfica (dada) ----
fig, ax = plt.subplots(figsize=(9, 4.5))
for i, (familia, (cs, m)) in enumerate(resultados.items()):
    ax.scatter(cs, [i] * len(cs), s=90, color=OKABE[i], zorder=3, alpha=0.85)
    ax.scatter([m], [i], marker="|", s=600, color="black", zorder=4)
ax.axvline(0, color="gray", lw=1, ls="--")
ax.set_yticks(range(len(resultados))); ax.set_yticklabels(list(resultados))
ax.set_xlim(-1.05, 1.05); ax.set_xlabel("coseno")
ax.set_title("Cada punto es un par; la barra negra es la media de la familia")
ax.grid(axis="x", alpha=0.3); ax.set_axisbelow(True)
for lado in ("top", "right"): ax.spines[lado].set_visible(False)
plt.tight_layout(); plt.show()

Pregunta escrita 1

Mira la gráfica y tus números, y responde con las cifras que mediste:

  1. ¿Se cumplió la tabla de arriba? ¿Cuál de las tres expectativas falló?
  2. ¿Qué coseno dio el par "The cat is sleeping" vs "The cat is not sleeping"? ¿Te parece razonable que dos frases que afirman lo contrario queden ahí?
  3. Un retriever de RAG devuelve los documentos con mayor coseno. Con lo que acabas de medir, ¿qué problema concreto tendría un sistema que busca "¿la política permite home office?" en un manual que dice "la política no permite home office"?

1. La tabla se cumplió solo a medias. "Casi sinónimos" (media +0.839) y "sin relación" (media +0.034) sí coinciden con lo esperado (cerca de +1 y cerca de 0). La que falló fue "opuestos": en vez de acercarse a −1 dio una media de +0.812, prácticamente igual que la de los sinónimos (+0.839). El modelo trata "excelente/terrible" o "caliente/frío" casi como si fueran lo mismo, no como contrarios.

2. El par "The cat is sleeping" vs "The cat is not sleeping" pertenece a la familia "negación", que dio media +0.821 con rango [+0.736, +0.905] (son solo 2 pares en esa familia, así que esos dos valores del rango son justamente los cosenos de los dos pares). O sea que ese par cayó en algún punto entre 0.74 y 0.90: alto en cualquier caso. No, no parece razonable — dos frases que afirman literalmente lo contrario deberían tener un coseno bajo o negativo, y en cambio el modelo las deja casi tan "parecidas" como dos sinónimos.

3. El problema es el mismo que se mide arriba: como "opuestos" y "negación" comparten casi todo el vocabulario y solo cambia una palabra que invierte el sentido, el coseno entre "la política permite home office" y "la política **no** permite home office" saldría alto (del orden de +0.8, según lo medido). Un retriever que ordena por coseno pondría ese documento en un lugar muy alto de la lista — es decir, respondería con alta confianza citando el párrafo que dice justo lo contrario de lo que preguntó la persona que le pregunta al RAG. El embedding por sí solo no distingue afirmación de negación; hace falta otra señal (re-ranking, lectura del pasaje, o un LLM que verifique el contenido) antes de confiar ciegamente en el coseno.


Ejercicio 3 · Reducción de dimensiones y clustering

Los vectores tienen 384 dimensiones. Para ver si hay grupos semánticos hay que proyectarlos a 2D.

Vas a hacer tres cosas:

  1. codificar las sinopsis del catálogo,
  2. proyectarlas a 2D con PCA y con t-SNE,
  3. agrupar con KMeans (sin decirle el género) y medir cuánto coincide con el género real.

Para medir la coincidencia usa pureza: para cada cluster, la fracción que representa su género más común; luego el promedio ponderado por tamaño. 1.0 = clusters perfectos.

In [9]:
from sklearn.decomposition import PCA
from sklearn.manifold import TSNE
from sklearn.cluster import KMeans

# 1) los vectores de las 45 sinopsis
# TU CODIGO AQUI  (usa codificar sobre df["sinopsis"])
V = codificar(df["sinopsis"].tolist())

# 2) proyecciones a 2D  (t-SNE: usa perplexity=8, random_state=0, init="pca")
# TU CODIGO AQUI
proyector_pca = PCA(n_components=2, random_state=0)
C_pca = proyector_pca.fit_transform(V)
# TU CODIGO AQUI
proyector_tsne = TSNE(n_components=2, perplexity=8, random_state=0, init="pca")
C_tsne = proyector_tsne.fit_transform(V)

# 3) clustering sobre los vectores COMPLETOS (384 dim), no sobre la proyección
# TU CODIGO AQUI  (KMeans con tantos clusters como géneros, random_state=0, n_init=10)
km = KMeans(n_clusters=len(GENEROS), random_state=0, n_init=10)
km.fit(V)
# TU CODIGO AQUI  (el cluster asignado a cada película)
etiquetas = km.labels_


def pureza(etiquetas, verdad):
    '''Promedio ponderado de la fracción del género dominante en cada cluster.'''
    # TU CODIGO AQUI
    etiquetas = np.asarray(etiquetas)
    verdad = np.asarray(verdad)
    total_de_aciertos_del_genero_dominante = 0
    for id_de_cluster in np.unique(etiquetas):
        # dentro de cada cluster, contamos cuántas veces aparece cada género real
        generos_dentro_del_cluster = verdad[etiquetas == id_de_cluster]
        _, conteo_por_genero = np.unique(generos_dentro_del_cluster, return_counts=True)
        # el "acierto óptimo" de un cluster es quedarse con su género más frecuente
        total_de_aciertos_del_genero_dominante += conteo_por_genero.max()
    return total_de_aciertos_del_genero_dominante / len(etiquetas)
In [10]:
# ---- verificación ----
for _n, _o in [("V", V), ("C_pca", C_pca), ("C_tsne", C_tsne), ("etiquetas", etiquetas)]:
    assert _o is not None, f"«{_n}» sigue en None: completa el ejercicio 3"
assert V.shape == (len(df), 384), f"V debería ser ({len(df)}, 384), es {V.shape}"
assert C_pca.shape == (len(df), 2) and C_tsne.shape == (len(df), 2), "las proyecciones deben ser 2D"
assert len(np.unique(etiquetas)) == len(GENEROS), "deberían salir tantos clusters como géneros"
assert abs(pureza([0, 0, 1, 1], ["a", "a", "b", "b"]) - 1.0) < 1e-9, "pureza perfecta debería dar 1.0"
assert abs(pureza([0, 0, 0, 0], ["a", "a", "b", "b"]) - 0.5) < 1e-9, "un solo cluster mitad y mitad = 0.5"

p = pureza(etiquetas, df["genero"].values)
print(f"✔ ejercicio 3 correcto · pureza de los clusters: {p:.3f}")
print(f"  (al azar sería aproximadamente {1/len(GENEROS):.3f})")
✔ ejercicio 3 correcto · pureza de los clusters: 0.422
  (al azar sería aproximadamente 0.200)
In [11]:
# ---- gráfica (dada) ----
fig, axes = plt.subplots(1, 3, figsize=(17, 5.5))
for ax, C, titulo in [(axes[0], C_pca, "PCA · coloreado por género REAL"),
                      (axes[1], C_tsne, "t-SNE · coloreado por género REAL")]:
    for g in GENEROS:
        idx = df.index[df["genero"] == g]
        ax.scatter(C[idx, 0], C[idx, 1], s=90, color=COLOR_GENERO[g], label=g,
                   edgecolors="white", linewidths=0.8)
    ax.set_title(titulo, fontsize=11); ax.set_xticks([]); ax.set_yticks([])
axes[0].legend(frameon=False, fontsize=8.5)

for c in range(len(GENEROS)):
    idx = np.where(etiquetas == c)[0]
    axes[2].scatter(C_tsne[idx, 0], C_tsne[idx, 1], s=90, color=OKABE[c],
                    label=f"cluster {c}", edgecolors="white", linewidths=0.8)
axes[2].set_title(f"t-SNE · coloreado por CLUSTER (pureza {p:.2f})", fontsize=11)
axes[2].set_xticks([]); axes[2].set_yticks([]); axes[2].legend(frameon=False, fontsize=8.5)
for ax in axes:
    for lado in ("top", "right", "left", "bottom"): ax.spines[lado].set_visible(False)
plt.tight_layout(); plt.show()

Ejercicio 4 · Búsqueda semántica por vecinos más cercanos

Con los vectores ya calculados, buscar es ordenar por coseno. Implementa buscar_knn.

In [12]:
def buscar_knn(consulta, k=5, subconjunto=None):
    '''Devuelve una lista de (indice, puntaje) con los k más parecidos a la consulta.

    subconjunto: si se pasa una lista de índices, busca SOLO entre esos.
                 Si se pasa una lista VACÍA, devuelve [] (ojo: `if subconjunto:`
                 también es falso para [], y ahí buscarías en todo el catálogo).

    Devuelve el índice como `int` de Python y el puntaje como `float`, NO como
    tipos de numpy: `np.argsort` te da `np.int64`, que no es un `int` y rompe
    la verificación. Castea con int(...) y float(...).
    '''
    # TU CODIGO AQUI
    # comprobamos con `is not None` (no con `if subconjunto:`) porque una lista vacía
    # es un subconjunto válido: significa "no busques en nada", y debe devolver []
    if subconjunto is not None:
        if len(subconjunto) == 0:
            return []
        indices_candidatos = list(subconjunto)
    else:
        indices_candidatos = list(range(len(df)))

    vector_de_la_consulta = codificar([consulta])[0]
    vectores_de_los_candidatos = V[indices_candidatos]
    # como ambos lados están normalizados, el producto punto ya es el coseno
    puntajes_de_similitud = vectores_de_los_candidatos @ vector_de_la_consulta

    # argsort ordena de menor a mayor; lo invertimos para tener los más parecidos primero
    posiciones_ordenadas_de_mayor_a_menor = np.argsort(puntajes_de_similitud)[::-1][:k]

    resultados_knn = [
        (int(indices_candidatos[posicion]), float(puntajes_de_similitud[posicion]))
        for posicion in posiciones_ordenadas_de_mayor_a_menor
    ]
    return resultados_knn
In [13]:
# ---- verificación ----
r = buscar_knn("a story about space and astronauts", k=3)
assert len(r) == 3, f"esperaba 3 resultados, obtuve {len(r)}"
assert all(isinstance(i, int) for i, _ in r), \
    "los índices deben ser int de Python, no np.int64: castea con int(...)"
assert r[0][1] >= r[1][1] >= r[2][1], "los resultados deben venir ordenados de mayor a menor"
assert df.loc[r[0][0], "genero"] == "science fiction", \
    f"el primero debería ser de ciencia ficción, salió «{df.loc[r[0][0], 'genero']}»"
assert buscar_knn("anything", k=3, subconjunto=[0, 1])[0][0] in (0, 1), "no respetó el subconjunto"
assert buscar_knn("anything", k=3, subconjunto=[]) == [], "con subconjunto vacío devuelve []"
print("✔ ejercicio 4 correcto")
for i, s in buscar_knn("a story about space and astronauts", k=4):
    print(f"   {s:.3f}  [{df.loc[i,'genero']:<16}] {df.loc[i,'titulo']}")
✔ ejercicio 4 correcto
   0.454  [science fiction ] The Last Signal
   0.351  [science fiction ] Orbital Decay
   0.326  [science fiction ] Vanishing Point Nine
   0.253  [science fiction ] Third Body Problem

Ejercicio 5 · Filtros de metadata: el orden importa

Una consulta como "películas de terror posteriores a 2018" tiene dos partes: una semántica (terror) y una que es un filtro (anio > 2018).

Hay dos formas de combinarlas, y no dan lo mismo:

  • post-filtrado — buscas los k mejores y después descartas los que no cumplen,
  • pre-filtrado — te quedas primero con los que cumplen y buscas solo ahí.

Implementa las tres funciones y comprueba la diferencia.

In [14]:
OPERADORES = {
    "eq":  lambda v, o: v == o,
    "ne":  lambda v, o: v != o,
    "gt":  lambda v, o: v >  o,
    "gte": lambda v, o: v >= o,
    "lt":  lambda v, o: v <  o,
    "lte": lambda v, o: v <= o,
}

def aplicar_filtros(filtros):
    '''Devuelve la lista de índices del catálogo que cumplen TODOS los filtros.

    filtros: [{"campo": "anio", "op": "gt", "valor": 2018}, ...]
    Si la lista está vacía, devuelve todos los índices.
    '''
    # TU CODIGO AQUI
    if not filtros:
        return list(df.index)
    indices_que_cumplen_todos_los_filtros = [
        indice for indice in df.index
        if all(
            OPERADORES[filtro["op"]](df.loc[indice, filtro["campo"]], filtro["valor"])
            for filtro in filtros
        )
    ]
    return indices_que_cumplen_todos_los_filtros


def buscar_postfiltro(consulta, filtros, k=5):
    '''Busca los k mejores en TODO el catálogo y luego descarta los que no cumplen.'''
    # TU CODIGO AQUI
    # primero buscamos sin mirar los filtros...
    mejores_k_de_todo_el_catalogo = buscar_knn(consulta, k=k)
    indices_que_pasan_el_filtro = set(aplicar_filtros(filtros))
    # ...y después descartamos los que no cumplen: por eso puede devolver menos de k
    resultado_postfiltrado = [
        (indice, puntaje) for indice, puntaje in mejores_k_de_todo_el_catalogo
        if indice in indices_que_pasan_el_filtro
    ]
    return resultado_postfiltrado


def buscar_prefiltro(consulta, filtros, k=5):
    '''Filtra primero y busca los k mejores solo dentro de lo que quedó.'''
    # TU CODIGO AQUI
    # primero nos quedamos solo con lo que cumple el filtro...
    indices_que_pasan_el_filtro = aplicar_filtros(filtros)
    # ...y buscamos los k mejores SOLO ahí adentro: por eso nunca devuelve menos que el post-filtrado
    resultado_prefiltrado = buscar_knn(consulta, k=k, subconjunto=indices_que_pasan_el_filtro)
    return resultado_prefiltrado
In [15]:
# ---- verificación ----
assert len(aplicar_filtros([])) == len(df), "sin filtros deben pasar todas"
f_terror = [{"campo": "genero", "op": "eq", "valor": "horror"}]
assert len(aplicar_filtros(f_terror)) == (df["genero"] == "horror").sum(), "filtro de género mal"
f_dos = f_terror + [{"campo": "anio", "op": "gt", "valor": 2018}]
esperado = ((df["genero"] == "horror") & (df["anio"] > 2018)).sum()
assert len(aplicar_filtros(f_dos)) == esperado, "los filtros deben combinarse con AND"

post = buscar_postfiltro("a scary story in an old building", f_dos, k=5)
pre  = buscar_prefiltro ("a scary story in an old building", f_dos, k=5)
assert all(df.loc[i, "genero"] == "horror" and df.loc[i, "anio"] > 2018 for i, _ in post + pre), \
    "algún resultado no cumple los filtros"
assert len(pre) >= len(post), "el pre-filtrado nunca debería devolver menos que el post-filtrado"
print("✔ ejercicio 5 correcto")
print(f"   post-filtrado devolvió {len(post)} de {5} pedidos")
print(f"   pre-filtrado  devolvió {len(pre)} de {5} pedidos")
print(f"   (documentos que pasan el filtro: {len(aplicar_filtros(f_dos))})")
✔ ejercicio 5 correcto
   post-filtrado devolvió 2 de 5 pedidos
   pre-filtrado  devolvió 4 de 5 pedidos
   (documentos que pasan el filtro: 4)

Pregunta escrita 2

  1. ¿Cuántos resultados devolvió cada estrategia? ¿Por qué el post-filtrado devolvió menos?
  2. Inventa un filtro más selectivo (por ejemplo idioma == "Korean"), córrelo con las dos estrategias y pega aquí los números. ¿Cuántos resultados devuelve el post-filtrado?
  3. Si tu aplicación siempre necesita 5 resultados para armar el prompt del LLM, ¿cuál de las dos estrategias puedes usar? ¿Qué le pasa a la otra cuando el filtro es muy selectivo?

1. Con el filtro "horror posterior a 2018" (4 películas de 45 cumplen), el post-filtrado devolvió 2 de 5 pedidos y el pre-filtrado devolvió 4 de 5 pedidos. El post-filtrado devuelve menos porque primero busca los 5 vecinos más parecidos en todo el catálogo y después descarta los que no cumplen el filtro — de esos 5 solo 2 resultaron pertenecer también al grupo filtrado, y los otros 3 se pierden sin reemplazo. El pre-filtrado, en cambio, ya buscó únicamente entre las 4 películas que cumplen el filtro, así que devolvió las 4 que había disponibles (no puede devolver 5 porque solo existen 4 candidatos).

2. (pendiente de un número real — ver nota abajo) Un filtro más selectivo, por ejemplo idioma == "Korean", solo lo cumplen 3 películas de las 45 del catálogo (The Lending Library, The Last Mile, The Quietest Room), contra las 4 del filtro de horror. Con un filtro tan chico, el post-filtrado corre un riesgo real de devolver 0 resultados si ninguna de esas 3 películas queda entre los 5 vecinos más cercanos calculados sobre el catálogo completo. Para poner la cifra exacta que realmente salió, corre esto en una celda nueva y pásame el resultado:

f_korean = [{"campo": "idioma", "op": "eq", "valor": "Korean"}]
print(len(aplicar_filtros(f_korean)))
print(len(buscar_postfiltro("a story worth telling", f_korean, k=5)))
print(len(buscar_prefiltro("a story worth telling", f_korean, k=5)))

3. Si la aplicación siempre necesita 5 resultados para el prompt del LLM, hay que usar el pre-filtrado: mientras existan al menos 5 candidatos que cumplan el filtro, siempre entrega 5. El post-filtrado se degrada cuanto más selectivo es el filtro — ya con nuestro filtro de 4 películas de 45 devolvió solo 2 de 5, y con un filtro más chico (como el de idioma == "Korean", con solo 3 candidatos en todo el catálogo) puede llegar a devolver 0 de 5 si ninguno de esos candidatos aparece entre los vecinos más cercanos calculados sin filtro.


Ejercicio 6 · Self-query: que el LLM arme el filtro

Hasta aquí escribiste los filtros a mano. Un self-query retriever se los pide a un LLM: recibe la pregunta en lenguaje natural y devuelve query (para la búsqueda semántica) y filtros (para la metadata).

El modelo y la función generar ya están dados. Lo tuyo es el prompt y el parseo.

In [16]:
# ---- el LLM (dado) ----
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM

NOMBRE_LLM = "Qwen/Qwen2.5-1.5B-Instruct"
tok_llm = AutoTokenizer.from_pretrained(NOMBRE_LLM)
llm = AutoModelForCausalLM.from_pretrained(
    NOMBRE_LLM, torch_dtype=torch.float16 if torch.cuda.is_available() else torch.float32,
    device_map="auto" if torch.cuda.is_available() else None)
llm.eval()

def generar(prompt, max_new_tokens=220):
    msgs = [{"role": "user", "content": prompt}]
    texto = tok_llm.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True)
    ent = tok_llm(texto, return_tensors="pt").to(llm.device)
    with torch.no_grad():
        out = llm.generate(**ent, max_new_tokens=max_new_tokens, do_sample=False,
                           temperature=None, top_p=None, top_k=None,
                           pad_token_id=tok_llm.eos_token_id)
    return tok_llm.decode(out[0][ent["input_ids"].shape[1]:], skip_special_tokens=True).strip()

ESQUEMA = '''titulo        texto
anio          entero
genero        uno de: science fiction, horror, comedy, documentary, drama
calificacion  decimal de 0 a 10
duracion_min  entero
idioma        uno de: English, Spanish, Japanese, French, Korean'''
print("LLM listo ·", NOMBRE_LLM)
[transformers] `torch_dtype` is deprecated! Use `dtype` instead!
Loading weights: 100%|██████████| 338/338 [00:00<00:00, 2226.43it/s]
LLM listo · Qwen/Qwen2.5-1.5B-Instruct
In [17]:
def prompt_self_query(pregunta):
    '''Construye el prompt que le pide al LLM separar query y filtros.

    Tiene que: describir la tarea, incluir ESQUEMA, listar los operadores permitidos
    (eq, ne, gt, gte, lt, lte), pedir SOLO JSON con las llaves "query" y "filtros",
    y terminar con la pregunta del usuario.
    '''
    # TU CODIGO AQUI
    prompt_para_el_llm = f"""Eres un asistente que separa una pregunta sobre películas en dos \
partes: una consulta semántica ("query", lo que se busca por significado) y una lista de \
filtros exactos sobre la metadata del catálogo ("filtros").

El catálogo tiene estos campos:
{ESQUEMA}

Cada filtro es un objeto con las llaves "campo", "op" y "valor". Los operadores permitidos \
para "op" son: eq, ne, gt, gte, lt, lte.

Responde ÚNICAMENTE con un objeto JSON (sin explicaciones ni texto adicional) con esta forma:
{{"query": "<texto para buscar por significado, o vacío si no aplica>", "filtros": [{{"campo": "<un campo del esquema>", "op": "<uno de eq, ne, gt, gte, lt, lte>", "valor": <valor>}}]}}

Pregunta del usuario: {pregunta}"""
    return prompt_para_el_llm


def parsear_self_query(salida):
    '''Extrae el JSON de la respuesta del LLM y lo devuelve como dict.

    Tiene que ser tolerante: el modelo suele envolver el JSON en ```json ... ```
    o agregar texto antes y después. Si el JSON no se puede parsear, devuelve
    {"query": "", "filtros": []} en vez de lanzar excepción.

    Y tiene que VALIDAR: descarta los filtros cuyo "campo" no esté en COLUMNAS
    o cuyo "op" no esté en OPERADORES. Descarta sólo el filtro inválido, no la
    respuesta entera — el "query" se conserva. El LLM inventa campos que no
    existen; comprobarlos es tu trabajo, no el suyo.
    '''
    # TU CODIGO AQUI
    respuesta_por_defecto = {"query": "", "filtros": []}

    # buscamos el primer '{' hasta el último '}': así ignoramos las cercas ```json ... ```
    # y cualquier texto que el modelo agregue antes o después del JSON
    bloque_json_encontrado = re.search(r"\{.*\}", salida, re.DOTALL)
    if bloque_json_encontrado is None:
        return respuesta_por_defecto

    try:
        plan_bruto = json.loads(bloque_json_encontrado.group(0))
    except json.JSONDecodeError:
        return respuesta_por_defecto

    query_extraida = plan_bruto.get("query", "")
    filtros_sin_validar = plan_bruto.get("filtros", [])
    # el LLM puede inventar un "campo" o un "op" que no existen: descartamos SOLO
    # ese filtro (no toda la respuesta), porque el "query" sigue siendo válido
    filtros_validados = [
        filtro for filtro in filtros_sin_validar
        if filtro.get("campo") in COLUMNAS and filtro.get("op") in OPERADORES
    ]
    return {"query": query_extraida, "filtros": filtros_validados}
In [18]:
# ---- verificación (no llama al LLM: prueba tus funciones con casos fijos) ----
p = prompt_self_query("horror movies after 2018")
assert isinstance(p, str) and len(p) > 120, "el prompt parece demasiado corto"
assert "duracion_min" in p, "el prompt debe incluir el ESQUEMA"
assert "gte" in p, "el prompt debe listar los operadores permitidos"
assert "horror movies after 2018" in p, "el prompt debe terminar con la pregunta"

casos = [
  ('{"query": "space", "filtros": [{"campo": "anio", "op": "gt", "valor": 2018}]}', "space", 1),
  ('```json\n{"query": "", "filtros": []}\n```', "", 0),
  ('Claro, aquí tienes:\n{"query": "scary", "filtros": []}\nEspero que sirva.', "scary", 0),
  ('esto no es json', "", 0),
  ('{"query": "x", "filtros": [{"campo": "inventado", "op": "eq", "valor": 1}]}', "x", 0),
]
for bruto, q_esp, n_esp in casos:
    d = parsear_self_query(bruto)
    assert d["query"] == q_esp, f"query mal en: {bruto[:40]}… (esperaba «{q_esp}», dio «{d['query']}»)"
    assert len(d["filtros"]) == n_esp, f"filtros mal en: {bruto[:40]}… (esperaba {n_esp})"
print("✔ ejercicio 6 correcto")
✔ ejercicio 6 correcto
In [19]:
# ---- el sistema completo (dado): self-query + pre-filtrado + kNN ----
def buscar_self_query(pregunta, k=4, verbose=True):
    bruto = generar(prompt_self_query(pregunta))
    plan = parsear_self_query(bruto)
    if verbose:
        print(f"pregunta : {pregunta}")
        print(f"query    : «{plan['query']}»")
        print(f"filtros  : {plan['filtros']}")
    candidatos = aplicar_filtros(plan["filtros"])
    if verbose:
        print(f"pasan el filtro: {len(candidatos)} de {len(df)}")
    if plan["query"].strip() == "":
        elegidos = [(i, float("nan")) for i in candidatos[:k]]
    else:
        elegidos = buscar_knn(plan["query"], k=k, subconjunto=candidatos)
    for i, s in elegidos:
        r = df.loc[i]
        p = "" if np.isnan(s) else f"  {s:.3f}  "
        print(f"   {p}{r['titulo']}  ({r['anio']}, {r['genero']}, {r['calificacion']}, {r['idioma']})")
    return plan, elegidos

PREGUNTAS = [
    "horror movies released after 2018",
    "documentaries in Spanish",
    "a funny movie about work, shorter than 100 minutes",
    "highly rated science fiction about first contact",
]
for q in PREGUNTAS:
    buscar_self_query(q); print("-" * 78)
pregunta : horror movies released after 2018
query    : «horror»
filtros  : [{'campo': 'anio', 'op': 'gt', 'valor': 2018}]
pasan el filtro: 24 de 45
     0.246  Cellar Door  (2024, horror, 7.6, Spanish)
     0.200  Nine Nights of Rain  (2020, horror, 7.1, Japanese)
     0.177  The Understory  (2024, drama, 8.6, English)
     0.131  The Inheritance Clause  (2019, drama, 7.8, English)
------------------------------------------------------------------------------
pregunta : documentaries in Spanish
query    : «documentaries»
filtros  : [{'campo': 'idioma', 'op': 'eq', 'valor': 'Spanish'}]
pasan el filtro: 5 de 45
     0.195  Salt and Ash  (2015, horror, 6.1, Spanish)
     0.121  Cellar Door  (2024, horror, 7.6, Spanish)
     0.078  The Night Shift  (2018, drama, 7.5, Spanish)
     0.058  Salt Roads  (2014, documentary, 8.5, Spanish)
------------------------------------------------------------------------------
pregunta : a funny movie about work, shorter than 100 minutes
query    : «funny»
filtros  : [{'campo': 'genero', 'op': 'eq', 'valor': 'comedy'}, {'campo': 'duracion_min', 'op': 'lt', 'valor': 100}]
pasan el filtro: 6 de 45
     0.177  The Group Project  (2023, comedy, 7.5, English)
     0.077  Overqualified  (2022, comedy, 7.1, English)
     0.069  Tax Season  (2017, comedy, 7.3, English)
     0.060  Return Policy  (2019, comedy, 7.7, English)
------------------------------------------------------------------------------
pregunta : highly rated science fiction about first contact
query    : «first contact»
filtros  : [{'campo': 'genero', 'op': 'eq', 'valor': 'science fiction'}, {'campo': 'calificacion', 'op': 'gt', 'valor': 7}]
pasan el filtro: 6 de 45
     0.510  Third Body Problem  (2024, science fiction, 8.7, English)
     0.114  Paper Suns  (1998, science fiction, 7.5, Japanese)
     0.077  Echoes of Tomorrow  (2019, science fiction, 7.8, English)
     0.072  Orbital Decay  (2023, science fiction, 7.2, English)
------------------------------------------------------------------------------

Pregunta escrita 3

  1. De las cuatro preguntas, ¿en cuáles el LLM extrajo bien los filtros y en cuáles se equivocó? Pega los filtros que devolvió en el caso que peor salió.
  2. En "documentaries in Spanish" el query debería quedar prácticamente vacío. ¿Qué hace tu sistema cuando eso pasa, y por qué tiene sentido?
  3. Corre dos veces la misma pregunta. ¿Sale igual? Con lo que sabes del muestreo, ¿qué tendrías que validar siempre antes de mandarle los filtros a la base de datos?

1. De las cuatro preguntas, dos salieron bien y dos con filtros incompletos:

  • "horror movies released after 2018"filtros = [{"campo": "anio", "op": "gt", "valor": 2018}]. Le faltó el filtro de género (genero == "horror" quedó solo como parte del query). De los 4 resultados mostrados, 2 sí son horror (Cellar Door, Nine Nights of Rain) y 2 son drama (The Understory, The Inheritance Clause).
  • "documentaries in Spanish"filtros = [{"campo": "idioma", "op": "eq", "valor": "Spanish"}]. Este es el que peor salió: también le faltó el filtro de género (genero == "documentary"). De los 4 resultados, solo 1 (Salt Roads) es realmente un documental; los otros 3 (Salt and Ash, Cellar Door, The Night Shift) son horror y drama en español.
  • "a funny movie about work, shorter than 100 minutes"filtros = [{"campo": "genero", "op": "eq", "valor": "comedy"}, {"campo": "duracion_min", "op": "lt", "valor": 100}]. Correcto: los 4 resultados son comedias.
  • "highly rated science fiction about first contact"filtros = [{"campo": "genero", "op": "eq", "valor": "science fiction"}, {"campo": "calificacion", "op": "gt", "valor": 7}]. Correcto: los 4 resultados son ciencia ficción, con Third Body Problem (0.510) como el más parecido — la sinopsis literalmente habla de primer contacto.

2. En "documentaries in Spanish" el query que salió realmente fue "documentaries", no vacío como sería lo ideal (el filtro idioma == "Spanish" ya cubre todo lo relevante). Cuando el query sí queda vacío, buscar_self_query (ya dado) hace elegidos = [(i, float("nan")) for i in candidatos[:k]]: en vez de rankear por similitud, toma directamente los primeros k candidatos que pasaron el filtro y les pone un puntaje (nan). Tiene sentido porque sin una parte semántica no hay nada contra qué medir un coseno — lo único que importa en ese caso es el filtro exacto, así que ordenar por "similitud a la cadena vacía" sería arbitrario.

3. Como generar() usa do_sample=False (con temperature/top_p/top_k en None), la decodificación es greedy y determinista: corriendo la misma pregunta dos veces con el mismo modelo se obtiene exactamente la misma salida — el "muestreo" ni siquiera está activo aquí. Pero determinismo no es lo mismo que corrección: como se ve arriba, el LLM puede devolver un JSON sintácticamente válido, con campos y operadores que sí existen en COLUMNAS/OPERADORES (por lo que parsear_self_query no lo descarta), pero semánticamente incompleto (le faltó un filtro que un humano sí habría puesto). Antes de mandarle los filtros a una base de datos real habría que validar, además de lo que ya hace parsear_self_query, que el tipo del valor coincide con el campo (p. ej. anio debe ser un entero, no un string) y que, para campos categóricos como genero o idioma, el valor esté dentro de las categorías que lista el ESQUEMA — nada de esto se verifica todavía, y con muestreo activado (do_sample=True) sí podría fallar de esa forma en otra corrida.


Reporte final (dado)

In [20]:
print("=" * 74)
print("RESUMEN DE LA TAREA")
print("=" * 74)
print(f"catálogo               : {len(df)} películas, {len(GENEROS)} géneros")
print(f"dimensión del embedding: {V.shape[1]}")
print()
print("coseno medio por familia de pares:")
for f, (cs, m) in resultados.items():
    print(f"   {f:<16} {m:+.3f}")
print()
print(f"pureza de los clusters  : {pureza(etiquetas, df['genero'].values):.3f}  "
      f"(al azar {1/len(GENEROS):.3f})")
print()
ej = [{"campo": "genero", "op": "eq", "valor": "horror"},
      {"campo": "anio", "op": "gt", "valor": 2018}]
print(f"filtro de ejemplo       : terror posterior a 2018 → {len(aplicar_filtros(ej))} películas")
print(f"   post-filtrado k=5    : {len(buscar_postfiltro('a scary story', ej, k=5))} resultados")
print(f"   pre-filtrado  k=5    : {len(buscar_prefiltro('a scary story', ej, k=5))} resultados")
print("=" * 74)
==========================================================================
RESUMEN DE LA TAREA
==========================================================================
catálogo               : 45 películas, 5 géneros
dimensión del embedding: 384

coseno medio por familia de pares:
   casi sinónimos   +0.839
   opuestos         +0.812
   negación         +0.821
   sin relación     +0.034

pureza de los clusters  : 0.422  (al azar 0.200)

filtro de ejemplo       : terror posterior a 2018 → 4 películas
   post-filtrado k=5    : 2 resultados
   pre-filtrado  k=5    : 4 resultados
==========================================================================

Cómo se califica

Los 6 bloques de código, con sus verificaciones en verde 60 %
Las 3 preguntas escritas, respondidas con tus números 30 %
Que el notebook corra de principio a fin sin errores 10 %

Una respuesta escrita que no cite ninguna cifra medida por ti no cuenta.