Рекомендательные системы
Все задачи трека до этого момента имели общий вид: есть объект x, нужно предсказать y. Регрессия предсказывала число, деревья и бустинг — класс или число, кластеризация искала структуру без ответов. Рекомендации ломают эту рамку сразу в трёх местах.
Во-первых, объект — это пара. Не «пользователь» и не «товар», а взаимодействие (u, i). Модель должна выучить не свойства объектов, а совместимость между ними.
Во-вторых, целевая переменная почти всегда отсутствует там, где она интереснее всего. Мы видим, что пользователь купил три товара из миллиона. Про остальные 999 997 мы не знаем ничего: то ли не понравились, то ли просто не попались на глаза. Это фундаментально иная ситуация, чем «есть размеченная выборка с балансом классов 1:100».
В-третьих, система влияет на данные, на которых учится. Модель показала пользователю десять карточек — он кликнул на одну из них. Завтра эти клики станут обучающей выборкой. Ни в одной другой задаче трека предсказание не меняет распределение будущих данных так прямо и так быстро.
Ставка при этом очень высокая. Amazon ещё в 2003 году сообщал, что рекомендации отвечают за существенную долю продаж (Linden, Smith, York. Amazon.com Recommendations: Item-to-Item Collaborative Filtering, IEEE Internet Computing 2003), Netflix в 2015 оценивал экономию от персонализации примерно в миллиард долларов в год (Gomez-Uribe, Hunt. The Netflix Recommender System, TMIS 2015), а на YouTube львиная доля просмотров приходит из рекомендательной ленты, а не из поиска.
Эта статья — про то, как такие системы устроены изнутри: от матрицы взаимодействий и baseline из трёх строк до двухстадийной продакшн-архитектуры, ранжирующих метрик и борьбы со смещениями, которые модель сама себе создаёт.
Постановка задачи: три разные задачи под одним словом
Пусть есть множество пользователей U (|U| = n) и объектов I (|I| = m). Наблюдения — множество взаимодействий D = {(u, i, r, t)}: пользователь, объект, сила сигнала, время.
Под «рекомендациями» скрываются как минимум три постановки:
| Постановка | Что предсказываем | Метрики | Когда актуально |
|---|---|---|---|
| Rating prediction | значение r̂ᵤᵢ для пары |
RMSE, MAE | явные оценки, редко в вебе |
| Top-N ранжирование | упорядоченный список из N объектов для u |
Recall@k, NDCG@k, MAP@k | 90% продовых систем |
| Next-item / sequential | следующее действие с учётом порядка | HitRate@k, MRR | сессии, музыка, лента, e-commerce |
Главный урок Netflix Prize состоял именно в этом различии. Конкурс мерил RMSE предсказания оценок, победившее решение (ансамбль из десятков моделей) улучшило RMSE на 10.06%, но в продакшн этот ансамбль так и не попал: Netflix сам об этом написал в Netflix Recommendations: Beyond the 5 stars. Причина — стоимость инженерии и то, что бизнесу нужен хороший топ-10, а не точная оценка всех фильмов. Модель может отлично предсказывать, что пользователь поставит фильму 2.1 вместо 2.0, и это никак не улучшит первые десять карточек.
Практический вывод: если вы формулируете задачу как регрессию оценок, вы почти наверняка решаете не ту задачу. Правильная рамка по умолчанию — ранжирование.
Явный и неявный отклик
Явный отклик (explicit) — пользователь сознательно оценил объект: звёзды, лайк/дизлайк, «не интересно». Чистый сигнал, но его катастрофически мало, и он смещён: оценивают в основном то, что вызвало эмоцию.
Неявный отклик (implicit) — клики, просмотры, покупки, время просмотра, добавления в корзину. Данных на несколько порядков больше, но у него три неприятных свойства:
- Нет негативов. Отсутствие клика — не «не нравится», а «не видел ИЛИ не нравится». Это problem of one-class collaborative filtering.
- Сигнал не бинарный, а «уверенность». Просмотр сериала 8 раз сильнее одного просмотра, но не в 8 раз «нравится».
- Он загрязнён позиционным смещением. Первая позиция получает клики просто потому, что первая.
Ключевая инженерная рекомендация, которую систематически игнорируют: логируйте показы (impressions), а не только клики. Без лога показов вы никогда не отличите «не понравилось» от «не увидел», не сможете обучать ранкер на честных негативах и не оцените позиционное смещение. Стоимость — терабайты логов; цена отсутствия — модель, которая учится на выдаче самой себя.
Разреженность — определяющее свойство данных
Возьмём типичный маркетплейс: 10⁷ пользователей, 10⁶ товаров, 10⁹ событий. Плотность матрицы взаимодействий:
density = 10⁹ / (10⁷ · 10⁶) = 10⁹ / 10¹³ = 0.0001 = 0.01%
Заполнено одно значение из десяти тысяч. Для сравнения: MovieLens-1M имеет плотность около 4.5%, и это считается «плотным» датасетом. Отсюда два следствия, определяющих всю дальнейшую инженерию:
- Хранить матрицу плотно нельзя: 10¹³ float32 — это 40 ТБ. Только CSR/COO (
scipy.sparse) и специализированные форматы. - Любая модель работает в режиме экстремальной экстраполяции. Регуляризация здесь не «опция для борьбы с переобучением», а условие существования решения — подробности механики в статье про переобучение и регуляризацию.
Карта подходов
Не воспринимайте это как список «выберите один». Реальная система — почти всегда конвейер, где неперсональные эвристики, контентные и коллаборативные модели поставляют кандидатов, а один ранкер сводит всё воедино.
Baseline, который нужно побить
Первая модель в проекте не должна быть нейросетью. Она должна быть настолько простой, чтобы её нельзя было сломать, и настолько сильной, чтобы половина «умных» подходов ей проиграла.
Уровень 0: топ популярных. Отсортировать объекты по числу взаимодействий за последние T дней, вычесть уже виденное. Удивительно часто это даёт 60–80% метрики сложных моделей — и практически всегда выигрывает у них по холодным пользователям.
Уровень 1: bias-модель. Разложим оценку на три аддитивных эффекта:
r̂ᵤᵢ = μ + bᵤ + bᵢ
где μ — глобальное среднее, bᵤ — систематическая склонность пользователя ставить выше/ниже среднего, bᵢ — «объект в среднем нравится больше/меньше». Минимизируем регуляризованную ошибку:
L = Σ_{(u,i) ∈ D} (rᵤᵢ − μ − bᵤ − bᵢ)² + λ (Σ bᵤ² + Σ bᵢ²)
Решается покоординатно за пару итераций:
import numpy as np
from collections import defaultdict
def fit_biases(triples, lam=10.0, n_iter=10):
"""Baseline r̂ = μ + b_u + b_i, покоординатная минимизация.
triples: список (user_id, item_id, rating).
lam: регуляризация — «сдвиг к среднему» для редких сущностей.
Сложность: O(n_iter · |D|) по времени, O(n + m) по памяти.
"""
mu = np.mean([r for _, _, r in triples])
bu, bi = defaultdict(float), defaultdict(float)
by_user, by_item = defaultdict(list), defaultdict(list)
for u, i, r in triples:
by_user[u].append((i, r))
by_item[i].append((u, r))
for _ in range(n_iter):
# при фиксированных b_i оптимум по b_u берётся в явном виде
for u, rows in by_user.items():
s = sum(r - mu - bi[i] for i, r in rows)
bu[u] = s / (lam + len(rows))
for i, rows in by_item.items():
s = sum(r - mu - bu[u] for u, r in rows)
bi[i] = s / (lam + len(rows))
return mu, bu, bi
Обратите внимание на знаменатель lam + len(rows). Это и есть содержательная часть регуляризации: объект с тремя оценками получает bias, сжатый к нулю, объект с тремя тысячами — почти несмещённую оценку. Тот же приём в байесовской терминологии называется сглаживанием к априорному среднему. Без него первое место в рейтинге займёт товар с единственной пятёркой.
Практическое правило: любую новую модель сравнивайте не с нулём, а с топом популярного и bias-моделью, на одном и том же временном сплите. Публикации, где сложная нейросеть выигрывает у «слабых бейзлайнов», регулярно не воспроизводятся — систематический разбор: Dacrema, Cremonesi, Jannach. Are We Really Making Much Progress? (RecSys 2019), где из 18 нейросетевых методов лишь 7 удалось воспроизвести, и большинство проиграло аккуратно настроенным классическим бейзлайнам.
Коллаборативная фильтрация по соседям
Идея, с которой всё началось (GroupLens, 1994): похожие пользователи любят похожие вещи. Два симметричных варианта.
User-user. Найти k пользователей, наиболее похожих на u, и рекомендовать то, что нравилось им:
r̂ᵤᵢ = b̄ᵤ + Σ_{v ∈ Nᵏ(u,i)} sim(u,v) · (r_vᵢ − b̄_v) / Σ |sim(u,v)|
Item-item. Найти объекты, похожие на те, с которыми u уже взаимодействовал. Именно этот вариант Amazon вывела в прод в начале 2000-х, и по очень практичным причинам:
- Объектов обычно меньше, чем пользователей, и их профили стабильнее во времени — матрицу похожестей можно считать раз в сутки офлайн.
- Рекомендация объяснима: «похоже на то, что вы смотрели». Объяснимость измеримо повышает доверие и CTR.
- Новое действие пользователя мгновенно меняет выдачу без переобучения — просто добавляется ещё один «якорь».
Меры схожести:
| Мера | Формула | Когда |
|---|---|---|
| Косинус | ⟨a, b⟩ / (‖a‖‖b‖) |
implicit, разреженные бинарные векторы |
| Пирсон | косинус после вычитания средних | explicit-оценки, разные «шкалы» пользователей |
| Жаккар | ` | A ∩ B |
| Условная вероятность | c_ij / (c_i^α · c_j) |
контроль популярности через α |
import numpy as np
import scipy.sparse as sp
def item_item_similarity(X: sp.csr_matrix, shrink: float = 100.0, topk: int = 200):
"""Косинусная похожесть объектов с усадкой по числу совстречаемостей.
X: (n_users × n_items) разреженная матрица implicit-сигналов.
Возвращает разреженную (n_items × n_items) матрицу, где в каждой
строке оставлены только topk соседей.
Сложность: O(nnz · среднее_число_объектов_на_пользователя).
"""
X = X.tocsc().astype(np.float32)
# нормировка столбцов -> скалярное произведение = косинус
norms = np.sqrt(np.asarray(X.multiply(X).sum(axis=0)).ravel()) + 1e-9
Xn = X.multiply(sp.csr_matrix(1.0 / norms)).tocsr()
S = (Xn.T @ Xn).tocsr() # (m × m), уже косинус
counts = (X > 0).astype(np.float32)
co = (counts.T @ counts).tocsr() # сколько пользователей видели оба
# усадка: пара с двумя совстречаемостями не должна давать sim = 1.0
factor = co.copy()
factor.data = factor.data / (factor.data + shrink)
S = S.multiply(factor).tocsr()
S.setdiag(0.0)
S.eliminate_zeros()
return _keep_topk_per_row(S, topk)
def _keep_topk_per_row(S: sp.csr_matrix, topk: int) -> sp.csr_matrix:
"""Оставляем topk наибольших значений в каждой строке — обрезаем хвост шума."""
rows, cols, vals = [], [], []
for r in range(S.shape[0]):
start, end = S.indptr[r], S.indptr[r + 1]
if end == start:
continue
idx = S.indices[start:end]
v = S.data[start:end]
if len(v) > topk:
sel = np.argpartition(-v, topk)[:topk]
idx, v = idx[sel], v[sel]
rows.extend([r] * len(idx))
cols.extend(idx.tolist())
vals.extend(v.tolist())
return sp.csr_matrix((vals, (rows, cols)), shape=S.shape)
def recommend_item_knn(X: sp.csr_matrix, S: sp.csr_matrix, u: int, n: int = 10):
"""Скор = сумма похожестей на всё, что пользователь уже трогал."""
user_row = X[u]
scores = np.asarray((user_row @ S).todense()).ravel()
scores[user_row.indices] = -np.inf # не рекомендуем уже виденное
top = np.argpartition(-scores, n)[:n]
return top[np.argsort(-scores[top])]
Две детали здесь важнее самой формулы.
Усадка (shrink). Без неё пара объектов, которую посмотрел ровно один общий пользователь, получает косинус 1.0 и лезет в топ. Множитель co / (co + shrink) штрафует малую статистику ровно так же, как lam в bias-модели.
Обрезка до topk соседей. Полная матрица m × m для m = 10⁶ — 10¹² чисел, не существует. Плюс хвост слабых похожестей — почти чистый шум, его удаление обычно улучшает и качество, и скорость.
Сложность. Построение — O(Σ_u |Iᵤ|²) в худшем случае: пользователь с 10 000 действий даёт 10⁸ пар. Отсюда обязательная практика — отсечение сверхактивных пользователей и сверхпопулярных объектов при построении похожестей (крауд-боты и «пакеты» портят статистику). Применение — O(|Iᵤ| · topk) на запрос, то есть микросекунды.
Отдельно стоит знать про EASE^R (Steck. Embarrassingly Shallow Autoencoders, WWW 2019) — модель item-item, у которой матрица весов имеет замкнутое решение через обращение (XᵀX + λI). Одна формула, никакого обучения, и на многих бенчмарках она бьёт нейросетевые подходы. Ограничение — O(m³) на обращение, то есть до ~50 000 объектов.
Матричная факторизация
Соседский подход не обобщает: если два товара ни разу не встретились в одной корзине, их похожесть строго ноль, даже если они очевидно взаимозаменяемы. Латентные модели решают это, заставляя данные пройти через узкое место.
Идея: каждому пользователю сопоставим вектор pᵤ ∈ ℝᵏ, каждому объекту — qᵢ ∈ ℝᵏ, а предсказание получим скалярным произведением:
r̂ᵤᵢ = μ + bᵤ + bᵢ + pᵤᵀ qᵢ
Координаты p и q никто не задаёт руками — они выучиваются. Апостериори в них часто видны интерпретируемые оси («серьёзность против лёгкости», «мейнстрим против нишевого»), но это побочный эффект, а не цель. Связь с снижением размерности прямая: это то же самое низкоранговое приближение, что и в SVD, но с двумя принципиальными отличиями.
Почему нельзя просто взять SVD. Классический SVD определён для полной матрицы, а у нас 99.99% пропусков. Заполнить их нулями — значит утверждать «пользователь ненавидит всё, чего не видел», и модель добросовестно выучит именно это. Заполнить средними — внести гигантский объём выдуманных данных, которые задавят реальный сигнал. Правильный ход, придуманный Simon Funk во время Netflix Prize, — оптимизировать ошибку только на наблюдённых ячейках:
min_{P,Q} Σ_{(u,i) ∈ D} (rᵤᵢ − μ − bᵤ − bᵢ − pᵤᵀqᵢ)² + λ(‖pᵤ‖² + ‖qᵢ‖² + bᵤ² + bᵢ²)
Задача невыпуклая по (P, Q) совместно (произведение двух неизвестных), но выпуклая по каждому из них при фиксированном другом. Отсюда два стандартных алгоритма: SGD и ALS.
FunkSVD: стохастический градиентный спуск
def funk_svd(triples, n_users, n_items, k=32, lr=0.01, lam=0.05, n_epochs=20, seed=0):
"""Матричная факторизация с bias-ами, обучение SGD (алгоритм Simon Funk).
Сложность: O(n_epochs · |D| · k) времени, O((n + m) · k) памяти.
"""
rng = np.random.default_rng(seed)
P = rng.normal(0, 0.05, (n_users, k))
Q = rng.normal(0, 0.05, (n_items, k))
bu = np.zeros(n_users)
bi = np.zeros(n_items)
mu = np.mean([r for _, _, r in triples])
data = np.array(triples, dtype=np.float64)
for epoch in range(n_epochs):
rng.shuffle(data)
sq_err = 0.0
for u_f, i_f, r in data:
u, i = int(u_f), int(i_f)
pred = mu + bu[u] + bi[i] + P[u] @ Q[i]
e = r - pred
sq_err += e * e
# градиентный шаг: ∂L/∂p_u = -2 e q_i + 2 λ p_u
bu[u] += lr * (e - lam * bu[u])
bi[i] += lr * (e - lam * bi[i])
p_old = P[u].copy()
P[u] += lr * (e * Q[i] - lam * P[u])
Q[i] += lr * (e * p_old - lam * Q[i])
rmse = np.sqrt(sq_err / len(data))
if epoch % 5 == 0:
print(f"epoch {epoch:3d} train RMSE = {rmse:.4f}")
return P, Q, bu, bi, mu
Расширение, давшее заметный прирост на Netflix, — SVD++ (Koren. Factorization Meets the Neighborhood, KDD 2008): к вектору пользователя добавляется сумма латентных векторов всех объектов, с которыми он взаимодействовал, pᵤ + |N(u)|^{-1/2} Σ_{j ∈ N(u)} yⱼ. Смысл — сам факт взаимодействия несёт информацию даже без оценки, а заодно новый пользователь получает осмысленный вектор без переобучения модели.
Implicit ALS: правильная модель для кликов
Для неявного отклика квадратичная ошибка по наблюдённым ячейкам бессмысленна: там все значения положительные, и оптимум — «предсказывать всем всё». Нужна модель, которая учитывает нули как слабые негативы. Каноническое решение — Hu, Koren, Volinsky. Collaborative Filtering for Implicit Feedback Datasets (ICDM 2008).
Разделяем «факт предпочтения» и «уверенность в нём»:
pᵤᵢ = 1, если rᵤᵢ > 0, иначе 0 (бинарное предпочтение)
cᵤᵢ = 1 + α · rᵤᵢ (уверенность растёт с числом действий)
L = Σ_{u,i} cᵤᵢ (pᵤᵢ − xᵤᵀyᵢ)² + λ(Σ‖xᵤ‖² + Σ‖yᵢ‖²)
Сумма идёт по всем n · m парам, а не только по наблюдённым: нули входят с весом 1 (слабое «скорее нет»), наблюдения — с весом 1 + αr (сильное «да»). Прямое вычисление стоило бы O(n·m·k²) — неприемлемо. Спасает алгебраический трюк: при фиксированном Y
xᵤ = (YᵀCᵘY + λI)⁻¹ YᵀCᵘp(u), где YᵀCᵘY = YᵀY + Yᵀ(Cᵘ − I)Y
Матрица YᵀY считается один раз для всех пользователей за O(m·k²), а Cᵘ − I отлична от нуля только на объектах пользователя — то есть на nnzᵤ позициях. Итоговая стоимость эпохи — O(k² · nnz + k³ · (n + m)), линейно по числу наблюдений.
def implicit_als(X: sp.csr_matrix, k=64, alpha=40.0, lam=0.1, n_iter=15, seed=0):
"""Implicit ALS (Hu, Koren, Volinsky 2008).
X: (n_users × n_items) счётчики действий (просмотры, покупки).
Возвращает эмбеддинги пользователей и объектов.
Сложность: O(n_iter · (k²·nnz + k³·(n+m))) времени, O((n+m)k) памяти.
"""
n, m = X.shape
rng = np.random.default_rng(seed)
Xu = X.tocsr()
Xi = X.T.tocsr()
P = rng.normal(0, 0.01, (n, k))
Q = rng.normal(0, 0.01, (m, k))
I = np.eye(k)
def solve_side(mat: sp.csr_matrix, fixed: np.ndarray, out: np.ndarray):
# YᵀY считаем один раз — это и есть весь фокус производительности
gram = fixed.T @ fixed
for row in range(mat.shape[0]):
start, end = mat.indptr[row], mat.indptr[row + 1]
idx = mat.indices[start:end]
if len(idx) == 0:
out[row] = 0.0
continue
conf = 1.0 + alpha * mat.data[start:end] # c_ui
F = fixed[idx] # (nnz_u × k)
# A = YᵀY + Yᵀ(C-I)Y + λI, b = YᵀC p(u) = Σ c_ui y_i
A = gram + F.T @ ((conf - 1.0)[:, None] * F) + lam * I
b = F.T @ conf
out[row] = np.linalg.solve(A, b)
for _ in range(n_iter):
solve_side(Xu, Q, P) # обновляем пользователей при фиксированных объектах
solve_side(Xi, P, Q) # и наоборот
return P, Q
ALS хорош ещё и тем, что тривиально параллелится: строки независимы. Продовая реализация — библиотека implicit (Cython + опционально GPU), которая на матрице в миллиард ненулевых значений обучается за десятки минут.
Важная деталь, о которой забывают: alpha — самый чувствительный гиперпараметр iALS, часто важнее k. При больших alpha модель почти игнорирует нули и скатывается к запоминанию, при малых — размывает сигнал. Диапазон поиска обычно 1–100 по логарифмической сетке.
BPR: учим порядок, а не значение
Если цель — ранжирование, то и функция потерь должна быть ранжирующей. BPR (Rendle et al., BPR: Bayesian Personalized Ranking from Implicit Feedback, UAI 2009) оптимизирует попарное условие: для тройки «пользователь u, позитив i, случайный негатив j» скор позитива должен быть выше.
max Σ_{(u,i,j)} ln σ(x̂ᵤᵢ − x̂ᵤⱼ) − λ‖Θ‖²
def bpr_sgd(X: sp.csr_matrix, k=64, lr=0.05, lam=0.01, n_steps=2_000_000, seed=0):
"""BPR-MF: pairwise-ранжирование на implicit-данных.
На каждом шаге: пользователь u, позитив i из его истории,
негатив j — равномерно случайный объект вне истории.
Сложность: O(n_steps · k).
"""
n, m = X.shape
rng = np.random.default_rng(seed)
P = rng.normal(0, 0.01, (n, k))
Q = rng.normal(0, 0.01, (m, k))
Xl = X.tolil()
users = np.array([u for u in range(n) if X.indptr[u + 1] > X.indptr[u]])
for _ in range(n_steps):
u = int(rng.choice(users))
pos_items = X.indices[X.indptr[u]:X.indptr[u + 1]]
i = int(rng.choice(pos_items))
j = int(rng.integers(m))
while Xl[u, j] != 0: # negative sampling
j = int(rng.integers(m))
x_uij = P[u] @ (Q[i] - Q[j])
# градиент ln σ(x) даёт множитель σ(−x)
g = 1.0 / (1.0 + np.exp(x_uij))
pu, qi, qj = P[u].copy(), Q[i].copy(), Q[j].copy()
P[u] += lr * (g * (qi - qj) - lam * pu)
Q[i] += lr * (g * pu - lam * qi)
Q[j] += lr * (-g * pu - lam * qj)
return P, Q
Выбор между iALS и BPR на практике: iALS обычно сильнее по Recall@k на плотных данных и заметно быстрее сходится, BPR лучше работает, когда важен точный порядок в самом верху и когда можно позволить себе умный сэмплинг негативов (популярностный, hard negatives из текущей выдачи). Вопреки распространённому мнению, что iALS устарел, аккуратно настроенный iALS остаётся конкурентоспособным даже против современных нейросетей — см. Rendle et al., Revisiting the Performance of iALS on Item Recommendation Benchmarks (2021).
Метрики: как измерять качество ранжирования
Все метрики считаются по top-k выдаче для каждого пользователя и усредняются по пользователям.
| Метрика | Что измеряет | Учитывает порядок |
|---|---|---|
| Precision@k | доля релевантных среди k |
нет |
| Recall@k (HitRate) | доля найденных из всех релевантных | нет |
| MAP@k | средняя точность по позициям попаданий | да |
| MRR | 1 / позиция первого попадания |
да |
| NDCG@k | взвешенная логарифмом позиции полезность | да |
| Coverage | доля каталога, попадающая хоть кому-то | — |
| Novelty | средняя «непопулярность» рекомендаций | — |
Основная рабочая метрика — NDCG@k:
DCG@k = Σ_{i=1..k} (2^{relᵢ} − 1) / log₂(i + 1)
NDCG@k = DCG@k / IDCG@k (IDCG — DCG идеального порядка)
Логарифмический дисконт формализует очевидное: попадание на первой позиции ценнее, чем на десятой, но не в десять раз.
def dcg_at_k(rels, k):
rels = np.asarray(rels, dtype=float)[:k]
if rels.size == 0:
return 0.0
discounts = np.log2(np.arange(2, rels.size + 2))
return float(np.sum((2 ** rels - 1) / discounts))
def ndcg_at_k(recommended, relevant, k=10):
"""recommended: список item_id по убыванию скора.
relevant: dict item_id -> релевантность (или set для бинарной)."""
if isinstance(relevant, set):
relevant = {i: 1.0 for i in relevant}
gains = [relevant.get(i, 0.0) for i in recommended[:k]]
ideal = sorted(relevant.values(), reverse=True)[:k]
idcg = dcg_at_k(ideal, k)
return dcg_at_k(gains, k) / idcg if idcg > 0 else 0.0
def average_precision_at_k(recommended, relevant: set, k=10):
hits, score = 0, 0.0
for rank, item in enumerate(recommended[:k], start=1):
if item in relevant:
hits += 1
score += hits / rank
return score / min(len(relevant), k) if relevant else 0.0
Метрики «за пределами точности»
Система, максимизирующая только NDCG, сходится к показу одного и того же топ-100 всем подряд. Это локально оптимально и стратегически катастрофично: длинный хвост каталога не продаётся, поставщики уходят, лента становится однообразной. Поэтому в дашборд обязательно входят:
- Catalog coverage — доля объектов, показанных хоть одному пользователю за период.
- Intra-list diversity — среднее попарное расстояние внутри выдачи.
- Novelty —
−log₂ p(i)по популярности: насколько неочевидно то, что показали. - Serendipity — доля полезных рекомендаций, которые пользователь не нашёл бы сам (обычно: релевантные и при этом непохожие на историю).
- Gini / энтропия распределения показов — концентрация внимания.
Хорошая практика — фиксировать точностные метрики как оптимизируемые, а разнообразие как ограничение («NDCG максимизируем при coverage не ниже X»).
Валидация: главный источник самообмана
Здесь ошибаются чаще, чем в моделировании. Три правила.
1. Сплит только по времени. Случайный сплит взаимодействий — прямая утечка из будущего: модель видит, что пользователь купил утюг в марте, и «предсказывает» покупку гладильной доски в феврале. Правильно: выбрать момент T, обучаться на t < T, оценивать на t ≥ T. Механика утечек подробно разобрана в статье про данные и признаки.
2. Leave-one-out — только с осторожностью. Схема «спрятать последнее действие каждого пользователя» популярна, но у неё разные T для разных пользователей, то есть частичная утечка глобальных трендов. Для отчётности лучше глобальный временной сплит.
3. Не считайте метрики на сэмплированных негативах. Распространённая схема — ранжировать 1 позитив против 100 случайных негативов. Krichene, Rendle. On Sampled Metrics for Item Recommendation (KDD 2020) показали, что такие метрики несогласованы с полными: модель A обгоняет B на сэмпле и проигрывает на полном каталоге. Считайте по всему каталогу — это дешевле, чем кажется (одно матричное умножение).
def temporal_split(events, test_days=7):
"""Глобальный временной сплит: единый момент отсечения для всех.
events: DataFrame с колонками user_id, item_id, ts.
"""
t_max = events["ts"].max()
t_split = t_max - np.timedelta64(test_days, "D")
train = events[events["ts"] < t_split]
test = events[events["ts"] >= t_split]
# Только «тёплые» пользователи: холодные оцениваются отдельным протоколом,
# иначе их нули размывают метрику и прячут реальные проблемы.
warm = set(train["user_id"].unique())
test = test[test["user_id"].isin(warm)]
return train, test
Отдельно фиксируйте, что офлайн-метрика — лишь фильтр гипотез. Корреляция офлайн-прироста с онлайн-эффектом положительная, но далёкая от единицы: офлайн вы измеряете способность угадать то, что пользователь сделал под действием старой системы. Финальный судья — только A/B-тест.
Продакшн-архитектура: две стадии
Ни одна нетривиальная модель не может проскорить миллион объектов за 50 миллисекунд. Отсюда универсальная схема, описанная в том числе в Covington, Adams, Sargin. Deep Neural Networks for YouTube Recommendations (RecSys 2016).
Стадия 1: отбор кандидатов (retrieval)
Задача — из 10⁶–10⁸ объектов достать ~1000 потенциально релевантных за единицы миллисекунд. Требование к модели жёсткое: скор должен раскладываться так, чтобы поиск сводился к приближённому поиску ближайших соседей в векторном пространстве. Скалярное произведение pᵤᵀqᵢ подходит идеально: вектор пользователя считается один раз, дальше работает индекс.
Отсюда популярность two-tower архитектуры: две независимые сети, одна кодирует пользователя (история, контекст, демография), другая — объект (контент, категория, id). Пересечение только в финальном скалярном произведении, поэтому все qᵢ можно посчитать офлайн и залить в индекс.
import torch
import torch.nn as nn
import torch.nn.functional as F
class TwoTower(nn.Module):
"""Классическая two-tower модель для стадии retrieval.
Ключевое ограничение: башни не общаются между собой до финального
dot product — иначе эмбеддинги объектов нельзя посчитать офлайн.
"""
def __init__(self, n_users, n_items, n_cats, dim=64):
super().__init__()
self.user_emb = nn.Embedding(n_users, dim)
self.item_emb = nn.Embedding(n_items, dim)
self.cat_emb = nn.Embedding(n_cats, dim)
self.user_mlp = nn.Sequential(nn.Linear(dim, 128), nn.ReLU(), nn.Linear(128, dim))
self.item_mlp = nn.Sequential(nn.Linear(2 * dim, 128), nn.ReLU(), nn.Linear(128, dim))
def user_tower(self, user_ids):
return F.normalize(self.user_mlp(self.user_emb(user_ids)), dim=-1)
def item_tower(self, item_ids, cat_ids):
z = torch.cat([self.item_emb(item_ids), self.cat_emb(cat_ids)], dim=-1)
return F.normalize(self.item_mlp(z), dim=-1)
def in_batch_softmax_loss(u_vec, i_vec, log_q, temperature=0.05):
"""Sampled softmax с in-batch негативами и logQ-коррекцией.
Остальные объекты батча служат негативами — это бесплатно и эффективно,
но популярные объекты попадают в негативы чаще пропорционально их частоте,
поэтому из логитов вычитается log Q(i) (поправка Bengio-Senecal).
"""
logits = (u_vec @ i_vec.T) / temperature # (B × B)
logits = logits - log_q.unsqueeze(0) # коррекция смещения сэмплинга
labels = torch.arange(u_vec.size(0), device=u_vec.device)
return F.cross_entropy(logits, labels)
Про logQ-коррекцию: без неё модель систематически недооценивает популярные объекты, потому что они чаще оказываются негативами. Разбор — Yi et al., Sampling-Bias-Corrected Neural Modeling for Large Corpus Item Recommendations (RecSys 2019).
Готовые эмбеддинги кладутся в ANN-индекс: FAISS (IVF-PQ для миллиардов векторов), HNSW (граф, отличный recall при низкой латентности), ScaNN. Типичная точка на кривой: recall@100 около 0.95 при 1–3 мс на запрос.
Важно: retrieval почти никогда не бывает один. В проде параллельно работают 5–15 источников кандидатов: ANN по вкусам, item2item по последнему просмотру, «дозаказ» по истории покупок, новинки категории, тренды региона, ретаргетинг брошенной корзины. Разнообразие источников защищает от отказа любого одного и покрывает разные интенты.
Стадия 2: ранжирование
Здесь кандидатов уже сотни, а не миллионы, поэтому можно позволить дорогую модель с признаками, зависящими одновременно от u и i. Промышленный стандарт — градиентный бустинг (LightGBM/CatBoost с lambdarank или YetiRank), иногда DNN уровня Wide & Deep или DLRM.
Типичные группы признаков:
| Группа | Примеры |
|---|---|
| Пользователь | активность за 7/30 дней, средний чек, доля категорий, время с последнего визита |
| Объект | CTR за 1/7/30 дней, цена и её перцентиль в категории, возраст, рейтинг, наличие |
Пара (u, i) |
скор из retrieval, сколько раз показывали и не кликали, покупал ли бренд, косинус эмбеддингов |
| Контекст | час, день недели, устройство, источник трафика, длина сессии |
| Смещения | позиция показа (только на обучении), версия предыдущей модели |
Цель ранкера редко бывает одна. Обычно это композиция вероятностей и ценностей:
score = p(click)^α · p(purchase | click)^β · value(i)^γ · penalty(i)
Показатели степеней подбирают под бизнес-цель — это самая честная и самая недооценённая точка контроля системы: она позволяет переносить приоритеты (выручка/удержание/маржа) без переобучения моделей.
Позиционное смещение на этой стадии критично. Если обучать ранкер на кликах, он выучит, что «объекты на первой позиции хороши», и закрепит выдачу вчерашней модели. Стандартные лечения: добавить позицию как признак и подставлять фиксированное значение на инференсе (position-as-feature из Zhao et al., Recommending What Video to Watch Next, RecSys 2019); либо взвешивание примеров через inverse propensity scoring 1 / p(examination | position).
Стадия 3: переранжирование
Последний фильтр решает задачи, которые ранкер решить не может, потому что он смотрит на объекты по одному, а пользователь видит список целиком.
def mmr_rerank(candidates, scores, sim_matrix, k=10, lam=0.7):
"""Maximal Marginal Relevance: баланс релевантности и разнообразия.
lam = 1.0 — чистая релевантность; lam = 0.0 — максимальное разнообразие.
Сложность: O(k · |candidates|).
"""
selected, pool = [], list(range(len(candidates)))
while len(selected) < k and pool:
best, best_val = None, -np.inf
for idx in pool:
div = max((sim_matrix[idx, s] for s in selected), default=0.0)
val = lam * scores[idx] - (1 - lam) * div
if val > best_val:
best, best_val = idx, val
selected.append(best)
pool.remove(best)
return [candidates[i] for i in selected]
Плюс жёсткие правила: не больше двух товаров одного бренда в выдаче, не показывать то же самое, что вчера, гарантировать слот новинкам, соблюдать возрастные ограничения и региональные лицензии. Правила выглядят «неинтеллектуально», но именно они обычно спасают продукт от жалоб.
Холодный старт
Три разных холодных старта, лечатся по-разному:
Холодный объект. Коллаборативного сигнала нет по определению. Решение — построить вектор объекта из контента: текст описания через эмбеддинг-модель, картинка через CV-энкодер, категориальные атрибуты. Затем обучить проекцию из контентного пространства в коллаборативное на тёплых объектах (по сути регрессия q̂ᵢ = f(contentᵢ)), и использовать её для новых. Плюс обязательный бюджет исследования.
Холодный пользователь. Работают: неперсональные топы по сегменту (регион, устройство, источник), онбординг («выберите 3 интересующих жанра»), контекст первой сессии (первый же просмотр — сильнейший сигнал), перенос из смежного продукта. Отдельно важно, что сессионные модели здесь выигрывают у user-эмбеддингов: они не требуют истории вообще.
Холодная система. Данных нет ни у кого. Единственный честный путь — начать с контентных правил и ручных подборок, параллельно логировать всё, и переключаться на коллаборативку по мере накопления. Попытка стартовать с нейросети на 500 событиях — гарантированная потеря месяцев.
Исследование vs эксплуатация. Задача выбора «показать проверенное или изучить новое» — ровно та, которую формализуют многорукие бандиты: Thompson sampling для быстрой сходимости, LinUCB когда есть контекст. Это прямой мост к статье про обучение с подкреплением, где бандиты разбираются как вырожденный случай RL с горизонтом в один шаг.
Смещения и петля обратной связи
Самая коварная проблема рекомендательных систем не техническая, а системная: модель обучается на данных, которые сама породила.
только с показанным] E --> L[Логи: клики по показанному] L --> T[Переобучение на этих логах] T --> M S -.->|непоказанное
не получает шанса| DARK[Тёмная зона каталога] DARK -.->|нет данных → низкий скор
→ снова не показано| DARK T --> P[Усиление популярного:
богатые становятся богаче] P --> NAR[Сужение выдачи:
coverage падает, разнообразие падает] NAR --> CHURN[Пользователю скучно,
метрики удержания проседают] subgraph Лечение EX[Бюджет исследования 2-5%] IPS[IPS-взвешивание по propensity] DIV[Ограничения на разнообразие
и coverage в переранжировании] RAND[Небольшой рандомизированный слой
для несмещённой оценки] end EX -.-> S IPS -.-> T DIV -.-> S RAND -.-> L
Три конкретных смещения, которые нужно уметь называть:
Позиционное (position bias). Клик зависит от позиции сильнее, чем от релевантности. Оценивается экспериментом со случайной перестановкой (result randomization) или интервенцией swap-теста; лечится IPS или position-as-feature.
Популярностное (popularity bias). Популярные объекты чаще показываются → чаще кликаются → выглядят ещё популярнее. Лечится нормализацией скора на популярность (score / pop(i)^β, β ∈ [0, 0.5]), сэмплингом негативов пропорционально популярности, ограничениями на coverage.
Смещение выбора (selection bias / closed loop). Мы наблюдаем только то, что показали. Единственный полностью честный способ получить несмещённые данные — держать небольшой слой полностью случайной выдачи (0.1–1% трафика). Это дорого и почти всегда окупается: на этом слое можно считать честные метрики и калибровать propensity. Теория — Schnabel et al., Recommendations as Treatments: Debiasing Learning and Evaluation (ICML 2016).
Стоит понимать и социальную сторону: усиление популярного и сужение выдачи создают «пузырь фильтров». Насколько эффект силён — вопрос спорный, но выдача, которая на 90% состоит из одной категории, вредит и пользователю, и метрикам удержания.
Последовательные модели
Матричная факторизация теряет порядок: она видит множество взаимодействий, а не траекторию. Между тем «купил телефон → нужен чехол» и «купил чехол → нужен телефон» — совершенно разные ситуации.
Эволюция подходов:
- Марковские цепи / FPMC — вероятность следующего объекта зависит от предыдущего. Просто и на удивление сильно на коротких сессиях.
- GRU4Rec (Hidasi et al., 2015) — рекуррентная сеть по сессии; первая работа, показавшая ценность сессионного моделирования.
- SASRec (Kang, McAuley, 2018) — self-attention с каузальной маской: предсказание следующего элемента, ровно как языковая модель. Быстрее RNN и обычно точнее.
- BERT4Rec (Sun et al., 2019) — двунаправленный трансформер с masked-item задачей.
Трезвый взгляд: прирост трансформерных моделей над хорошо настроенным item-item kNN на многих реальных датасетах скромный, а стоимость обслуживания высокая. Известна работа о проблемах воспроизводимости BERT4Rec (RecSys 2022), где для повторения заявленных чисел потребовалось на порядок больше эпох обучения. Начинайте с простого; переходите к трансформеру, когда сессионная динамика доказанно важна (музыка, короткие видео, новости) и когда есть инженерный ресурс.
Архитектурная связь с нейросетями прямая: механизм внимания в SASRec — тот же, что в языковых моделях, только словарь состоит из идентификаторов товаров.
Инженерия: что ломается в проде
Разделение офлайн и онлайн путей. Тяжёлое обучение (iALS, two-tower, GBDT) — батчем раз в сутки. Инкрементальные обновления (счётчики, свежие взаимодействия, новые объекты) — потоком. Эмбеддинги пользователей часто пересчитывают «на лету» из истории сессии, чтобы реакция была мгновенной.
Согласованность признаков (training/serving skew). Классическая катастрофа: офлайн признак «средний чек за 30 дней» считается по полной таблице, а онлайн — по кэшу, который отстаёт на два часа. Модель деградирует незаметно. Лечение — единый feature store с одним кодом вычислений для обучения и инференса и обязательный мониторинг распределений. Это территория MLOps.
Пересборка ANN-индекса. Полная перестройка индекса на 100 млн векторов занимает десятки минут. Нужны: инкрементальная вставка новых объектов, атомарное переключение версий (build → warm-up → swap), проверка recall на эталонном наборе перед выкаткой.
Деградация. Если ранкер не ответил за бюджет — отдать порядок из retrieval. Если retrieval упал — отдать популярное по категории. Пустая лента хуже неоптимальной; каскад фолбэков должен быть явным и протестированным.
Идентификация. Один человек = несколько устройств и часто несколько аккаунтов. Склейка профилей даёт больший прирост качества, чем смена модели, и почти всегда стоит дешевле.
Приватность и право на забвение. Удаление пользователя должно вычищать его из обучающих данных и — в пределе — из уже обученных эмбеддингов. Полное переобучение по каждому запросу невозможно, поэтому фиксируйте политику заранее (обычно: удаление из витрин + плановое переобучение в течение N дней).
Онлайн-эксперименты
Офлайн-метрики отбирают гипотезы, но решение принимает A/B-тест. Специфика рекомендаций:
- Метрики выбирают заранее и слоями. Первичная (например, конверсия в покупку), вторичные (CTR, глубина просмотра), guardrail (время загрузки, доля жалоб, coverage, доля новинок). Рост CTR при падении покупок — типичный признак кликбейта.
- Сетевые и каннибализационные эффекты. Если тестовая группа скупает дефицитный товар, контрольной он не достанется. При сильной связности нужен кластерный дизайн.
- Долгосрочность. Персонализация даёт мгновенный прирост CTR и отложенный эффект на удержание. Тест на 3 дня систематически переоценивает эксплуатацию и недооценивает исследование. Минимум — недельные циклы с учётом дневной сезонности.
- Интерливинг — смешивание выдач двух моделей в одном списке — даёт статистически значимый ответ на порядок быстрее классического A/B при сравнении ранжирований (Chapelle et al., Large-Scale Validation and Analysis of Interleaved Search Evaluation, TOIS 2012). Отличный инструмент для быстрой отбраковки — но не заменяет A/B при оценке бизнес-эффекта.
Общая методология экспериментов — в статье про оценку моделей.
Типичные ошибки
- Оптимизировать RMSE, когда продукту нужен топ-10. Метрика должна повторять форму задачи: ранжирование меряется ранжирующими метриками.
- Случайный сплит вместо временного. Даёт фантастические офлайн-числа и нулевой онлайн-эффект.
- Не логировать показы. Отрезает возможность обучать ранкер, оценивать позиционное смещение и делать IPS. Восстановить задним числом невозможно.
- Не сравниваться с топом популярного. Половина «улучшений» ему проигрывает; узнать об этом лучше до релиза.
- Игнорировать усадку/регуляризацию. Топ рекомендаций захватывают объекты с одним взаимодействием.
- Считать метрики на сэмплированных негативах. Ранжирование моделей меняется относительно полного каталога.
- Забыть про фильтр «уже куплено/просмотрено». Рекомендация только что купленного холодильника — самый частый мем про рекомендации, и он всегда про отсутствие фильтра.
- Нулевое исследование. Без бюджета на новинки каталог замерзает, и через полгода система рекомендует ассортимент прошлого года.
- Одна модель на все поверхности. Главная страница, карточка товара, корзина и письмо — четыре разных интента; общий кандидатный слой возможен, общий ранкер обычно нет.
- Смешивать обучение и инференс-признаки разным кодом. Тихая деградация, которую замечают через месяцы.
Мини-итог
- Рекомендации — задача о паре
(пользователь, объект), с преимущественно неявным откликом, экстремальной разреженностью (0.01–1%) и обратной связью, где система формирует собственную обучающую выборку. - Правильная рамка — ранжирование, а не предсказание оценок; отсюда метрики NDCG@k, Recall@k, MAP@k и обязательные метрики разнообразия и покрытия.
- Baseline из топа популярных и bias-модели
μ + bᵤ + bᵢобязателен: он побеждает чаще, чем принято думать. - Item-item kNN даёт объяснимость и мгновенную реакцию; implicit ALS правильно моделирует нули как слабые негативы и стоит
O(k²·nnz + k³·(n+m))за эпоху; BPR оптимизирует непосредственно порядок. - Прод — это воронка: retrieval (ANN по эмбеддингам, миллионы → тысячи), ranking (GBDT/DNN на признаках пары, тысячи → сотни), reranking (разнообразие и бизнес-правила, сотни → десять).
- Валидация только по времени, метрики только по полному каталогу, финальное решение только по A/B — офлайн-метрика есть фильтр гипотез, а не доказательство.
- Холодный старт лечится контентными векторами и явным бюджетом исследования; петля обратной связи — рандомизированным слоем, IPS-взвешиванием и ограничениями на разнообразие.
Источники
- Linden, Smith, York. Amazon.com Recommendations: Item-to-Item Collaborative Filtering, IEEE Internet Computing 2003 — работа, определившая индустриальный стандарт.
- Hu, Koren, Volinsky. Collaborative Filtering for Implicit Feedback Datasets, ICDM 2008 — implicit ALS, обязательное чтение.
- Koren, Bell, Volinsky. Matrix Factorization Techniques for Recommender Systems, IEEE Computer 2009 — итог Netflix Prize.
- Rendle et al. BPR: Bayesian Personalized Ranking from Implicit Feedback, UAI 2009.
- Rendle et al. Revisiting the Performance of iALS on Item Recommendation Benchmarks, 2021 — почему классика всё ещё конкурентна.
- Covington, Adams, Sargin. Deep Neural Networks for YouTube Recommendations, RecSys 2016 — каноническое описание двухстадийной архитектуры.
- Yi et al. Sampling-Bias-Corrected Neural Modeling for Large Corpus Item Recommendations, RecSys 2019 — two-tower и logQ-коррекция.
- Steck. Embarrassingly Shallow Autoencoders for Sparse Data, WWW 2019 — EASE^R.
- Dacrema, Cremonesi, Jannach. Are We Really Making Much Progress?, RecSys 2019 — кризис воспроизводимости в области.
- Krichene, Rendle. On Sampled Metrics for Item Recommendation, KDD 2020.
- Schnabel et al. Recommendations as Treatments, ICML 2016 — IPS и несмещённая оценка.
- Kang, McAuley. Self-Attentive Sequential Recommendation, ICDM 2018.
- Aggarwal. Recommender Systems: The Textbook, Springer 2016 — самый полный современный учебник.
- Falk. Practical Recommender Systems, Manning 2019 — инженерная сторона, много про логирование и архитектуру.
- Библиотеки: implicit (ALS/BPR на Cython и GPU), LightFM (гибрид контента и коллаборативки), RecBole (сотня моделей для сравнения), FAISS и hnswlib для ANN.
Смежные материалы трека: подготовка признаков и утечки — данные и признаки; ранкер на бустинге — ансамбли и бустинг; латентные пространства и низкоранговые разложения — снижение размерности; метрики и протоколы валидации — оценка моделей; соседские методы и метрики близости — k ближайших соседей; бандиты и исследование — обучение с подкреплением; эксплуатация моделей — MLOps.
Что дальше
В этой статье время появлялось постоянно — временной сплит, свежесть объектов, дневная сезонность трафика, затухание интереса, — но всегда как ограничение, которое нужно не нарушить. Существует целый класс задач, где время не ограничение, а сам предмет моделирования: спрос на завтра, нагрузка на сервис, цена через неделю. Там ломаются привычные предположения (наблюдения не независимы, распределение дрейфует, а кросс-валидация в обычном виде запрещена) и появляется собственный инструментарий — от классических авторегрессионных моделей до того же градиентного бустинга на хитро сконструированных лагах. Об этом следующая статья: Временные ряды: от ARIMA до градиентного бустинга.