machine-learningiapythontensorflowrecomendacionestutorial
En un artículo anterior explicamos la teoría detrás de los modelos Two-Tower: cómo representar usuarios y ítems como vectores en un espacio compartido, y por qué eso permite encontrar recomendaciones relevantes a escala. Ahora vamos a construir uno desde cero.
En este tutorial vas a entrenar un sistema de recomendación de películas completo usando TensorFlow Recommenders y el dataset MovieLens 100K. Al final tendrás un modelo que puede sugerir películas personalizadas para cada usuario y también encontrar películas similares basándose en patrones de gustos colectivos.
Qué necesitas: Python 3.11, pip, y aproximadamente 30 minutos.
El pipeline completo
Antes de escribir código, conviene tener una imagen mental de todo el proceso. Cada paso alimenta al siguiente: los datos crudos se convierten en vocabularios y tensores, el modelo aprende embeddings que luego sirven tanto para recomendaciones por usuario como para búsqueda de similares.
Instalar dependencias
Instala las librerías necesarias con pip. Si trabajas en un entorno virtual, actívalo antes:
bash
pip install tensorflow==2.15 tensorflow-recommenders==0.7.3 numpy pandasCompatibilidad con Python 3.11
TensorFlow Recommenders 0.7.3 está probado con Python 3.11 y TensorFlow 2.15. Si usas Python 3.12 o superior puedes encontrar problemas de compatibilidad con algunas dependencias internas. Se recomienda crear un entorno virtual dedicado para este proyecto:
python3.11 -m venv venv.Descargar y explorar MovieLens
MovieLens 100K es uno de los benchmarks más usados en sistemas de recomendación. Contiene 100,000 calificaciones de películas hechas por usuarios reales de la plataforma MovieLens entre 1943 y 1998. Puedes descargarlo directamente con Python:
python
import urllib.request
import zipfile
import os
# Descargar MovieLens 100K
url = "https://files.grouplens.org/datasets/movielens/ml-100k.zip"
urllib.request.urlretrieve(url, "ml-100k.zip")
with zipfile.ZipFile("ml-100k.zip", "r") as zip_ref:
zip_ref.extractall(".")
print("Descarga completa.")python
import pandas as pd
# Cargar ratings
ratings = pd.read_csv(
"ml-100k/u.data",
sep="\t",
names=["user_id", "movie_id", "rating", "timestamp"]
)
# Cargar información de películas
movies = pd.read_csv(
"ml-100k/u.item",
sep="|",
encoding="latin-1",
usecols=[0, 1],
names=["movie_id", "title"]
)
# Unir
df = ratings.merge(movies, on="movie_id")
df["user_id"] = df["user_id"].astype(str)
df["movie_id"] = df["movie_id"].astype(str)
print(df.head())Explora las estadísticas básicas del dataset:
python
print(f"Total de ratings: {len(df):,}")
print(f"Usuarios únicos: {df['user_id'].nunique():,}")
print(f"Películas en catálogo: {df['movie_id'].nunique():,}")
print()
print("Distribución de ratings:")
print(df['rating'].value_counts().sort_index())El dataset tiene exactamente las siguientes estadísticas:
| Métrica | Valor |
|---|---|
| Total de ratings | 100,000 |
| Usuarios únicos | 943 |
| Películas en catálogo | 1,682 |
| Rating 1 (no me gustó) | 6,110 |
| Rating 2 | 11,370 |
| Rating 3 (neutro) | 27,145 |
| Rating 4 | 34,174 |
| Rating 5 (me encantó) | 21,201 |
Preparar los datos
TensorFlow trabaja con
tf.data.Dataset, un formato eficiente que permite procesar datos en mini-batches sin cargar todo en memoria. También necesitamos vocabularios de usuarios y películas para que las capas StringLookup puedan convertir IDs de texto en índices enteros.python
import tensorflow as tf
import numpy as np
# Crear tf.data.Dataset desde el DataFrame
dataset = tf.data.Dataset.from_tensor_slices({
"user_id": df["user_id"].values,
"movie_title": df["title"].values,
})
# Vocabularios
user_ids = df["user_id"].unique().tolist()
movie_titles = df["title"].unique().tolist()
print(f"Vocabulario de usuarios: {len(user_ids)}")
print(f"Vocabulario de películas: {len(movie_titles)}")python
# División 80/20 (80k train, 20k test)
tf.random.set_seed(42)
shuffled = dataset.shuffle(100_000, seed=42, reshuffle_each_iteration=False)
train = shuffled.take(80_000)
test = shuffled.skip(80_000).take(20_000)
# Batching
train = train.batch(256).cache()
test = test.batch(256).cache()
print(f"Batches de entrenamiento: {len(list(train))}")
print(f"Batches de evaluación: {len(list(test))}")Feedback implícito vs. ratings explícitos
En este tutorial usamos todos los ratings como señal de interacción, sin importar si son 1 o 5 estrellas. Esto se llama feedback implícito: el modelo aprende a recomendar películas que el usuario vio y calificó, independientemente de si le gustaron. Un modelo de feedback explícito distinguiría entre ratings positivos y negativos. Para un primer modelo, el implícito es más simple y sorprendentemente efectivo.
Construir el modelo Two-Tower
El corazón del modelo son dos torres independientes: una para usuarios y otra para películas. Cada torre recibe un ID de texto y lo transforma en un vector de dimensión fija. La arquitectura de cada torre es la misma:
StringLookup → Embedding(32) → Dense(64, relu) → Dense(32).python
import tensorflow_recommenders as tfrs
class UserModel(tf.keras.Model):
def __init__(self, user_ids):
super().__init__()
self.user_lookup = tf.keras.layers.StringLookup(
vocabulary=user_ids, mask_token=None
)
self.user_embedding = tf.keras.layers.Embedding(
len(user_ids) + 1, 32
)
self.dense1 = tf.keras.layers.Dense(64, activation="relu")
self.dense2 = tf.keras.layers.Dense(32)
def call(self, user_id):
x = self.user_lookup(user_id)
x = self.user_embedding(x)
x = self.dense1(x)
return self.dense2(x)
class MovieModel(tf.keras.Model):
def __init__(self, movie_titles):
super().__init__()
self.movie_lookup = tf.keras.layers.StringLookup(
vocabulary=movie_titles, mask_token=None
)
self.movie_embedding = tf.keras.layers.Embedding(
len(movie_titles) + 1, 32
)
self.dense1 = tf.keras.layers.Dense(64, activation="relu")
self.dense2 = tf.keras.layers.Dense(32)
def call(self, movie_title):
x = self.movie_lookup(movie_title)
x = self.movie_embedding(x)
x = self.dense1(x)
return self.dense2(x)Estos modelos tienen los siguientes parámetros entrenables:
| Componente | Parámetros |
|---|---|
| User Tower | 34,400 |
| Movie Tower | 57,472 |
| Total | 91,872 |
¿Por qué subclassing y no Sequential?
La API de subclassing de Keras permite definir arquitecturas más flexibles que las capas
Sequential. En particular, StringLookupy Embedding tienen requisitos de inicialización (vocabulario, tamaño) que se pasan como argumentos al constructor, algo que no es posible con Sequential. Además, tfrs.Model —del que hereda el modelo principal— ya es una subclase de tf.keras.Model.Configurar la tarea de retrieval
TensorFlow Recommenders separa el entrenamiento en tareas. La tarea de retrieval calcula la pérdida usando in-batch negatives: para cada par (usuario, película) en el batch, todas las demás películas del batch se usan como negativos implícitos. Esto hace el entrenamiento muy eficiente.
python
# Dataset de películas para el índice de retrieval
movies_dataset = tf.data.Dataset.from_tensor_slices(movie_titles)
class MovielensModel(tfrs.Model):
def __init__(self):
super().__init__()
self.user_model = UserModel(user_ids)
self.movie_model = MovieModel(movie_titles)
self.task = tfrs.tasks.Retrieval(
metrics=tfrs.metrics.FactorizedTopK(
candidates=movies_dataset.batch(128).map(self.movie_model)
)
)
def compute_loss(self, features, training=False):
user_embeddings = self.user_model(features["user_id"])
movie_embeddings = self.movie_model(features["movie_title"])
return self.task(user_embeddings, movie_embeddings)Retrieval vs. Ranking
En un sistema de recomendación de producción existen dos etapas. La etapa de retrieval (o candidate generation) filtra el catálogo completo —a veces millones de ítems— hasta un conjunto pequeño de candidatos relevantes. La etapa de ranking toma esos candidatos y los ordena con un modelo más preciso que considera más señales (precio, contexto, historial detallado). Este tutorial implementa la etapa de retrieval.
Entrenar con early stopping
Entrenar demasiadas épocas hace que el modelo memorice el conjunto de entrenamiento en lugar de aprender patrones generales (overfitting). El early stopping detiene el entrenamiento automáticamente cuando la métrica de evaluación deja de mejorar, y restaura los pesos de la mejor época encontrada.
En el diagrama puedes ver cómo la métrica de validación deja de mejorar alrededor de la época 15, sigue igual por 3 épocas más (patience=3), y el entrenamiento se detiene en la época 18 restaurando automáticamente los pesos de la época 15.
python
model = MovielensModel()
model.compile(optimizer=tf.keras.optimizers.Adagrad(learning_rate=0.1))
early_stopping = tf.keras.callbacks.EarlyStopping(
monitor="val_factorized_top_k/top_100_categorical_accuracy",
patience=3,
mode="max",
restore_best_weights=True,
verbose=1,
)
history = model.fit(
train,
validation_data=test,
epochs=30,
callbacks=[early_stopping],
verbose=1,
)El entrenamiento real produjo los siguientes resultados:
| Métrica | Valor |
|---|---|
| Épocas entrenadas | 18 (early stopping en 15) |
| top_100_accuracy (val) | 20.52 % |
| top_50_accuracy (val) | 10.71 % |
| top_10_accuracy (val) | 1.70 % |
¿Cómo interpretar estas métricas?
top_100_accuracy de 20.52 % significa que en el 20.52 % de los casos, la película que el usuario realmente vio aparece entre las 100 primeras recomendaciones del modelo (de un catálogo de 1,682 películas). Para un modelo base sin características adicionales ni ajuste de hiperparámetros, es un resultado razonable como punto de partida.Generar recomendaciones
Para generar recomendaciones usamos un índice BruteForce: el modelo calcula el embedding de todos los ítems del catálogo, y para un usuario dado compara su embedding contra todos ellos con producto punto. El índice retorna los K más similares.
python
# Construir el índice BruteForce
index = tfrs.layers.factorized_top_k.BruteForce(model.user_model)
index.index_from_dataset(
movies_dataset.batch(128).map(
lambda title: (title, model.movie_model(title))
)
)
def recomendar(user_id: str, k: int = 5):
_, titles = index(tf.constant([user_id]))
print(f"\nRecomendaciones para usuario {user_id}:")
for title in titles[0, :k].numpy():
print(f" - {title.decode('utf-8')}")python
# Películas vistas por cada usuario (para contexto)
def peliculas_vistas(user_id: str, n: int = 3):
vistas = df[df["user_id"] == user_id].nlargest(n, "rating")["title"].tolist()
print(f"\nUsuario {user_id} calificó bien: {', '.join(vistas)}")
# Usuarios de ejemplo
for uid in ["42", "100", "7"]:
peliculas_vistas(uid)
recomendar(uid)Resultados reales del experimento:
| Usuario | Películas calificadas | Recomendaciones |
|---|---|---|
| 42 | E.T., Terminator 2, Sabrina | Land Before Time III, All Dogs Go to Heaven 2, Aladdin and the King of Thieves |
| 100 | Apostle, Sphere, Amistad | Star Kid, Truman Show, Big Bang Theory |
| 7 | Crumb, Vertigo, Jackie Chan | Delta of Venus, Collectionneuse La, Shadows |
Buscar películas similares
Los embeddings de películas codifican patrones de gusto colectivo: dos películas quedan cerca en el espacio vectorial si las mismas personas tienden a ver ambas, no necesariamente porque sean del mismo género. Podemos usar similitud coseno para encontrar las películas más cercanas a una dada.
python
# Extraer embeddings de todas las películas
all_titles = tf.constant(movie_titles)
all_embeddings = model.movie_model(all_titles)
# Normalizar para similitud coseno
norms = tf.norm(all_embeddings, axis=1, keepdims=True)
normalized = all_embeddings / norms
def peliculas_similares(title: str, k: int = 5):
# Buscar la película en el vocabulario
idx = movie_titles.index(title)
query = normalized[idx:idx+1] # (1, 32)
# Similitud coseno con todas las películas
similarities = tf.squeeze(query @ tf.transpose(normalized)) # (N,)
# Top-K excluyendo la propia película
top_indices = tf.argsort(similarities, direction="DESCENDING").numpy()
top_indices = [i for i in top_indices if i != idx][:k]
print(f"\nPelículas similares a '{title}':")
for i in top_indices:
sim = similarities[i].numpy()
print(f" {movie_titles[i]:<45} (similitud: {sim:.2f})")python
peliculas_similares("Star Wars (1977)")
peliculas_similares("Godfather, The (1972)")Resultados del experimento real:
| Película base | Similar | Similitud |
|---|---|---|
| Star Wars (1977) | Twelve Monkeys (1995) | 0.93 |
| Star Wars (1977) | Godfather, The (1972) | 0.87 |
| Star Wars (1977) | Star Trek: First Contact (1996) | 0.86 |
| Star Wars (1977) | Men in Black (1997) | 0.78 |
| Star Wars (1977) | Das Boot (1981) | 0.76 |
| Godfather, The (1972) | Star Wars (1977) | 0.87 |
| Godfather, The (1972) | Twelve Monkeys (1995) | 0.82 |
| Godfather, The (1972) | Das Boot (1981) | 0.81 |
| Godfather, The (1972) | Close Shave, A (1995) | 0.78 |
| Godfather, The (1972) | Trainspotting (1996) | 0.77 |
Fíjate en algo interesante: Star Wars y El Padrino son muy similares según el modelo (0.87), aunque son géneros completamente distintos (ciencia ficción vs. drama criminal). Esto no es un error. El filtrado colaborativo no compara géneros ni actores; compara perfiles de audiencia. Ambas películas las ven las mismas personas, aquellas con buen gusto cinematográfico que van más allá de un solo género. El modelo aprendió ese patrón latente sin que se lo dijéramos explícitamente.
Collaborative filtering vs. content-based filtering
Un sistema de content-based filtering recomendaría Star Wars si te gustó otra película de ciencia ficción. Un sistema de collaborative filteringcomo este recomienda lo que personas con gustos similares a los tuyos también disfrutaron. Ambos enfoques son complementarios y en producción se suelen combinar.
BruteForce vs FAISS: por qué importa la búsqueda
Hasta ahora usamos
BruteForce para buscar recomendaciones. Esto funciona bien con 1,664 películas, pero en producción los catálogos tienen millones de ítems. BruteForce compara el embedding del usuario contra cada uno de los ítems:eso escala linealmente y se vuelve demasiado lento.FAISS (Facebook AI Similarity Search) es una librería que resuelve exactamente este problema. Ofrece estructuras de datos optimizadas para búsqueda de vecinos cercanos en espacios de alta dimensión. Veamos cómo se compara:
bash
pip install faiss-cpupython
import faiss
import numpy as np
# Normalizar embeddings para similitud coseno
norms = np.linalg.norm(all_movie_embs, axis=1, keepdims=True)
normalized_embs = all_movie_embs / (norms + 1e-8)
user_norm = user_emb / (np.linalg.norm(user_emb) + 1e-8)
# --- FAISS IndexFlatIP (búsqueda exacta) ---
faiss_flat = faiss.IndexFlatIP(32) # 32 = dimensión del embedding
faiss_flat.add(normalized_embs)
scores, indices = faiss_flat.search(user_norm.reshape(1, -1), 10)
# --- FAISS IndexIVFFlat (búsqueda aproximada) ---
nlist = 10 # número de clusters
quantizer = faiss.IndexFlatIP(32)
faiss_ivf = faiss.IndexIVFFlat(quantizer, 32, nlist, faiss.METRIC_INNER_PRODUCT)
faiss_ivf.train(normalized_embs)
faiss_ivf.add(normalized_embs)
faiss_ivf.nprobe = 3 # buscar en 3 de 10 clusters
scores, indices = faiss_ivf.search(user_norm.reshape(1, -1), 10)
# --- FAISS HNSW (grafo de vecinos) ---
faiss_hnsw = faiss.IndexHNSWFlat(32, 32, faiss.METRIC_INNER_PRODUCT)
faiss_hnsw.add(normalized_embs)
scores, indices = faiss_hnsw.search(user_norm.reshape(1, -1), 10)Los resultados con nuestro dataset (1,664 películas, promedio de 1,000 queries):
| Método | Tiempo/query | Tipo | Coincidencia top-10 |
|---|---|---|---|
| TFRS BruteForce | 0.635 ms | Exacto | — |
| FAISS IndexFlatIP | 0.009 ms | Exacto | 7/10 |
| FAISS IndexIVFFlat | 0.003 ms | Aproximado | 7/10 |
| FAISS IndexHNSWFlat | 0.003 ms | Aproximado | 7/10 |
FAISS Flat es 70 veces más rápido que BruteForce y da resultados exactos. Los índices aproximados (IVF, HNSW) son 200 veces más rápidos con una coincidencia de 7/10 en el top-10, un trade-off excelente.
En resumen
En este tutorial construiste un sistema de recomendación completo desde cero:
- Descargaste y exploraste MovieLens 100K (100,000 ratings, 943 usuarios, 1,682 películas).
- Preparaste los datos como
tf.data.Datasetcon vocabularios y división 80/20. - Implementaste dos torres (UserModel y MovieModel) con 91,872 parámetros totales.
- Configuraste la tarea de retrieval con in-batch negatives y métricas FactorizedTopK.
- Entrenaste con early stopping, deteniéndote en la época 18 con los pesos de la época 15 (top_100: 20.52%).
- Generaste recomendaciones personalizadas con un índice BruteForce.
- Encontraste películas similares usando similitud coseno sobre embeddings normalizados.
- Comparaste BruteForce vs FAISS y viste mejoras de hasta 200x en velocidad de búsqueda.
Limitaciones de este modelo: el modelo actual solo usa IDs de usuario y película. Incorporar características adicionales (género, año, historial temporal) mejoraría significativamente la calidad de las recomendaciones.
Algo importante: lo que construimos aquí es solo la primera etapa de un sistema de recomendación real. En producción, este modelo funciona como candidate generation:su trabajo es reducir millones de ítems a unos cientos de candidatos relevantes. Después viene un second-pass ranker, un modelo más pesado que reordena esos candidatos considerando features más detalladas. Y antes de mostrar las recomendaciones al usuario, suele haber pasos adicionales: filtros de negocio, diversificación, reglas de frescura, y una multi-objective function donde cada coeficiente del polinomio altera el orden final en base a las necesidades del negocio (relevancia, frescura, rentabilidad, diversidad, etc.).
Cada una de esas etapas merece su propio tutorial. Y una vez que tienes el pipeline completo, necesitas medir si realmente mejora la experiencia del usuario:ahí es donde entra la experimentación con A/B testing.
Próximo paso: servir en producción con NVIDIA Triton
En un próximo artículo vamos a tomar este modelo de candidate generation, exportarlo como SavedModel, y servirlo en producción con NVIDIA Triton Inference Server para que pueda responder recomendaciones en tiempo real a través de una API HTTP/gRPC. Veremos cómo configurar el modelo ensemble, cómo medir la latencia P99, y qué ajustes son necesarios para pasar de un notebook de experimentación a un servicio que puede atender miles de solicitudes por segundo.