Эксперименты и бизнес
~18 мин

Diversity и Fairness

Фильтр-пузыри, diversity-aware ранжирование, exploration vs exploitation.

Diversity и Fairness — как не превратить рекомендации в ловушку

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

Ты слушаешь один трек — и Spotify начинает крутить похожие. Через неделю весь Discover Weekly — один жанр. Ты покупаешь корм для кота — и Amazon месяц показывает только кошачьи товары. Это фильтр-пузырь (filter bubble): рекомендательная система, оптимизированная на релевантность, сужает мир пользователя до узкой ниши.

Фильтр-пузырь — не просто дискомфорт. Это прямой ущерб бизнесу: пользователь скучает, перестаёт кликать, уходит. YouTube внутри называет это «rabbit hole problem» — зритель попадает в спираль всё более однотипного контента, а потом выгорает. Борьба с этим — задача diversity (разнообразия) и fairness (честности) рекомендаций.

В этой ноде: почему одна релевантность губит систему → MMR и DPP как инструменты разнообразия → fairness и popularity bias → exploration-стратегии. Это знание отличает junior-инженера («я обучил модель, NDCG отличный») от middle/senior («я построил систему, которая не деградирует со временем»).

Большая картина: почему релевантность — не всё

Представь: модель предсказывает CTR и выдаёт топ-10 по убыванию вероятности клика. Что происходит?

Шаг 1. Popularity bias. Популярные айтемы накопили больше кликов → модель уверена в их CTR → они попадают в топ чаще → собирают ещё больше кликов. Это замкнутый цикл (feedback loop). 80% показов достаётся 1% каталога. Длинный хвост (long tail) — тысячи нишевых товаров — никогда не показывается.

Шаг 2. Однообразие. Даже если модель идеально знает вкусы пользователя, 10 идентичных кроссовок в выдаче — ужасный UX. Пользователь хочет набор, а не 10 копий лучшего айтема. В ресторане ты не заказываешь 5 тарелок одного блюда — ты хочешь меню.

Шаг 3. Стагнация. Система рекомендует → пользователь кликает → система учится на кликах → рекомендует ещё уже. Exploration (показ нового) падает до нуля, модель перестаёт узнавать новое о пользователе. Его вкусы меняются, а модель застряла в прошлом.

Фильтр-пузырь: замкнутый цикл сужения рекомендаций
Feedback loop: модель рекомендует → юзер кликает → модель учится → рекомендует ещё уже. Без diversity система схлопывается

Вывод: оптимизировать только relevance — это оптимизировать краткосрочный CTR ценой долгосрочного engagement. Diversity, fairness и exploration — механизмы, которые удерживают систему здоровой.

MMR — Maximal Marginal Relevance

MMR — самый простой и самый популярный способ добавить diversity. Идея: выбираем следующий айтем в список так, чтобы он был релевантен пользователю и одновременно отличался от уже выбранных.

Аналогия: ты составляешь плейлист для вечеринки. Каждый следующий трек должен быть хорошим (релевантность) и непохожим на предыдущие (diversity). Если поставил три рок-хита подряд — следующим лучше добавить электронику, даже если четвёртый рок-трек чуть популярнее.

rel(i) — релевантность айтема i для пользователя, sim(i,j) — сходство с уже выбранным j ∈ S, λ — баланс: 1 = только релевантность, 0 = только разнообразие

Алгоритм — жадный: 1. Начинаем с пустого S. Первый айтем — самый релевантный 2. На каждом шаге из оставшихся кандидатов выбираем i с максимальным MMR(i) 3. Добавляем i в S, повторяем пока |S| = K Сложность: O(K·N), где N — число кандидатов. На практике кандидатов 100-1000 (после стадии retrieval), так что это быстро.

Lambda (λ) — ключевой гиперпараметр. На практике λ ≈ 0.5–0.7. Подбирается через A/B-тест по бизнес-метрикам (session length, retention), а не по offline NDCG. Маленький λ — слишком случайные рекомендации, большой λ — нет эффекта.

import numpy as np
from typing import List, Tuple

def mmr_rerank(
    relevance: np.ndarray,    # (N,) — скоры релевантности
    embeddings: np.ndarray,   # (N, dim) — эмбеддинги айтемов
    lam: float = 0.6,
    k: int = 10,
) -> List[int]:
    """MMR re-ranking: жадно выбираем K айтемов."""
    # Нормализуем эмбеддинги для косинусного сходства
    norms = np.linalg.norm(embeddings, axis=1, keepdims=True) + 1e-8
    emb_norm = embeddings / norms

    selected: List[int] = []
    remaining = set(range(len(relevance)))

    for _ in range(min(k, len(relevance))):
        best_score, best_idx = -np.inf, -1

        for idx in remaining:
            if selected:
                sims = emb_norm[idx] @ emb_norm[selected].T
                max_sim = float(np.max(sims))
            else:
                max_sim = 0.0

            score = lam * relevance[idx] - (1 - lam) * max_sim
            if score > best_score:
                best_score, best_idx = score, idx

        selected.append(best_idx)
        remaining.discard(best_idx)

    return selected

# Пример: 5 айтемов, первые три — кроссовки (похожие эмбеддинги)
rel = np.array([0.95, 0.90, 0.85, 0.70, 0.65])
embs = np.array([
    [1.0, 0.1], [0.9, 0.15], [0.95, 0.12],  # кроссовки
    [0.1, 0.9], [0.2, 0.85],                  # куртки
])
print(mmr_rerank(rel, embs, lam=1.0, k=3))  # [0,1,2] — три кроссовки
print(mmr_rerank(rel, embs, lam=0.5, k=3))  # [0,3,1] — разнообразие!

DPP — Determinantal Point Processes

MMR — жадный: каждый шаг оптимален локально, но не глобально. DPP (Determinantal Point Process) решает задачу diversity на уровне всего набора: вероятность выбрать подмножество S пропорциональна определителю подматрицы ядра L.

Аналогия: представь, что ты расставляешь магниты на столе. Одноимённые полюса отталкиваются — магниты естественно распределяются равномерно по поверхности. DPP работает так же: «похожие» айтемы «отталкиваются», и модель автоматически выбирает разнообразный набор.

L — ядро (kernel matrix), q_i — качество (релевантность) айтема i, φ_i — нормализованный вектор признаков. det(L_S) → 0, если айтемы в S похожи (строки/столбцы пропорциональны)

Почему DPP лучше MMR? MMR жадный — выбирает локально оптимально. DPP оценивает всё подмножество целиком — комбинация из 10 айтемов может быть лучше, даже если каждый отдельный уступает. DPP имеет вероятностную интерпретацию (можно семплировать разные наборы) и естественно моделирует repulsion — определитель падает, когда строки коррелируют.

Почему DPP используют реже? Точный inference O(N³), жадная аппроксимация O(N·K²) vs O(N·K) у MMR. Сложнее в реализации, а разница с хорошо настроенным MMR часто невелика. Вывод: MMR — рабочая лошадка для 90% случаев. DPP — когда diversity критична (новостные ленты, поиск) и бюджет на latency позволяет.

Fairness — честность рекомендаций

Diversity — про UX: «покажи пользователю разное». Fairness — про этику и бизнес: «не дискриминируй ни пользователей, ни поставщиков контента». Рекомендательная система распределяет внимание (attention), а внимание = деньги.

Exposure bias (перекос показов). Маркетплейс: 100 продавцов, но 90% показов достаются 5 крупнейшим. Остальные 95 продавцов не получают трафика → уходят → ассортимент сужается → пользователь получает меньше выбора → платформа деградирует. Это не просто «нечестно» — это разрушает экосистему.

Provider fairness (честность к поставщикам). Каждый продавец/автор заслуживает fair share показов, пропорциональных качеству. Метрики: exposure parity (равномерность показов между группами), amortized fairness (на одном запросе можно быть «нечестным», но за N запросов показы выравниваются), envy-freeness (ни один поставщик не предпочёл бы оказаться на месте другого).

Consumer fairness (честность к пользователям). Модель не должна давать худшие рекомендации определённым группам: новичкам (мало истории), пользователям из «небогатых» регионов, определённым демографическим группам. Gender/race bias в рекомендациях вакансий — реальная проблема с юридическими последствиями.

import numpy as np

def exposure_fairness(
    recommendations: list[list[int]],   # список рекомендаций для N юзеров
    item_groups: dict[int, str],        # item_id → группа поставщика
) -> dict[str, float]:
    """Считаем долю показов для каждой группы поставщиков."""
    from collections import Counter
    total = Counter()
    for recs in recommendations:
        for item in recs:
            total[item_groups.get(item, "unknown")] += 1

    n_total = sum(total.values())
    return {group: count / n_total for group, count in total.items()}

# Пример: 3 группы поставщиков, 1000 запросов по 10 айтемов
# Идеально: ~33% на каждую. Реальность: крупный поставщик забирает 70%
# exposure = {"big_seller": 0.72, "medium": 0.21, "small": 0.07}  ← проблема

Popularity bias и long tail

Popularity bias — конкретный и самый частый вид unfairness в рекомендациях. Распределение взаимодействий следует степенному закону (power law): 1% айтемов собирают 80% кликов. Остальные 99% — это long tail, нишевые товары, которые в совокупности могут быть ценнее головы распределения.

Почему модели усиливают bias: (1) больше данных → лучше предсказание — модель уверена в CTR популярных, но не уверена в нишевых; (2) feedback loop — популярные показываются чаще → больше кликов → кажутся ещё лучше; (3) sparse signal — для айтема с 10 взаимодействиями нельзя выучить хороший эмбеддинг.

Calibrated recommendations (Steck, 2018) — элегантный подход: распределение категорий в рекомендациях должно соответствовать реальным вкусам пользователя. Если 60% истории пользователя — рок и 40% — джаз, то и в рекомендациях должно быть примерно 60/40, а не 100% рок.

KL-дивергенция между p (распределение жанров в истории юзера) и q (распределение жанров в рекомендациях). Минимизируем CKL → калиброванные рекомендации

  • IPS (Inverse Propensity Scoring) — вес нишевых айтемов при обучении: weight = 1/P(shown). Редкий айтем → высокий вес
  • Causal debiasing — popularity как confounding variable, вычитаем её эффект
  • Popularity-aware regularization — штраф за рекомендацию и без того популярных
  • Post-processing — не более N айтемов из одной категории в выдаче

Exploration — как выбраться из пузыря

Все предыдущие методы (MMR, DPP, calibration) работают на этапе re-ranking — перетасовывают уже сгенерированных кандидатов. Exploration идёт дальше: целенаправленно показывает айтемы, в которых модель не уверена, чтобы собрать новый сигнал.

Это классическая дилемма exploration vs exploitation: exploitation — рекомендуй то, что точно зайдёт (по текущей модели), exploration — покажи что-то новое, чтобы узнать вкусы лучше. Как в ресторане: можно всегда заказывать любимую пиццу (exploitation), а можно попробовать суши (exploration). Без exploration ты никогда не узнаешь, что суши тебе нравятся больше.

ε-greedy — простейшая стратегия: с вероятностью (1−ε) рекомендуем топ по модели, с вероятностью ε показываем случайный айтем. Типичное ε = 0.05–0.1. Плюс: тривиально реализовать. Минус: случайный explore неэффективен — лучше исследовать там, где uncertainty максимальна.

Thompson Sampling — умнее: для каждого айтема моделируем распределение CTR (не точечную оценку). На каждом запросе семплируем из распределений и показываем айтем с наибольшим семплом. Айтем с высоким средним CTR часто выигрывает (exploit), а с широким распределением (мало данных) — иногда тоже (explore). Чем больше данных — тем уже распределение → exploration снижается автоматически.

import numpy as np

class ThompsonSamplingRecommender:
    """Thompson Sampling для выбора из K кандидатов."""

    def __init__(self, n_items: int):
        # Beta(alpha, beta) prior для CTR каждого айтема
        self.alpha = np.ones(n_items)  # число кликов + 1
        self.beta = np.ones(n_items)   # число не-кликов + 1

    def recommend(self, candidates: list[int], k: int = 5) -> list[int]:
        # Семплируем CTR из апостериорного Beta-распределения
        sampled_ctr = np.random.beta(
            self.alpha[candidates], self.beta[candidates]
        )
        # Показываем топ-K по семплированному CTR
        top_k = np.argsort(sampled_ctr)[-k:][::-1]
        return [candidates[i] for i in top_k]

    def update(self, item: int, clicked: bool):
        if clicked:
            self.alpha[item] += 1  # больше кликов → распределение сдвигается вправо
        else:
            self.beta[item] += 1   # больше показов без кликов → сдвигается влево

# Новый айтем: alpha=1, beta=1 → широкое распределение → часто explore
# Популярный: alpha=500, beta=1500 → узкое около 0.25 → стабильный exploit

Contextual bandits — ещё умнее: учитывают контекст (фичи пользователя, время суток, устройство). Модель не просто оценивает средний CTR айтема, а предсказывает CTR для данного пользователя в данный момент. LinUCB и NeuralUCB — популярные алгоритмы. В контексте diversity: bandit-подход позволяет exploration подстраивать под конкретного юзера — новичку больше explore, опытному — меньше.

Метрики diversity: как измерить разнообразие

  • ILS (Intra-List Similarity) — среднее попарное сходство айтемов в списке. ILS = 0 → все разные, ILS = 1 → все одинаковые. Target: < 0.5
  • Coverage — какой % каталога рекомендуется хоть кому-то. 5% = модель игнорирует 95% айтемов
  • Novelty — средняя «неожиданность»: novelty(i) = −log₂(popularity(i)). Популярные дают низкую novelty
  • Calibration — KL-дивергенция между распределением категорий в истории и в рекомендациях. 0 = идеально
  • Serendipity — рекомендация, которая и неожиданна, и понравилась. Измеряется только онлайн

На практике diversity-метрики трекают рядом с качеством (NDCG, Hit Rate). Типичный A/B: «MMR с λ=0.5 vs baseline — NDCG −2%, но session length +5% и coverage +15%». Бизнес решает: стоит ли потеря точности выигрыша в engagement.

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

Junior

Зачем нужна diversity в рекомендациях? Без неё — filter bubble: система сужает мир пользователя, он скучает и уходит. Diversity = показать разное, не только «самое релевантное». • Что такое MMR? Жадный алгоритм: следующий айтем = max(λ·relevance − (1−λ)·max_similarity_to_selected). λ управляет балансом. • Что такое exploration vs exploitation? Exploitation — показывай точно хорошее. Exploration — показывай новое, чтобы узнать вкусы лучше. Без exploration система застревает в пузыре.

Middle

Чем DPP лучше MMR? MMR жадный — локально оптимален. DPP оценивает всё подмножество через определитель: P(S) ∝ det(L_S). Глобально оптимальнее, но дороже (O(N³) vs O(NK)). • Что такое popularity bias и как с ним бороться? 1% айтемов забирает 80% показов из-за feedback loop. Методы: IPS (inverse propensity), calibrated recommendations (KL-дивергенция вкусов vs рекомендаций), popularity-aware regularization. • Как измерить diversity? ILS (попарное сходство), coverage (% каталога), novelty (−log popularity), calibration (KL-дивергенция). • Thompson Sampling vs ε-greedy? ε-greedy исследует случайно. Thompson семплирует из распределения CTR — айтемы с высокой uncertainty чаще попадают в exploration. Эффективнее в 2-5 раз.

Senior

Как обеспечить provider fairness на маркетплейсе? Amortized fairness: на одном запросе можно быть «нечестным», но за N запросов показы должны выравниваться. ILP или контроллер с бюджетом показов на поставщика. • Как совместить diversity с бизнес-метриками? A/B тест: diversity-метод обычно снижает NDCG на 1-3%, но повышает session length и retention. Настройка λ/constraint — через онлайн оптимизацию. • Feedback loop debiasing? Модель обучается на biased данных (показано = популярное). IPS: weight = 1/P(shown|features). Или causal подход: popularity как confounder, вычитаем direct effect. • Contextual bandits в рекомендациях? LinUCB/NeuralUCB: exploration зависит от контекста пользователя. Uncertainty высока → explore больше. Новичкам — больше exploration, лоялам — меньше.

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

Relevance — необходимое, но недостаточное условие хорошей рекомендательной системы. Без diversity пользователь попадает в filter bubble и теряет интерес. Без fairness платформа теряет поставщиков и деградирует. Без exploration модель перестаёт учиться.

Инструментарий: MMR — рабочая лошадка для re-ranking (λ-баланс между relevance и diversity). DPP — когда нужна глобальная оптимизация набора. Calibrated recommendations — чтобы рекомендации отражали реальные вкусы. Thompson Sampling / contextual bandits — для умного exploration.

Если запомнить одну вещь: хорошая рекомендательная система оптимизирует долгосрочный engagement, а не краткосрочный CTR. Diversity и fairness — не «nice to have», а часть бизнес-метрики. Дальше: Multi-Stage Ranking покажет, где именно в pipeline встраивается diversity, а A/B-тестирование — как измерять бизнес-эффект от diversity.