RecSys · учебник
Тренажёр Виджеты Повторение О проекте Все главы ← История Exploration →

Часть IV · Последовательности и выдача · глава 15 из 19

Переранжирование и разнообразие

Ранкер выдал скоры, осталось взять верхние \(k\). На этом можно было бы закончить — если бы ценность айтема не зависела от того, что стоит рядом. Но она зависит, и потому существует отдельный слой, который собирает не список лучших айтемов, а лучший список. Это разные задачи, и вторая тяжелее.

Что унести из главы
  • Лучший слейт — это не топ по скору. Менее релевантный айтем из другой темы даёт выдачу на 48% лучше, чем более релевантный дубликат.
  • Разнообразие покупается дёшево, но только вначале. Первые проценты стоят 7.6% релевантности и покрывают все темы; дальнейшие 0.07 разнообразия — ещё 13 процентных пунктов.
  • Жадный отбор здесь не хак. На субмодулярной функции он гарантирует не хуже 63.2% оптимума, а перебор слейта из 10 по 500 кандидатам — это 2.5 · 1020 вариантов.
  • Офлайн-метрика всегда голосует за \(\lambda = 1\). Она не умеет считать ценность непохожести, поэтому решает только A/B.

1. Почему топ по скору — не лучшая выдача

Ранжирующая модель оценивает айтемы поштучно: \(f(u, i)\) не знает, что ещё попадёт в выдачу. А пользователь видит список целиком, и айтемы в нём взаимодействуют.

Арифметика внешнего эффекта

Два айтема, у каждого вероятность клика 0.20. Если они независимы, вероятность хотя бы одного клика — 0.36. А если это почти дубликаты, второй не добавляет ничего: 0.20.

Теперь возьмём третий айтем — из другой темы, с меньшей вероятностью 0.12:

СлейтВероятность клика
оригинал + дубликат (скоры 0.20 и 0.20)0.2000
оригинал + другая тема (скоры 0.20 и 0.12)0.2960

Числа воспроизводятся скриптом _tools/rerank_demo.py в этом репозитории.

Менее релевантный айтем даёт слейт на 48% лучше. Ранкер, честно отсортировавший по скору, поставил бы дубликат — и был бы формально прав по каждому айтему в отдельности.

Отсюда и существование отдельного слоя: задача «выбрать лучшие \(k\) айтемов» и задача «собрать лучший список из \(k\) айтемов» — разные, и вторая не сводится к первой сортировкой.

Разнообразие — это не exploration

Эти две вещи легко перепутать, и на собеседовании путаница видна сразу. Разница в цели, а не в механизме.

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

Механизм похож — и там и там мы отклоняемся от жадного топа. Но exploration оплачивается будущим качеством модели, а разнообразие — текущим удовлетворением пользователя. Это разные бюджеты и разные метрики успеха.

2. Чем меряют разнообразие

Прежде чем оптимизировать, надо уметь померить. Три уровня, и их полезно различать.

УровеньМетрикаЧто ловит
Внутри одной выдачиintra-list diversity — средняя непохожесть пар в слейте; энтропия категорий«десять одинаковых товаров подряд»
По пользователю за периоддоля новых для него категорий, серендипность«его заперли в одной теме»
По всей системеCoverage и Джини по показам«каталог не крутится, хвост не показывается»
Энтропия категорий — почему она лучше простого счёта

Слейт из 10 позиций, разные раскладки по категориям:

РаскладкаКатегорийЭнтропияМаксимум
одна категория10.00000.0000
8 + 1 + 130.63901.0986
5 + 3 + 231.02971.0986
4 + 3 + 331.08891.0986
по 2 на пять категорий51.60941.6094

Числа воспроизводятся скриптом _tools/rerank_demo.py.

Сравните вторую и четвёртую строки: категорий одинаково три, а энтропия отличается вдвое. Простой счёт категорий сказал бы, что эти слейты равны, — а «восемь плюс по одному» это выдача из одной темы с двумя случайными вкраплениями.

Метрика штрафует не только за монотему, но и за перекос. Это ровно то, что нужно.

3. Бизнес-правила: самый частый вариант

Прежде чем говорить про MMR и DPP, стоит честно сказать, что в проде чаще всего работает не они. Самый распространённый и самый дешёвый способ — жадное переранжирование под явное правило.

Сколько стоит правило «не больше двух подряд»

Кандидаты со скорами и категориями: 0.95A, 0.93A, 0.91A, 0.90A, 0.88A, 0.86B, 0.84A, 0.80C, 0.78B, 0.70C. Берём шесть.

без правила0.95A 0.93A 0.91A 0.90A 0.88A 0.86Bсумма 5.432 категории
с правилом0.95A 0.93A 0.86B 0.91A 0.90A 0.80Cсумма 5.353 категории

Потеря — 1.47% суммы скоров.

Числа воспроизводятся скриптом _tools/rerank_demo.py.

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

Обратная сторона: если скоры разъезжаются резко, та же подмена станет дорогой. Стоимость правила — не константа продукта, а свойство распределения скоров, и её надо мерить, а не предполагать.

Плюсы подхода: просто, предсказуемо, легко объяснить продукту и легко откатить. Минус один, но существенный — правило ничего не знает про содержательную похожесть. Два товара из разных категорий могут быть практически одинаковы, и формально правило будет соблюдено.

4. MMR

Жадный отбор с штрафом за похожесть

Строим слейт жадно. На шаге \(k\) выбираем айтем:

$$ i_k = \arg\max_{i \notin S}\Bigl[\lambda \cdot \mathrm{rel}(u,i) - (1-\lambda)\cdot \max_{j \in S}\mathrm{sim}(i,j)\Bigr] $$

где \(S\) — уже выбранные элементы слейта, похожесть обычно считается по контентному эмбеддингу, а \(\lambda\) — гиперпараметр. Читается буквально: «бери самое релевантное из того, что не слишком похоже на уже взятое».

Обратите внимание на \(\max_{j \in S}\): штраф идёт по ближайшему уже выбранному, а не по среднему. Это существенно — один дубликат в слейте отравляет кандидата целиком, даже если со всеми остальными он не похож.

Кривая размена

18 айтемов в трёх тематических кластерах, слейт из пяти. Релевантность сконцентрирована в одном кластере — так и бывает, похожие айтемы получают похожие скоры.

\(\lambda\)Кластеров покрытоРазнообразиеСумма релевантностиПотеря
1.010.31734.37450.0%
0.910.31734.37450.0%
0.820.61874.22803.3%
0.730.81594.04257.6%
0.630.81594.04257.6%
0.330.86913.695015.5%
0.030.88743.464220.8%

Числа воспроизводятся скриптом _tools/rerank_demo.py; он же независимо повторяет вычисления виджета.

Читать эту таблицу надо снизу вверх и сверху вниз одновременно.

  • При \(\lambda = 1\) это обычный топ, и вся выдача сползла в один кластер — покрыт 1 из 3. Причина именно та, что описана выше: похожие айтемы получают похожие скоры, поэтому топ по скору тематически однороден по построению.
  • Первые проценты разнообразия почти бесплатны. Переход к \(\lambda = 0.7\) покрывает все три кластера и поднимает разнообразие с 0.32 до 0.82 — за 7.6% релевантности.
  • Дальше цена растёт резко. От \(\lambda = 0.7\) до \(\lambda = 0\) разнообразие прибавляет всего 0.07, а потеря вырастает с 7.6% до 20.8%.

Форма кривой типична и она же практический совет: работать надо в колене, а не на краях.

Офлайн всегда голосует за \(\lambda = 1\)

Заметьте первую строку таблицы: при \(\lambda = 1\) сумма релевантности максимальна по определению. И NDCG будет максимален, и Recall@k, и любая другая офлайн-метрика ранжирования.

Офлайн-метрики не умеют считать ценность непохожести. Они оценивают выдачу как множество независимых айтемов — то самое допущение, из-за которого этот слой вообще понадобился.

Практическое следствие жёсткое: \(\lambda\) невозможно подобрать офлайн. Только A/B, и только по продуктовым метрикам — возвращаемость, длина сессии, доля пользователей, взаимодействующих больше чем с одной темой.

Что здесь надо увидеть
  1. Поставьте \(\lambda = 1\) и посмотрите на картинку: выбранные точки кучкуются в одном кластере.
  2. Опускайте \(\lambda\) и следите за двумя числами сразу — разнообразием и суммой релевантности. Найдите колено.
  3. Переключитесь в режим DPP и сравните: там нет ручного \(\lambda\), размен зашит в конструкцию.

Что сказать на собесе: «MMR — жадный отбор со штрафом за похожесть на уже выбранное. Офлайн он всегда выглядит проигрышем, потому что офлайн-метрики не умеют считать ценность непохожести; решает только A/B».

5. DPP: разнообразие через объём

Определитель вместо двух слагаемых

Задаём распределение на подмножествах, в котором вероятность пропорциональна определителю соответствующей подматрицы матрицы близостей, и сэмплируем из него:

$$ P(S) \;\propto\; \det\bigl(L_S\bigr), \qquad L = \operatorname{diag}(q)\, S\, \operatorname{diag}(q) $$

Здесь \(q\) — релевантности, \(S\) — матрица похожестей. Интуиция геометрическая и её стоит запомнить: определитель матрицы Грама равен квадрату объёма параллелепипеда, натянутого на векторы.

похожие айтемы: векторы почти коллинеарны площадь ≈ 0 det(L_S) → 0, подмножество почти не сэмплируется разные айтемы: векторы почти ортогональны большая площадь det(L_S) велик — такой слейт вероятен
Длины векторов задают релевантность, углы — непохожесть. Объём учитывает и то и другое сразу.
Как объём реагирует на угол и на длину

Для двух айтемов с единичными длинами \(\det = 1 - \cos^2\theta = \sin^2\theta\):

Угол15°30°60°90°
\(\det\)0.00000.06700.25000.75001.0000

А теперь зафиксируем угол 60° и подвигаем релевантность первого айтема:

\(q_1\)0.51.02.0
\(\det\)0.18750.75003.0000

Числа воспроизводятся скриптом _tools/rerank_demo.py.

Коллинеарные векторы дают \(\det = 0\) — такое подмножество не сэмплируется вовсе, не «реже», а никогда. А определитель растёт как квадрат релевантности и как квадрат синуса угла.

Отсюда главное преимущество DPP над MMR: одна конструкция учитывает и релевантность (длины), и разнообразие (углы), без ручного \(\lambda\), балансирующего два слагаемых разной природы.

Честный дисклеймер

Явно построить такое распределение очень тяжело. Существуют алгоритмы приближённого сэмплирования за разумное время, но в реальном проде DPP применяется редко.

Причина инженерная: даже приближённые алгоритмы обычно требуют явно посчитать матрицу близостей и разложить её. Для сотен кандидатов на запрос и бюджета в единицы миллисекунд это дорого — а MMR требует ровно \(k \cdot |C|\) вычислений похожести и укладывается всегда.

Знать DPP стоит, потому что про него спрашивают и потому что геометрическая интуиция полезна сама по себе. Ставить в прод — скорее нет.

6. Почему жадный отбор — законный алгоритм

И MMR, и бизнес-ранкеры, и почти всё остальное в этом слое — жадные. Возникает законный вопрос, не халтура ли это. Ответ: нет, и у него есть доказательство.

Субмодулярность и гарантия

Функция «польза слейта» обычно монотонна (добавление айтема не вредит) и субмодулярна: каждый следующий айтем добавляет не больше, чем добавил бы, будь он взят раньше. Это формализация насыщения — второй товар той же категории полезен меньше первого.

$$ f(S \cup \{i\}) - f(S) \;\ge\; f(T \cup \{i\}) - f(T) \quad \text{при } S \subseteq T $$

Для таких функций классический результат: жадный алгоритм даёт не хуже \(1 - 1/e \approx 0.6321\) от оптимума.

Почему это не утешительный приз

Альтернатива жадности — перебор подмножеств:

слейт 5 из 18 (как в виджете)8 568 вариантовперебираемо
слейт 10 из 500 (как в проде)2.46e+20 вариантовневозможно

Числа воспроизводятся скриптом _tools/rerank_demo.py.

При реальных размерах задачи жадность — единственный вариант, у которого вообще есть доказанная гарантия. Это не компромисс с качеством, это единственная точка на карте, где качество хоть чем-то ограничено снизу.

7. Авторегрессивный слейт

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

Почему это красиво и почему это не бесплатно

Красиво: модель сама учится, что после кроссовок не надо показывать ещё четыре пары кроссовок, — потому что в обучающих данных за кроссовками обычно следует не то же самое. Разнообразие получается как побочный эффект правильной постановки, а не как отдельный слой с ручным \(\lambda\). Это тот же приём, что генеративный ретривал: заменить внешнее ограничение структурой задачи.

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

Вопросы с собеседований

Зачем нужен отдельный слой переранжирования, если ранкер уже всё отсортировал?

Потому что ранкер оценивает айтемы поштучно, а пользователь видит список целиком, и айтемы в нём взаимодействуют. Два айтема с вероятностью клика 0.20 дают 0.36, если независимы, и всего 0.20, если это дубликаты. А оригинал плюс менее релевантный айтем из другой темы (0.12) дают 0.296 — слейт на 48% лучше.

То есть «выбрать лучшие k айтемов» и «собрать лучший список из k айтемов» — разные задачи, и вторая не сводится к первой сортировкой.

Чем разнообразие отличается от exploration?

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

Механизм похож, но бюджеты и метрики успеха разные.

Как измерить разнообразие?

На трёх уровнях. Внутри выдачи — intra-list diversity (средняя непохожесть пар) и энтропия категорий. По пользователю за период — доля новых для него категорий, серендипность. По системе — coverage и Джини по показам.

Энтропия удобнее простого счёта категорий, потому что штрафует и за перекос: слейт «8 + 1 + 1» и слейт «4 + 3 + 3» содержат по три категории, но энтропия 0.639 против 1.089 — почти вдвое. А «8 + 1 + 1» это выдача из одной темы с двумя случайными вкраплениями.

Что такое MMR?

Жадный отбор со штрафом за похожесть на уже выбранное: на каждом шаге берём \(\arg\max [\lambda\,\mathrm{rel}(u,i) - (1-\lambda)\max_{j\in S}\mathrm{sim}(i,j)]\). Штраф идёт по ближайшему уже выбранному, а не по среднему: один дубликат в слейте отравляет кандидата целиком.

Форма кривой размена типична: первые проценты разнообразия почти бесплатны (все три кластера покрываются за 7.6% релевантности, разнообразие растёт с 0.32 до 0.82), дальше цена резко растёт — оставшиеся 0.07 разнообразия стоят ещё 13 процентных пунктов. Работать надо в колене.

Как подобрать λ?

Только A/B. Офлайн-метрики всегда голосуют за λ = 1: при нём сумма релевантности максимальна по определению, и NDCG, и Recall@k. Они оценивают выдачу как множество независимых айтемов — то самое допущение, из-за которого слой переранжирования вообще понадобился.

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

Что такое DPP и почему определитель означает разнообразие?

Задаём распределение на подмножествах с \(P(S) \propto \det(L_S)\), где \(L = \mathrm{diag}(q)\,S\,\mathrm{diag}(q)\), и сэмплируем из него. Определитель матрицы Грама — квадрат объёма параллелепипеда на этих векторах.

Коллинеарные векторы (почти одинаковые айтемы) дают объём ноль, и такое подмножество не сэмплируется вовсе. Ортогональные дают максимум. Для двух единичных векторов det = sin²θ: 0 при 0°, 0.75 при 60°, 1 при 90°. А длины отвечают за релевантность — det растёт как её квадрат.

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

Почему жадный алгоритм здесь допустим?

Потому что польза слейта обычно монотонна и субмодулярна: каждый следующий айтем добавляет не больше, чем добавил бы раньше — формализация насыщения. Для таких функций жадный алгоритм даёт не хуже 1 − 1/e ≈ 0.632 от оптимума.

И это не утешительный приз: перебор слейта из 10 по 500 кандидатам — это 2.5·10²⁰ вариантов. При реальных размерах задачи жадность — единственный вариант, у которого вообще есть гарантия снизу.

Можно ли обойтись без отдельного слоя разнообразия?

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

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

Шпаргалка одним экраном

Зачем слой

Скор поштучный, выдача общая. Дубликат: 0.20 против 0.296 у менее релевантного из другой темы.

Не путать

Exploration — ради данных. Разнообразие — ради пользователя сейчас. Разные бюджеты.

Метрики

ILD и энтропия в слейте, новизна по пользователю, coverage и Джини по системе.

Правила

Самый частый вариант. «Не больше двух подряд» стоит 1.47% — соседние скоры почти равны.

MMR

\(\lambda\,\mathrm{rel} - (1-\lambda)\max_j \mathrm{sim}\). Штраф по ближайшему. Колено кривой — 7.6%.

λ только онлайн

Офлайн всегда за \(\lambda=1\): метрики не умеют считать ценность непохожести.

DPP

\(\det\) = квадрат объёма: длины дают релевантность, углы — разнообразие. В прод редко.

Жадность

Субмодулярность даёт \(1-1/e = 0.632\). Перебор 10 из 500 — это 2.5e20 вариантов.

Первоисточники