Saltar al contenido

Cómo construir un motor de recomendaciones: conceptos básicos

Introducción práctica a los sistemas de recomendación: filtrado colaborativo, basado en contenido e híbrido, con implementaciones en TypeScript.

5 min de lectura
Diagrama que muestra un motor de recomendaciones que empareja usuarios con elementos según sus preferencias y similitud

Los motores de recomendación impulsan las funciones de "también te puede gustar" en todas las grandes plataformas: Netflix, Spotify, Amazon, YouTube. En el fondo, resuelven un problema engañosamente simple: dado lo que sabemos sobre el comportamiento pasado de un usuario, predecir qué querrá a continuación.

Existen dos enfoques fundamentales: el filtrado colaborativo (encontrar usuarios similares y recomendar lo que les gustó) y el filtrado basado en contenido (encontrar elementos similares a los que ya le gustaron al usuario). La mayoría de los sistemas en producción usan un híbrido de ambos.

Filtrado colaborativo: a los usuarios que les gustó X también les gustó Y

El filtrado colaborativo no necesita entender qué es un elemento. Funciona únicamente a partir de patrones de comportamiento de los usuarios: si los usuarios A y B valoraron positivamente los elementos 1, 2 y 3, y el usuario A también valoró el elemento 4, entonces al usuario B probablemente también le gustará el elemento 4.

tstypescript
// User-item interaction matrix
// Rows: users, Columns: items, Values: ratings (or 1/0 for implicit feedback)
type RatingMatrix = number[][];
 
const ratings: RatingMatrix = [
  //        Item0  Item1  Item2  Item3  Item4
  /* User0 */ [5,    3,     0,     1,     0],
  /* User1 */ [4,    0,     0,     1,     1],
  /* User2 */ [1,    1,     0,     5,     0],
  /* User3 */ [0,    0,     5,     4,     4],
  /* User4 */ [0,    1,     4,     0,     5],
];
// 0 means "no rating" — the user hasn't interacted with that item
 
// Cosine similarity between two users
function cosineSimilarity(userA: number[], userB: number[]): number {
  let dotProduct = 0;
  let normA = 0;
  let normB = 0;
 
  for (let i = 0; i < userA.length; i++) {
    // Only compare items both users have rated
    if (userA[i] !== 0 && userB[i] !== 0) {
      dotProduct += userA[i] * userB[i];
      normA += userA[i] * userA[i];
      normB += userB[i] * userB[i];
    }
  }
 
  if (normA === 0 || normB === 0) return 0;
  return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
}
 
// Find the K most similar users
function findSimilarUsers(
  targetUser: number,
  ratings: RatingMatrix,
  k: number
): Array<{ userId: number; similarity: number }> {
  const similarities: Array<{ userId: number; similarity: number }> = [];
 
  for (let i = 0; i < ratings.length; i++) {
    if (i === targetUser) continue;
 
    const sim = cosineSimilarity(ratings[targetUser], ratings[i]);
    if (sim > 0) {
      similarities.push({ userId: i, similarity: sim });
    }
  }
 
  return similarities
    .sort((a, b) => b.similarity - a.similarity)
    .slice(0, k);
}
tstypescript
// Generate recommendations based on similar users
function recommendItems(
  targetUser: number,
  ratings: RatingMatrix,
  k: number = 3,
  numRecommendations: number = 5
): Array<{ itemId: number; predictedScore: number }> {
  const similarUsers = findSimilarUsers(targetUser, ratings, k);
  const userRatings = ratings[targetUser];
  const predictions: Array<{ itemId: number; predictedScore: number }> = [];
 
  for (let item = 0; item < userRatings.length; item++) {
    // Skip items the user has already rated
    if (userRatings[item] !== 0) continue;
 
    // Weighted average of similar users' ratings for this item
    let weightedSum = 0;
    let similaritySum = 0;
 
    for (const { userId, similarity } of similarUsers) {
      const rating = ratings[userId][item];
      if (rating !== 0) {
        weightedSum += similarity * rating;
        similaritySum += Math.abs(similarity);
      }
    }
 
    if (similaritySum > 0) {
      predictions.push({
        itemId: item,
        predictedScore: weightedSum / similaritySum,
      });
    }
  }
 
  return predictions
    .sort((a, b) => b.predictedScore - a.predictedScore)
    .slice(0, numRecommendations);
}
 
// Example: Recommend items for User 0
const recs = recommendItems(0, ratings);
// Might return: [{ itemId: 4, predictedScore: 3.2 }]
// "Users similar to you also liked Item 4"

Filtrado basado en contenido: elementos similares

El filtrado basado en contenido usa los atributos de los elementos para encontrar elementos similares. Si a un usuario le gustaron las películas de acción con elementos de ciencia ficción, recomienda otras películas de acción y ciencia ficción, sin importar lo que piensen otros usuarios.

tstypescript
// Content-based: represent items as feature vectors
interface Item {
  id: string;
  title: string;
  features: Record<string, number>;  // Feature name → weight
}
 
const articles: Item[] = [
  {
    id: 'a1',
    title: 'React Performance Optimization',
    features: { react: 1, performance: 1, frontend: 1, javascript: 0.8 },
  },
  {
    id: 'a2',
    title: 'PostgreSQL Query Tuning',
    features: { postgresql: 1, performance: 1, database: 1, sql: 0.8 },
  },
  {
    id: 'a3',
    title: 'React State Management',
    features: { react: 1, state: 1, frontend: 1, javascript: 0.8 },
  },
  {
    id: 'a4',
    title: 'Database Indexing Strategies',
    features: { database: 1, performance: 0.8, postgresql: 0.5, indexing: 1 },
  },
];
 
// Compute similarity between two items based on their features
function itemSimilarity(itemA: Item, itemB: Item): number {
  const allFeatures = new Set([
    ...Object.keys(itemA.features),
    ...Object.keys(itemB.features),
  ]);
 
  let dotProduct = 0;
  let normA = 0;
  let normB = 0;
 
  for (const feature of allFeatures) {
    const a = itemA.features[feature] ?? 0;
    const b = itemB.features[feature] ?? 0;
    dotProduct += a * b;
    normA += a * a;
    normB += b * b;
  }
 
  if (normA === 0 || normB === 0) return 0;
  return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
}
 
// Recommend items similar to ones the user liked
function contentBasedRecommend(
  likedItems: Item[],
  allItems: Item[],
  numRecommendations: number = 3
): Item[] {
  const likedIds = new Set(likedItems.map(i => i.id));
 
  const candidates = allItems
    .filter(item => !likedIds.has(item.id))
    .map(candidate => {
      // Average similarity to all liked items
      const avgSim = likedItems.reduce(
        (sum, liked) => sum + itemSimilarity(liked, candidate),
        0
      ) / likedItems.length;
 
      return { item: candidate, score: avgSim };
    })
    .sort((a, b) => b.score - a.score);
 
  return candidates.slice(0, numRecommendations).map(c => c.item);
}
tstypescript
// ❌ Recommend based on a single signal
function naiveRecommend(user: User): Item[] {
  // Only uses the most recent item — ignores the full preference profile
  const lastViewed = user.history[user.history.length - 1];
  return findSimilar(lastViewed);
}
 
// ✅ Build a user preference profile from all interactions
function buildUserProfile(
  interactions: UserInteraction[]
): Record<string, number> {
  const profile: Record<string, number> = {};
 
  for (const interaction of interactions) {
    const weight = interactionWeight(interaction.type);
    // 'purchase' > 'bookmark' > 'view' > 'skip'
 
    for (const [feature, value] of Object.entries(interaction.item.features)) {
      profile[feature] = (profile[feature] ?? 0) + value * weight;
    }
  }
 
  // Normalize the profile
  const maxValue = Math.max(...Object.values(profile), 1);
  for (const key of Object.keys(profile)) {
    profile[key] /= maxValue;
  }
 
  return profile;
}

El enfoque híbrido

Ni el filtrado colaborativo ni el basado en contenido resuelven por sí solos el problema completo. El filtrado colaborativo sufre del problema de arranque en frío (los usuarios nuevos no tienen historial), y el filtrado basado en contenido crea burbujas de filtro (solo recomienda elementos similares).

tstypescript
// Hybrid recommendation: combine both approaches
function hybridRecommend(
  userId: string,
  userHistory: UserInteraction[],
  ratings: RatingMatrix,
  allItems: Item[],
  config: { collaborativeWeight: number; contentWeight: number }
): RecommendedItem[] {
  // Get collaborative filtering scores
  const collabScores = collaborativeRecommend(userId, ratings);
 
  // Get content-based scores
  const likedItems = userHistory
    .filter(h => h.type === 'like' || h.type === 'purchase')
    .map(h => h.item);
  const contentScores = contentBasedScores(likedItems, allItems);
 
  // Combine scores with configurable weights
  const combined = new Map<string, number>();
 
  for (const [itemId, score] of collabScores) {
    combined.set(itemId,
      (combined.get(itemId) ?? 0) + score * config.collaborativeWeight
    );
  }
 
  for (const [itemId, score] of contentScores) {
    combined.set(itemId,
      (combined.get(itemId) ?? 0) + score * config.contentWeight
    );
  }
 
  // Sort by combined score
  return [...combined.entries()]
    .sort((a, b) => b[1] - a[1])
    .slice(0, 10)
    .map(([itemId, score]) => ({ itemId, score }));
}
 
// Cold start strategy: use content-based for new users,
// blend in collaborative as interaction history grows
function getWeights(interactionCount: number) {
  if (interactionCount < 5) {
    return { collaborativeWeight: 0.1, contentWeight: 0.9 };
  }
  if (interactionCount < 20) {
    return { collaborativeWeight: 0.4, contentWeight: 0.6 };
  }
  return { collaborativeWeight: 0.6, contentWeight: 0.4 };
}

Métricas de evaluación

¿Cómo sabes si tu motor de recomendaciones está funcionando? Las métricas estándar de clasificación aplican, pero con algunos matices propios del dominio.

tstypescript
interface RecommendationMetrics {
  // Precision@K: of the top K recommendations, how many did the user like?
  precisionAtK: number;
  
  // Recall@K: of all items the user would like, how many appeared in top K?
  recallAtK: number;
  
  // NDCG: normalized discounted cumulative gain — measures ranking quality
  ndcg: number;
  
  // Coverage: what percentage of items ever get recommended?
  catalogCoverage: number;
  
  // Diversity: how different are the recommendations from each other?
  intralistDiversity: number;
}
 
function precisionAtK(
  recommended: string[],
  relevant: Set<string>,
  k: number
): number {
  const topK = recommended.slice(0, k);
  const hits = topK.filter(item => relevant.has(item)).length;
  return hits / k;
}

Conclusiones clave

  1. El filtrado colaborativo encuentra usuarios similares: funciona solo con patrones de comportamiento, sin necesidad de conocer los elementos, pero tiene problemas con el arranque en frío
  2. El filtrado basado en contenido encuentra elementos similares: usa las características de los elementos para ajustarse a las preferencias, funciona para usuarios nuevos, pero crea burbujas de filtro
  3. Los enfoques híbridos combinan ambos: dale más peso al filtrado colaborativo a medida que crece el historial del usuario, y más peso al basado en contenido para usuarios nuevos
  4. La similitud del coseno es la herramienta principal: mide el ángulo entre vectores, normalizando las diferencias de magnitud
  5. Maneja el arranque en frío de forma explícita: los usuarios nuevos necesitan recomendaciones basadas en contenido o en popularidad hasta que se acumulen suficientes datos de interacción
  6. Mide más allá de la precisión: la cobertura, la diversidad y la serendipia importan tanto como la precisión para la satisfacción del usuario
Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX