Amazon ввёл «покупатели, купившие это, также купили» в 1998 году. В 2013 мы строили нечто подобное для регионального ритейлера без инфраструктуры Amazon - без Spark, без ML-пайплайнов реального времени, без GPU. Мы построили на SQL, Python и ночном batch-задании. Работало.
Данные: что у нас было
18 месяцев истории заказов. 180 000 выполненных заказов. 42 000 уникальных покупателей. 3 800 товаров. Не «большие данные» - всё это удобно помещалось в MySQL.
Item-based коллаборативная фильтрация
Основной алгоритм: найти товары, которые часто покупают вместе.
Шаг 1: Матрица совместных покупок:
-- Все пары товаров, купленных в одном заказе
CREATE TABLE product_cooccurrence AS
SELECT
a.product_id AS product_a,
b.product_id AS product_b,
COUNT(DISTINCT a.order_id) AS cooccurrence_count
FROM order_items a
JOIN order_items b ON a.order_id = b.order_id AND a.product_id < b.product_id
JOIN orders o ON a.order_id = o.id
WHERE o.status = 'completed'
GROUP BY a.product_id, b.product_id
HAVING COUNT(DISTINCT a.order_id) >= 3;
Этот запрос работал 4 минуты в production. Сразу перенесли на read-реплику.
Шаг 2: Коэффициент Жаккара:
Сырые подсчёты совместных покупок благоприятствуют популярным товарам. Нормализуем через коэффициент Жаккара: |A ∩ B| / |A ∪ B|.
def calculate_jaccard_similarities(db):
# Получаем количество заказов на товар
cursor.execute("""
SELECT product_id, COUNT(DISTINCT order_id) as order_count
FROM order_items JOIN orders USING (order_id)
WHERE orders.status = 'completed'
GROUP BY product_id
""")
order_counts = {row['product_id']: row['order_count'] for row in cursor.fetchall()}
cursor.execute("SELECT product_a, product_b, cooccurrence_count FROM product_cooccurrence")
similarities = []
for row in cursor.fetchall():
a, b, co = row['product_a'], row['product_b'], row['cooccurrence_count']
union = order_counts.get(a, 0) + order_counts.get(b, 0) - co
jaccard = co / union if union > 0 else 0
similarities.extend([(a, b, jaccard), (b, a, jaccard)]) # Симметрично
return similarities
def save_recommendations(db, similarities):
by_product = defaultdict(list)
for a, b, score in similarities:
by_product[a].append((b, score))
cursor.execute("TRUNCATE TABLE product_recommendations")
for product_id, related in by_product.items():
top_10 = sorted(related, key=lambda x: x[1], reverse=True)[:10]
for rank, (related_id, score) in enumerate(top_10):
cursor.execute("""
INSERT INTO product_recommendations (product_id, recommended_product_id, similarity_score, rank)
VALUES (%s, %s, %s, %s)
""", (product_id, related_id, score, rank + 1))
Ночной batch-процесс
# /etc/cron.d/recommendations
# 0 2 * * * recommender /usr/bin/python3 /app/scripts/rebuild_recommendations.py
Время работы: 8 минут на read-реплике. Запись на primary - 2 минуты. Итого: 10 минут, 2:00-2:10, ноль влияния на пользователей.
Результаты A/B теста (30 дней)
| Метрика | Контроль (без рекомендаций) | Тест (с рекомендациями) |
|---|---|---|
| Средний чек | 2 850 сом | 3 420 сом |
| Товаров в заказе | 1.8 | 2.3 |
| Кликабельность рекомендаций | - | 8.4% |
| Конверсия рекомендаций | - | 14.2% |
Средний чек вырос на 20%. Система рекомендаций окупила себя на второй неделе A/B теста.
Кликабельность 8.4% значительно выше отраслевых бенчмарков (2-4% для email-рекомендаций). Разница: контекстные рекомендации на странице товара vs рекламные рассылки. Релевантность в контексте конвертирует намного лучше, чем релевантность без контекста.
Ограничения и что пришло после
Проблема холодного старта: новые товары не имели истории покупок → не было совместных покупок → нет рекомендаций. Решение: использовать сходство по категории как запасной вариант.
Новые пользователи: нет истории покупок → рекомендации на основе популярности (топ заказов за последние 30 дней) + фильтрация по истории просмотров сессии.
К 2015-2016 годам Apache Spark и MLlib упростили системы рекомендаций. К 2020 - облачные ML-сервисы (AWS Personalize) сделали это задачей конфигурации.
Но алгоритм - коллаборативная фильтрация через совместные покупки и коэффициент Жаккара - остаётся фундаментом. Инфраструктура изменилась; математика - нет.