178 KiB
178 KiB
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... |
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_vectoresIn [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
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()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()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_knnIn [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
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_prefiltradoIn [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)
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)
------------------------------------------------------------------------------
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 ==========================================================================