Часть 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 оплачивается будущим качеством модели, а разнообразие — текущим удовлетворением пользователя. Это разные бюджеты и разные метрики успеха.
2. Чем меряют разнообразие
Прежде чем оптимизировать, надо уметь померить. Три уровня, и их полезно различать.
| Уровень | Метрика | Что ловит |
|---|---|---|
| Внутри одной выдачи | intra-list diversity — средняя непохожесть пар в слейте; энтропия категорий | «десять одинаковых товаров подряд» |
| По пользователю за период | доля новых для него категорий, серендипность | «его заперли в одной теме» |
| По всей системе | Coverage и Джини по показам | «каталог не крутится, хвост не показывается» |
Слейт из 10 позиций, разные раскладки по категориям:
| Раскладка | Категорий | Энтропия | Максимум |
|---|---|---|---|
| одна категория | 1 | 0.0000 | 0.0000 |
| 8 + 1 + 1 | 3 | 0.6390 | 1.0986 |
| 5 + 3 + 2 | 3 | 1.0297 | 1.0986 |
| 4 + 3 + 3 | 3 | 1.0889 | 1.0986 |
| по 2 на пять категорий | 5 | 1.6094 | 1.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.43 | 2 категории |
| с правилом | 0.95A 0.93A 0.86B 0.91A 0.90A 0.80C | сумма 5.35 | 3 категории |
Потеря — 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.0 | 1 | 0.3173 | 4.3745 | 0.0% |
| 0.9 | 1 | 0.3173 | 4.3745 | 0.0% |
| 0.8 | 2 | 0.6187 | 4.2280 | 3.3% |
| 0.7 | 3 | 0.8159 | 4.0425 | 7.6% |
| 0.6 | 3 | 0.8159 | 4.0425 | 7.6% |
| 0.3 | 3 | 0.8691 | 3.6950 | 15.5% |
| 0.0 | 3 | 0.8874 | 3.4642 | 20.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\) сумма релевантности максимальна по определению. И NDCG будет максимален, и Recall@k, и любая другая офлайн-метрика ранжирования.
Офлайн-метрики не умеют считать ценность непохожести. Они оценивают выдачу как множество независимых айтемов — то самое допущение, из-за которого этот слой вообще понадобился.
Практическое следствие жёсткое: \(\lambda\) невозможно подобрать офлайн. Только A/B, и только по продуктовым метрикам — возвращаемость, длина сессии, доля пользователей, взаимодействующих больше чем с одной темой.
- Поставьте \(\lambda = 1\) и посмотрите на картинку: выбранные точки кучкуются в одном кластере.
- Опускайте \(\lambda\) и следите за двумя числами сразу — разнообразием и суммой релевантности. Найдите колено.
- Переключитесь в режим 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\) — матрица похожестей. Интуиция геометрическая и её стоит запомнить: определитель матрицы Грама равен квадрату объёма параллелепипеда, натянутого на векторы.
Для двух айтемов с единичными длинами \(\det = 1 - \cos^2\theta = \sin^2\theta\):
| Угол | 0° | 15° | 30° | 60° | 90° |
|---|---|---|---|---|---|
| \(\det\) | 0.0000 | 0.0670 | 0.2500 | 0.7500 | 1.0000 |
А теперь зафиксируем угол 60° и подвигаем релевантность первого айтема:
| \(q_1\) | 0.5 | 1.0 | 2.0 |
|---|---|---|---|
| \(\det\) | 0.1875 | 0.7500 | 3.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 вариантов.
Первоисточники
- J. Carbonell, J. Goldstein. The Use of MMR, Diversity-Based Reranking for Reordering Documents and Producing Summaries, SIGIR 1998 — исходная работа по MMR.
- A. Kulesza, B. Taskar. Determinantal Point Processes for Machine Learning, 2012 — исчерпывающий обзор DPP.
- L. Chen, G. Zhang, H. Zhou. Fast Greedy MAP Inference for Determinantal Point Process to Improve Recommendation Diversity, NeurIPS 2018 — приближённый DPP в рекомендациях.
- G. Nemhauser, L. Wolsey, M. Fisher. An analysis of approximations for maximizing submodular set functions, Mathematical Programming 1978 — та самая гарантия \(1 - 1/e\).
- C.-N. Ziegler et al. Improving Recommendation Lists Through Topic Diversification, WWW 2005 — метрики внутрисписочного разнообразия.
- Числа главы:
_tools/rerank_demo.pyв этом репозитории.