Zum Inhalt springen

Eine Recommendation Engine bauen: Die Grundlagen

Eine praktische Einführung in Empfehlungssysteme: Collaborative Filtering, Content-based Filtering und hybride Ansätze, in TypeScript umgesetzt.

5 Min. Lesezeit
Diagramm, das zeigt, wie eine Recommendation Engine Nutzer anhand von Präferenzen und Ähnlichkeit mit Elementen abgleicht

Recommendation Engines stecken hinter den „Das könnte dir auch gefallen"-Funktionen aller großen Plattformen – Netflix, Spotify, Amazon, YouTube. Im Kern lösen sie ein trügerisch einfaches Problem: Aus dem, was wir über das bisherige Verhalten eines Nutzers wissen, vorhersagen, was er als Nächstes wollen wird.

Es gibt zwei grundlegende Ansätze: Collaborative Filtering (ähnliche Nutzer finden und empfehlen, was ihnen gefallen hat) und Content-based Filtering (Elemente finden, die dem ähneln, was dem Nutzer bereits gefallen hat). Die meisten Produktivsysteme nutzen eine Mischung aus beiden.

Collaborative Filtering: Nutzer, denen X gefiel, mochten auch Y

Collaborative Filtering muss nicht verstehen, was ein Element ist. Es funktioniert rein auf Basis von Nutzerverhaltensmustern – wenn die Nutzer A und B beide die Elemente 1, 2 und 3 mochten und Nutzer A außerdem Element 4 mochte, dann wird Nutzer B wahrscheinlich auch Element 4 mögen.

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"

Content-based Filtering: Ähnliche Elemente

Content-based Filtering nutzt die Attribute der Elemente, um ähnliche Elemente zu finden. Wenn ein Nutzer Actionfilme mit Sci-Fi-Elementen mochte, empfiehl andere Action-Sci-Fi-Filme – unabhängig davon, was andere Nutzer denken.

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;
}

Der hybride Ansatz

Weder Collaborative Filtering noch Content-based Filtering lösen allein das gesamte Problem. Collaborative Filtering leidet unter dem Cold-Start-Problem (neue Nutzer haben keinen Verlauf), und Content-based Filtering erzeugt Filterblasen (es empfiehlt nur ähnliche Elemente).

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 };
}

Bewertungsmetriken

Woher weißt du, ob deine Recommendation Engine funktioniert? Die üblichen Klassifikationsmetriken gelten, allerdings mit einigen domänenspezifischen Feinheiten.

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;
}

Die wichtigsten Erkenntnisse

  1. Collaborative Filtering findet ähnliche Nutzer – es funktioniert allein aus Verhaltensmustern, ganz ohne Wissen über die Elemente, hat aber Probleme mit dem Cold Start
  2. Content-based Filtering findet ähnliche Elemente – es nutzt Element-Merkmale, um Präferenzen zu treffen, funktioniert für neue Nutzer, erzeugt aber Filterblasen
  3. Hybride Ansätze kombinieren beides – gewichte Collaborative Filtering stärker, je mehr Nutzerverlauf vorliegt, und Content-based Filtering stärker bei neuen Nutzern
  4. Kosinus-Ähnlichkeit ist das Arbeitstier – sie misst den Winkel zwischen Vektoren und normalisiert dabei Betragsunterschiede
  5. Behandle Cold Start explizit – neue Nutzer brauchen content-basierte oder popularitätsbasierte Empfehlungen, bis genügend Interaktionsdaten vorliegen
  6. Miss mehr als nur Genauigkeit – Coverage, Diversität und Serendipität sind für die Nutzerzufriedenheit genauso wichtig wie Precision
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX