Los sistemas de recomendación son un rompecabezas en la IA que parecen fáciles al principio. Sin embargo, la complejidad llega rápidamente cuando intentas ponerlos en producción. Puedes crear un modelo colaborativo básico en unas pocas horas, pero hacer que funcione de verdad con usuarios nuevos, datos escasos y cambios en el comportamiento es donde la mayoría de los proyectos fracasan. Este artículo no es simplemente una introducción a los recommendation engines; es una guía práctica que necesitas al pasar del tutorial a un sistema real que debe servir a miles de usuarios con patrones impredecibles.
Photo: Steve A Johnson on Unsplash
Después de implementar sistemas de recomendación en e-commerce, edtech y fintech, he descubierto que lo más complicado no es el modelo base. La verdadera dificultad está en resolver el problema del cold start, manejar la degradación del modelo a lo largo del tiempo y analizar qué métricas realmente importan para predecir el engagement. Además, es crucial construir una arquitectura que no colapse cuando tu base de usuarios crece rápidamente. Vamos a desglosar todo esto.
El problema real: por qué collaborative filtering estándar fracasa en producción
La mayoría de los tutoriales te llevan directamente a la matrix factorization o al deep collaborative filtering. Implementas algo como ALS (Alternating Least Squares), entrenas con MovieLens y obtienes un RMSE atractivo. Pero, ojo, cuando lanzas tu modelo a producción, te das cuenta de que el 40% de tus usuarios son nuevos cada semana. Tu modelo, lamentablemente, no sabe qué recomendarles en ese caso.
El collaborative filtering puro tiene tres puntos ciegos críticos:
Cold start para usuarios nuevos: Sin historial de interacciones, tu modelo devuelve recomendaciones basadas en popularidad o simplemente ruido aleatorio. Esto provoca una experiencia negativa en los primeros días del usuario, justo cuando él o ella deciden si vale la pena seguir utilizando la plataforma.
Cold start para ítems nuevos: Si decides lanzar un producto, curso o artículo nuevo, puede tardar semanas en conseguir suficientes interacciones para aparecer en las recomendaciones relevantes. Mientras tanto, solo aquellos que lo buscan explícitamente lo verán.
Sparsity extrema: Considera que tienes 100K usuarios y 10K ítems; tu matriz de interacciones tiene potencialmente 1B de celdas. Aunque haya algunos usuarios activos, rara vez superarás el 0.5% de densidad. Esto hace que el aprendizaje sea ruidoso y las recomendaciones se tornen repetitivas.
La solución no radica en desechar el collaborative filtering, sino en combinarlo con content-based filtering. Así, puedes crear un sistema híbrido que utilice características descriptivas en aquellos casos donde no hay suficiente señal de comportamiento.
Arquitectura híbrida: cuando dos modelos valen más que uno perfecto
Photo: Kevin Ku on Unsplash
Aquí presento una arquitectura que funciona en producción. No es la más sofisticada desde un punto de vista académico, pero es la que resiste cuando se enfrenta a usuarios reales:
# Capa 1: Content-based embedding model
# Entrena sobre features descriptivas (categorías, tags, metadatos)
class ContentEmbedding(tf.keras.Model):
def __init__(self, num_items, embedding_dim=128):
super().__init__()
self.item_embedding = tf.keras.layers.Embedding(
num_items,
embedding_dim,
embeddings_regularizer=tf.keras.regularizers.l2(1e-6)
)
self.dense1 = tf.keras.layers.Dense(256, activation='relu')
self.dense2 = tf.keras.layers.Dense(embedding_dim)
def call(self, item_features):
x = self.item_embedding(item_features['item_id'])
# Concatenar con features adicionales (categoría, precio, etc.)
if 'category' in item_features:
x = tf.concat([x, item_features['category_embedding']], axis=-1)
x = self.dense1(x)
return self.dense2(x)
# Capa 2: Collaborative filtering model
class CollaborativeModel(tf.keras.Model):
def __init__(self, num_users, num_items, embedding_dim=128):
super().__init__()
self.user_embedding = tf.keras.layers.Embedding(
num_users,
embedding_dim,
embeddings_regularizer=tf.keras.regularizers.l2(1e-6)
)
self.item_embedding = tf.keras.layers.Embedding(
num_items,
embedding_dim,
embeddings_regularizer=tf.keras.regularizers.l2(1e-6)
)
self.user_bias = tf.keras.layers.Embedding(num_users, 1)
self.item_bias = tf.keras.layers.Embedding(num_items, 1)
def call(self, inputs):
user_vector = self.user_embedding(inputs['user_id'])
item_vector = self.item_embedding(inputs['item_id'])
dot_product = tf.reduce_sum(user_vector * item_vector, axis=1)
user_bias = tf.squeeze(self.user_bias(inputs['user_id']), axis=-1)
item_bias = tf.squeeze(self.item_bias(inputs['item_id']), axis=-1)
return dot_product + user_bias + item_bias
El truco radica en la capa de ensamblado. En vez de simplemente promediar scores o elegir uno de los modelos de acuerdo a reglas heurísticas, entrenas una tercera red que aprenderá cuándo confiar en cada señal:
class HybridRecommender(tf.keras.Model):
def __init__(self, content_model, collab_model):
super().__init__()
self.content_model = content_model
self.collab_model = collab_model
# Meta-learner que decide peso de cada modelo
self.mixer = tf.keras.Sequential([
tf.keras.layers.Dense(64, activation='relu'),
tf.keras.layers.Dropout(0.3),
tf.keras.layers.Dense(2, activation='softmax') # Pesos para content vs collab
])
def call(self, inputs, training=False):
content_score = self.content_model(inputs['item_features'])
collab_score = self.collab_model({
'user_id': inputs['user_id'],
'item_id': inputs['item_id']
})
# Features de contexto para el mixer
context = tf.concat([
inputs['user_interaction_count'], # Cuántas interacciones tiene el usuario
inputs['item_interaction_count'], # Cuántas interacciones tiene el ítem
inputs['days_since_signup']
], axis=-1)
weights = self.mixer(context, training=training)
final_score = (weights[:, 0] * content_score +
weights[:, 1] * collab_score)
return final_score
Esta arquitectura aprende automáticamente a usar el modelo content-based para usuarios nuevos (donde user_interaction_count es bajo) y va transicionando gradualmente hacia collaborative filtering a medida que acumulas más señal de comportamiento. En mi experiencia, en un catálogo de cursos online, esto redujo el tiempo hasta la primera conversión de nuevos usuarios de 8.3 días a 2.1 días.
Pipeline de datos: de eventos a entrenamiento en menos de 30 minutos
La ingeniería de datos es clave. Aquí es donde realmente se decide si tu sistema puede escalar o si va a naufragar. Necesitas un pipeline que sea capaz de procesar millones de eventos de interacción (views, clicks, purchases, ratings) y regenerar embeddings sin afectar las recomendaciones en producción.
import tensorflow as tf
import apache_beam as beam
from google.cloud import bigquery
import numpy as np
class InteractionProcessor(beam.DoFn):
"""Procesa eventos crudos y genera ejemplos de entrenamiento"""
def process(self, element):
user_id = element['user_id']
item_id = element['item_id']
timestamp = element['timestamp']
event_type = element['event_type']
# Generar implicit feedback basado en tipo de evento
weight_map = {
'view': 0.1,
'click': 0.3,
'add_to_cart': 0.6,
'purchase': 1.0,
'rating_1': -0.5, # Rating bajo es señal negativa
'rating_5': 1.0
}
weight = weight_map.get(event_type, 0.1)
# Generar ejemplos positivos y negativos
positive_example = {
'user_id': user_id,
'item_id': item_id,
'label': 1.0,
'weight': weight,
'timestamp': timestamp
}
yield positive_example
# Negative sampling: elegir ítems que el usuario NO interactuó
for _ in range(3): # 3 negativos por cada positivo
random_item = np.random.randint(0, self.num_items)
if random_item != item_id:
yield {
'user_id': user_id,
'item_id': random_item,
'label': 0.0,
'weight': 1.0,
'timestamp': timestamp
}
def build_training_pipeline():
"""Pipeline completo de BigQuery a TFRecords"""
pipeline_options = beam.options.pipeline_options.PipelineOptions(
project='your-project',
runner='DataflowRunner',
region='us-central1',
temp_location='gs://your-bucket/temp'
)
with beam.Pipeline(options=pipeline_options) as p:
interactions = (
p
| 'ReadFromBQ' >> beam.io.ReadFromBigQuery(
query="""
SELECT user_id, item_id, event_type, timestamp
FROM `project.dataset.user_events`
WHERE timestamp >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 90 DAY)
""",
use_standard_sql=True
)
| 'ProcessInteractions' >> beam.ParDo(InteractionProcessor())
| 'ShuffleExamples' >> beam.Reshuffle()
| 'WriteToTFRecord' >> beam.io.WriteToTFRecord(
'gs://your-bucket/training-data/interactions',
coder=beam.coders.ProtoCoder(tf.train.Example)
)
)
Este pipeline se ejecuta cada seis horas en Dataflow. Lo curioso es que el truco del negative sampling es clave; sin ejemplos negativos, tu modelo termina prediciendo una alta probabilidad para todo y las recomendaciones se vuelven inútiles. La proporción 1:3 (positivos a negativos) suele funcionar bien en la mayoría de los casos, aunque en catálogos muy grandes he logrado incluso 1:10.
Las métricas que realmente predicen negocio (no RMSE)
Aquí está el gran error que cometen muchos equipos técnicos: optimizar para métricas offline que no están relacionadas con los resultados de negocio. RMSE, MAE, precision@k: todas son útiles durante el desarrollo, pero en producción solo importan las métricas que realmente afectan el revenue o el engagement.
Click-Through Rate (CTR) en las primeras 3 recomendaciones: Si tu homepage muestra cinco productos recomendados, lo que realmente importa es si el usuario hace clic en alguno de los primeros tres. Las posiciones cuatro y cinco rara vez son vistas. Por lo tanto, mide CTR@3, no el CTR general.
Time to First Conversion (TTFC): Este es el tiempo que tarda un usuario nuevo desde que se registra hasta que realiza su primera compra, suscripción o acción de valor. Un sistema de recomendación efectivo puede reducir este tiempo de manera significativa. En mi último proyecto de e-learning, logramos pasar de un TTFC de ocho días a 2.1 días optimizando específicamente para este indicador.
Diversity Score: Un modelo que solo recomienda los ítems más populares puede maximizar el CTR a corto plazo, pero a la larga, esto perjudica el engagement. Es recomendable medir cuántas categorías diferentes aparecen en tus diez mejores recomendaciones. Si el 80% de tus recomendaciones proviene de solo dos categorías cuando en total tienes 15, honestamente, estás dejando dinero sobre la mesa.
def calculate_diversity_score(recommendations, item_categories):
"""
Mide la diversidad categórica en recomendaciones
Args:
recommendations: Lista de item_ids recomendados
item_categories: Dict que mapea item_id a category_id
Returns:
Diversity score entre 0 y 1
"""
categories_seen = set()
for item_id in recommendations[:10]: # Top-10 recommendations
categories_seen.add(item_categories.get(item_id))
# Normalizar por número total de categorías
return len(categories_seen) / len(set(item_categories.values()))
def evaluate_production_metrics(model, test_users, items_catalog):
"""Métricas que realmente importan en producción"""
metrics = {
'ctr_at_3': [],
'diversity_scores': [],
'coverage': set() # Qué % del catálogo se