Машинное обучение Рекомендательные системы
0%

Рекомендательные системы

Рекомендательные системы

Все задачи трека до этого момента имели общий вид: есть объект 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) — клики, просмотры, покупки, время просмотра, добавления в корзину. Данных на несколько порядков больше, но у него три неприятных свойства:

  1. Нет негативов. Отсутствие клика — не «не нравится», а «не видел ИЛИ не нравится». Это problem of one-class collaborative filtering.
  2. Сигнал не бинарный, а «уверенность». Просмотр сериала 8 раз сильнее одного просмотра, но не в 8 раз «нравится».
  3. Он загрязнён позиционным смещением. Первая позиция получает клики просто потому, что первая.

Ключевая инженерная рекомендация, которую систематически игнорируют: логируйте показы (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 объектов.

Матричная факторизация

Соседский подход не обобщает: если два товара ни разу не встретились в одной корзине, их похожесть строго ноль, даже если они очевидно взаимозаменяемы. Латентные модели решают это, заставляя данные пройти через узкое место.

Матричная факторизация: R ≈ P · Qᵀ

Идея: каждому пользователю сопоставим вектор 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).

Воронка выдачи: retrieval → ranking → reranking

Стадия 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 с горизонтом в один шаг.

Смещения и петля обратной связи

Самая коварная проблема рекомендательных систем не техническая, а системная: модель обучается на данных, которые сама породила.

Три конкретных смещения, которые нужно уметь называть:

Позиционное (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 при оценке бизнес-эффекта.

Общая методология экспериментов — в статье про оценку моделей.

Типичные ошибки

  1. Оптимизировать RMSE, когда продукту нужен топ-10. Метрика должна повторять форму задачи: ранжирование меряется ранжирующими метриками.
  2. Случайный сплит вместо временного. Даёт фантастические офлайн-числа и нулевой онлайн-эффект.
  3. Не логировать показы. Отрезает возможность обучать ранкер, оценивать позиционное смещение и делать IPS. Восстановить задним числом невозможно.
  4. Не сравниваться с топом популярного. Половина «улучшений» ему проигрывает; узнать об этом лучше до релиза.
  5. Игнорировать усадку/регуляризацию. Топ рекомендаций захватывают объекты с одним взаимодействием.
  6. Считать метрики на сэмплированных негативах. Ранжирование моделей меняется относительно полного каталога.
  7. Забыть про фильтр «уже куплено/просмотрено». Рекомендация только что купленного холодильника — самый частый мем про рекомендации, и он всегда про отсутствие фильтра.
  8. Нулевое исследование. Без бюджета на новинки каталог замерзает, и через полгода система рекомендует ассортимент прошлого года.
  9. Одна модель на все поверхности. Главная страница, карточка товара, корзина и письмо — четыре разных интента; общий кандидатный слой возможен, общий ранкер обычно нет.
  10. Смешивать обучение и инференс-признаки разным кодом. Тихая деградация, которую замечают через месяцы.

Мини-итог

  • Рекомендации — задача о паре (пользователь, объект), с преимущественно неявным откликом, экстремальной разреженностью (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-взвешиванием и ограничениями на разнообразие.

Источники

Смежные материалы трека: подготовка признаков и утечки — данные и признаки; ранкер на бустинге — ансамбли и бустинг; латентные пространства и низкоранговые разложения — снижение размерности; метрики и протоколы валидации — оценка моделей; соседские методы и метрики близости — k ближайших соседей; бандиты и исследование — обучение с подкреплением; эксплуатация моделей — MLOps.

Что дальше

В этой статье время появлялось постоянно — временной сплит, свежесть объектов, дневная сезонность трафика, затухание интереса, — но всегда как ограничение, которое нужно не нарушить. Существует целый класс задач, где время не ограничение, а сам предмет моделирования: спрос на завтра, нагрузка на сервис, цена через неделю. Там ломаются привычные предположения (наблюдения не независимы, распределение дрейфует, а кросс-валидация в обычном виде запрещена) и появляется собственный инструментарий — от классических авторегрессионных моделей до того же градиентного бустинга на хитро сконструированных лагах. Об этом следующая статья: Временные ряды: от ARIMA до градиентного бустинга.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов