Production RecSys
~18 мин

Решение холодного старта

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

Холодный старт — как рекомендовать, когда данных нет

Загрузка интерактивного виджета...

Ты построил рекомендательную систему: collaborative filtering, матричные разложения, нейросетевой ранкер — всё работает. Метрики отличные. А потом приходит пользователь, который только что зарегистрировался. Ноль кликов, ноль покупок, ноль истории. Что рекомендовать?

Или другой сценарий: маркетплейс добавил 10 000 новых товаров за ночь. Ни у одного нет взаимодействий. Collaborative filtering их не видит — для модели этих айтемов не существует. Это холодный старт — одна из самых болезненных и практически важных проблем в RecSys.

Холодный старт — не edge case. Каждый маркетплейс, стриминг, новостной агрегатор сталкивается с ним ежедневно. На быстро растущей платформе 30-50% пользователей в любой момент — «холодные». Если ты не решил cold start, ты теряешь пользователей прямо на входе: получил нерелевантные рекомендации → закрыл приложение → не вернулся.

Большая картина: замкнутый круг данных

Корень проблемы — порочный круг: нет данных → нет хороших рекомендаций → пользователь не взаимодействует → нет данных. Коллаборативная фильтрация учится на взаимодействиях — а их нет. Эмбеддинг пользователя строится из истории — а история пустая. Модель буквально не знает, с чего начать.

Два отдельных типа холодного старта требуют разных стратегий: Cold user — новый пользователь без истории. Нет эмбеддинга, нет профиля предпочтений. Задача: за 3-5 взаимодействий понять вкусы и перейти от случайных рекомендаций к персонализированным. Cold item — новый товар/фильм/трек без взаимодействий. CF-модель его не видит. Задача: встроить в каталог так, чтобы правильные пользователи его увидели.

Холодный старт: каскад стратегий от popularity через exploration к collaborative filtering
Стратегии cold start выстраиваются в каскад: popularity baseline, onboarding, бандиты для exploration, и CF после накопления данных

Холодный пользователь: от популярного к персональному

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

Этап 1: Popularity baseline (0 взаимодействий) Ничего не знаем — показываем то, что нравится большинству. Топ-продажи по категории, тренды за неделю, бестселлеры. Это не персонализация, но лучше, чем случайный набор. Популярное хотя бы гарантирует минимальное качество — по определению это контент, который понравился многим.

Этап 2: Onboarding / анкета (первые секунды) Спрашиваем напрямую. Spotify при регистрации просит выбрать 3 жанра. Pinterest — 5 пинов. Netflix — 3 фильма. Это 3-5 бесплатных сигналов, которые мгновенно превращают нулевой профиль в грубый, но рабочий. Важно: анкета должна быть быстрой (≤30 секунд) и визуальной (картинки > текст). Каждый дополнительный шаг онбординга теряет 10-15% пользователей.

Этап 3: Exploration через бандитов (1-10 взаимодействий) После онбординга начинаем активный exploration: показываем разнообразные айтемы и быстро учимся из реакций. Thompson Sampling или UCB (подробнее ниже) решают, что показать следующим — exploitation (то, что, по нашим данным, нравится) или exploration (то, что мы ещё не пробовали, чтобы узнать вкусы).

Этап 4: Collaborative Filtering (после 5-10 действий) После 5-10 взаимодействий у пользователя появляется достаточно истории, чтобы CF-модели начали работать: матричные разложения находят ближайших соседей, нейросетевые модели строят эмбеддинг. Пользователь «прогрелся» — дальше работаем как обычно.

def recommend_cold_user(user, n_interactions: int, k: int = 10):
    """Каскадная стратегия для холодного пользователя."""
    if n_interactions == 0 and not user.onboarding_done:
        # Этап 1: чистый popularity
        return get_popular_items(k=k, category=user.signup_category)

    if n_interactions <= 2:
        # Этап 2: popularity + контекст (устройство, время, гео)
        context = extract_context(user)  # device, time, geo
        return get_contextual_popular(context, k=k)

    if n_interactions <= 10:
        # Этап 3: бандит (exploration + exploitation)
        return bandit.recommend(user_id=user.id, k=k)

    # Этап 4: полноценный CF
    return cf_model.recommend(user_id=user.id, k=k)

Холодный айтем: контент вместо коллабораций

Новый товар, фильм, трек — ни одного клика. CF-модель бессильна: нет столбца в матрице взаимодействий, нет эмбеддинга. Но у айтема есть контент: название, описание, категория, изображение, аудио. Из этого можно построить эмбеддинг до первого показа.

Content-based эмбеддинги: пропускаем описание через E5/BGE, картинку через CLIP, аудио через модель спектрограмм. Получаем вектор, который можно сравнить с векторами существующих айтемов. Новые кроссовки Adidas → ближайшие соседи — другие беговые кроссовки → показываем тем же пользователям.

Cold-start embedding — продвинутый подход: обучаем отдельную модель, которая по контентным признакам предсказывает collaborative-эмбеддинг. На обучении она видит пары (контент айтема → его CF-эмбеддинг). На инференсе: новый айтем → подаём контент → получаем pseudo-CF эмбеддинг. Spotify так делает с новыми треками: нейросеть по аудио-спектрограмме предсказывает collaborative-эмбеддинг, и новый трек сразу попадает в правильное «соседство».

Exploration boost: даже лучший эмбеддинг — это предсказание. Чтобы быстро получить реальные данные, новым айтемам искусственно повышают скор первые N показов. Два подхода: • Additive boost — прибавляем бонус к скору, который decay-ится со временем (первые 7 дней) • Exploration slot — резервируем 1-2 позиции в ленте для новых айтемов. Фиксированные слоты = гарантированные показы для exploration

from sentence_transformers import SentenceTransformer
import faiss
import numpy as np

# Строим cold-start эмбеддинг для нового товара
model = SentenceTransformer("intfloat/multilingual-e5-large")

# Существующие товары уже в FAISS-индексе
existing = ["Кроссовки Nike Air Max", "Куртка Columbia", "Наушники Sony"]
existing_embs = model.encode(["query: " + t for t in existing])
index = faiss.IndexFlatIP(existing_embs.shape[1])
index.add(existing_embs.astype("float32"))

# Новый товар — сразу находим соседей по описанию
new_item = "Беговые кроссовки Adidas Ultraboost 2024"
new_emb = model.encode(["query: " + new_item]).astype("float32")
D, I = index.search(new_emb, k=5)
# I = [0, ...] — ближайший сосед: Nike Air Max (тоже кроссовки)
# → показываем новый товар пользователям, которые покупали Nike

Meta-learning: научиться учиться за 3 клика

Опытный продавец в магазине за 3 вопроса понимает, что тебе нужно. Не потому что знает именно тебя — а потому что видел тысячи покупателей и научился быстро понимать новых. Meta-learning переносит эту идею в ML: модель тренируется не предсказывать конкретного пользователя, а быстро адаптироваться к любому новому по 1-5 взаимодействиям.

MAML (Model-Agnostic Meta-Learning) — ключевой алгоритм. Идея: найти такие начальные веса модели, из которых 2-3 шага градиентного спуска на данных нового пользователя дают хорошие рекомендации. Как это работает для RecSys: 1. Каждый существующий пользователь — отдельная «задача» (task) 2. Для каждой задачи: берём 3-5 взаимодействий как support set, остальные — как query set 3. Делаем пару шагов SGD на support set → получаем адаптированные веса 4. Считаем loss на query set → обновляем начальные веса через мета-градиент 5. После обучения: новый пользователь + 3 клика → 2 шага SGD → персонализированная модель

Prototypical Networks (ProtNet) — ещё проще. Идея: каждый «класс» (предпочтение пользователя) представлен прототипом — средним эмбеддингом его примеров. Для cold start: берём 3-5 айтемов, с которыми пользователь взаимодействовал → усредняем их эмбеддинги → получаем прототип пользователя → рекомендуем айтемы, ближайшие к прототипу. Не нужны градиентные шаги — только forward pass.

MAML vs ProtNet: когда что

MAML: мощнее, но дороже в обучении (мета-градиенты через граф вычислений = O(2x) память). Подходит для сложных моделей с нелинейной адаптацией. ProtNet: простой, быстрый, стабильный. Достаточно для большинства задач cold start. Прототип можно обновлять инкрементально: каждый новый клик → пересчитываем среднее. На практике ProtNet чаще побеждает в RecSys-задачах: он проще в продакшне, устойчивее к шуму в малых выборках и не требует дорогого мета-обучения.

Exploration vs Exploitation: бандиты для холодного старта

Центральная дилемма cold start: показать то, что, вероятно, понравится (exploitation) или попробовать новое, чтобы узнать больше (exploration)? Чистый exploitation на холодном пользователе — это popularity, одинаковая для всех. Чистый exploration — случайные рекомендации, ужасный UX. Нужен баланс.

ε-greedy — простейший подход. С вероятностью (1−ε) показываем лучшее по текущей модели, с вероятностью ε — случайный айтем. ε = 0.1 означает: 90% exploitation, 10% random exploration. Проблема: exploration полностью случайный — не учитывает неопределённость, может «разведывать» заведомо плохие айтемы.

Thompson Sampling — элегантнее. Для каждого айтема (или категории) храним распределение CTR — Beta(α, β). При каждом запросе сэмплируем из распределения и выбираем айтем с максимальным сэмплом. Если мы неуверены в айтеме (мало данных → широкое распределение), он иногда получит высокий сэмпл и будет показан. По мере накопления данных распределение сужается → exploitation.

UCB (Upper Confidence Bound) — выбираем айтем с максимальным score + bonus за неопределённость. Формула: score_i = μ_i + c·√(ln(N)/n_i), где μ_i — средний reward айтема, N — общее число показов, n_i — число показов айтема i. Чем реже показывали айтем — тем больше бонус. UCB детерминирован (в отличие от стохастического Thompson Sampling) и имеет теоретические гарантии сходимости.

import numpy as np

class ThompsonSamplingColdStart:
    """Быстрый онбординг нового пользователя через Thompson Sampling."""
    def __init__(self, n_categories: int):
        # Beta(α, β) для каждой категории
        self.alpha = np.ones(n_categories)  # successes + 1 (prior)
        self.beta = np.ones(n_categories)   # failures + 1 (prior)

    def recommend(self, k: int = 5) -> list[int]:
        # Сэмплируем CTR из апостериорного распределения
        samples = np.random.beta(self.alpha, self.beta)
        return np.argsort(samples)[-k:][::-1].tolist()

    def update(self, category: int, clicked: bool):
        if clicked:
            self.alpha[category] += 1  # успех → α растёт
        else:
            self.beta[category] += 1   # неудача → β растёт

# Новый пользователь: первые показы — почти exploration
bandit = ThompsonSamplingColdStart(n_categories=20)
recs = bandit.recommend(k=5)  # широкие Beta(1,1) → почти random

# Кликнул на электронику, проигнорировал одежду
bandit.update(category=3, clicked=True)
bandit.update(category=7, clicked=False)

# Уже через 5 кликов: Beta(3,1) для электроники → высокий CTR
recs = bandit.recommend(k=5)  # электроника чаще попадает в топ

Практические решения: как это работает в продакшне

Hybrid warmup — основной паттерн. Для холодного пользователя используешь content-based + popularity + контекст. По мере накопления данных плавно переключаешь вес на CF-модель:

def hybrid_score(user, item, n_interactions: int) -> float:
    """Плавный переход от content-based к collaborative."""
    # Вес CF растёт с числом взаимодействий
    cf_weight = min(n_interactions / 10, 1.0)  # 0→1 за 10 кликов
    cb_weight = 1.0 - cf_weight

    score_cf = cf_model.predict(user, item)     # collaborative
    score_cb = content_model.predict(user, item) # content-based

    return cf_weight * score_cf + cb_weight * score_cb

Contextual bandits — продвинутый подход. Обычные бандиты (Thompson Sampling, UCB) не учитывают контекст: время суток, устройство, категорию страницы. Contextual bandit принимает на вход вектор контекста и выбирает действие (какой айтем показать). LinUCB — линейная модель, которая для каждого arm (айтема) решает ridge regression и строит confidence interval.

На практике contextual bandits часто реализуют через explore-exploit slot: основная лента генерируется CF-моделью, а 1-2 позиции отданы бандиту, который экспериментирует. Это безопасно: пользователь видит в основном знакомый контент, а бандит собирает данные для cold start.

Метрики для cold start — отдельная история. Обычные офлайн-метрики не работают: для холодных пользователей нет истории для валидации. • First-N metric — качество рекомендаций в первых 5-10 взаимодействиях. Именно тут решается: уйдёт пользователь или останется • Retention D1/D7 — вернулся ли через 1/7 дней. Главная бизнес-метрика для cold start • Когортный анализ — оценивай отдельно «холодную» (0-3 действия) и «тёплую» (>10) когорты • Time-split — обучайся на первых N днях, тестируй на новых пользователях следующих дней

🎯 На собеседовании

Junior

Что такое холодный старт? Нет данных о новом пользователе или новом айтеме → CF не работает. Два типа: cold user (нет истории) и cold item (нет взаимодействий). • Как рекомендовать новому пользователю? Popularity → onboarding-анкета → exploration → CF после 5-10 действий. • Как рекомендовать новый айтем? Content-based фичи (описание, картинка → эмбеддинг), exploration boost первые N дней.

Middle

Exploration vs exploitation для cold start? ε-greedy — просто, но exploration случайный. Thompson Sampling — сэмплируем из Beta-распределения CTR, неопределённость = exploration. UCB — score + бонус за неопределённость, детерминирован. • Что такое cold-start embedding? Модель предсказывает CF-эмбеддинг по контентным признакам. Обучается на парах (контент → CF-embedding). Новый айтем → подаём контент → pseudo-CF вектор. • Как оценить качество cold start? First-N metric (качество первых 5-10 взаимодействий), Retention D1/D7, когортный анализ (отдельно cold vs warm).

Senior

Meta-learning для cold start? MAML: находим начальные веса, из которых 2-3 шага SGD на 3-5 взаимодействиях дают персонализированную модель. ProtNet: усредняем эмбеддинги взаимодействий → прототип пользователя. ProtNet проще в продакшне. • Hybrid warmup — как реализовать? Вес CF = min(n_interactions / threshold, 1.0). Плавный переход от content-based к CF. Порог обычно 5-15 взаимодействий, подбирается по A/B. • Contextual bandits vs обычные? Contextual (LinUCB) учитывают вектор контекста (устройство, время, категория). Обычные (Thompson Sampling) — только reward по arm. Contextual быстрее адаптируются, но сложнее в реализации. • Feedback loop при cold start? Popularity bias → популярное получает больше показов → больше кликов → ещё популярнее. Решение: IPS (inverse propensity scoring), явный exploration slot, diversity constraint.

Собираем всё вместе

Холодный старт — это не один алгоритм, а стратегия перехода: от popularity через exploration к полноценному CF. Для пользователей: каскад popularity → onboarding → bandits → CF. Для айтемов: content-based эмбеддинг → exploration boost → CF по мере накопления данных.

Если запомнить одну вещь: cold start решается не выбором алгоритма, а правильной цепочкой fallback-стратегий. Thompson Sampling для exploration, content-based embedding для новых айтемов, hybrid warmup для плавного перехода — всё это вместе, а не по отдельности.

Дальше на роадмапе: Two-Tower покажет архитектуру, которая естественно решает cold start через content-ветку башни, а Multi-Stage Ranking объяснит, как exploration встраивается в production pipeline.