Cómo construir un sistema de recomendación Content-Based
En el primer artículo de esta serie vimos que un sistema de recomendación no es simplemente un modelo de Machine Learning, sino una…
Cómo construir un sistema de recomendación Content-Based
En el primer artículo de esta serie vimos que un sistema de recomendación no es simplemente un modelo de Machine Learning, sino una herramienta de decisión: nos ayuda a decidir qué mostrar, a quién, cuándo y con qué objetivo de negocio.
En el segundo artículo bajamos al terreno práctico y construimos recomendadores con Collaborative Filtering usando el dataset de Instacart y trabajando con dos enfoques clásicos: user-based e item-based.
Ese ejercicio nos dejó una conclusión importante: el Collaborative Filtering puede aportar personalización frente a recomendar lo mismo a todo el mundo, pero depende mucho de que existan suficientes interacciones históricas.
Y aquí aparece una de sus limitaciones: el cold start de producto.
Si un producto es nuevo o tiene pocas compras, un sistema puramente colaborativo tendrá dificultades para recomendarlo porque todavía no tiene suficiente comportamiento asociado.
Aquí es donde entran los recomendadores Content-Based.

Qué es un recomendador Content-Based
En Collaborative Filtering, dos productos son similares si muchos usuarios los compran juntos.
En Content-Based, dos productos son similares si sus características se parecen.
En nuestro caso, esas características vendrán del catálogo de Instacart de productos en grocery:
- Nombre del producto
- Pasillo
- Departamento
La idea para construir el recomendador Content-Based es la siguiente:
- Representamos cada producto como texto: nombre, pasillo y departamento
- Convertimos ese texto en vectores numéricos
- Construimos un perfil de usuario a partir de los productos que compró
- Recomendamos productos parecidos a ese perfil
Por ejemplo, si un usuario compra frutas, verduras orgánicas y snacks saludables, el recomendador construye un perfil de preferencias a partir de esos productos y busca otros artículos con descripciones parecidas.
La ventaja es que no necesitamos que dos productos hayan sido comprados por los mismos usuarios para considerarlos similares. Si el catálogo está bien descrito, podemos recomendar productos nuevos o con pocas interacciones.
La limitación es que el modelo tiende a recomendar productos parecidos a lo que el usuario ya consume, por lo que puede faltarle diversidad si no lo combinamos con otras señales.
Enfoques
En este artículo vamos a construir recomendadores Content-Based con dos enfoques:
TF-IDF
Es una técnica clásica de procesamiento de lenguaje natural (NLP) que convierte texto en vectores numéricos, asignando un peso a cada palabra dentro de un documento según su relevancia.
TF = Term Frequency → mide cuántas veces aparece una palabra en un documento
IDF = Inverse Document Frequency → mide lo especial o rara que es una palabra dentro de todos los documentos
Una palabra es importante para un documento si aparece en ese documento, pero no aparece en demasiados documentos del conjunto total.
Por eso TF-IDF es útil para encontrar palabras que realmente caracterizan un documento.
Embeddings
Los embeddings son una técnica de procesamiento de lenguaje natural que convierte texto en vectores numéricos, pero con un enfoque más semántico que TF-IDF.
Mientras que TF-IDF representa un documento a partir de las palabras que aparecen en él y de su relevancia dentro del conjunto de documentos, los embeddings intentan capturar el significado del texto. Es decir, no solo tienen en cuenta si dos textos comparten palabras exactas, sino también si expresan ideas parecidas.
Para ello, se utiliza un modelo previamente entrenado que recibe como entrada un texto y devuelve un vector denso (embeddings) que representa su significado dentro de un espacio vectorial.
Textos con significados similares quedan cerca entre sí en el espacio vectorial.
Por ejemplo, productos descritos como “cookies” y “biscuits” pueden quedar próximos aunque no usen exactamente la misma palabra, porque semánticamente hacen referencia a conceptos parecidos.
Por eso, los embeddings son especialmente útiles cuando queremos capturar similitudes más flexibles que las basadas únicamente en coincidencias de palabras.
Caso práctico: dataset y enfoque
Primero, como hicimos en el anterior artículo, filtramos los usuarios que se encuentran en train ya que son los que podremos validar.
Paso 1. Construir el catálogo enriquecido
Al ser un recomendador Content-Based, vamos a utilizar la información del catálogo.
Creamos el dataframe del catálogo enriquecido con el pasillo y la sección, además del nombre del producto.
df_catalog = df_products.merge(df_aisles, on="aisle_id", how="left")
df_catalog = df_catalog.merge(df_departments, on="department_id", how="left")
df_catalog.head()

Después creamos un texto compacto para cada producto:
def crear_texto_producto(catalog):
return (
catalog["product_name"].astype(str)
+ " aisle "
+ catalog["aisle"].astype(str)
+ " department "
+ catalog["department"].astype(str)
)
product_text = crear_texto_producto(df_catalog)

Este texto será la materia prima de nuestros recomendadores Content-Based.
Paso 2. Crear históricos de usuario
Al igual que en el artículo anterior, entrenamos con los pedidos históricos prior y reservamos train para evaluar.
Cada usuario queda representado por la lista de productos que compró en el pasado (user_histories).
df_interactions = df_orders.merge(
df_order_products_prior, on="order_id", how="inner"
)[["user_id", "product_id"]].drop_duplicates()
user_histories = (
df_interactions
.groupby("user_id")["product_id"]
.apply(lambda x: list(x.unique()))
.to_dict()
)
Este historial será la base para construir su perfil de contenido.
Paso 3. Recomendador Content-Based con TF-IDF
El primer enfoque será TF-IDF. TF-IDF convierte textos en vectores numéricos. Aquí nuestros “documentos” serán la descripción del producto.
Cada producto se convierte en un vector que representa las palabras más relevantes de su nombre, pasillo y departamento.
from sklearn.feature_extraction.text import TfidfVectorizer
import numpy as np
vectorizer = TfidfVectorizer(
lowercase=True, # convierte el texto a minúsculas
stop_words="english", # Elimina palabras comunes en inglés que aportan poco significado (the, and, of, in, etc.)
ngram_range=(1, 2), # Indica qué grupos de palabras se van a usar como características
max_features=30000, # Limita el número máximo de características, es decir, el tamaño del vocabulario TF-IDF
dtype=np.float32, # Guardamos los valores de la matriz TF-IDF como float32 en lugar de float64 para reducir el uso de memoria
sublinear_tf=True, # Usamos tf = 1 + log(tf) para calcular los pesos
norm="l2", # Normalizamos cada vector con L2
)
features_tfidf = vectorizer.fit_transform(product_text)
Algunos parámetros importantes:
-
**ngram_range=(1, 2)**: usamos palabras individuales (chocolate, cakes, green, fresh) y pares de palabras (chocolate sandwich, cat food, All-Seasons Salt, facial care). -
**max_features=30000**: limitamos el vocabulario para controlar la memoria. -
**sublinear_tf=True**: suavizamos la frecuencia de términos muy repetidos. -
**norm=”l2"**: dejamos los vectores preparados para similitud coseno.
Así, obtenemos una matriz dispersa (CSR: Compressed Sparse Row) de 49688 productos x 30000 features.
Definimos la función que construye el perfil de usuario, agregando los vectores TF-IDF de los productos que ya compró. Después, ya podemos recomendar a ese usuario:
from scipy.sparse import csr_matrix
from sklearn.preprocessing import normalize
def recommend_cb_tfidf(
user_product_ids,
product_features,
product_encoder,
idx_to_product,
top_k
):
# 1. Crear perfil del usuario a partir de los productos que ha comprado
product_indices = [
product_encoder[int(product_id)]
for product_id in user_product_ids
if int(product_id) in product_encoder
]
profile = csr_matrix(product_features[product_indices].sum(axis=0))
profile = normalize(profile, norm="l2", copy=False)
# 2. Calcular scores del producto a partir de la matriz tf-idf creada anteriormente
# Crea el vector que representa los gustos del usuario según los productos que ya compró
# Cada valor de scores es la similitud entre el usuario y un producto
scores = profile @ product_features.T
# Convierte los scores a un array plano de numpy
scores_array = scores.toarray().ravel()
# Si ya ha comprado el producto, le ponemos score -infinito
scores_array[product_indices] = -np.inf
# Filtramos productos válidos
valid_indices = np.flatnonzero(
np.isfinite(scores_array) & (scores_array > 0)
)
# Si no hay ninguno, no podemos recomendar
if valid_indices.size == 0:
return pd.DataFrame(columns=["product_id", "score"])
# Los productos a recomendar serán el mínimo entre top_k y el número de productos válidos
n_candidates = min(top_k, valid_indices.size)
# Tomamos los mejores n_candidates productos
top_positions = np.argpartition(
scores_array[valid_indices],
-n_candidates,
)[-n_candidates:]
top_indices = valid_indices[top_positions]
# Ordena los productos de mayor a menor score
top_indices = top_indices[
np.argsort(scores_array[top_indices])[::-1]
]
return pd.DataFrame(
{
"product_id": [
idx_to_product[int(idx)]
for idx in top_indices
],
"score": scores_array[top_indices],
}
).reset_index(drop=True)
Hay dos detalles importantes:
- No recomendamos productos que el usuario ya compró (como hicimos con los recomendadores Collaborative Filtering).
- El score representa la similitud entre el perfil del usuario y cada producto del catálogo.
Lo aplicamos para todos los usuarios:
from joblib import Parallel, delayed
from tqdm import tqdm
# Codificamos/Mapeamos los identificadores en índices para la matriz y viceversa
product_encoder = {
int(product_id): idx
for idx, product_id in enumerate(df_catalog["product_id"])
}
idx_to_product = {
idx: int(product_id)
for product_id, idx in product_encoder.items()
}
top_k = 10
def execution_cb_tfidf(uid, top_k):
recommendations = recommend_cb_tfidf(
user_product_ids=user_histories[int(uid)],
product_features=features_tfidf,
product_encoder=product_encoder,
idx_to_product=idx_to_product,
top_k=top_k,
)
# Añadimos el user_id en la primera columna
recommendations.insert(0, "user_id", int(uid))
return recommendations
recommendations_cb_tfidf = Parallel(n_jobs=8, batch_size=100)(
delayed(execution_cb_tfidf)(uid, top_k=top_k)
for uid in tqdm(users_id)
)
df_recommendations_cb_tfidf = pd.concat(
recommendations_cb_tfidf,
ignore_index=True,
)
Nota: esta implementación prioriza la claridad del proceso sobre la eficiencia. A medida que crece el volumen de usuarios o productos, el coste en tiempo y memoria aumenta significativamente, ya que estamos generando recomendaciones de forma iterativa para cada usuario.
En entornos reales, este tipo de cálculos se hacen por batches o de forma vectorizada sobre toda la matriz, utilizando enfoques más eficientes. Sin embargo, eso implica introducir técnicas más avanzadas que se salen del objetivo de este artículo, centrado en entender el funcionamiento del modelo y validar su comportamiento.
Añadimos información del producto:
df_recommendations_cb_tfidf = df_recommendations_cb_tfidf.merge(
df_catalog[["product_id", "product_name", "aisle", "department"]],
on="product_id",
how="left",
)

Vemos que el modelo está capturando una preferencia muy clara por productos frescos y orgánicos.
Pasamos ahora al recomendador Content-based con embeddings
Paso 4. Recomendador Content-Based con embeddings
Los embeddings permiten representar la descripción de cada producto como un vector numérico que captura parte de su significado semántico, utilizando para ello un modelo previamente entrenado.
Para este caso usamos un modelo preentrenado de sentence-transformers, concretamente all-MiniLM-L6-v2. El modelo recibe como entrada el texto de cada producto y devuelve un vector denso (embeddings) que representa semánticamente ese producto.
from sentence_transformers import SentenceTransformer
embedding_model_name = "sentence-transformers/all-MiniLM-L6-v2"
model_embeddings = SentenceTransformer(embedding_model_name)
product_text_embedding = product_text.tolist()
# Generamos los embeddings de producto
product_embeddings = model_embeddings.encode(
product_text_embedding,
normalize_embeddings=True,
show_progress_bar=True
)
product_embeddings = np.asarray(
product_embeddings,
dtype=np.float32 # Reducimos el uso de memoria guardandolo como float32
)
Después, el perfil del usuario se construye como la media de los embeddings de los productos que compró, creando un único vector que representa el perfil semántico del usuario:
def profile_user_embeddings(product_ids):
product_indices = [
product_encoder[int(product_id)]
for product_id in product_ids
if int(product_id) in product_encoder
]
# Calculamos la media
profile = product_embeddings[product_indices].mean(axis=0)
# Normalizamos
profile_norm = np.linalg.norm(profile)
return profile / profile_norm if profile_norm > 0 else profile
Y creamos la función para recomendar productos a un usuario calculando similitud entre el perfil y todos los productos del catálogo:
def recommend_cb_embeddings(uid, top_k):
# Creamos el perfil de usuario
profile = profile_user_embeddings(user_histories[int(uid)])
# Calculamos los scores de similitud
scores = product_embeddings @ profile
# Excluimos los productos ya comprados
seen_indices = [
product_encoder[int(product_id)]
for product_id in user_histories[int(uid)]
if int(product_id) in product_encoder
]
scores[seen_indices] = -np.inf
# Seleccionamos los productos válidos
# (los que no haya comprado el usuario y que tengan score positivo)
valid_indices = np.flatnonzero(
np.isfinite(scores) & (scores > 0)
)
if valid_indices.size == 0:
return pd.DataFrame(columns=["user_id", "product_id", "score"])
# Los productos a recomendar serán el mínimo entre top_k y el número de productos válidos
n_candidates = min(top_k, valid_indices.size)
# Tomamos los mejores n_candidates productos
top_positions = np.argpartition(
scores[valid_indices],
-n_candidates
)[-n_candidates:]
top_indices = valid_indices[top_positions]
# Ordenar los productos de mayor a menor score
top_indices = top_indices[np.argsort(scores[top_indices])[::-1]]
return pd.DataFrame(
{
"user_id": int(uid),
"product_id": [idx_to_product[int(idx)] for idx in top_indices],
"score": scores[top_indices],
}
)
Lo aplicamos para todos los usuarios:
from joblib import Parallel, delayed
from tqdm import tqdm
recommendations_embeddings = Parallel(n_jobs=8, batch_size=100)(
delayed(recommend_cb_embeddings)(uid, top_k=top_k)
for uid in tqdm(users_id)
)
df_recommendations_cb_embeddings = pd.concat(
recommendations_embeddings,
ignore_index=True,
)
# Añadimos información del producto
df_recommendations_cb_embeddings = df_recommendations_cb_embeddings.merge(
df_catalog[["product_id", "product_name", "aisle", "department"]],
on="product_id",
how="left",
)

La recomendación ya no se queda solo en productos frescos. Aparecen snacks de fruta y verdura, productos orgánicos y artículos semánticamente relacionados con el perfil.
Evaluación del sistema
Ahora necesitamos evaluar si nuestros recomendadores están generando buenas recomendaciones.
Hacemos una evaluación offline, no causal. Es decir, respondiendo a la pregunta:
Si hubiéramos recomendado este producto, ¿habría coincidido con la compra real del usuario?
Este tipo de evaluación nos permite comprobar si las recomendaciones se parecen a los productos que el usuario terminó comprando. Sin embargo, no nos permite medir si el recomendador cambia realmente el comportamiento del usuario, si aumenta las ventas o si ayuda a descubrir productos nuevos.
Para medir ese impacto real necesitaríamos una evaluación online, mediante A/B testing, analizando métricas como CTR, conversión, revenue u otros indicadores de negocio.
Para esta evaluación usamos el pedido train como ground truth.
train_orders = df_orders.loc[
df_orders["eval_set"] == "train",
["order_id", "user_id"]
].copy()
train_interactions = df_order_products_train.merge(
train_orders,
on="order_id",
how="inner",
)
ground_truth = train_interactions.groupby("user_id")["product_id"].agg(
lambda x: set(x.tolist())
).to_dict()
Las métricas que utilizaremos son las mismas que empleamos con los recomendadores basados en Collaborative Filtering, lo que nos permitirá comparar los resultados:
- Precision@k: De los
kproductos recomendados, cuántos resultaron relevantes (es decir, cuántos fueron finalmente comprados por el usuario). - Recall@k: De todos los productos relevantes (comprados por el usuario), cuántos hemos recomendado. Además de la métrica modificada en la que excluimos los productos ya comprados por el usuario para reflejar mejor el escenario real.
Se han comparado estos dos recomendadores con los Collaborative Filtering User-based e Item-based.

Cómo interpretar estos resultados
Antes de pensar que “el Content-Based funciona peor que Collaborative Filtering”, hay que entender que estamos evaluando con una compra futura real en grocery. Y este es un sector muy dominado por la recompra.
El usuario suele volver a comprar sus productos habituales, por eso, los modelos basados en comportamiento suelen tener ventaja en una validación offline de este tipo.
Nuestro Content-Based aporta una señal diferente, recomienda productos parecidos a los intereses históricos, pero que el usuario no había comprado antes.
Estos recomendadores son especialmente útiles en otros escenarios, ampliando la experiencia del usuario:
- descubrir productos nuevos
- activar catálogo con pocas ventas
- proponer sustitutos
- generar carruseles de “productos similares”
- resolver el cold start de producto
- aportar diversidad a los rankings
A partir de aquí, deberíamos construir un modelo híbrido que combine varios de los recomendadores anteriores. La idea es mezclar señales de comportamiento, como productos comprados por usuarios similares, con señales de contenido, como productos parecidos a los que el propio usuario ya ha comprado.
En el próximo y último artículo de esta serie, profundizaremos en este enfoque estratégico para concluir nuestro análisis.
Conclusión
En este artículo hemos construido dos recomendadores Content-Based: uno basado en TF-IDF y otro basado en embeddings. Ambos parten de la misma idea: representar cada producto por su contenido y construir un perfil de usuario a partir de los productos que ya compró.
El principal aprendizaje es que no existe un único “mejor” recomendador fuera de contexto.
- Los Collaborative Filtering y los baseline predicen mejor la siguiente compra en un entorno dominado por recompra
- El Content-Based aporta una señal diferente, especialmente útil para descubrimiento y cold start
Por eso, lo interesante es entender qué aporta cada señal y saber cómo combinarlas para diseñar una arquitectura de recomendación alineada con el objetivo de negocio.
메타데이터
- post_id
- 0b7deed7a67a
- slug
- cómo-construir-un-sistema-de-recomendación-content-based-0b7deed7a67a
- url
- https://medium.com/kraz-ai-solutions/c%C3%B3mo-construir-un-sistema-de-recomendaci%C3%B3n-content-based-0b7deed7a67a
- canonical_url
- https://medium.com/kraz-ai-solutions/c%C3%B3mo-construir-un-sistema-de-recomendaci%C3%B3n-content-based-0b7deed7a67a
- author_url
- https://medium.com/@angels.fuertes
- status
- ok
- fetched_at
- 2026-06-20 20:29:01