Спроектировать рекомендательную систему для e-commerce платформы с 10M пользователей и 1M товаров. Система должна генерировать персонализированные рекомендации в реальном времени.
Требования
Требование
Значение
Latency
< 100ms для online рекомендаций
Throughput
10,000 RPS
Freshness
Учитывать последние действия (< 1 час)
Coverage
Рекомендовать > 80% каталога
Diversity
Не повторять одни и те же товары
Подходы к рекомендациям
Сравнение подходов
Подход
Идея
Плюсы
Минусы
Collaborative Filtering
Похожие пользователи любят похожее
Не нужен контент
Cold start
Content-Based
Рекомендовать похожее на то, что нравилось
Нет cold start для items
Filter bubble
Hybrid
Комбинация подходов
Лучшее качество
Сложность
Knowledge-Based
Правила и ограничения
Прозрачность
Не масштабируется
Deep Learning
Neural networks
State-of-the-art
Требует данных и GPU
Collaborative Filtering
User-Based CF
Найти пользователей с похожими вкусами и рекомендовать то, что нравится им.
User A: [Item1: 5, Item2: 3, Item3: ?, Item4: 4]
User B: [Item1: 4, Item2: 3, Item3: 5, Item4: ?]
User C: [Item1: 2, Item2: 1, Item3: 2, Item4: 1]
A и B похожи → рекомендовать Item3 для A (B поставил 5)
Item-Based CF
Найти товары, которые часто покупают вместе.
Users who bought Item1 also bought: Item3 (80%), Item5 (65%), Item7 (40%)
package recommendation
import (
"math"
"sort"
)
// ItemBasedCF implements item-based collaborative filtering.
type ItemBasedCF struct{}
// Recommendation holds an item ID and its predicted score.
type Recommendation struct {
ItemID string
Score float64
}
// UserItemMatrix maps user IDs to their item ratings.
type UserItemMatrix map[string]map[string]float64
// ItemSimilarity maps item IDs to similar items with scores.
type ItemSimilarity map[string]map[string]float64
// CalculateItemSimilarity computes item-item cosine similarity.
func (ItemBasedCF) CalculateItemSimilarity(matrix UserItemMatrix) ItemSimilarity {
// Transpose: item -> users who rated it
itemUsers := make(map[string]map[string]float64)
for userID, items := range matrix {
for itemID, rating := range items {
if itemUsers[itemID] == nil {
itemUsers[itemID] = make(map[string]float64)
}
itemUsers[itemID][userID] = rating
}
}
items := make([]string, 0, len(itemUsers))
for id := range itemUsers {
items = append(items, id)
}
similarity := make(ItemSimilarity)
for i := 0; i < len(items); i++ {
for j := i + 1; j < len(items); j++ {
score := cosineSim(itemUsers[items[i]], itemUsers[items[j]])
if score > 0.1 {
if similarity[items[i]] == nil {
similarity[items[i]] = make(map[string]float64)
}
if similarity[items[j]] == nil {
similarity[items[j]] = make(map[string]float64)
}
similarity[items[i]][items[j]] = score
similarity[items[j]][items[i]] = score
}
}
}
return similarity
}
// Recommend returns top-N recommendations for a user.
func (ItemBasedCF) Recommend(userRatings map[string]float64, itemSim ItemSimilarity, topN int) []Recommendation {
scores := make(map[string]float64)
simSums := make(map[string]float64)
for ratedItem, rating := range userRatings {
for candidate, sim := range itemSim[ratedItem] {
if _, alreadyRated := userRatings[candidate]; alreadyRated {
continue
}
scores[candidate] += sim * rating
simSums[candidate] += sim
}
}
recs := make([]Recommendation, 0, len(scores))
for itemID, score := range scores {
if simSums[itemID] > 0 {
recs = append(recs, Recommendation{
ItemID: itemID,
Score: math.Round(score/simSums[itemID]*10000) / 10000,
})
}
}
sort.Slice(recs, func(i, j int) bool {
return recs[i].Score > recs[j].Score
})
if topN < len(recs) {
recs = recs[:topN]
}
return recs
}
func cosineSim(a, b map[string]float64) float64 {
var dot, normA, normB float64
for user, va := range a {
if vb, ok := b[user]; ok {
dot += va * vb
normA += va * va
normB += vb * vb
}
}
denom := math.Sqrt(normA) * math.Sqrt(normB)
if denom == 0 {
return 0
}
return dot / denom
}
// File-scoped alias keeps the nested dictionary signatures readable.
using UserItemMatrix =
System.Collections.Generic.IReadOnlyDictionary<
string,
System.Collections.Generic.IReadOnlyDictionary<string, double>>;
namespace App.Recommendation;
/// Holds an item ID and its predicted score.
public readonly record struct Recommendation(string ItemId, double Score);
/// Implements item-based collaborative filtering.
public sealed class ItemBasedCf
{
private const double MinSimilarity = 0.1;
// Calculate item-item similarity using cosine similarity.
public Dictionary<string, Dictionary<string, double>> CalculateItemSimilarity(
UserItemMatrix userItemMatrix)
{
// Transpose: item -> users who rated it
var itemUsers = new Dictionary<string, Dictionary<string, double>>();
foreach (var (userId, items) in userItemMatrix)
{
foreach (var (itemId, rating) in items)
{
if (!itemUsers.TryGetValue(itemId, out var users))
{
users = [];
itemUsers[itemId] = users;
}
users[userId] = rating;
}
}
var ids = itemUsers.Keys.ToArray();
var similarity = new Dictionary<string, Dictionary<string, double>>();
for (var i = 0; i < ids.Length; i++)
{
for (var j = i + 1; j < ids.Length; j++)
{
var score = CosineSimilarity(itemUsers[ids[i]], itemUsers[ids[j]]);
if (score <= MinSimilarity) // Only store meaningful similarities
{
continue;
}
AddPair(similarity, ids[i], ids[j], score);
AddPair(similarity, ids[j], ids[i], score);
}
}
return similarity;
}
// Get top-N recommendations for a user.
public IReadOnlyList<Recommendation> Recommend(
IReadOnlyDictionary<string, double> userRatings,
IReadOnlyDictionary<string, Dictionary<string, double>> itemSimilarity,
int topN = 10)
{
var scores = new Dictionary<string, double>();
var simSums = new Dictionary<string, double>();
foreach (var (ratedItem, rating) in userRatings)
{
if (!itemSimilarity.TryGetValue(ratedItem, out var similar))
{
continue;
}
foreach (var (candidate, similarity) in similar)
{
// Skip items the user already rated
if (userRatings.ContainsKey(candidate))
{
continue;
}
scores[candidate] = scores.GetValueOrDefault(candidate) + similarity * rating;
simSums[candidate] = simSums.GetValueOrDefault(candidate) + similarity;
}
}
return scores
.Where(pair => simSums[pair.Key] > 0)
.Select(pair => new Recommendation(
pair.Key,
Math.Round(pair.Value / simSums[pair.Key], 4)))
.OrderByDescending(rec => rec.Score)
.Take(topN)
.ToArray();
}
private static void AddPair(
Dictionary<string, Dictionary<string, double>> similarity,
string from,
string to,
double score)
{
if (!similarity.TryGetValue(from, out var neighbours))
{
neighbours = [];
similarity[from] = neighbours;
}
neighbours[to] = score;
}
private static double CosineSimilarity(
IReadOnlyDictionary<string, double> a,
IReadOnlyDictionary<string, double> b)
{
double dot = 0, normA = 0, normB = 0;
foreach (var (user, valueA) in a)
{
if (!b.TryGetValue(user, out var valueB))
{
continue;
}
dot += valueA * valueB;
normA += valueA * valueA;
normB += valueB * valueB;
}
var denominator = Math.Sqrt(normA) * Math.Sqrt(normB);
return denominator > 0 ? dot / denominator : 0.0;
}
}
import math
from collections import defaultdict
from dataclasses import dataclass
from typing import Mapping
MIN_SIMILARITY = 0.1
# Type aliases keep the nested mapping types readable
UserItemMatrix = Mapping[str, Mapping[str, float]]
ItemSimilarity = dict[str, dict[str, float]]
@dataclass(frozen=True, slots=True)
class Recommendation:
"""Holds an item ID and its predicted score."""
item_id: str
score: float
class ItemBasedCF:
def calculate_item_similarity(self, user_item_matrix: UserItemMatrix) -> ItemSimilarity:
"""Calculate item-item similarity using cosine similarity."""
# Transpose: item -> users who rated it
item_users: dict[str, dict[str, float]] = defaultdict(dict)
for user_id, items in user_item_matrix.items():
for item_id, rating in items.items():
item_users[item_id][user_id] = rating
items_ids = list(item_users)
similarity: ItemSimilarity = defaultdict(dict)
for i, left in enumerate(items_ids):
for right in items_ids[i + 1 :]:
score = self._cosine_sim(item_users[left], item_users[right])
if score <= MIN_SIMILARITY: # Only store meaningful similarities
continue
similarity[left][right] = score
similarity[right][left] = score
return dict(similarity)
def recommend(
self,
user_ratings: Mapping[str, float],
item_similarity: ItemSimilarity,
top_n: int = 10,
) -> list[Recommendation]:
"""Get top-N recommendations for a user."""
scores: dict[str, float] = defaultdict(float)
sim_sums: dict[str, float] = defaultdict(float)
for rated_item, rating in user_ratings.items():
for candidate, similarity in item_similarity.get(rated_item, {}).items():
# Skip items the user already rated
if candidate in user_ratings:
continue
scores[candidate] += similarity * rating
sim_sums[candidate] += similarity
recommendations = [
Recommendation(item_id, round(score / sim_sums[item_id], 4))
for item_id, score in scores.items()
if sim_sums[item_id] > 0
]
recommendations.sort(key=lambda rec: rec.score, reverse=True)
return recommendations[:top_n]
@staticmethod
def _cosine_sim(a: Mapping[str, float], b: Mapping[str, float]) -> float:
common = a.keys() & b.keys()
if not common:
return 0.0
dot = sum(a[user] * b[user] for user in common)
norm_a = math.sqrt(sum(a[user] ** 2 for user in common))
norm_b = math.sqrt(sum(b[user] ** 2 for user in common))
denominator = norm_a * norm_b
return dot / denominator if denominator > 0 else 0.0
## Content-Based Filtering
Рекомендовать товары, похожие на те, что пользователь уже оценил.
<?php
declare(strict_types=1);
namespace App\Recommendation;
final readonly class ContentBasedRecommender
{
public function __construct(
private ProductRepository $products,
) {}
/**
* Recommend similar products based on attributes.
*
* @param array<string> $likedProductIds Products user liked
* @param int $limit Max recommendations
* @return array<array{product_id: string, score: float}>
*/
public function recommend(array $likedProductIds, int $limit = 10): array
{
// Build user profile from liked items
$userProfile = $this->buildUserProfile($likedProductIds);
// Score all candidate products
$candidates = $this->products->findAll();
$scores = [];
foreach ($candidates as $product) {
if (in_array($product['id'], $likedProductIds, true)) {
continue; // Skip already liked
}
$productFeatures = $this->extractFeatures($product);
$score = $this->similarity($userProfile, $productFeatures);
if ($score > 0.1) {
$scores[] = [
'product_id' => $product['id'],
'score' => round($score, 4),
];
}
}
usort($scores, static fn($a, $b) => $b['score'] <=> $a['score']);
return array_slice($scores, 0, $limit);
}
/**
* Build user preference profile from liked items.
*/
private function buildUserProfile(array $productIds): array
{
$aggregated = [];
$count = 0;
foreach ($productIds as $id) {
$product = $this->products->findById($id);
if ($product === null) {
continue;
}
$features = $this->extractFeatures($product);
foreach ($features as $key => $value) {
$aggregated[$key] = ($aggregated[$key] ?? 0) + $value;
}
$count++;
}
// Average
if ($count > 0) {
foreach ($aggregated as &$value) {
$value /= $count;
}
}
return $aggregated;
}
/**
* Extract numerical features from a product.
*/
private function extractFeatures(array $product): array
{
return [
'price_normalized' => min($product['price'] / 10000, 1.0),
'category_' . ($product['category'] ?? 'other') => 1.0,
'brand_' . ($product['brand'] ?? 'other') => 1.0,
'rating' => ($product['rating'] ?? 0) / 5.0,
'popularity' => min(($product['sales_count'] ?? 0) / 1000, 1.0),
];
}
private function similarity(array $a, array $b): float
{
$allKeys = array_unique(array_merge(array_keys($a), array_keys($b)));
$dot = 0.0;
$normA = 0.0;
$normB = 0.0;
foreach ($allKeys as $key) {
$va = $a[$key] ?? 0.0;
$vb = $b[$key] ?? 0.0;
$dot += $va * $vb;
$normA += $va ** 2;
$normB += $vb ** 2;
}
$denom = sqrt($normA) * sqrt($normB);
return $denom > 0 ? $dot / $denom : 0.0;
}
}
package recommendation
import (
"math"
"sort"
)
// Product represents a product with attributes.
type Product struct {
ID string
Price float64
Category string
Brand string
Rating float64
SalesCount int
}
// ProductRepository defines the interface for product access.
type ProductRepository interface {
FindAll() []Product
FindByID(id string) *Product
}
// ContentBasedRecommender recommends products by attribute similarity.
type ContentBasedRecommender struct {
products ProductRepository
}
// NewContentBasedRecommender creates a content-based recommender.
func NewContentBasedRecommender(repo ProductRepository) *ContentBasedRecommender {
return &ContentBasedRecommender{products: repo}
}
// Recommend returns products similar to the ones the user liked.
func (r *ContentBasedRecommender) Recommend(likedIDs []string, limit int) []Recommendation {
likedSet := make(map[string]struct{}, len(likedIDs))
for _, id := range likedIDs {
likedSet[id] = struct{}{}
}
// Build user profile
userProfile := r.buildUserProfile(likedIDs)
// Score candidates
candidates := r.products.FindAll()
var recs []Recommendation
for _, p := range candidates {
if _, liked := likedSet[p.ID]; liked {
continue
}
features := extractFeatures(p)
score := featureSimilarity(userProfile, features)
if score > 0.1 {
recs = append(recs, Recommendation{
ItemID: p.ID,
Score: math.Round(score*10000) / 10000,
})
}
}
sort.Slice(recs, func(i, j int) bool {
return recs[i].Score > recs[j].Score
})
if limit < len(recs) {
recs = recs[:limit]
}
return recs
}
func (r *ContentBasedRecommender) buildUserProfile(ids []string) map[string]float64 {
agg := make(map[string]float64)
count := 0
for _, id := range ids {
p := r.products.FindByID(id)
if p == nil {
continue
}
for k, v := range extractFeatures(*p) {
agg[k] += v
}
count++
}
if count > 0 {
for k := range agg {
agg[k] /= float64(count)
}
}
return agg
}
func extractFeatures(p Product) map[string]float64 {
cat := p.Category
if cat == "" {
cat = "other"
}
brand := p.Brand
if brand == "" {
brand = "other"
}
return map[string]float64{
"price_normalized": math.Min(p.Price/10000, 1.0),
"category_" + cat: 1.0,
"brand_" + brand: 1.0,
"rating": p.Rating / 5.0,
"popularity": math.Min(float64(p.SalesCount)/1000, 1.0),
}
}
func featureSimilarity(a, b map[string]float64) float64 {
allKeys := make(map[string]struct{})
for k := range a {
allKeys[k] = struct{}{}
}
for k := range b {
allKeys[k] = struct{}{}
}
var dot, normA, normB float64
for k := range allKeys {
va := a[k]
vb := b[k]
dot += va * vb
normA += va * va
normB += vb * vb
}
denom := math.Sqrt(normA) * math.Sqrt(normB)
if denom == 0 {
return 0
}
return dot / denom
}
namespace App.Recommendation;
/// Represents a product with attributes.
public sealed record Product(
string Id,
decimal Price,
string? Category,
string? Brand,
double Rating,
int SalesCount);
/// Defines the contract for product access.
public interface IProductRepository
{
IReadOnlyList<Product> FindAll();
Product? FindById(string id);
}
/// Recommends products by attribute similarity.
public sealed class ContentBasedRecommender
{
private const double MinScore = 0.1;
private readonly IProductRepository _products;
public ContentBasedRecommender(IProductRepository products) => _products = products;
// Recommend products similar to the ones the user liked.
public IReadOnlyList<Recommendation> Recommend(IReadOnlyList<string> likedProductIds, int limit = 10)
{
var likedSet = likedProductIds.ToHashSet();
// Build user profile from liked items
var userProfile = BuildUserProfile(likedProductIds);
// Score all candidate products
return _products.FindAll()
.Where(product => !likedSet.Contains(product.Id)) // Skip already liked
.Select(product => new Recommendation(
product.Id,
Math.Round(Similarity(userProfile, ExtractFeatures(product)), 4)))
.Where(rec => rec.Score > MinScore)
.OrderByDescending(rec => rec.Score)
.Take(limit)
.ToArray();
}
// Build a user preference profile by averaging features of liked items.
private Dictionary<string, double> BuildUserProfile(IReadOnlyList<string> productIds)
{
var aggregated = new Dictionary<string, double>();
var count = 0;
foreach (var id in productIds)
{
var product = _products.FindById(id);
if (product is null)
{
continue;
}
foreach (var (key, value) in ExtractFeatures(product))
{
aggregated[key] = aggregated.GetValueOrDefault(key) + value;
}
count++;
}
if (count == 0)
{
return aggregated;
}
foreach (var key in aggregated.Keys.ToArray())
{
aggregated[key] /= count;
}
return aggregated;
}
// Extract numerical features from a product.
private static Dictionary<string, double> ExtractFeatures(Product product) => new()
{
["price_normalized"] = Math.Min((double)product.Price / 10000, 1.0),
[$"category_{product.Category ?? "other"}"] = 1.0,
[$"brand_{product.Brand ?? "other"}"] = 1.0,
["rating"] = product.Rating / 5.0,
["popularity"] = Math.Min(product.SalesCount / 1000.0, 1.0),
};
private static double Similarity(
IReadOnlyDictionary<string, double> a,
IReadOnlyDictionary<string, double> b)
{
double dot = 0, normA = 0, normB = 0;
foreach (var key in a.Keys.Union(b.Keys))
{
var valueA = a.GetValueOrDefault(key);
var valueB = b.GetValueOrDefault(key);
dot += valueA * valueB;
normA += valueA * valueA;
normB += valueB * valueB;
}
var denominator = Math.Sqrt(normA) * Math.Sqrt(normB);
return denominator > 0 ? dot / denominator : 0.0;
}
}
import math
from collections import defaultdict
from dataclasses import dataclass
from decimal import Decimal
from typing import Mapping, Protocol, Sequence
MIN_SCORE = 0.1
@dataclass(frozen=True, slots=True)
class Product:
"""Represents a product with attributes."""
id: str
price: Decimal
category: str | None
brand: str | None
rating: float
sales_count: int
class ProductRepository(Protocol):
# Python has no interfaces; typing.Protocol gives structural typing,
# so any repository with these methods can be injected.
def find_all(self) -> Sequence[Product]: ...
def find_by_id(self, product_id: str) -> Product | None: ...
class ContentBasedRecommender:
"""Recommend products by attribute similarity."""
# Python has no compile-time DI container; the repository is injected manually.
def __init__(self, products: ProductRepository) -> None:
self._products = products
def recommend(
self,
liked_product_ids: Sequence[str],
limit: int = 10,
) -> list[Recommendation]:
"""Recommend products similar to the ones the user liked."""
liked = set(liked_product_ids)
# Build user profile from liked items
user_profile = self._build_user_profile(liked_product_ids)
# Score all candidate products
scored = [
Recommendation(
item_id=product.id,
score=round(
self._similarity(user_profile, self._extract_features(product)), 4
),
)
for product in self._products.find_all()
if product.id not in liked # Skip already liked
]
recommendations = [rec for rec in scored if rec.score > MIN_SCORE]
recommendations.sort(key=lambda rec: rec.score, reverse=True)
return recommendations[:limit]
def _build_user_profile(self, product_ids: Sequence[str]) -> dict[str, float]:
"""Build a user preference profile by averaging features of liked items."""
aggregated: dict[str, float] = defaultdict(float)
count = 0
for product_id in product_ids:
product = self._products.find_by_id(product_id)
if product is None:
continue
for key, value in self._extract_features(product).items():
aggregated[key] += value
count += 1
if count == 0:
return dict(aggregated)
return {key: value / count for key, value in aggregated.items()}
@staticmethod
def _extract_features(product: Product) -> dict[str, float]:
"""Extract numerical features from a product."""
return {
"price_normalized": min(float(product.price) / 10000, 1.0),
f"category_{product.category or 'other'}": 1.0,
f"brand_{product.brand or 'other'}": 1.0,
"rating": product.rating / 5.0,
"popularity": min(product.sales_count / 1000, 1.0),
}
@staticmethod
def _similarity(a: Mapping[str, float], b: Mapping[str, float]) -> float:
all_keys = a.keys() | b.keys()
dot = sum(a.get(key, 0.0) * b.get(key, 0.0) for key in all_keys)
norm_a = math.sqrt(sum(a.get(key, 0.0) ** 2 for key in all_keys))
norm_b = math.sqrt(sum(b.get(key, 0.0) ** 2 for key in all_keys))
denominator = norm_a * norm_b
return dot / denominator if denominator > 0 else 0.0
## Архитектура Production-системы
[API Request] → [Recommendation Service]
↓
┌───────────────┐
│ Candidate │ ← Pre-computed candidates (batch)
│ Generation │ ← User history (online)
└───────┬───────┘
↓
┌───────────────┐
│ Ranking │ ← ML model (online prediction)
│ Model │
└───────┬───────┘
↓
┌───────────────┐
│ Post- │ ← Dedup, diversity, business rules
│ Processing │ ← Remove out-of-stock, seen items
└───────┬───────┘
↓
[Final Recommendations]
Two-stage подход
Этап
Задача
Latency
Кандидаты
Candidate Generation
Выбрать ~1000 кандидатов
< 20ms
1M → 1000
Ranking
Ранжировать кандидатов
< 50ms
1000 → 50
Post-processing
Фильтры, разнообразие
< 10ms
50 → 10
Cold Start Problem
Тип
Проблема
Решения
New User
Нет истории действий
Популярные товары, демографика, onboarding quiz
New Item
Нет рейтингов/покупок
Content-based, editorial curation, boost in ranking
New System
Нет данных вообще
Правила, тренды, экспертные рекомендации
Метрики рекомендательных систем
Метрика
Формула
Что измеряет
Precision@K
Relevant in top-K / K
Релевантность
Recall@K
Relevant in top-K / Total relevant
Полнота
NDCG
Normalized DCG
Качество ранжирования
CTR
Clicks / Impressions
Вовлечённость
Coverage
Recommended items / All items
Охват каталога
Diversity
1 - avg similarity between recs
Разнообразие
Novelty
Avg popularity rank of recs
Неожиданность
Итоги
Концепция
Суть
Collaborative Filtering
Похожие пользователи / похожие товары
Content-Based
Рекомендовать похожее по атрибутам
Hybrid
Комбинация для лучшего качества
Two-stage
Candidate generation → ranking
Cold Start
Решается через fallback стратегии
A/B Testing
Единственный способ проверить quality
Принцип: Простая модель, развёрнутая в production с хорошим A/B тестированием, всегда лучше совершенной модели, которая живёт в notebook.